Skip to content

Commit 06a1c65

Browse files
hyperpolymathclaude
andcommitted
feat(coupling): wire echidnabot → VeriSimDB + Julia retrain pipeline
Three missing pieces from the ECHIDNA ↔ Hypatia learning loop: 1. echidnabot/src/verisim_writer.rs (new) VeriSimWriter records every proof attempt (success or failure) to VeriSimDB's proof_attempts table via POST /api/v1/proof_attempts. Configured by HYPATIA_VERISIM_URL; fully non-fatal if unset or unreachable. Closes the loop: attempt → table → mv_prover_success_by_class → Hypatia ProofStrategySelection → next echidnabot prover hint. 2. echidnabot/src/main.rs Construct VeriSimWriter once per process_job; call record() after every verify_proof() call in the per-file loop. Adds chrono::Utc for started_at timestamps that match the existing proof_attempts schema. 3. src/julia/retrain_from_verisim.jl (new) Reads proof_attempts from VeriSimDB (row-level or synthetic from strategy aggregates), upsamples minority provers with inverse-frequency weights to address the 99.6% Metamath imbalance, retrains the logistic- regression premise selector (SGD + momentum), grows the premise vocab from successful traces, writes updated model artefacts, and hot-swaps the live server via POST /reload on port 8090. Prover-agnostic: discovers all provers present in the table at runtime. 4. src/julia/api_server.jl Add POST /reload endpoint: reloads tactic_model.txt and premise_vocab.txt from disk without restarting the process. Called by retrain_from_verisim.jl. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 158cd00 commit 06a1c65

5 files changed

Lines changed: 934 additions & 0 deletions

File tree

echidnabot/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ pub mod result_formatter; // Bridge between dispatcher results and bot modes
2525
pub mod scheduler;
2626
pub mod store;
2727
pub mod trust; // ECHIDNA Trust Bridge (confidence, integrity, axiom tracking)
28+
pub mod verisim_writer; // Records proof attempts to VeriSimDB (closes learning loop)
2829

2930
pub use config::Config;
3031
pub use error::{Error, Result};

echidnabot/src/main.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ use echidnabot::adapters::github::GitHubAdapter;
1010
use echidnabot::adapters::gitlab::GitLabAdapter;
1111
use echidnabot::api::graphql::GraphQLState;
1212
use echidnabot::api::{create_schema, webhook_router};
13+
use chrono::Utc;
1314
use echidnabot::dispatcher::{EchidnaClient, ProverKind};
1415
use echidnabot::dispatcher::echidna_client::ProverStatus;
1516
use echidnabot::scheduler::{JobScheduler, ProofJob};
1617
use echidnabot::store::{SqliteStore, Store};
1718
use echidnabot::store::models::{ProofResultRecord, Repository as StoreRepository};
19+
use echidnabot::verisim_writer::VeriSimWriter;
1820
use std::path::{Path, PathBuf};
1921
use std::sync::Arc;
2022
use std::time::Instant;
@@ -580,14 +582,25 @@ async fn process_job(
580582
let mut failed = Vec::new();
581583
let mut prover_output = String::new();
582584

585+
// Construct once per job — reads HYPATIA_VERISIM_URL; no-op if unset.
586+
let repo_slug = format!("{}/{}", repo.owner, repo.name);
587+
let verisim_writer = VeriSimWriter::from_env();
588+
583589
for path in &file_paths {
584590
let full_path = if Path::new(path).is_absolute() {
585591
PathBuf::from(path)
586592
} else {
587593
repo_path.join(path)
588594
};
589595
let content = fs::read_to_string(&full_path).await?;
596+
let attempt_started = Utc::now();
590597
let result = echidna.verify_proof(job.prover, &content).await?;
598+
599+
// Record to VeriSimDB — non-fatal, log-only on error.
600+
verisim_writer
601+
.record(&result, job.prover, &repo_slug, path, attempt_started)
602+
.await;
603+
591604
if result.status == echidnabot::dispatcher::ProofStatus::Verified {
592605
verified.push(path.to_string());
593606
} else {

echidnabot/src/verisim_writer.rs

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
// SPDX-License-Identifier: EUPL-1.2
2+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
4+
//! VeriSimDB proof-attempt writer for echidnabot.
5+
//!
6+
//! Records every proof attempt (success or failure) to VeriSimDB's
7+
//! `proof_attempts` table via `POST /api/v1/proof_attempts`.
8+
//!
9+
//! This closes the learning loop:
10+
//!
11+
//! ```text
12+
//! echidnabot verify_proof
13+
//! → VeriSimWriter::record
14+
//! → POST /api/v1/proof_attempts
15+
//! → ClickHouse proof_attempts table
16+
//! → mv_prover_success_by_class (auto-maintained)
17+
//! → Hypatia ProofStrategySelection.recommend/2
18+
//! → echidnabot prover_hint in next dispatch
19+
//! ```
20+
//!
21+
//! The writer is fully non-fatal: if VeriSimDB is unreachable, it logs a
22+
//! warning at `WARN` level and returns so the calling job continues. The
23+
//! learning loop degrades gracefully to the last known recommendations.
24+
//!
25+
//! # Configuration
26+
//!
27+
//! Set `HYPATIA_VERISIM_URL` to the verisim-api base URL
28+
//! (e.g. `http://localhost:8080`). If the variable is absent or empty,
29+
//! the writer is disabled and every call to `record` is a no-op.
30+
//!
31+
//! # Prover-agnosticism
32+
//!
33+
//! `prover_to_str` maps every `ProverKind` variant to the lowercase
34+
//! string the ClickHouse `Enum8` expects. Adding a new prover to
35+
//! `ProverKind` requires adding one arm here — no other change needed.
36+
37+
use chrono::{DateTime, Utc};
38+
use reqwest::Client;
39+
use serde::{Deserialize, Serialize};
40+
use tracing::{debug, warn};
41+
use uuid::Uuid;
42+
43+
use crate::dispatcher::{ProofResult, ProofStatus, ProverKind};
44+
45+
// ─── Row type ─────────────────────────────────────────────────────────────────
46+
47+
/// A single row written to VeriSimDB's `proof_attempts` table.
48+
///
49+
/// Field names are snake_case to match the JSON body expected by
50+
/// `POST /api/v1/proof_attempts` on verisim-api. The `prover_used` and
51+
/// `outcome` strings are lowercase Enum8 values in ClickHouse.
52+
///
53+
/// Schema note: `obligation_id` is a stable hash of `(repo, file)` that
54+
/// groups retries of the same obligation. `attempt_id` is unique per call.
55+
#[derive(Debug, Serialize, Deserialize)]
56+
struct ProofAttemptRow {
57+
/// UUID v4 — unique to this invocation.
58+
attempt_id: String,
59+
/// Stable 16-hex-char hash of `(repo, file)` — groups retries.
60+
obligation_id: String,
61+
/// Repository slug, e.g. `"hyperpolymath/echidna"`.
62+
repo: String,
63+
/// Relative file path within the repo.
64+
file: String,
65+
/// Human-readable description of what is being proved.
66+
claim: String,
67+
/// Mathematical obligation class for strategy lookup.
68+
/// Examples: `"linearity"`, `"termination"`, `"equiv"`, `"safety"`.
69+
obligation_class: String,
70+
/// Lowercase prover name matching ClickHouse Enum8:
71+
/// `"coq"` | `"lean"` | `"agda"` | `"isabelle"` | `"z3"` | `"cvc5"`
72+
/// | `"metamath"` | `"hol_light"` | `"mizar"` | `"pvs"` | `"acl2"`
73+
/// | `"hol4"` | `"other"`.
74+
prover_used: String,
75+
/// Outcome: `"success"` | `"failure"` | `"timeout"` | `"unknown"`.
76+
outcome: String,
77+
/// Wall-clock duration in milliseconds.
78+
duration_ms: u64,
79+
/// Confidence in \[0.0, 1.0\]. Derived from outcome for echidnabot jobs.
80+
confidence: f32,
81+
/// Set for retries; `None` for first attempts.
82+
#[serde(skip_serializing_if = "Option::is_none")]
83+
parent_attempt_id: Option<String>,
84+
/// Strategy that dispatched this attempt. Fixed to `"echidnabot"` here.
85+
strategy_tag: String,
86+
/// ISO-8601 UTC — when the prover was invoked.
87+
started_at: String,
88+
/// ISO-8601 UTC — when the result was received.
89+
completed_at: String,
90+
/// Truncated prover stdout/stderr (≤ 8 KiB recommended).
91+
prover_output: String,
92+
/// Non-`None` when `outcome != "success"`.
93+
#[serde(skip_serializing_if = "Option::is_none")]
94+
error_message: Option<String>,
95+
}
96+
97+
// ─── Writer ───────────────────────────────────────────────────────────────────
98+
99+
/// Asynchronously records proof attempts to VeriSimDB.
100+
///
101+
/// Construct once per process with [`VeriSimWriter::from_env`], then call
102+
/// [`VeriSimWriter::record`] after every [`EchidnaClient::verify_proof`].
103+
pub struct VeriSimWriter {
104+
base_url: String,
105+
http: Client,
106+
enabled: bool,
107+
}
108+
109+
impl VeriSimWriter {
110+
/// Build from the `HYPATIA_VERISIM_URL` environment variable.
111+
///
112+
/// If the variable is absent or empty the writer is disabled:
113+
/// every [`record`] call returns immediately without network I/O.
114+
pub fn from_env() -> Self {
115+
match std::env::var("HYPATIA_VERISIM_URL") {
116+
Ok(url) if !url.trim().is_empty() => {
117+
let http = Client::builder()
118+
.timeout(std::time::Duration::from_secs(10))
119+
.build()
120+
.unwrap_or_else(|_| Client::new());
121+
Self {
122+
base_url: url.trim_end_matches('/').to_string(),
123+
http,
124+
enabled: true,
125+
}
126+
}
127+
_ => {
128+
tracing::info!(
129+
"HYPATIA_VERISIM_URL not set — VeriSimDB proof-attempt recording disabled"
130+
);
131+
Self {
132+
base_url: String::new(),
133+
http: Client::new(),
134+
enabled: false,
135+
}
136+
}
137+
}
138+
}
139+
140+
/// Record one proof attempt to VeriSimDB.
141+
///
142+
/// Non-fatal: any network or HTTP error is logged at `WARN` and
143+
/// the function returns so the caller's job loop continues.
144+
///
145+
/// # Arguments
146+
///
147+
/// * `result` — the outcome from [`EchidnaClient::verify_proof`]
148+
/// * `prover` — which prover backend was used
149+
/// * `repo` — repository slug, e.g. `"hyperpolymath/echidna"`
150+
/// * `file_path` — proof file path (relative or absolute)
151+
/// * `started_at` — timestamp recorded just before `verify_proof` was called
152+
pub async fn record(
153+
&self,
154+
result: &ProofResult,
155+
prover: ProverKind,
156+
repo: &str,
157+
file_path: &str,
158+
started_at: DateTime<Utc>,
159+
) {
160+
if !self.enabled {
161+
return;
162+
}
163+
164+
let completed_at = Utc::now();
165+
let obligation_class = classify_obligation_from_path(file_path, prover);
166+
let outcome = outcome_str(&result.status);
167+
let confidence = confidence_from_status(&result.status);
168+
let prover_str = prover_to_str(prover);
169+
170+
let row = ProofAttemptRow {
171+
attempt_id: Uuid::new_v4().to_string(),
172+
obligation_id: stable_obligation_id(repo, file_path),
173+
repo: repo.to_string(),
174+
file: file_path.to_string(),
175+
claim: format!("Verify proof in {file_path}"),
176+
obligation_class,
177+
prover_used: prover_str.to_string(),
178+
outcome,
179+
duration_ms: result.duration_ms,
180+
confidence,
181+
parent_attempt_id: None,
182+
strategy_tag: "echidnabot".to_string(),
183+
started_at: started_at.to_rfc3339(),
184+
completed_at: completed_at.to_rfc3339(),
185+
prover_output: truncate_utf8(&result.prover_output, 8 * 1024),
186+
error_message: if result.status == ProofStatus::Verified {
187+
None
188+
} else {
189+
Some(result.message.clone())
190+
},
191+
};
192+
193+
let url = format!("{}/api/v1/proof_attempts", self.base_url);
194+
195+
match self.http.post(&url).json(&row).send().await {
196+
Ok(resp) if resp.status().is_success() => {
197+
debug!(
198+
attempt_id = %row.attempt_id,
199+
prover = prover_str,
200+
outcome = %row.outcome,
201+
file = file_path,
202+
"proof_attempt recorded in VeriSimDB"
203+
);
204+
}
205+
Ok(resp) => {
206+
let status = resp.status();
207+
let body = resp.text().await.unwrap_or_default();
208+
warn!(
209+
%status,
210+
body = %body,
211+
prover = prover_str,
212+
file = file_path,
213+
"VeriSimDB proof_attempts endpoint returned error — attempt not recorded"
214+
);
215+
}
216+
Err(e) => {
217+
warn!(
218+
error = %e,
219+
prover = prover_str,
220+
file = file_path,
221+
"VeriSimDB unreachable — proof_attempt not recorded (learning loop degraded)"
222+
);
223+
}
224+
}
225+
}
226+
}
227+
228+
// ─── Helpers ──────────────────────────────────────────────────────────────────
229+
230+
/// Map `ProofStatus` to the lowercase ClickHouse Enum8 outcome string.
231+
fn outcome_str(status: &ProofStatus) -> String {
232+
match status {
233+
ProofStatus::Verified => "success",
234+
ProofStatus::Timeout => "timeout",
235+
ProofStatus::Failed | ProofStatus::Error => "failure",
236+
ProofStatus::Unknown => "unknown",
237+
}
238+
.to_string()
239+
}
240+
241+
/// Derive an obligation class from the file path and prover.
242+
///
243+
/// Keyword-matching against the path provides coarse-grained class labels
244+
/// that feed `mv_prover_success_by_class`. Unknown paths fall back to a
245+
/// prover-namespaced class (e.g. `"metamath_proof"`) so the MV always has
246+
/// a non-null class to aggregate on.
247+
fn classify_obligation_from_path(file_path: &str, prover: ProverKind) -> String {
248+
let lower = file_path.to_lowercase();
249+
for (keyword, class) in &[
250+
("terminat", "termination"),
251+
("linear", "linearity"),
252+
("equiv", "equiv"),
253+
("correct", "equiv"),
254+
("safety", "safety"),
255+
("secure", "safety"),
256+
("memory", "safety"),
257+
("sort", "termination"),
258+
("bound", "termination"),
259+
] {
260+
if lower.contains(keyword) {
261+
return class.to_string();
262+
}
263+
}
264+
// Fallback: prover-namespaced so the MV still groups by (class, prover)
265+
format!("{}_proof", prover_to_str(prover))
266+
}
267+
268+
/// Derive a rough confidence score from the proof outcome.
269+
///
270+
/// These are priors; the `mv_prover_success_by_class` MV computes true
271+
/// empirical rates from the growing table — the stored `confidence` here
272+
/// is per-attempt metadata, not a prediction.
273+
fn confidence_from_status(status: &ProofStatus) -> f32 {
274+
match status {
275+
ProofStatus::Verified => 0.95,
276+
ProofStatus::Failed => 0.10,
277+
ProofStatus::Timeout => 0.05,
278+
ProofStatus::Error => 0.10,
279+
ProofStatus::Unknown => 0.50,
280+
}
281+
}
282+
283+
/// Stable 16-char hex obligation ID derived from `(repo, file)`.
284+
///
285+
/// Uses a non-cryptographic hash — good enough for grouping retries.
286+
/// The same `(repo, file)` always produces the same ID across runs.
287+
fn stable_obligation_id(repo: &str, file: &str) -> String {
288+
use std::collections::hash_map::DefaultHasher;
289+
use std::hash::{Hash, Hasher};
290+
let mut h = DefaultHasher::new();
291+
repo.hash(&mut h);
292+
b'\0'.hash(&mut h);
293+
file.hash(&mut h);
294+
format!("{:016x}", h.finish())
295+
}
296+
297+
/// Truncate `s` to at most `max_bytes` bytes, preserving UTF-8 boundaries.
298+
fn truncate_utf8(s: &str, max_bytes: usize) -> String {
299+
if s.len() <= max_bytes {
300+
return s.to_string();
301+
}
302+
let boundary = s
303+
.char_indices()
304+
.map(|(i, _)| i)
305+
.take_while(|&i| i <= max_bytes)
306+
.last()
307+
.unwrap_or(0);
308+
format!("{}…", &s[..boundary])
309+
}
310+
311+
/// Map every `ProverKind` variant to the lowercase ClickHouse Enum8 string.
312+
///
313+
/// Adding a new prover requires adding one arm here. The `_ => "other"` arm
314+
/// is intentionally absent so the compiler flags missing variants.
315+
pub fn prover_to_str(kind: ProverKind) -> &'static str {
316+
match kind {
317+
ProverKind::Agda => "agda",
318+
ProverKind::Coq => "coq",
319+
ProverKind::Lean => "lean",
320+
ProverKind::Isabelle => "isabelle",
321+
ProverKind::Z3 => "z3",
322+
ProverKind::Cvc5 => "cvc5",
323+
ProverKind::Metamath => "metamath",
324+
ProverKind::HolLight => "hol_light",
325+
ProverKind::Mizar => "mizar",
326+
ProverKind::Pvs => "pvs",
327+
ProverKind::Acl2 => "acl2",
328+
ProverKind::Hol4 => "hol4",
329+
}
330+
}

0 commit comments

Comments
 (0)