Skip to content

Commit 21f306b

Browse files
ArghDA M5: SMT solver backends (Z3, CVC5) — the Solver kind goes live (#35)
## What & why The first **`Solver`-kind** backends, on the M1 trait — arghda now spans *both* interaction models: **assistants** (typecheck a file → exit code) and **solvers** (answer an SMT-LIB2 query → sat/unsat/unknown). Chosen at the CLI with `--backend z3|cvc5`. ## Changes - **`src/prover/smt.rs`** — one `Smt { name, cmd }` type with `Smt::z3()` / `Smt::cvc5()`. `check_file` runs `<solver> <file.smt2>` and **parses stdout** (never the bare exit code — both `sat` and `unsat` exit 0): - `unsat` → **Proven** (VC discharged: `¬P` has no model, so `P` holds) - `sat` → **Refuted** (a model/counterexample exists) - `unknown` → **Unknown**; `(error …)` / non-zero → **Error**; absent → **Unavailable** - multi-`(check-sat)` aggregate: any `sat` ⇒ Refuted; else any `unknown` ⇒ Unknown; else all `unsat` ⇒ Proven; **no result token ⇒ Error (never a silent pass)** - solvers are standalone queries: no import edges (isolated nodes — valid), no root convention (so `wired` isn't meaningful), no lint pack. - ground-truthed against **Z3 4.8.12 + CVC5 1.2.0**. - **`src/main.rs`** — `--backend z3|cvc5` wired via `backend_for`; `about`/help updated (Agda, Idris2, Z3, CVC5). ## Verified (ran, not inferred) - `cargo test` → **100 passing, 0 failed** (75 lib + 25 integration; +5 SMT unit incl. the verdict-mapping table and the `sat`⊂`unsat` token trap, +2 SMT integration — hermetic, no solver needed for the graph path). - `cargo clippy -D warnings` clean · `cargo fmt --check` clean · `check-spdx.sh` OK. - **Dogfooded real Z3 + CVC5:** - `check --backend z3 unsat.smt2` → `proven-eligible` - `check --backend z3 sat.smt2` → `rejected` (exit 0 but verdict Refuted — honest) - `reason --backend cvc5 --check` → `sat`=refuted/unsound, `unsat`=proven/sound ## Milestone status Both backend interaction models are now real. Engine spine + four backends (Agda, Idris2, Z3, CVC5) done. Next: M2 Cubical-Agda, M6 Lean4. --- _Generated by [Claude Code](https://claude.ai/code/session_012MpYSh6Wy8YMBH2E3qVyT7)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 315464b commit 21f306b

8 files changed

Lines changed: 342 additions & 20 deletions

File tree

.machine_readable/6a2/STATE.a2ml

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ milestones = [
4545
{ name = "M2: Cubical-Agda variant (Agda::cubical(), --cubical --safe island)", completion = 0 },
4646
{ name = "M3: Flying-Logic reasoning graph (src/reason: And|Or juncts, demote-only cycle-safe Verdict propagation, additive studio JSON)", completion = 100 },
4747
{ name = "M4: Idris2 adapter (core/ABI language: .ipkg/--check/totality; escape-hatches)", completion = 90 },
48-
{ name = "M5: SMT solver backends (Z3, CVC5): SMT-LIB2 -> sat/unsat/unknown -> Verdict", completion = 0 },
48+
{ name = "M5: SMT solver backends (Z3, CVC5): SMT-LIB2 -> sat/unsat/unknown -> Verdict", completion = 100 },
4949
{ name = "M6: Lean4 adapter (lake build + #print axioms audit)", completion = 0 },
5050
{ name = "M7: Echidna dispatch seam (optional route to orchestrator :8090, same Outcome)", completion = 0 },
5151
{ name = "M8: Coq/Rocq adapter (--heavy provisioning; Section-aware postulate classifier)", completion = 0 },
@@ -60,6 +60,21 @@ milestones = [
6060
# Remaining M0: `arghda doctor` Rust subcommand (interim: `just doctor` shells
6161
# the script's --verify-only table).
6262
#
63+
# M5 landed 2026-07-01: SMT solver backends (Z3, CVC5) — the FIRST Solver-kind
64+
# backends, exercising the other half of the taxonomy. src/prover/smt.rs: one
65+
# `Smt { name, cmd }` type with `Smt::z3()`/`Smt::cvc5()`. Kind=Solver, ext
66+
# .smt2. check_file runs `<solver> <file.smt2>` and PARSES stdout (never the
67+
# bare exit code — both sat and unsat exit 0): unsat→Proven (VC discharged),
68+
# sat→Refuted (model exists), unknown→Unknown, (error…)/nonzero→Error, absent→
69+
# Unavailable. Multi-check aggregate: any sat⇒Refuted; else any unknown⇒Unknown;
70+
# else all unsat⇒Proven; no token⇒Error (no silent pass). Solvers are standalone
71+
# queries: direct_imports=[] (isolated nodes — valid), discover_roots=[] (no
72+
# root convention → wired not meaningful), lint_rules=[]. Wired via --backend
73+
# z3|cvc5 (backend_for). Ground-truthed vs Z3 4.8.12 + CVC5 1.2.0. 100 tests
74+
# (75 lib + 25 integration; +5 smt unit +2 smt integration, hermetic). Dogfooded
75+
# REAL: `check --backend z3 unsat.smt2`→proven-eligible; `…sat.smt2`→rejected;
76+
# `reason --backend cvc5 --check`→sat=refuted/unsound, unsat=proven/sound.
77+
#
6378
# M4 landed 2026-07-01 (90%): the Idris2 backend — arghda is now genuinely
6479
# multi-backend. src/prover/idris2.rs: `idris2 --check --source-dir <root>
6580
# <file>` (ground-truthed vs Idris2 0.7.0), exit-code-only verdict; dotted
@@ -120,12 +135,12 @@ milestones = [
120135

121136
[critical-next-actions]
122137
actions = [
123-
"M1 (Backend trait) + M3 (reasoning graph) + M4 (Idris2) DONE. Next, all unblocked by the stable trait + --backend selector:",
124-
"Flying-Logic epic M5: SMT solver backends (Z3, CVC5) — the FIRST Solver-kind backend; SMT-LIB2 -> unsat(Proven)/sat(Refuted)/unknown(Unknown). Both binaries provisioned. Exercises the Solver half of the taxonomy (no imports; isolated nodes).",
125-
"Flying-Logic epic M2: Cubical-Agda variant (Agda::cubical(), --cubical --safe island; cross-flag import edges forbidden).",
126-
"M4 follow-on (the missing 10%): .ipkg-declared roots; totality-hole (?name) + per-def `partial` lint.",
127-
"M3 follow-on: feed real verdicts into `reason` from a workspace `proven/` state + wire staleness from the content-hash closure (proven.rs) so `arghda reason <workspace>` lights the cone without --check.",
128-
"Finish M0: add the `arghda doctor` Rust subcommand and repoint `just doctor` at it (interim shells provision-provers.sh --verify-only).",
138+
"M1 (Backend trait) + M3 (reasoning graph) + M4 (Idris2) + M5 (Z3/CVC5) DONE. Both interaction models (Assistant + Solver) now real. Next:",
139+
"Flying-Logic epic M2: Cubical-Agda variant (Agda::cubical(), --cubical --safe island; cross-flag import edges forbidden). Small — extends the existing Agda backend.",
140+
"Flying-Logic epic M6: Lean4 adapter (lake build + #print axioms audit → honest Unknown soundness when the audit is absent). Lean 4.13.0 provisioned.",
141+
"Finish M0: add the `arghda doctor` Rust subcommand (report which backends are runnable — now agda/idris2/z3/cvc5) and repoint `just doctor` at it.",
142+
"M4 follow-on: .ipkg-declared roots; totality-hole (?name) + per-def `partial` lint.",
143+
"M3 follow-on: feed real verdicts into `reason` from a workspace `proven/` state + wire staleness from the content-hash closure (proven.rs).",
129144
"Serve /.well-known/groove for PanLL discovery (Groove protocol) — folds into M11.",
130145
]
131146

@@ -135,11 +150,9 @@ last-result = "pass" # unknown | pass | warn | fail
135150
open-warnings = 0
136151
open-failures = 0
137152
# `just check` (fmt-check + clippy -D warnings + build + test + SPDX) green
138-
# post-M4: 70 lib + 23 integration = 93 tests pass. Dogfooded via real CLI
139-
# runs on BOTH backends: agda (`check`/`reason --check`) and idris2 (`check
140-
# --backend idris2` → proven-eligible; `reason --backend idris2 --check` →
141-
# live-typechecked cone). Dogfooded historically against echo-types +
142-
# agda-unused.
153+
# post-M5: 75 lib + 25 integration = 100 tests pass. Dogfooded via real CLI on
154+
# all four backends: agda + idris2 (Assistant), z3 + cvc5 (Solver). Dogfooded
155+
# historically against echo-types + agda-unused.
143156

144157
[ecosystem]
145158
part-of = ["arghda"]

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,6 @@ pub use diagnostic::{Diagnostic, LintReport, Severity};
3030
pub use event::{Event, EventKind};
3131
pub use graph::{build as build_graph, ImportGraph};
3232
pub use lint::{default_rules, rules_with_config, run_lints, LintRule, RuleConfig};
33-
pub use prover::{default_backend, Agda, Backend, BackendKind, Idris2, Outcome, Verdict};
33+
pub use prover::{default_backend, Agda, Backend, BackendKind, Idris2, Outcome, Smt, Verdict};
3434
pub use reason::{build as build_reason, Junct, ReasonDocument, ReasonEdge, ReasonNode};
3535
pub use workspace::{StaleEntry, State, Workspace};

src/main.rs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use anyhow::{Context, Result};
55
use arghda_core::lint::LintContext;
66
use arghda_core::{
77
build_dag, build_reason, event, run_lints, unused, watcher, Agda, Backend, Idris2, LintRule,
8-
RuleConfig, State, Workspace,
8+
RuleConfig, Smt, State, Workspace,
99
};
1010
use clap::{Parser, Subcommand};
1111
use std::path::{Path, PathBuf};
@@ -21,15 +21,17 @@ fn backend_for(name: &str) -> Result<Box<dyn Backend>> {
2121
match name {
2222
"agda" => Ok(Box::new(Agda)),
2323
"idris2" => Ok(Box::new(Idris2)),
24-
other => anyhow::bail!("unknown backend `{other}` (known: agda, idris2)"),
24+
"z3" => Ok(Box::new(Smt::z3())),
25+
"cvc5" => Ok(Box::new(Smt::cvc5())),
26+
other => anyhow::bail!("unknown backend `{other}` (known: agda, idris2, z3, cvc5)"),
2527
}
2628
}
2729

2830
#[derive(Parser)]
2931
#[command(
3032
name = "arghda",
3133
version,
32-
about = "Proof-workspace manager for provers/solvers (Agda, Idris2)"
34+
about = "Proof-workspace manager for provers/solvers (Agda, Idris2, Z3, CVC5)"
3335
)]
3436
struct Cli {
3537
#[command(subcommand)]
@@ -56,7 +58,7 @@ enum Cmd {
5658
/// `<PATH>/.arghda/config.toml` if present.
5759
#[arg(long)]
5860
config: Option<PathBuf>,
59-
/// Prover/solver backend to use: `agda` (default) or `idris2`.
61+
/// Prover/solver backend: `agda` (default), `idris2`, `z3`, `cvc5`.
6062
#[arg(long, default_value = "agda")]
6163
backend: String,
6264
/// Also run the external `agda-unused` analyser and re-emit its
@@ -75,7 +77,7 @@ enum Cmd {
7577
/// Include root (search path). Defaults to the file's directory.
7678
#[arg(long)]
7779
include_root: Option<PathBuf>,
78-
/// Prover/solver backend to use: `agda` (default) or `idris2`.
80+
/// Prover/solver backend: `agda` (default), `idris2`, `z3`, `cvc5`.
7981
#[arg(long, default_value = "agda")]
8082
backend: String,
8183
/// Emit the report as JSON.
@@ -98,7 +100,7 @@ enum Cmd {
98100
/// `<PATH>/.arghda/config.toml` if present.
99101
#[arg(long)]
100102
config: Option<PathBuf>,
101-
/// Prover/solver backend to use: `agda` (default) or `idris2`.
103+
/// Prover/solver backend: `agda` (default), `idris2`, `z3`, `cvc5`.
102104
#[arg(long, default_value = "agda")]
103105
backend: String,
104106
},
@@ -121,7 +123,7 @@ enum Cmd {
121123
/// `<PATH>/.arghda/config.toml` if present.
122124
#[arg(long)]
123125
config: Option<PathBuf>,
124-
/// Prover/solver backend to use: `agda` (default) or `idris2`.
126+
/// Prover/solver backend: `agda` (default), `idris2`, `z3`, `cvc5`.
125127
#[arg(long, default_value = "agda")]
126128
backend: String,
127129
/// Run the backend on every node to populate REAL prover verdicts

src/prover/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@ use std::path::{Path, PathBuf};
2828

2929
pub mod agda;
3030
pub mod idris2;
31+
pub mod smt;
3132

3233
pub use agda::Agda;
3334
pub use idris2::Idris2;
35+
pub use smt::Smt;
3436

3537
/// The two backend interaction models.
3638
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]

src/prover/smt.rs

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
4+
//! SMT solver backends (Z3, CVC5) — the first `Solver`-kind backends.
5+
//!
6+
//! The other half of the backend taxonomy: where an assistant typechecks a
7+
//! file (exit code is the verdict), a *solver* is fed an SMT-LIB2 query and
8+
//! answers `sat`/`unsat`/`unknown` on stdout. Ground-truthed against Z3
9+
//! 4.8.12 and CVC5 1.2.0: `<solver> <file.smt2>` prints one result token per
10+
//! `(check-sat)` and exits 0; a malformed file prints `(error …)` and exits
11+
//! non-zero.
12+
//!
13+
//! Verdict mapping (the documented convention — the query author frames their
14+
//! problem to suit it):
15+
//! * `unsat` → [`Verdict::Proven`] — the assertion set is unsatisfiable (a
16+
//! verification condition is discharged: `¬P` has no model, so `P` holds).
17+
//! * `sat` → [`Verdict::Refuted`] — a model/counterexample exists.
18+
//! * `unknown` / timeout → [`Verdict::Unknown`].
19+
//! * a solver error / non-zero exit → [`Verdict::Error`].
20+
//! * the binary absent → [`Verdict::Unavailable`].
21+
//!
22+
//! The verdict is derived from the solver's ACTUAL output, never from a
23+
//! bare exit code (both `sat` and `unsat` exit 0). Over multiple
24+
//! `(check-sat)`s: any `sat` ⇒ Refuted; else any `unknown` ⇒ Unknown; else
25+
//! all `unsat` ⇒ Proven.
26+
//!
27+
//! SMT files are standalone queries: there is no import graph
28+
//! ([`Backend::direct_imports`] is empty → isolated DAG nodes, which is
29+
//! valid) and no root convention ([`Backend::discover_roots`] is empty, so
30+
//! `wired` is not meaningful for solver nodes).
31+
32+
use super::{Backend, BackendKind, Outcome, Verdict};
33+
use crate::graph;
34+
use crate::lint::{LintRule, RuleConfig};
35+
use anyhow::Result;
36+
use std::path::{Path, PathBuf};
37+
use std::process::Command;
38+
39+
const TAIL_LINES: usize = 40;
40+
41+
/// An SMT-LIB2 solver backend, parameterised by its binary.
42+
#[derive(Clone, Copy, Debug)]
43+
pub struct Smt {
44+
name: &'static str,
45+
cmd: &'static str,
46+
}
47+
48+
impl Smt {
49+
/// The Z3 solver.
50+
pub fn z3() -> Self {
51+
Self {
52+
name: "z3",
53+
cmd: "z3",
54+
}
55+
}
56+
57+
/// The CVC5 solver.
58+
pub fn cvc5() -> Self {
59+
Self {
60+
name: "cvc5",
61+
cmd: "cvc5",
62+
}
63+
}
64+
}
65+
66+
impl Backend for Smt {
67+
fn name(&self) -> &'static str {
68+
self.name
69+
}
70+
71+
fn kind(&self) -> BackendKind {
72+
BackendKind::Solver
73+
}
74+
75+
fn extensions(&self) -> &'static [&'static str] {
76+
&["smt2"]
77+
}
78+
79+
fn check_file(&self, file: &Path, _include_root: &Path) -> Result<Outcome> {
80+
// Solvers read the query file directly; no include-root search path.
81+
let output = Command::new(self.cmd).arg(file).output();
82+
match output {
83+
Ok(out) => {
84+
let mut combined = String::from_utf8_lossy(&out.stdout).into_owned();
85+
combined.push_str(&String::from_utf8_lossy(&out.stderr));
86+
let verdict = parse_verdict(&combined, out.status.success());
87+
Ok(Outcome {
88+
available: true,
89+
exit_code: out.status.code(),
90+
// For a solver "ok" = the goal was discharged (unsat).
91+
ok: verdict == Verdict::Proven,
92+
output_tail: tail(&combined, TAIL_LINES),
93+
kind: BackendKind::Solver,
94+
verdict,
95+
})
96+
}
97+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
98+
Ok(Outcome::unavailable(BackendKind::Solver))
99+
}
100+
Err(e) => Err(e.into()),
101+
}
102+
}
103+
104+
fn module_name_of(&self, file: &Path, include_root: &Path) -> Option<String> {
105+
// No true module names; use the relative path as a stable node id.
106+
graph::module_name_of(file, include_root)
107+
}
108+
109+
fn module_to_path(&self, module: &str, include_root: &Path) -> PathBuf {
110+
let mut p = include_root.to_path_buf();
111+
for part in module.split('.') {
112+
p.push(part);
113+
}
114+
p.set_extension("smt2");
115+
p
116+
}
117+
118+
fn direct_imports(&self, _file: &Path) -> Result<Vec<String>> {
119+
// SMT queries are standalone: no dependency edges.
120+
Ok(Vec::new())
121+
}
122+
123+
fn discover_roots(&self, _include_root: &Path) -> Vec<PathBuf> {
124+
// No `All.agda`/`Main.idr`-style root convention for SMT.
125+
Vec::new()
126+
}
127+
128+
fn lint_rules(&self, _cfg: &RuleConfig) -> Result<Vec<Box<dyn LintRule>>> {
129+
// No source-level escape-hatch class for SMT-LIB2.
130+
Ok(Vec::new())
131+
}
132+
}
133+
134+
/// Map solver output + exit status to a [`Verdict`]. Parses the actual
135+
/// `sat`/`unsat`/`unknown` result tokens; never trusts the bare exit code
136+
/// (both `sat` and `unsat` exit 0).
137+
fn parse_verdict(output: &str, exit_ok: bool) -> Verdict {
138+
if !exit_ok || output.contains("(error") {
139+
return Verdict::Error;
140+
}
141+
let mut saw_unsat = false;
142+
let mut saw_unknown = false;
143+
for tok in output.split_whitespace() {
144+
match tok {
145+
"sat" => return Verdict::Refuted, // any model ⇒ refuted
146+
"unsat" => saw_unsat = true,
147+
"unknown" => saw_unknown = true,
148+
_ => {}
149+
}
150+
}
151+
if saw_unknown {
152+
Verdict::Unknown
153+
} else if saw_unsat {
154+
Verdict::Proven
155+
} else {
156+
// Ran cleanly but produced no result token — treat as an error, not
157+
// a silent pass.
158+
Verdict::Error
159+
}
160+
}
161+
162+
fn tail(s: &str, n: usize) -> String {
163+
let lines: Vec<&str> = s.lines().collect();
164+
let start = lines.len().saturating_sub(n);
165+
lines[start..].join("\n")
166+
}
167+
168+
#[cfg(test)]
169+
mod tests {
170+
use super::*;
171+
172+
#[test]
173+
fn solver_identity() {
174+
assert_eq!(Smt::z3().name(), "z3");
175+
assert_eq!(Smt::cvc5().name(), "cvc5");
176+
assert_eq!(Smt::z3().kind(), BackendKind::Solver);
177+
assert_eq!(Smt::z3().extensions(), &["smt2"]);
178+
}
179+
180+
#[test]
181+
fn module_to_path_uses_smt2_extension() {
182+
assert_eq!(
183+
Smt::z3().module_to_path("queries.overflow", Path::new("/r")),
184+
PathBuf::from("/r/queries/overflow.smt2")
185+
);
186+
}
187+
188+
#[test]
189+
fn solvers_have_no_edges_or_roots() {
190+
let tmp = tempfile::tempdir().unwrap();
191+
let f = tmp.path().join("q.smt2");
192+
std::fs::write(&f, "(check-sat)\n").unwrap();
193+
assert!(Smt::z3().direct_imports(&f).unwrap().is_empty());
194+
assert!(Smt::z3().discover_roots(tmp.path()).is_empty());
195+
}
196+
197+
#[test]
198+
fn verdict_mapping_is_honest() {
199+
// unsat ⇒ Proven (VC discharged), sat ⇒ Refuted, unknown ⇒ Unknown.
200+
assert_eq!(parse_verdict("unsat\n", true), Verdict::Proven);
201+
assert_eq!(parse_verdict("sat\n", true), Verdict::Refuted);
202+
assert_eq!(parse_verdict("unknown\n", true), Verdict::Unknown);
203+
// "sat" is a substring of "unsat" but a distinct token — no confusion.
204+
assert_eq!(parse_verdict("unsat\nunsat\n", true), Verdict::Proven);
205+
// Any sat among several checks ⇒ Refuted; any unknown (no sat) ⇒ Unknown.
206+
assert_eq!(parse_verdict("unsat\nsat\n", true), Verdict::Refuted);
207+
assert_eq!(parse_verdict("unsat\nunknown\n", true), Verdict::Unknown);
208+
// Errors / non-zero exit / no result ⇒ Error, never a silent pass.
209+
assert_eq!(
210+
parse_verdict("(error \"parse error\")", false),
211+
Verdict::Error
212+
);
213+
assert_eq!(parse_verdict("(error \"x\")", true), Verdict::Error);
214+
assert_eq!(parse_verdict("", true), Verdict::Error);
215+
}
216+
217+
#[test]
218+
fn check_file_is_honest_about_availability() {
219+
let tmp = tempfile::tempdir().unwrap();
220+
let f = tmp.path().join("q.smt2");
221+
std::fs::write(
222+
&f,
223+
"(declare-const x Int)\n(assert (and (> x 2) (< x 1)))\n(check-sat)\n",
224+
)
225+
.unwrap();
226+
let out = Smt::z3().check_file(&f, tmp.path()).unwrap();
227+
assert_eq!(out.kind, BackendKind::Solver);
228+
if out.available {
229+
// If z3 is present this query is unsat ⇒ Proven; either way the
230+
// verdict is derived from real output, not fabricated.
231+
assert!(matches!(
232+
out.verdict,
233+
Verdict::Proven | Verdict::Refuted | Verdict::Unknown | Verdict::Error
234+
));
235+
assert_eq!(out.ok, out.verdict == Verdict::Proven);
236+
} else {
237+
assert_eq!(out.verdict, Verdict::Unavailable);
238+
}
239+
}
240+
}

tests/fixtures/smt/sat.smt2

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
; A satisfiable query: a model (counterexample) exists.
2+
; sat => Refuted.
3+
(set-logic QF_LIA)
4+
(declare-const x Int)
5+
(assert (> x 2))
6+
(check-sat)

0 commit comments

Comments
 (0)