Skip to content

Commit 0bbfe59

Browse files
hyperpolymathclaude
andcommitted
feat: BI-1 serve socket + Julia oracle + project-root + sandbox + parser fix
BI-1 (per docs/ECHIDNA-INTEGRATION.adoc §BI-1): - New `burrower serve --socket <path>` Unix-socket endpoint exposing swarm/attempt/ledger/ping over line-delimited JSON. - Sync std::os::unix UnixListener with thread-per-connection (no tokio dep added — corpus loads + ledger ops are independent per request). - Verified via socat: ping/unknown-cmd/malformed-json all roundtrip. - SocketGuard cleans the socket on normal Drop; defensive bind-side unlink handles SIGTERM-killed stale sockets (known Drop limitation). Oracle (safe-learning c — adversarial test generation): - New oracle module dispatches `tools/julia-oracle.jl` as a subprocess with an A2ML descriptor argument; parses the verdict line. - OracleVerdict::Disagree and FuzzCounter block the proof attempt ("lemma statement is suspect"); other verdicts are advisory. - record_to_ledger appends an Oracle-authored learning entry with pattern_kind in {oracle-counter-example, oracle-fuzz-counter, oracle-agree, oracle-fuzz-clean, oracle-inapplicable, oracle- subprocess-error}. - 5 unit tests over verdict parsing. - Wired into burrower-cli `attempt` via --oracle / --oracle-descriptor / --oracle-project / --oracle-julia. End-to-end verified: a deliberately wrong tropical-determinant lemma is blocked BEFORE specialists run. Project-root + sandbox plumbing: - ProverConfig gains project_root: Option<PathBuf> + sandbox: String. - run_probe forwards `--project-root` and `--sandbox` to echidna when set. burrower-cli `attempt` exposes both as flags. - This is what makes the EI-1 + safe-learning-b chain functional from the burrower side. Critical bugfix (discovered during the headline dogfood): - run_probe's success/failure marker was stdout-only. Echidna's OutputFormatter writes its verdict to STDERR, so every attempt was misclassified as "inconclusive output" and demoted to a generic-failure anti-pattern. Fixed: parser now scans both streams AND falls back on the exit code. Without this, the swarm's failure diagnostics were useless. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent afcd0b8 commit 0bbfe59

5 files changed

Lines changed: 684 additions & 22 deletions

File tree

crates/burrower-cli/src/main.rs

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,44 @@ enum Cmd {
9898
/// Output format: `text` (default) or `json`.
9999
#[arg(long, default_value = "text")]
100100
format: String,
101+
/// Project root for echidna's EI-1 `--project-root` flag.
102+
/// Required for goals importing project-specific theories.
103+
#[arg(long)]
104+
project_root: Option<PathBuf>,
105+
/// Sandbox mode for prover invocation (safe-learning b).
106+
/// One of: none (default), bwrap, podman.
107+
#[arg(long, default_value = "none")]
108+
sandbox: String,
109+
/// Path to tools/julia-oracle.jl (safe-learning c). When set,
110+
/// the oracle runs BEFORE the specialist playbook; a
111+
/// `disagree` or `fuzz-counter` verdict skips the proof.
112+
#[arg(long)]
113+
oracle: Option<PathBuf>,
114+
/// Oracle descriptor (a2ml). Required when --oracle is set.
115+
#[arg(long)]
116+
oracle_descriptor: Option<PathBuf>,
117+
/// Optional --project=<dir> for julia (path to repo with Manifest.toml).
118+
#[arg(long)]
119+
oracle_project: Option<PathBuf>,
120+
/// Path to the julia binary (default: julia on PATH).
121+
#[arg(long, default_value = "julia")]
122+
oracle_julia: PathBuf,
101123
},
102124
/// Burrow Ledger inspection.
103125
Ledger {
104126
#[command(subcommand)]
105127
sub: LedgerCmd,
106128
},
129+
/// BI-1: expose the swarm over a Unix socket so echidna and
130+
/// echidnabot can both consume `swarm` / `attempt` / `ledger`
131+
/// commands without re-instantiating the corpus per call.
132+
/// Protocol: line-delimited JSON {cmd, args} → {ok, result|error}.
133+
/// See proof-burrower/docs/ECHIDNA-INTEGRATION.adoc §BI-1.
134+
Serve {
135+
/// Path to the Unix socket to bind. Defaults to /tmp/burrower.sock.
136+
#[arg(long, default_value = "/tmp/burrower.sock")]
137+
socket: PathBuf,
138+
},
107139
}
108140

109141
#[derive(Subcommand)]
@@ -225,20 +257,63 @@ fn handle_ledger(cmd: LedgerCmd) -> Result<()> {
225257
Ok(())
226258
}
227259

260+
#[allow(clippy::too_many_arguments)]
228261
fn handle_attempt(
229262
goal: String,
230263
echidna: PathBuf,
231264
timeout: u32,
232265
ledger: PathBuf,
233266
format: String,
267+
project_root: Option<PathBuf>,
268+
sandbox: String,
269+
oracle: Option<PathBuf>,
270+
oracle_descriptor: Option<PathBuf>,
271+
oracle_project: Option<PathBuf>,
272+
oracle_julia: PathBuf,
234273
) -> Result<()> {
235274
let parsed = parse_goal(&goal);
236275
let swarm = Swarm::new();
237276
let l = Ledger::open(&ledger)?;
277+
278+
// Safe-learning (c): if an oracle script + descriptor were
279+
// supplied, run the oracle BEFORE specialist playbooks. A blocking
280+
// verdict (disagree / fuzz-counter) means the lemma statement is
281+
// suspect and we skip the playbook entirely. Either way the result
282+
// is recorded in the ledger so the swarm learns the lemma's
283+
// numerical character even when no proof is attempted.
284+
if let (Some(script), Some(descriptor)) = (oracle.as_ref(), oracle_descriptor.as_ref()) {
285+
let cfg = burrower_core::oracle::OracleConfig {
286+
script: script.clone(),
287+
descriptor: descriptor.clone(),
288+
julia: oracle_julia.clone(),
289+
project: oracle_project.clone(),
290+
};
291+
let verdict = burrower_core::oracle::run(&cfg);
292+
burrower_core::oracle::record_to_ledger(&l, &parsed, &cfg, &verdict);
293+
eprintln!("oracle: {:?}", verdict);
294+
if verdict.blocks_attempt() {
295+
eprintln!(
296+
"oracle BLOCKS attempt (lemma statement suspect or oracle inconsistent); \
297+
skipping specialist playbooks. Ledger records this as `{}`.",
298+
verdict.pattern_kind()
299+
);
300+
// Emit a json result-shape consistent with the normal path.
301+
if format == "json" {
302+
println!("{}", serde_json::json!({
303+
"blocked_by_oracle": true,
304+
"verdict_kind": verdict.pattern_kind(),
305+
}));
306+
}
307+
return Ok(());
308+
}
309+
}
310+
238311
let prover = ProverConfig {
239312
echidna_path: echidna,
240313
timeout_secs: timeout,
241314
workdir: None,
315+
project_root,
316+
sandbox,
242317
};
243318
let attempts = swarm.attempt_all(&parsed, &prover, Some(&l));
244319

@@ -293,10 +368,19 @@ fn handle_attempt(
293368
fn main() -> Result<()> {
294369
let cli = Cli::parse();
295370
match cli.cmd {
296-
Cmd::Attempt { goal, echidna, timeout, ledger, format } => {
297-
return handle_attempt(goal, echidna, timeout, ledger, format)
371+
Cmd::Attempt {
372+
goal, echidna, timeout, ledger, format,
373+
project_root, sandbox,
374+
oracle, oracle_descriptor, oracle_project, oracle_julia,
375+
} => {
376+
return handle_attempt(
377+
goal, echidna, timeout, ledger, format,
378+
project_root, sandbox,
379+
oracle, oracle_descriptor, oracle_project, oracle_julia,
380+
)
298381
}
299382
Cmd::Ledger { sub } => return handle_ledger(sub),
383+
Cmd::Serve { socket } => return burrower_core::serve::run(socket),
300384
Cmd::Index { root, output } => {
301385
let corpus = Corpus::index(&root)?;
302386
corpus.save(&output)?;

crates/burrower-core/src/attempt.rs

Lines changed: 69 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,17 @@ pub struct ProverConfig {
6565
pub timeout_secs: u32,
6666
/// Workdir for probe files. Defaults to `/tmp` if unset.
6767
pub workdir: Option<PathBuf>,
68+
/// Project root for echidna's EI-1 `--project-root` flag (2026-04-26).
69+
/// When set, every probe is dispatched as
70+
/// `echidna prove --project-root <p> ...`, letting the probe's
71+
/// theory imports resolve against the project's existing ROOT.
72+
/// Without this, probes can only `imports Main`.
73+
pub project_root: Option<PathBuf>,
74+
/// Sandbox mode forwarded to echidna's `--sandbox` flag
75+
/// (safe-learning b, 2026-04-26). One of "none" | "bwrap" | "podman".
76+
/// Default "none" preserves backwards compatibility; recommend
77+
/// "bwrap" when running specialist playbooks against unverified goals.
78+
pub sandbox: String,
6879
}
6980

7081
impl Default for ProverConfig {
@@ -73,6 +84,8 @@ impl Default for ProverConfig {
7384
echidna_path: PathBuf::from("echidna"),
7485
timeout_secs: 60,
7586
workdir: None,
87+
project_root: None,
88+
sandbox: "none".to_string(),
7689
}
7790
}
7891
}
@@ -207,51 +220,85 @@ pub fn run_probe(
207220
}
208221

209222
let start = Instant::now();
210-
let output = Command::new(&config.echidna_path)
211-
.arg("prove")
223+
let mut cmd = Command::new(&config.echidna_path);
224+
cmd.arg("prove")
212225
.arg(&probe_path)
213226
.arg("--prover")
214227
.arg("Isabelle")
215228
.arg("-t")
216-
.arg(config.timeout_secs.to_string())
217-
.output();
229+
.arg(config.timeout_secs.to_string());
230+
// EI-1: forward the project root so probes importing project-
231+
// specific theories (Tropical_v2, walks_def, ...) resolve.
232+
if let Some(p) = config.project_root.as_ref() {
233+
cmd.arg("--project-root").arg(p);
234+
}
235+
// safe-learning b: forward the sandbox mode. echidna defaults to
236+
// "none" so an unset value is the same as the legacy path.
237+
if config.sandbox != "none" && !config.sandbox.is_empty() {
238+
cmd.arg("--sandbox").arg(&config.sandbox);
239+
}
240+
let output = cmd.output();
218241
let elapsed = start.elapsed();
219242
let elapsed_ms = elapsed.as_millis() as u64;
220243

221244
match output {
222245
Ok(o) => {
223246
let stdout = String::from_utf8_lossy(&o.stdout);
224247
let stderr = String::from_utf8_lossy(&o.stderr);
225-
if stdout.contains("Proof verified successfully") {
226-
AttemptResult::Succeeded {
227-
duration_ms: elapsed_ms,
228-
}
229-
} else if stdout.contains("Proof verification failed")
230-
|| stderr.contains("FAILED")
231-
|| stderr.contains("error")
232-
{
233-
let err_excerpt: String = stdout
234-
.lines()
235-
.chain(stderr.lines())
248+
249+
// Discovered 2026-04-26 during the swarm-dogfood session:
250+
// echidna's "Proof verified successfully" / "Proof verification
251+
// failed" lines come from `OutputFormatter`, which writes to
252+
// STDERR, not stdout. The previous stdout-only check made every
253+
// attempt look "inconclusive" and demoted real failures to
254+
// generic-failure anti-patterns. We now scan BOTH streams and
255+
// also fall back on the exit code so a non-zero exit with no
256+
// standard marker still becomes Failed (not Inconclusive).
257+
let combined_lines: Vec<&str> =
258+
stdout.lines().chain(stderr.lines()).collect();
259+
let says_success = combined_lines
260+
.iter()
261+
.any(|l| l.contains("Proof verified successfully") || l.contains("✓ Proof verified"));
262+
let says_failure = combined_lines.iter().any(|l| {
263+
l.contains("Proof verification failed")
264+
|| l.contains("✗ Proof verification failed")
265+
|| l.contains("FAILED")
266+
});
267+
let exit_failed = !o.status.success();
268+
269+
if says_success {
270+
AttemptResult::Succeeded { duration_ms: elapsed_ms }
271+
} else if says_failure || exit_failed {
272+
let err_excerpt: String = combined_lines
273+
.iter()
236274
.filter(|l| {
237-
l.contains("***") || l.contains("Failed") || l.contains("error")
275+
l.contains("***")
276+
|| l.contains("Failed")
277+
|| l.contains("error")
278+
|| l.contains("Unable")
238279
})
239280
.take(3)
281+
.copied()
240282
.collect::<Vec<_>>()
241283
.join(" | ");
242284
AttemptResult::Failed {
243285
error: if err_excerpt.is_empty() {
244-
"non-success exit; no diagnostic captured".to_string()
286+
format!(
287+
"exit {} — no standard diagnostic captured",
288+
o.status.code().unwrap_or(-1)
289+
)
245290
} else {
246291
err_excerpt
247292
},
248293
duration_ms: elapsed_ms,
249294
}
250295
} else {
251-
// Inconclusive — treat as failed with raw context.
296+
// Truly inconclusive — exit 0, no markers either way.
252297
AttemptResult::Failed {
253-
error: format!("inconclusive output (first 200 chars): {}",
254-
stdout.chars().take(200).collect::<String>()),
298+
error: format!(
299+
"inconclusive output (no success/failure marker, exit 0): {}",
300+
stdout.chars().take(200).collect::<String>()
301+
),
255302
duration_ms: elapsed_ms,
256303
}
257304
}
@@ -433,6 +480,8 @@ mod tests {
433480
echidna_path: PathBuf::from("/nonexistent/echidna"),
434481
timeout_secs: 5,
435482
workdir: None,
483+
project_root: None,
484+
sandbox: "none".to_string(),
436485
};
437486
let r = run_probe(probe, &cfg, "missing_test.thy");
438487
assert!(matches!(r, AttemptResult::Skipped { .. }));

crates/burrower-core/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ pub mod ranking;
3232
pub mod specialist;
3333
pub mod ledger;
3434
pub mod attempt;
35+
pub mod serve;
36+
pub mod oracle;
3537

3638
pub use goal::{parse_goal, Goal};
3739
pub use corpus::{Corpus, IndexedLemma, LibraryKind};

0 commit comments

Comments
 (0)