Skip to content

Commit 60d2e75

Browse files
committed
feat(dispatch): wire VeriSim record_proof_attempt at verify_proof exits
Closes the cross-prover learning loop. Until now `record_proof_attempt` was defined but never called, so the `proof_attempts` table that backs `mv_prover_success_by_class` stayed empty in production — meaning the read path (`VeriSimAdvisor::suggest_prover`) was reading nothing. Changes: - ProverDispatcher gains optional `verisim_writer: VeriSimDBClient` field, parallel to the existing `verisim_advisor`. Configured via `with_verisim_writer(base_url)` on the builder. - `verify_proof()` fires `record_proof_attempt` as `tokio::spawn` fire-and-forget at both exit points (parse failure + final result). - New module-scope `build_proof_attempt` helper translates dispatch outputs into the ClickHouse-shaped ProofAttempt row, mapping all 7 ProverOutcome variants to the 4 schema strings (success/timeout/ failure/unknown) and using `proof_encoding::goal_identity` for the cross-prover obligation_id hash. - VeriSimDBClient gains `#[derive(Clone)]` (cheap — reqwest::Client is internally Arc'd) so the writer can be moved into the spawn closure. Trust guarantees preserved: - Writer is feature-gated (`feature = "verisim"`); default builds unaffected. - Spawn is detached; HTTP errors logged at warn level and dropped. - A VeriSimDB outage cannot block, fail, or alter dispatch — verified by `test_verify_proof_with_unreachable_writer_still_returns_ok`. Tests: 5 new (902 total with verisim feature, 870 default), all green.
1 parent aa7ada0 commit 60d2e75

2 files changed

Lines changed: 261 additions & 0 deletions

File tree

src/rust/dispatch.rs

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,14 @@ pub struct ProverDispatcher {
247247
/// is inviolable.
248248
#[cfg(feature = "verisim")]
249249
verisim_advisor: Option<VeriSimAdvisor>,
250+
/// Optional VeriSimDB writer for closing the learning loop.
251+
/// When set, every `verify_proof()` exit fires a fire-and-forget
252+
/// `record_proof_attempt` so historical outcomes accumulate in the
253+
/// `proof_attempts` table that backs `mv_prover_success_by_class`.
254+
/// A writer outage cannot affect the trust pipeline — failures are
255+
/// logged at warn level and dropped.
256+
#[cfg(feature = "verisim")]
257+
verisim_writer: Option<crate::verisim_bridge::VeriSimDBClient>,
250258
}
251259

252260
impl ProverDispatcher {
@@ -258,6 +266,8 @@ impl ProverDispatcher {
258266
llm_advisor: None,
259267
#[cfg(feature = "verisim")]
260268
verisim_advisor: None,
269+
#[cfg(feature = "verisim")]
270+
verisim_writer: None,
261271
}
262272
}
263273

@@ -269,6 +279,8 @@ impl ProverDispatcher {
269279
llm_advisor: None,
270280
#[cfg(feature = "verisim")]
271281
verisim_advisor: None,
282+
#[cfg(feature = "verisim")]
283+
verisim_writer: None,
272284
}
273285
}
274286

@@ -297,6 +309,24 @@ impl ProverDispatcher {
297309
self
298310
}
299311

312+
/// Attach a VeriSimDB writer that records every proof attempt.
313+
///
314+
/// `base_url` is the same VeriSimDB endpoint used by `VeriSimAdvisor`
315+
/// (e.g. `"http://verisimdb:7700"`). Once attached, `verify_proof()`
316+
/// fires `record_proof_attempt` as a `tokio::spawn` fire-and-forget at
317+
/// every exit (parse failure or final result), populating the table that
318+
/// `mv_prover_success_by_class` reads from. This closes the loop
319+
/// `attempt → table → MV → VeriSimAdvisor → next dispatch`.
320+
///
321+
/// **Trust guarantee**: the writer runs detached. A VeriSimDB outage,
322+
/// network partition, or 5xx response can never block, fail, or alter
323+
/// a dispatch result; failures are logged at warn level and dropped.
324+
#[cfg(feature = "verisim")]
325+
pub fn with_verisim_writer(mut self, base_url: &str) -> Self {
326+
self.verisim_writer = Some(crate::verisim_bridge::VeriSimDBClient::new(base_url));
327+
self
328+
}
329+
300330
/// Dispatch with VeriSimDB history-guided optimisation.
301331
///
302332
/// Consults `mv_prover_success_by_class` for the supplied
@@ -411,6 +441,8 @@ impl ProverDispatcher {
411441
} else {
412442
None
413443
};
444+
#[cfg(feature = "verisim")]
445+
self.spawn_record_attempt(prover_kind, None, &outcome, elapsed_ms, None);
414446
return Ok(DispatchResult {
415447
verified: false,
416448
trust_level: TrustLevel::Level1,
@@ -521,6 +553,15 @@ impl ProverDispatcher {
521553
None
522554
};
523555

556+
#[cfg(feature = "verisim")]
557+
self.spawn_record_attempt(
558+
prover_kind,
559+
state.goals.first(),
560+
&outcome,
561+
prover_elapsed_ms,
562+
None,
563+
);
564+
524565
Ok(DispatchResult {
525566
verified: verified && meets_minimum,
526567
trust_level,
@@ -536,6 +577,34 @@ impl ProverDispatcher {
536577
})
537578
}
538579

580+
/// Fire a `record_proof_attempt` to VeriSimDB without blocking dispatch.
581+
///
582+
/// No-op when `verisim_writer` is unset. HTTP errors from VeriSimDB are
583+
/// logged at warn level and swallowed — the trust pipeline must never
584+
/// depend on the writer succeeding. Must be called from inside a tokio
585+
/// runtime (the dispatch entry points are async, so this always holds).
586+
#[cfg(feature = "verisim")]
587+
fn spawn_record_attempt(
588+
&self,
589+
prover_kind: ProverKind,
590+
goal: Option<&crate::core::Goal>,
591+
outcome: &ProverOutcome,
592+
duration_ms: u64,
593+
obligation_class: Option<&str>,
594+
) {
595+
let Some(writer) = self.verisim_writer.as_ref() else {
596+
return;
597+
};
598+
let attempt =
599+
build_proof_attempt(prover_kind, goal, outcome, duration_ms, obligation_class);
600+
let writer = writer.clone();
601+
tokio::spawn(async move {
602+
if let Err(e) = writer.record_proof_attempt(&attempt).await {
603+
warn!("VeriSim record_proof_attempt failed: {}", e);
604+
}
605+
});
606+
}
607+
539608
/// Dispatch a proof with cross-checking (portfolio solving)
540609
pub async fn verify_proof_cross_checked(
541610
&self,
@@ -849,6 +918,64 @@ impl Default for ProverDispatcher {
849918
}
850919
}
851920

921+
/// Build a `ProofAttempt` row for VeriSimDB from a dispatch outcome.
922+
///
923+
/// `obligation_id` uses `proof_encoding::goal_identity` so the same goal
924+
/// produces the same hash across provers, which is what
925+
/// `mv_prover_success_by_class` depends on for cross-prover comparison.
926+
/// When `goal` is `None` (e.g. parse failure with no parsed state) the
927+
/// id falls back to the literal string `"unknown"`.
928+
#[cfg(feature = "verisim")]
929+
fn build_proof_attempt(
930+
prover_kind: ProverKind,
931+
goal: Option<&crate::core::Goal>,
932+
outcome: &ProverOutcome,
933+
duration_ms: u64,
934+
obligation_class: Option<&str>,
935+
) -> crate::verisim_bridge::ProofAttempt {
936+
use crate::verisim_bridge::{prover_kind_to_str, ProofAttempt};
937+
938+
let now = chrono::Utc::now();
939+
let obligation_id = goal
940+
.map(|g| crate::proof_encoding::goal_identity("dispatch", g))
941+
.unwrap_or_else(|| "unknown".to_string());
942+
let claim = goal.map(|g| g.target.to_string()).unwrap_or_default();
943+
let outcome_str = match outcome {
944+
ProverOutcome::Proved { .. } => "success",
945+
ProverOutcome::Timeout { .. } => "timeout",
946+
ProverOutcome::NoProofFound { .. }
947+
| ProverOutcome::InvalidInput { .. }
948+
| ProverOutcome::InconsistentPremises { .. }
949+
| ProverOutcome::ProverError { .. } => "failure",
950+
ProverOutcome::UnsupportedFeature { .. } | ProverOutcome::SystemError { .. } => "unknown",
951+
};
952+
let confidence = if matches!(outcome, ProverOutcome::Proved { .. }) {
953+
1.0
954+
} else {
955+
0.0
956+
};
957+
let started_at = now - chrono::Duration::milliseconds(duration_ms as i64);
958+
959+
ProofAttempt {
960+
attempt_id: uuid::Uuid::new_v4().to_string(),
961+
obligation_id,
962+
repo: String::new(),
963+
file: String::new(),
964+
claim,
965+
obligation_class: obligation_class.unwrap_or("unknown").to_string(),
966+
prover_used: prover_kind_to_str(prover_kind).to_string(),
967+
outcome: outcome_str.to_string(),
968+
duration_ms,
969+
confidence,
970+
parent_attempt_id: None,
971+
strategy_tag: "dispatch".to_string(),
972+
started_at: started_at.to_rfc3339(),
973+
completed_at: now.to_rfc3339(),
974+
prover_output: String::new(),
975+
error_message: None,
976+
}
977+
}
978+
852979
#[cfg(test)]
853980
mod tests {
854981
use super::*;
@@ -1006,4 +1133,133 @@ mod tests {
10061133
"Chapel parallel dispatch must return Ok (either Chapel or fallback)"
10071134
);
10081135
}
1136+
1137+
// ----- VeriSim writer wiring ------------------------------------------
1138+
1139+
#[cfg(feature = "verisim")]
1140+
#[test]
1141+
fn test_with_verisim_writer_attaches_client() {
1142+
// The builder must set the `verisim_writer` field so subsequent
1143+
// dispatch calls can fire `record_proof_attempt`. We can't exercise
1144+
// the spawn path without a live VeriSimDB, but we can confirm the
1145+
// writer is wired.
1146+
let dispatcher = ProverDispatcher::new().with_verisim_writer("http://127.0.0.1:7700");
1147+
assert!(
1148+
dispatcher.verisim_writer.is_some(),
1149+
"with_verisim_writer must populate the verisim_writer field"
1150+
);
1151+
}
1152+
1153+
#[cfg(feature = "verisim")]
1154+
#[test]
1155+
fn test_build_proof_attempt_proved_outcome() {
1156+
use crate::core::{Goal, Term};
1157+
1158+
let goal = Goal {
1159+
id: "g0".to_string(),
1160+
target: Term::Const("True".to_string()),
1161+
hypotheses: vec![],
1162+
};
1163+
let outcome = ProverOutcome::Proved { elapsed_ms: 42 };
1164+
1165+
let attempt =
1166+
build_proof_attempt(ProverKind::Z3, Some(&goal), &outcome, 42, Some("smoke"));
1167+
1168+
assert_eq!(attempt.outcome, "success");
1169+
assert_eq!(attempt.confidence, 1.0);
1170+
assert_eq!(attempt.obligation_class, "smoke");
1171+
assert_eq!(attempt.prover_used, "z3");
1172+
assert_eq!(attempt.duration_ms, 42);
1173+
assert_eq!(attempt.strategy_tag, "dispatch");
1174+
assert!(
1175+
!attempt.obligation_id.is_empty() && attempt.obligation_id != "unknown",
1176+
"obligation_id must be a real goal_identity hash"
1177+
);
1178+
}
1179+
1180+
#[cfg(feature = "verisim")]
1181+
#[test]
1182+
fn test_build_proof_attempt_outcome_mapping() {
1183+
use crate::core::{Goal, Term};
1184+
1185+
let goal = Goal {
1186+
id: "g0".to_string(),
1187+
target: Term::Const("False".to_string()),
1188+
hypotheses: vec![],
1189+
};
1190+
1191+
let cases: Vec<(ProverOutcome, &str)> = vec![
1192+
(
1193+
ProverOutcome::NoProofFound {
1194+
elapsed_ms: 10,
1195+
reason: None,
1196+
},
1197+
"failure",
1198+
),
1199+
(
1200+
ProverOutcome::InvalidInput {
1201+
reason: "syntax".to_string(),
1202+
location: None,
1203+
},
1204+
"failure",
1205+
),
1206+
(ProverOutcome::Timeout { limit_secs: 30 }, "timeout"),
1207+
(
1208+
ProverOutcome::UnsupportedFeature {
1209+
feature: "uf".to_string(),
1210+
},
1211+
"unknown",
1212+
),
1213+
(
1214+
ProverOutcome::SystemError {
1215+
detail: "io".to_string(),
1216+
},
1217+
"unknown",
1218+
),
1219+
];
1220+
1221+
for (outcome, expected) in cases {
1222+
let a = build_proof_attempt(ProverKind::Z3, Some(&goal), &outcome, 10, None);
1223+
assert_eq!(
1224+
a.outcome, expected,
1225+
"outcome {:?} should map to '{}'",
1226+
outcome, expected
1227+
);
1228+
assert_eq!(a.confidence, 0.0, "non-Proved outcomes get confidence 0.0");
1229+
}
1230+
}
1231+
1232+
#[cfg(feature = "verisim")]
1233+
#[test]
1234+
fn test_build_proof_attempt_no_goal_uses_unknown_id() {
1235+
// Parse-failure path passes goal=None. Must still produce a valid
1236+
// ProofAttempt rather than panicking.
1237+
let outcome = ProverOutcome::InvalidInput {
1238+
reason: "bad".to_string(),
1239+
location: None,
1240+
};
1241+
let attempt = build_proof_attempt(ProverKind::Coq, None, &outcome, 1, None);
1242+
assert_eq!(attempt.obligation_id, "unknown");
1243+
assert_eq!(attempt.claim, "");
1244+
assert_eq!(attempt.obligation_class, "unknown");
1245+
}
1246+
1247+
#[cfg(feature = "verisim")]
1248+
#[tokio::test]
1249+
async fn test_verify_proof_with_unreachable_writer_still_returns_ok() {
1250+
// Spawning a record to an unreachable VeriSimDB must NOT block or
1251+
// fail dispatch. The dispatch result must come back even though the
1252+
// background HTTP write will eventually time out and warn.
1253+
let dispatcher = ProverDispatcher::new().with_verisim_writer("http://127.0.0.1:1");
1254+
let result = dispatcher
1255+
.verify_proof(
1256+
ProverKind::Z3,
1257+
"(set-logic QF_LIA)\n(assert (> x 0))\n(check-sat)",
1258+
)
1259+
.await;
1260+
assert!(
1261+
result.is_ok(),
1262+
"dispatch must complete even with unreachable VeriSim writer"
1263+
);
1264+
}
10091265
}

src/rust/verisim_bridge.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,11 @@ impl ProofOctadBuilder {
480480
/// Sends octad payloads to VeriSimDB for persistent storage.
481481
/// Uses reqwest for async HTTP. Falls back gracefully if VeriSimDB
482482
/// is unavailable (proof memory continues in-memory).
483+
///
484+
/// `Clone` is cheap: `reqwest::Client` is internally `Arc`'d, and the
485+
/// base URL is just a `String`. Cloning is required for fire-and-forget
486+
/// `tokio::spawn` writes (e.g. `dispatch::ProverDispatcher::spawn_record_attempt`).
487+
#[derive(Clone)]
483488
pub struct VeriSimDBClient {
484489
// pub(crate) — the sibling vcl_ut module composes queries directly
485490
// against the base URL + shared HTTP client rather than going through

0 commit comments

Comments
 (0)