@@ -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
252260impl 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) ]
853980mod 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}
0 commit comments