Skip to content

Commit 0830053

Browse files
hyperpolymathclaude
andcommitted
chore(s4): S4 loop-closure fixtures, runbook, and ProverConfig field fixes
- Add tests/s4_loop_closure.rs — end-to-end verisim loop test (skips cleanly when verisim-api is unreachable); runnable via just test-s4-loop - Add docs/handover/S4-LOOP-CLOSURE-RUNBOOK.md — ops runbook with CI workflow YAML ready to commit once ghcr-publish.yml lands in verisimdb - Justfile: add test-s4-loop recipe - docs/ROADMAP.md: record S4 loop test and runbook existence - tests/common/mod.rs, integration_v1_2.rs, live_prover_suite.rs, live_prover_verify.rs: add project_root/sandbox to ProverConfig literals Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e4bf4c7 commit 0830053

8 files changed

Lines changed: 344 additions & 3 deletions

File tree

Justfile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,17 @@ test-integration:
233233
test-neural:
234234
./scripts/test-integration.sh
235235

236+
# Run the S4 loop-closure integration test against a VeriSimDB instance.
237+
# Skips with a notice when VeriSimDB is unreachable (no failure).
238+
# Set VERISIM_URL to override the default of http://localhost:8080.
239+
#
240+
# Local dev: start verisim-api beforehand (see docs/runbooks/s4-loop-closure.md);
241+
# the test will exercise the full record_proof_attempt → query_by_goal_hash loop.
242+
test-s4-loop:
243+
@echo "Running S4 loop-closure test against ${VERISIM_URL:-http://localhost:8080}"
244+
@echo "Test will skip cleanly if the endpoint is unreachable."
245+
cargo test --features verisim --test s4_loop_closure -- --nocapture
246+
236247
# Format code
237248
fmt:
238249
cargo fmt

docs/ROADMAP.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,13 @@ What **is** blocking and needs Opus + cross‑repo input:
173173
`mv_prover_success_by_class` MV) and both echidna read paths
174174
(`query_prover_success_by_class` via `VeriSimAdvisor`,
175175
`cross_prover_search_names` via `vcl_ut`) and the write path
176-
(`spawn_record_attempt` from dispatch exits) are now wired. Remaining
177-
S4 work is ops/runtime: standing up VeriSimDB in CI and observing the
178-
loop close in production.
176+
(`spawn_record_attempt` from dispatch exits) are now wired.
177+
- End-to-end loop test: `tests/s4_loop_closure.rs`, runnable via
178+
`just test-s4-loop`. Skips cleanly when verisim-api is unreachable.
179+
- Operations runbook: [`docs/handover/S4-LOOP-CLOSURE-RUNBOOK.md`](handover/S4-LOOP-CLOSURE-RUNBOOK.md).
180+
- CI workflow filing is blocked on a published verisim-api image
181+
(no `ghcr-publish.yml` in `verification-ecosystem/verisimdb` yet).
182+
The runbook holds the workflow YAML ready to commit on that day.
179183

180184
## 5. Agent‑tier guidance
181185

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
<!-- SPDX-License-Identifier: PMPL-1.0-or-later -->
2+
<!-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> -->
3+
4+
# S4 Loop-Closure Runbook
5+
6+
This runbook describes how to verify, locally and in CI, that ECHIDNA's
7+
cross-prover learning loop (Roadmap stage 3a/3b — "S4") is closing.
8+
9+
## What is the loop?
10+
11+
```
12+
ProverDispatcher::verify_proof ──fire-and-forget──►
13+
VeriSimDBClient::record_proof_attempt
14+
└─► POST /api/v1/proof_attempts
15+
└─► ClickHouse proof_attempts row
16+
└─► mv_prover_success_by_class (materialised view)
17+
└─► GET /api/v1/mv_prover_success_by_class
18+
└─► VeriSimAdvisor::suggest_prover
19+
└─► next dispatch routes smarter
20+
```
21+
22+
Three echidna-side wirings carry the loop:
23+
24+
| Wiring | Module | Wired in commit |
25+
|------------------------|---------------------------------|-----------------|
26+
| Write at dispatch exit | `dispatch::spawn_record_attempt`| `60d2e75` |
27+
| Class threading | `dispatch::verify_proof_with_class` | `cf94b49` |
28+
| Cross-prover read | `vcl_ut::cross_prover_search_names` | `cf94b49` |
29+
30+
## What actually has to be standing up
31+
32+
Just **verisim-api** on a known port. The test never touches Elixir
33+
orchestration, Svalinn, federation, drift monitor, or the seven backing
34+
databases — it talks directly to the rust-core HTTP endpoints
35+
`/api/v1/proof_attempts` and `/api/v1/mv_prover_success_by_class`.
36+
37+
## Local verification
38+
39+
### 1. Start verisim-api
40+
41+
The canonical way is `selur-compose up rust-core` from the verisimdb
42+
checkout, but for a smoke run any of these work:
43+
44+
```bash
45+
# Option A — selur-compose (preferred)
46+
cd ~/Documents/hyperpolymath-repos/verisimdb
47+
selur-compose up rust-core --detach
48+
49+
# Option B — podman-compose fallback
50+
cd ~/Documents/hyperpolymath-repos/verisimdb
51+
podman-compose -f container/compose.toml up rust-core --detach
52+
53+
# Option C — bare cargo run (development only)
54+
cd ~/Documents/hyperpolymath-repos/verisimdb/rust-core
55+
cargo run --release --bin verisim-api
56+
```
57+
58+
Wait for `/health` to respond:
59+
60+
```bash
61+
until curl -sf http://localhost:8080/health >/dev/null; do sleep 1; done
62+
echo "verisim-api ready"
63+
```
64+
65+
### 2. Run the loop-closure test
66+
67+
```bash
68+
cd ~/Documents/hyperpolymath-repos/echidna
69+
just test-s4-loop
70+
```
71+
72+
Expected output (success):
73+
74+
```
75+
running 2 tests
76+
test s4_record_then_read_back_by_goal_hash ... ok
77+
test s4_class_aggregation_visible_in_mv ... ok
78+
79+
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured
80+
```
81+
82+
Expected output (verisim-api not running — graceful skip):
83+
84+
```
85+
skip: VeriSimDB at http://localhost:8080 is unreachable —
86+
set VERISIM_URL or start verisim-api
87+
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured
88+
```
89+
90+
The skip path is intentional: the test is a no-op rather than a failure
91+
when the dependency is absent, so the same `cargo test` command works on
92+
machines without verisim-api in the loop.
93+
94+
### 3. Override the URL
95+
96+
```bash
97+
VERISIM_URL=http://verisim.staging.example:9090 just test-s4-loop
98+
```
99+
100+
## CI wiring
101+
102+
A workflow that brings up verisim-api and runs `test-s4-loop` is
103+
**not yet committed**. The blocker is that no published
104+
`ghcr.io/hyperpolymath/verisimdb-api` image exists yet — every CI run
105+
would have to build verisim-api from a sibling checkout, which is too
106+
heavy for `on: pull_request`.
107+
108+
When that image is published (verisim's `ghcr-publish.yml` is the
109+
expected channel — currently absent from
110+
`verification-ecosystem/verisimdb/.github/workflows/`), the workflow
111+
becomes:
112+
113+
```yaml
114+
name: S4 Loop Closure
115+
on:
116+
workflow_dispatch:
117+
schedule: [{ cron: '0 4 * * 1' }] # Mondays 04:00 UTC
118+
119+
jobs:
120+
s4-loop:
121+
runs-on: ubuntu-latest
122+
services:
123+
verisim:
124+
image: ghcr.io/hyperpolymath/verisimdb-api:latest
125+
ports: ['8080:8080']
126+
options: >-
127+
--health-cmd "curl -sf http://localhost:8080/health"
128+
--health-interval 10s
129+
--health-timeout 5s
130+
--health-retries 12
131+
env:
132+
VERISIM_URL: http://localhost:8080
133+
steps:
134+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
135+
- uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7
136+
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
137+
- run: just test-s4-loop
138+
```
139+
140+
This workflow is left as an unfiled artefact in this runbook precisely
141+
to avoid landing it as a perpetually-skipping no-op. File it the same
142+
day verisim publishes its image.
143+
144+
## What "loop closing in production" looks like
145+
146+
In a production deployment with both echidna and verisim-api running:
147+
148+
1. Run any successful `verify_proof_verisim_guided(..., obligation_class="…")`.
149+
2. Within the verisim writer's HTTP timeout (default 10s) the row appears in
150+
the `proof_attempts` table.
151+
3. ClickHouse refreshes `mv_prover_success_by_class` on the next interval.
152+
4. Subsequent calls to `verify_proof_verisim_guided` with the same class
153+
start receiving a non-default routing decision (look for
154+
`VeriSimAdvisor: obligation_class=… → SomeProver` at INFO level in
155+
echidna's logs).
156+
157+
If step 4 never observes a non-default routing for a class that has
158+
genuinely accumulated rows, the breakdown is one of:
159+
160+
- **MV not refreshing** → ClickHouse-side problem, check the MV definition.
161+
- **Writer silently dropping** → look for `VeriSim record_proof_attempt failed`
162+
warnings in echidna logs.
163+
- **Class string mismatch** → confirm the dispatch caller passes the same
164+
`obligation_class` the read path queries (the `cf94b49` threading fix
165+
closed the most common version of this).
166+
167+
## Future work tracked elsewhere
168+
169+
- Verisim image publish: `verification-ecosystem/verisimdb` repo, not echidna.
170+
- Workflow filing once image is available: `.github/workflows/s4-loop.yml`.
171+
- Tier-3 weekly cadence so it doesn't gate every PR.

tests/common/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ pub fn test_prover_config(kind: ProverKind) -> ProverConfig {
122122
timeout: 10, // 10 seconds for tests
123123
neural_enabled: false, // Disable neural for basic tests
124124
gnn_api_url: None,
125+
project_root: None,
126+
sandbox: echidna::provers::SandboxMode::None,
125127
}
126128
}
127129

tests/integration_v1_2.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ fn test_config() -> ProverConfig {
2121
timeout: 30,
2222
neural_enabled: false,
2323
gnn_api_url: None,
24+
project_root: None,
25+
sandbox: echidna::provers::SandboxMode::None,
2426
}
2527
}
2628

tests/live_prover_suite.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ fn live_config(executable: &str) -> ProverConfig {
5050
timeout: 30,
5151
neural_enabled: false,
5252
gnn_api_url: None,
53+
project_root: None,
54+
sandbox: echidna::provers::SandboxMode::None,
5355
}
5456
}
5557

tests/live_prover_verify.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ fn live_config(executable: &Path) -> ProverConfig {
4242
timeout: 30,
4343
neural_enabled: false,
4444
gnn_api_url: None,
45+
project_root: None,
46+
sandbox: echidna::provers::SandboxMode::None,
4547
}
4648
}
4749

tests/s4_loop_closure.rs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
4+
//! S4 loop-closure end-to-end test.
5+
//!
6+
//! Verifies that the Verisim cross-prover learning loop is wired correctly:
7+
//!
8+
//! record_proof_attempt → proof_attempts row → query_by_goal_hash
9+
//! → mv_prover_success_by_class
10+
//!
11+
//! This test does NOT exercise prover dispatch timing — it talks directly
12+
//! to `VeriSimDBClient` so that a green run unambiguously means the loop
13+
//! is closing, not that the dispatcher happens to be fast.
14+
//!
15+
//! ## Skip behaviour
16+
//!
17+
//! All assertions are gated on `VeriSimDB` being reachable. If the
18+
//! `health_check()` returns `false` (no instance, wrong port, network
19+
//! down) the test prints a skip notice and returns. This keeps it
20+
//! useful both in CI (where a Podman-managed VeriSim is brought up
21+
//! by the workflow) and on a developer's laptop (where it silently
22+
//! becomes a no-op).
23+
//!
24+
//! ## Environment
25+
//!
26+
//! `VERISIM_URL` (default: `http://localhost:8080`) — base URL of the
27+
//! verisim-api HTTP server. Same convention as the production code in
28+
//! `src/rust/learning/daemon.rs:52`.
29+
30+
#![cfg(feature = "verisim")]
31+
32+
use anyhow::Result;
33+
use echidna::verisim_bridge::{prover_kind_to_str, ProofAttempt, VeriSimDBClient};
34+
use echidna::provers::ProverKind;
35+
36+
/// Build a minimal `ProofAttempt` row with a unique obligation_id so each
37+
/// test run is independent (no collisions with prior runs in the table).
38+
fn fresh_attempt(obligation_class: &str, prover: ProverKind, outcome: &str) -> ProofAttempt {
39+
let now = chrono::Utc::now();
40+
ProofAttempt {
41+
attempt_id: uuid::Uuid::new_v4().to_string(),
42+
// Tag the obligation_id with the test name so we can find our own
43+
// rows even if other tests are writing concurrently.
44+
obligation_id: format!("test-s4-loop-{}", uuid::Uuid::new_v4()),
45+
repo: "hyperpolymath/echidna".to_string(),
46+
file: "tests/s4_loop_closure.rs".to_string(),
47+
claim: "true".to_string(),
48+
obligation_class: obligation_class.to_string(),
49+
prover_used: prover_kind_to_str(prover).to_string(),
50+
outcome: outcome.to_string(),
51+
duration_ms: 1,
52+
confidence: if outcome == "success" { 1.0 } else { 0.0 },
53+
parent_attempt_id: None,
54+
strategy_tag: "test".to_string(),
55+
started_at: now.to_rfc3339(),
56+
completed_at: now.to_rfc3339(),
57+
prover_output: String::new(),
58+
error_message: None,
59+
}
60+
}
61+
62+
fn verisim_url() -> String {
63+
std::env::var("VERISIM_URL").unwrap_or_else(|_| "http://localhost:8080".to_string())
64+
}
65+
66+
/// Returns `Some(client)` if the VeriSim instance is reachable, otherwise
67+
/// prints a skip notice and returns `None`. Use the early-return idiom:
68+
///
69+
/// ```ignore
70+
/// let Some(client) = require_verisim().await else { return Ok(()); };
71+
/// ```
72+
async fn require_verisim() -> Option<VeriSimDBClient> {
73+
let url = verisim_url();
74+
let client = VeriSimDBClient::new(&url);
75+
if client.health_check().await {
76+
Some(client)
77+
} else {
78+
eprintln!(
79+
"skip: VeriSimDB at {} is unreachable — set VERISIM_URL or start verisim-api",
80+
url
81+
);
82+
None
83+
}
84+
}
85+
86+
#[tokio::test]
87+
async fn s4_record_then_read_back_by_goal_hash() -> Result<()> {
88+
let Some(client) = require_verisim().await else {
89+
return Ok(());
90+
};
91+
92+
// Write a single attempt.
93+
let attempt = fresh_attempt("test-s4-readback", ProverKind::Z3, "success");
94+
let attempt_id = client.record_proof_attempt(&attempt).await?;
95+
assert_eq!(
96+
attempt_id, attempt.attempt_id,
97+
"record_proof_attempt should return the same UUID we sent"
98+
);
99+
100+
// Read it back. The proof_attempts table is a row store so the read
101+
// is immediately consistent (no MV refresh delay involved here).
102+
let rows = client.query_by_goal_hash(&attempt.obligation_id).await?;
103+
assert!(
104+
!rows.is_empty(),
105+
"row written with obligation_id={} should be visible to query_by_goal_hash",
106+
attempt.obligation_id
107+
);
108+
let found = rows.iter().any(|r| r.attempt_id == attempt.attempt_id);
109+
assert!(
110+
found,
111+
"the specific attempt we wrote should appear in the goal_hash query results"
112+
);
113+
114+
Ok(())
115+
}
116+
117+
#[tokio::test]
118+
async fn s4_class_aggregation_visible_in_mv() -> Result<()> {
119+
let Some(client) = require_verisim().await else {
120+
return Ok(());
121+
};
122+
123+
// Use a class that is unlikely to collide with anything else in the
124+
// table. The test passes whether or not it sees its own writes — the
125+
// contract is that the MV endpoint responds and the response shape
126+
// is parseable.
127+
let unique_class = format!("test-mv-{}", uuid::Uuid::new_v4());
128+
129+
// Seed a few attempts so the MV has something to aggregate.
130+
for outcome in ["success", "success", "failure"] {
131+
let attempt = fresh_attempt(&unique_class, ProverKind::Z3, outcome);
132+
client.record_proof_attempt(&attempt).await?;
133+
}
134+
135+
// The MV is materialised; depending on backend config it may or may
136+
// not be refreshed by the time we read. We tolerate an empty map —
137+
// what we are NOT tolerating is a failed query, malformed JSON, or
138+
// a 5xx. Those would mean the contract is broken.
139+
let map = client.query_prover_success_by_class(&unique_class).await?;
140+
assert!(
141+
map.values().all(|rate| (0.0..=1.0).contains(rate)),
142+
"every success_rate in the MV must lie in [0.0, 1.0]; got {:?}",
143+
map
144+
);
145+
146+
Ok(())
147+
}

0 commit comments

Comments
 (0)