Skip to content

Commit 95d867b

Browse files
feat(observability): structured logging + VeriSimDB audit-trail wiring (#50)
Replaces the runtime path's ad-hoc println breadcrumb with Logging-based structured records, and wires the previously-dormant VeriSimDB audit path into every tool call. - src/tools/observability.jl (new): correlation-id/tool-call-id minting, order-independent argument hashing (never raw args), shape-only result summaries (never raw values), configure_logging! (stderr ConsoleLogger, level from STATISTIKLES_LOG_LEVEL, default Info), log_tool_call (@info/@warn per tool call), and the audit-record construction + record_audit! persistence wrapper (opt-in via STATISTIKLES_AUDIT_PERSIST, off by default so nothing depends on a live VeriSimDB backend). - src/tools/lmstudio.jl: process_tool_calls now accepts/returns a per-chat-turn correlation_id, times each tool call, and emits a log_tool_call + record_audit! per call instead of the old println("[symbolic] executing: ..."). - src/tools/chat.jl: mints one correlation_id per turn, threads it through process_tool_calls and enforce_numeric_boundary! (including its guardrail retry), and adds @warn/@error structured records alongside the existing user-facing error messages (REPL stdout output is unchanged/unpolluted — logs go to stderr only). - src/tools/executor.jl: adds a @debug breadcrumb (off by default) in the catch-all, carrying an argument hash rather than raw arguments. - src/Statistikles.jl: `using Logging`, includes the new module, and calls configure_logging! from __init__ (re-read every load, unlike lmstudio.jl's precompiled BASE_URL const). - Project.toml: declares the (already-transitive) Logging stdlib dependency explicitly, matching the existing style for Dates/Random/UUIDs/etc. - test/observability_test.jl (new, wired into runtests.jl): correlation-id uniqueness, argument-hash order-independence and non-leakage, result-shape summaries, audit-record shape, audit_persistence_enabled parsing, log-level parsing, and captured-log assertions (Test.collect_test_logs) for both log_tool_call and process_tool_calls end to end. - QUICKSTART-MAINTAINER.adoc: documents STATISTIKLES_LOG_LEVEL and STATISTIKLES_AUDIT_PERSIST. Verification: full suite green under flock (WSL Debian, Julia 1.10.11) — 5710/5710 tests pass across all testsets, including the new 73-test Observability suite. No MANOVA flake encountered. <!-- SPDX-License-Identifier: CC-BY-SA-4.0 Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> --> ## Summary <!-- Briefly describe what this PR does and why. Link to related issues with "Closes #N". --> ## Changes <!-- List the key changes introduced by this PR. --> - ## RSR Quality Checklist <!-- Check all that apply. PRs that fail required checks will not be merged. --> ### Required - [ ] Tests pass (`just test` or equivalent) - [ ] Code is formatted (`just fmt` or equivalent) - [ ] Linter is clean (no new warnings or errors) - [ ] No banned language patterns (no TypeScript, no npm/bun, no Go/Python) - [ ] No `unsafe` blocks without `// SAFETY:` comments - [ ] No banned functions (`believe_me`, `unsafeCoerce`, `Obj.magic`, `Admitted`, `sorry`) - [ ] SPDX license headers present on all new/modified source files - [ ] No secrets, credentials, or `.env` files included ### As Applicable - [ ] `.machine_readable/STATE.a2ml` updated (if project state changed) - [ ] `.machine_readable/ECOSYSTEM.a2ml` updated (if integrations changed) - [ ] `.machine_readable/META.a2ml` updated (if architectural decisions changed) - [ ] Documentation updated for user-facing changes - [ ] `TOPOLOGY.md` updated (if architecture changed) - [ ] `CHANGELOG` or release notes updated - [ ] New dependencies reviewed for license compatibility (MPL-2.0 / MPL-2.0) - [ ] ABI/FFI changes validated (`src/abi/` and `ffi/zig/` consistent) ## Testing <!-- Describe how you tested these changes. --> ## Screenshots <!-- If applicable, add screenshots or terminal output demonstrating the change. --> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ddc83e8 commit 95d867b

9 files changed

Lines changed: 494 additions & 10 deletions

File tree

Project.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f"
1111
HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
1212
JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
1313
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
14+
Logging = "56ddb016-857b-54e1-b83d-db4d58db5568"
1415
Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7"
1516
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
1617
Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"
@@ -25,6 +26,7 @@ Distributions = "0.25.123"
2526
HTTP = "1.10.19"
2627
JSON3 = "1.14.3"
2728
LinearAlgebra = "1"
29+
Logging = "1"
2830
Printf = "1"
2931
Random = "1"
3032
Statistics = "1"

QUICKSTART-MAINTAINER.adoc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,15 @@ variables (no config file):
8686
* `STATISTIKLES_LM_URL` — OpenAI-compatible endpoint (default `http://localhost:1234/v1`)
8787
* `STATISTIKLES_API_KEY` — API key if the endpoint requires one
8888
* `STATISTIKLES_MODEL` — model identifier
89+
* `STATISTIKLES_LOG_LEVEL` — structured-logging verbosity: `Debug`/`Info`/`Warn`/`Error`
90+
(default `Info`). Logs go to stderr only — REPL prompts and answers stay on stdout
91+
and are never interleaved with log lines. Unlike `STATISTIKLES_LM_URL` above, this
92+
one is *not* baked into a precompile cache — it is re-read every time the package
93+
loads (`__init__`), so it works from a precompiled image or container too.
94+
* `STATISTIKLES_AUDIT_PERSIST` — set to `true`/`1`/`yes`/`on` to actually persist
95+
tool-call audit records to VeriSimDB (`STATISTIKLES_VERISIMDB_PORT`, default 8096).
96+
Off by default: audit records are always constructed, never persisted, unless
97+
explicitly opted in — no runtime or test dependency on a live VeriSimDB backend.
8998

9099
== Health Checks
91100

src/Statistikles.jl

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ using Dates
3030
using Printf
3131
using Random
3232
using UUIDs
33+
using Logging
3334

3435
# --- SYMBOLIC KERNEL: Verified Statistical Methods ---
3536
include("stats/validation.jl") # Shared ArgumentError input-validation helpers
@@ -99,12 +100,21 @@ include("integrations/zeroprob_integration.jl") # ZeroProb.jl zero-inflated mode
99100
include("integrations/quantum_integration.jl") # QuantumCircuit.jl Bell tests
100101

101102
# --- INTERFACE LAYER: LLM Integration ---
103+
include("tools/observability.jl") # Structured logging, correlation ids, audit-trail wiring
102104
include("tools/definitions.jl") # MCP / Function calling schemas
103105
include("tools/executor.jl") # Safe execution sandbox
104106
include("tools/lmstudio.jl") # Local LLM connectivity
105107
include("tools/guardrail.jl") # Neural-boundary numeric provenance enforcement
106108
include("tools/chat.jl") # Interactive session management
107109

110+
# Install the stderr-backed structured logger on every package load (not just
111+
# at precompile time), so STATISTIKLES_LOG_LEVEL is honoured even from a
112+
# precompiled image — unlike `const`s such as lmstudio.jl's BASE_URL, code in
113+
# __init__ re-runs each time `using Statistikles` happens.
114+
function __init__()
115+
configure_logging!()
116+
end
117+
108118
export main, run_examples, statistical_assistant_chat,
109119
# Descriptive
110120
descriptive_stats, power_mean, weighted_stats, frequency_table,

src/tools/chat.jl

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,19 +98,23 @@ function statistical_assistant_chat()
9898
lowercase(input) == "offline" && (run_examples(); continue)
9999

100100
# One malformed turn must never kill the session — isolate the turn body.
101+
# Every tool call this turn (including any guardrail retry) is logged
102+
# and audited under one per-chat-turn correlation id.
103+
correlation_id = new_correlation_id()
101104
try
102105
push!(messages, Dict{String,Any}("role" => "user", "content" => input))
103106

104107
print("\n Statistikles: ")
105108
response = call_lm_studio(messages, tools)
106109

107110
if haskey(response, "error")
111+
@warn "lm_studio_call_failed" correlation_id=correlation_id error=response["error"]
108112
println("Error: $(response["error"])")
109113
pop!(messages)
110114
continue
111115
end
112116

113-
pr = process_tool_calls(response, messages; tools=tools)
117+
pr = process_tool_calls(response, messages; tools=tools, correlation_id=correlation_id)
114118
final = pr.final
115119

116120
content = nothing
@@ -124,11 +128,12 @@ function statistical_assistant_chat()
124128
user_numbers = extract_numeric_values(input)
125129
content = enforce_numeric_boundary!(
126130
content, messages, tools, pr.tool_results,
127-
user_numbers, pr.tool_calls_made)
131+
user_numbers, pr.tool_calls_made; correlation_id=correlation_id)
128132
println(content)
129133
push!(messages, Dict{String,Any}("role" => "assistant", "content" => content))
130134
end
131135
catch e
136+
@error "chat_turn_error" correlation_id=correlation_id exception=(e, catch_backtrace())
132137
println("\n [turn error] $(sprint(showerror, e)) — continuing.")
133138
continue
134139
end
@@ -137,17 +142,23 @@ end
137142

138143
"""
139144
enforce_numeric_boundary!(content, messages, tools, tool_results,
140-
user_numbers, tool_calls_made) -> String
145+
user_numbers, tool_calls_made;
146+
correlation_id=new_correlation_id()) -> String
141147
142148
Run the numeric-provenance guardrail on an assistant reply. If unverified
143149
numbers remain, do ONE retry asking the model to restate using only numbers
144150
from the tool results (or, when no tool ran this turn, to compute them or
145151
refrain). If orphans still persist, return the reply with a clear warning block
146152
appended. The model's text is NEVER silently rewritten — orphans are flagged,
147153
never fabricated.
154+
155+
`correlation_id` is forwarded to the retry's `process_tool_calls` call so any
156+
tool calls it makes are logged/audited under the same per-chat-turn id as the
157+
original attempt.
148158
"""
149159
function enforce_numeric_boundary!(content::AbstractString, messages, tools,
150-
tool_results, user_numbers, tool_calls_made::Bool)
160+
tool_results, user_numbers, tool_calls_made::Bool;
161+
correlation_id::AbstractString=new_correlation_id())
151162
ok, orphans = validate_numeric_provenance(content, tool_results, user_numbers)
152163
ok && return String(content)
153164

@@ -168,7 +179,7 @@ function enforce_numeric_boundary!(content::AbstractString, messages, tools,
168179
new_content = String(content)
169180
retry_resp = call_lm_studio(messages, tools)
170181
if !haskey(retry_resp, "error")
171-
pr = process_tool_calls(retry_resp, messages; tools=tools)
182+
pr = process_tool_calls(retry_resp, messages; tools=tools, correlation_id=correlation_id)
172183
append!(tool_results, pr.tool_results)
173184
if haskey(pr.final, "choices") && !isempty(pr.final["choices"])
174185
c = get(pr.final["choices"][1]["message"], "content", nothing)

src/tools/executor.jl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,9 @@ function execute_tool(tool_name::String, arguments::Dict)
497497
if get(ENV, "STATISTIKLES_DEBUG", "") != ""
498498
err["trace"] = sprint(showerror, e, catch_backtrace())
499499
end
500+
# Structured breadcrumb (Debug level — off by default, opt in with
501+
# STATISTIKLES_LOG_LEVEL=Debug). Never the raw arguments.
502+
@debug "tool_execution_exception" tool=tool_name arg_hash=hash_arguments(arguments) error=string(e)
500503
return err
501504
end
502505
end

src/tools/lmstudio.jl

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,9 @@ function call_lm_studio(messages, tools=nothing; stream::Bool=false)
6565
end
6666

6767
"""
68-
process_tool_calls(response, messages; tools=nothing, max_rounds=5)
69-
-> (final, tool_results, tool_calls_made)
68+
process_tool_calls(response, messages; tools=nothing, max_rounds=5,
69+
correlation_id=new_correlation_id())
70+
-> (final, tool_results, tool_calls_made, correlation_id)
7071
7172
Drive the symbolic tool-call loop. For each round the assistant requests tool
7273
calls, execute the (verified Julia) functions, feed the results back, and ask
@@ -79,13 +80,22 @@ Returns a NamedTuple:
7980
* `tool_results` — every symbolic result produced this turn, in order, as
8081
native Julia Dicts (fuel for the numeric guardrail).
8182
* `tool_calls_made` — whether any tool call was executed at all.
83+
* `correlation_id` — the per-chat-turn id every tool call in this run was
84+
logged and audited under (caller-supplied, or a fresh
85+
one minted when omitted).
8286
8387
Each individual tool call is wrapped in try/catch: a malformed `tool_call`
8488
(missing keys, non-JSON `arguments`) yields a clean `Dict("error"=>...)` tool
8589
message so the model can recover instead of the session throwing. The `tools`
8690
parameter is forwarded on the follow-up call so the model can chain tools.
91+
92+
Every tool call is timed and emits one structured log record
93+
(`log_tool_call`) plus an audit-trail record (`record_audit!`, pluggable and
94+
no-op-safe unless `STATISTIKLES_AUDIT_PERSIST=true`) — see
95+
`tools/observability.jl`. Neither logs nor persists raw arguments/results.
8796
"""
88-
function process_tool_calls(response, messages; tools=nothing, max_rounds::Int=5)
97+
function process_tool_calls(response, messages; tools=nothing, max_rounds::Int=5,
98+
correlation_id::AbstractString=new_correlation_id())
8999
tool_results = Any[]
90100
tool_calls_made = false
91101
current = response
@@ -111,16 +121,23 @@ function process_tool_calls(response, messages; tools=nothing, max_rounds::Int=5
111121
# crash the turn — it becomes an error result the model can react to.
112122
for tool_call in message["tool_calls"]
113123
result = nothing
124+
fn_name = "unknown"
125+
args = Dict{String,Any}()
126+
tc_id = tool_call_identifier(tool_call)
127+
t0 = time()
114128
try
115129
fn_name = tool_call["function"]["name"]
116130
args = JSON3.read(tool_call["function"]["arguments"], Dict{String,Any})
117-
println("\n [symbolic] executing: $fn_name")
118131
result = execute_tool(fn_name, args)
119132
catch e
120133
result = Dict{String,Any}("error" => "Malformed tool call: $(string(e))")
121134
end
135+
duration_s = time() - t0
122136
push!(tool_results, result)
123137

138+
log_tool_call(correlation_id, tc_id, fn_name, args, result, duration_s)
139+
record_audit!(correlation_id, tc_id, fn_name, args, result; duration_s=duration_s)
140+
124141
tool_msg = Dict{String,Any}(
125142
"role" => "tool",
126143
"content" => JSON3.write(result)
@@ -136,5 +153,6 @@ function process_tool_calls(response, messages; tools=nothing, max_rounds::Int=5
136153
haskey(current, "error") && break
137154
end
138155

139-
return (final = current, tool_results = tool_results, tool_calls_made = tool_calls_made)
156+
return (final = current, tool_results = tool_results, tool_calls_made = tool_calls_made,
157+
correlation_id = correlation_id)
140158
end

0 commit comments

Comments
 (0)