Skip to content

Commit 982f007

Browse files
committed
S5 verification runbook + GNN health endpoint (#102)
2 parents 1d9a76a + c93e500 commit 982f007

8 files changed

Lines changed: 482 additions & 4 deletions

File tree

Justfile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,13 @@ train:
349349
train-cpu:
350350
ECHIDNA_MAX_PROOF_STATES=2000 ECHIDNA_NUM_EPOCHS=2 julia --project=src/julia src/julia/run_training_cpu.jl
351351

352+
# Evaluate trained model on held-out validation split (MRR / top-k).
353+
# Requires `just train-cpu` (or `just train`) to have produced models/neural/.
354+
# Appends one JSONL row to training_data/eval_results.jsonl and exits non-zero
355+
# if MRR < 0.66 (below the cosine baseline).
356+
eval:
357+
julia --project=src/julia src/julia/eval_held_out.jl
358+
352359
# End-to-end pipeline: provision → extract → merge → align → retrain.
353360
# Use `ECHIDNA_MAX_PROOF_STATES=0 just corpus-refresh` to lift the sample cap.
354361
corpus-refresh: provision-corpora extract-corpora merge-corpora align-premises retrain
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# S5 Verification Runbook: Trained-Weights Verification Flow
2+
3+
## Purpose
4+
5+
This runbook proves end-to-end that once `just train-cpu` produces real weights
6+
in `models/neural/`, (a) the Julia GNN server picks them up and stops using cosine
7+
similarity, (b) the Rust backends that call `gnn_augment_tactics` receive
8+
model-derived scores via the `/gnn/rank` wire format, and (c) MRR on a held-out
9+
validation split can be measured and compared against the 0.66 cosine baseline.
10+
Steps 1 and 2 can run before training has finished; only Step 3 requires weights.
11+
12+
---
13+
14+
## Prerequisite
15+
16+
`just train-cpu` (or `just train` on GPU) has completed and
17+
`models/neural/gnn_ranker/` (or `best_model/` or `final_model/`) exists.
18+
19+
---
20+
21+
## Step 1 — Confirm wire format (no training needed)
22+
23+
```bash
24+
cargo test --test gnn_augment_integration
25+
```
26+
27+
Spawns an in-process mock HTTP server and asserts:
28+
29+
- `GnnClient::health_status()` returns the richer payload (model_path, vocab_size,
30+
training_records_received).
31+
- For each of rocq, lean, agda, isabelle, z3: `suggest_tactics` returns
32+
`Tactic::Custom { command: "apply", args: ["lemma_foo"] }` as the first tactic,
33+
proving the `/gnn/rank` wire format is consumed correctly by every backend.
34+
35+
Expected output: `test result: ok. 6 passed; 0 failed`.
36+
37+
---
38+
39+
## Step 2 — Confirm model load
40+
41+
Start the Julia GNN server:
42+
43+
```bash
44+
julia --project=src/julia src/julia/api_server.jl
45+
```
46+
47+
(or however you normally start it — check `src/julia/run_server.jl` for args).
48+
Then query health:
49+
50+
```bash
51+
curl -s http://localhost:8090/gnn/health | jq .
52+
```
53+
54+
Expected response when weights are loaded:
55+
56+
```json
57+
{
58+
"status": "ok",
59+
"gnn_model_loaded": true,
60+
"model_path": "/absolute/path/to/models/neural/gnn_ranker",
61+
"vocab_size": <non-zero>,
62+
"training_records_received": 0,
63+
"num_gnn_layers": 4,
64+
"service": "echidna-gnn",
65+
"version": "2.1.0"
66+
}
67+
```
68+
69+
`gnn_model_loaded: true` and `vocab_size > 0` confirm the server picked up real weights
70+
and is no longer falling back to cosine similarity.
71+
72+
---
73+
74+
## Step 3 — Measure lift
75+
76+
```bash
77+
just eval
78+
```
79+
80+
Loads the trained model, runs `compute_metrics` on the held-out validation split
81+
(20% of `training_data/`), and appends one JSONL row to
82+
`training_data/eval_results.jsonl`. Read the result:
83+
84+
```bash
85+
tail -1 training_data/eval_results.jsonl | jq '{mrr, top1, top5, top10, val_size}'
86+
```
87+
88+
---
89+
90+
## Acceptance
91+
92+
| Result | Action |
93+
|--------|--------|
94+
| MRR ≥ 0.66 | Baseline met. Log the row. Run `just train` on GPU for full training. |
95+
| MRR < 0.66 | **STOP.** File a bug. Do not paper over it — this signals architecture mismatch, label leakage, or vocabulary miss. |
96+
97+
---
98+
99+
## Troubleshooting
100+
101+
| Symptom | Check |
102+
|---------|-------|
103+
| `gnn_model_loaded: false` after `just train-cpu` | Confirm `models/neural/gnn_ranker/` exists and contains `model.bson`, `vocabulary.bson`, `config.bson`. Restart the Julia server. |
104+
| Integration test fails on `connection refused` | Re-run: `cargo test --test gnn_augment_integration`. The mock binds `127.0.0.1:0`; transient OS port exhaustion is extremely rare but retry once. |
105+
| `just eval` errors with `No trained model found` | Run `just train-cpu` first. |
106+
| `just eval` errors on a missing solver method | File a bug in the `EchidnaML` Julia module. Do not patch the eval script to bypass. |
107+
| MRR unchanged from 0.66 after training | Check `models/model_metadata.txt` — if it still reads `0 words, 0 classes` the training run did not write weights. Check training logs. |

src/julia/api/gnn_endpoint.jl

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ using LinearAlgebra
2929

3030
# Reference to global GNN model (loaded at server startup)
3131
const GNN_MODEL = Ref{Any}(nothing)
32+
const GNN_MODEL_PATH = Ref{Union{Nothing,String}}(nothing)
3233
const GNN_VOCAB = Ref{Any}(nothing)
3334

3435
# Per-(prover, domain) success-rate weights pushed from Rust via
@@ -64,6 +65,7 @@ function load_gnn_model(models_dir::String)
6465
try
6566
solver = load_solver(model_path)
6667
GNN_MODEL[] = solver
68+
GNN_MODEL_PATH[] = model_path
6769
@info "GNN model loaded successfully from $model_path"
6870
return true
6971
catch e
@@ -74,6 +76,7 @@ function load_gnn_model(models_dir::String)
7476
# All candidates exhausted — cosine fallback is genuine missing-model path.
7577
@warn "No trained GNN model found in $(joinpath(models_dir, "neural")) — ranking will use cosine similarity until weights are trained (run: just train-cpu)"
7678
GNN_MODEL[] = nothing
79+
GNN_MODEL_PATH[] = nothing
7780
return false
7881
end
7982

@@ -360,9 +363,12 @@ function handle_gnn_health(req::HTTP.Request)
360363
response = Dict(
361364
"status" => "ok",
362365
"gnn_model_loaded" => model_loaded,
366+
"model_path" => model_loaded ? GNN_MODEL_PATH[] : nothing,
367+
"vocab_size" => model_loaded ? length(GNN_VOCAB[]) : 0,
368+
"training_records_received" => TOTAL_TRAINING_RECORDS[],
363369
"num_gnn_layers" => model_loaded ? 4 : 0,
364370
"service" => "echidna-gnn",
365-
"version" => "2.1.0"
371+
"version" => "2.1.0",
366372
)
367373

368374
return HTTP.Response(200, JSON3.write(response))

src/julia/eval_held_out.jl

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
#
4+
# eval_held_out.jl — Evaluate a trained GNN model on the held-out validation split.
5+
#
6+
# Measures MRR, top-1, top-5, top-10 and appends one JSONL row to
7+
# training_data/eval_results.jsonl. Compares against the 0.66 cosine baseline.
8+
#
9+
# Usage:
10+
# just eval
11+
# or:
12+
# cd src/julia && julia --project=. eval_held_out.jl
13+
14+
using Pkg
15+
Pkg.activate(joinpath(@__DIR__))
16+
17+
using EchidnaML
18+
using JSON3, Dates, Statistics, BSON
19+
20+
function main()
21+
repo_root = abspath(joinpath(@__DIR__, "..", ".."))
22+
data_dir = joinpath(repo_root, "training_data")
23+
24+
# Try candidate model directories in the same order as load_gnn_model.
25+
candidate_dirs = [
26+
joinpath(repo_root, "models", "neural", "gnn_ranker"),
27+
joinpath(repo_root, "models", "neural", "best_model"),
28+
joinpath(repo_root, "models", "neural", "final_model"),
29+
]
30+
31+
models_dir = nothing
32+
for d in candidate_dirs
33+
if isdir(d)
34+
models_dir = d
35+
break
36+
end
37+
end
38+
39+
if models_dir === nothing
40+
error("No trained model found — run 'just train-cpu' first. " *
41+
"Checked: $(join(candidate_dirs, ", "))")
42+
end
43+
44+
@info "Loading solver from $models_dir"
45+
solver = load_solver(models_dir)
46+
47+
@info "Loading validation data from $data_dir"
48+
t_start = time()
49+
(_train_ds, val_ds, vocab) = load_training_data(data_dir; train_split=0.8f0)
50+
@info "Validation split size: $(length(val_ds.examples)) vocab: $(vocab.vocab_size)"
51+
52+
if isempty(val_ds.examples)
53+
error("Validation dataset is empty — check training_data/ has proof_states and premises files.")
54+
end
55+
56+
# Evaluate using the same scoring path as train.jl::compute_metrics.
57+
# compute_metrics returns (precision, recall, mrr) using cosine over text embeddings;
58+
# when a trained model is loaded, the solver's text_encoder reflects learned weights.
59+
val_metrics = compute_metrics(solver, val_ds; k=10)
60+
mrr = val_metrics.mrr
61+
top1 = val_metrics.precision # precision@1 is top-1 accuracy at k=1
62+
top5 = val_metrics.recall # secondary; full top-k breakdown below
63+
top10 = val_metrics.precision # reuse — compute_metrics gives precision@k
64+
65+
# Finer breakdown: compute_metrics at k=1 and k=5.
66+
val_k1 = compute_metrics(solver, val_ds; k=1)
67+
val_k5 = compute_metrics(solver, val_ds; k=5)
68+
top1 = val_k1.precision
69+
top5 = val_k5.precision
70+
top10 = val_metrics.precision
71+
72+
duration = time() - t_start
73+
74+
git_sha = try
75+
strip(read(`git -C $repo_root rev-parse HEAD`, String))
76+
catch
77+
"unknown"
78+
end
79+
80+
metrics = Dict(
81+
"timestamp" => string(now()),
82+
"git_sha" => git_sha,
83+
"model_path" => models_dir,
84+
"val_size" => length(val_ds.examples),
85+
"vocab_size" => vocab.vocab_size,
86+
"mrr" => mrr,
87+
"top1" => top1,
88+
"top5" => top5,
89+
"top10" => top10,
90+
"cosine_baseline_mrr" => 0.66,
91+
"eval_duration_seconds" => duration,
92+
)
93+
94+
out_path = joinpath(data_dir, "eval_results.jsonl")
95+
open(out_path, "a") do io
96+
println(io, JSON3.write(metrics))
97+
end
98+
99+
@info "Eval complete: MRR=$mrr top1=$top1 top5=$top5 top10=$top10"
100+
@info "Results appended to $out_path"
101+
102+
if mrr >= 0.66f0
103+
@info "PASS: MRR=$mrr ≥ 0.66 cosine baseline — weights are improving retrieval."
104+
else
105+
@warn "FAIL: MRR=$mrr < 0.66 cosine baseline — stop, file a bug before proceeding."
106+
exit(1)
107+
end
108+
end
109+
110+
main()

src/rust/gnn/client.rs

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ struct GnnRankResponse {
138138
error: Option<String>,
139139
}
140140

141-
/// GNN health check response.
141+
/// GNN health check response (internal, minimal — kept for legacy deserialization path).
142142
#[derive(Debug, Deserialize)]
143143
struct GnnHealthResponse {
144144
/// Server status
@@ -150,6 +150,25 @@ struct GnnHealthResponse {
150150
num_gnn_layers: usize,
151151
}
152152

153+
/// Richer health payload returned by `/gnn/health` after the S5 extension.
154+
///
155+
/// All fields present when `gnn_model_loaded` is true; `model_path`,
156+
/// `vocab_size`, and `training_records_received` are the new additions.
157+
#[derive(Debug, Clone, Deserialize)]
158+
pub struct GnnHealth {
159+
pub status: String,
160+
pub gnn_model_loaded: bool,
161+
pub model_path: Option<String>,
162+
#[serde(default)]
163+
pub vocab_size: usize,
164+
#[serde(default)]
165+
pub training_records_received: u64,
166+
#[serde(default)]
167+
pub num_gnn_layers: usize,
168+
pub service: String,
169+
pub version: String,
170+
}
171+
153172
/// Result of a GNN inference call.
154173
#[derive(Debug, Clone, Serialize, Deserialize)]
155174
pub struct GnnInferenceResult {
@@ -199,6 +218,28 @@ impl GnnClient {
199218
}
200219
}
201220

221+
/// Fetch the richer `/gnn/health` payload introduced in S5.
222+
///
223+
/// Returns the full `GnnHealth` struct (model path, vocab size,
224+
/// training record count, etc.). Does NOT update `server_available`
225+
/// — callers that need the availability gate should use `check_health`.
226+
pub async fn health_status(&self) -> Result<GnnHealth> {
227+
let url = format!("{}/gnn/health", self.config.api_url);
228+
let response = self
229+
.client
230+
.get(&url)
231+
.send()
232+
.await
233+
.context("Failed to reach GNN health endpoint")?;
234+
if !response.status().is_success() {
235+
anyhow::bail!("GNN health returned HTTP {}", response.status());
236+
}
237+
response
238+
.json::<GnnHealth>()
239+
.await
240+
.context("Failed to parse GnnHealth response")
241+
}
242+
202243
/// Check if the GNN server is available and has a loaded model.
203244
///
204245
/// Updates `server_available` state. Call this before making

src/rust/gnn/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ pub mod fallback_monitor;
4949
pub mod graph;
5050
pub mod guided_search;
5151

52-
pub use client::{GnnClient, GnnConfig, GnnInferenceResult};
52+
pub use client::{GnnClient, GnnConfig, GnnHealth, GnnInferenceResult};
5353
pub use embeddings::{TermEmbedding, TermFeatureExtractor};
5454
pub use fallback_monitor::{FallbackMetrics, FallbackMonitor, FallbackSlaConfig};
5555
pub use graph::{EdgeKind, NodeKind, ProofGraph, ProofGraphBuilder};

src/rust/provers/mod.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1516,7 +1516,7 @@ pub(crate) async fn gnn_augment_tactics(
15161516
use crate::gnn::graph::ProofGraphBuilder;
15171517

15181518
let graph = ProofGraphBuilder::new(4).build_from_proof_state(state);
1519-
let gnn = GnnClient::with_config(GnnConfig {
1519+
let mut gnn = GnnClient::with_config(GnnConfig {
15201520
api_url: url,
15211521
timeout_ms: 2000,
15221522
top_k: 8,
@@ -1530,6 +1530,11 @@ pub(crate) async fn gnn_augment_tactics(
15301530
// so Julia's /gnn/rank can apply per-domain weights from prior outcomes.
15311531
// When metadata has no "aspects" key (e.g. direct REPL invocation), aspects
15321532
// is empty and behaviour is identical to the no-hint path.
1533+
// Ping the server so rank_premises_with_aspects sees server_available = true.
1534+
// Graceful: if check_health errors (server down), gnn.server_available stays
1535+
// false and rank_premises_with_aspects returns empty — no disruption to callers.
1536+
let _ = gnn.check_health().await;
1537+
15331538
let aspects: Vec<String> = state
15341539
.metadata
15351540
.get("aspects")

0 commit comments

Comments
 (0)