logicaffeine_proof/sat.rs
1//! Propositional SAT-discharge of a `ProofExpr` obligation — the engine behind in-browser,
2//! Z3-free hardware proving.
3//!
4//! A bounded hardware property (an SVA assertion unrolled to discrete timesteps, or a
5//! Kripke-lowered FOL spec) reduces to a quantifier-free propositional formula over
6//! `signal@t` atoms. This module discharges two questions over that fragment, reusing the
7//! existing trust tiers ([`crate::cnf`] Tseitin → [`crate::cdcl`] CDCL → [`crate::rup`] RUP
8//! certification) so the answers are certified, not merely asserted:
9//!
10//! - [`find_model`] — is the obligation satisfiable, and if so, a distinguishing assignment.
11//! This is `∃trace. φ`, the core of bounded model checking and counterexample extraction.
12//! - [`prove_equivalence`] — do two formulas denote the same Boolean function? `F ≡ S` iff
13//! `F ↔ S` is a tautology, certified via RUP; otherwise a concrete counterexample trace.
14//!
15//! Everything here is pure Rust with no Z3 dependency, so it runs unchanged in the browser
16//! (wasm32) and in native tests where its verdicts are checked against Z3 as the oracle.
17
18use crate::cdcl::{BudgetedResult, Lit, SolveResult, Solver, Var};
19use crate::cnf::Cnf;
20use crate::rup;
21use crate::ProofExpr;
22use std::collections::HashMap;
23use std::collections::BTreeSet;
24
25/// The result of a satisfiability query over a propositional obligation.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub enum ModelOutcome {
28 /// Satisfiable, with a model over the source atoms (sorted by name). For a hardware
29 /// obligation these are the `signal@t` bindings of a witnessing trace.
30 Sat(Vec<(String, bool)>),
31 /// Unsatisfiable — no assignment satisfies the obligation.
32 Unsat,
33 /// Not a quantifier-free propositional formula over recognisable atoms, so the SAT
34 /// engine cannot speak to it (the caller must escalate, e.g. bit-blast first).
35 Unsupported,
36}
37
38/// The result of an unsatisfiability query — the shared primitive behind equivalence,
39/// bounded model checking, k-induction, and vacuity.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub enum UnsatOutcome {
42 /// The formula is unsatisfiable — RUP-certified (the refutation replays to empty).
43 Refuted,
44 /// The formula is satisfiable, with a witnessing model over its atoms.
45 Sat(Vec<(String, bool)>),
46 /// Not a quantifier-free propositional formula, or the refutation could not be
47 /// certified — fail-closed (never a false `Refuted`).
48 Unsupported,
49}
50
51/// The result of an equivalence query between two formulas.
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub enum EquivOutcome {
54 /// The two formulas denote the same Boolean function. The verdict is RUP-certified:
55 /// `F ↔ S` was replayed to the empty clause, not merely reported UNSAT.
56 Equivalent,
57 /// The formulas differ. The assignment is a concrete counterexample: under it exactly
58 /// one of the two formulas holds. For hardware, this is the distinguishing waveform.
59 Differ(Vec<(String, bool)>),
60 /// Not purely propositional over recognisable atoms — the caller must escalate.
61 Unsupported,
62}
63
64/// Is `e` satisfiable? Returns a witnessing model over its atoms if so.
65///
66/// `∃assignment. e`. Tseitin-clausifies `e`, runs CDCL, and on SAT decodes the model back
67/// to the source atoms appearing in `e` (Tseitin auxiliaries are dropped). Used directly by
68/// bounded model checking (`∃trace. ¬property`) and for counterexample extraction.
69pub fn find_model(e: &ProofExpr) -> ModelOutcome {
70 let mut cnf = Cnf::new();
71 if cnf.assert(e).is_none() {
72 return ModelOutcome::Unsupported;
73 }
74 // Move the atom table out alongside the solver — no clause-database clone just to decode.
75 let (mut solver, atom_of) = cnf.into_solver_with_atoms();
76 match solver.solve() {
77 SolveResult::Unsat => ModelOutcome::Unsat,
78 SolveResult::Sat(model) => ModelOutcome::Sat(decode_model_from(&atom_of, &model, &[e])),
79 }
80}
81
82/// Are `a` and `b` equivalent? `F ≡ S` iff `F ↔ S` is valid, i.e. `¬(F ↔ S)` is UNSAT.
83///
84/// One solve discharges both outcomes: a satisfying assignment is the counterexample
85/// (`Differ`); UNSAT is RUP-certified into `Equivalent` (the certified trust tier — a
86/// solver bug that can't be replayed yields `Unsupported`, never a false `Equivalent`).
87/// Structurally identical formulas short-circuit with no solve at all.
88pub fn prove_equivalence(a: &ProofExpr, b: &ProofExpr) -> EquivOutcome {
89 // Sound fast-path: identical formulas are trivially equivalent (no solve).
90 if a == b {
91 return EquivOutcome::Equivalent;
92 }
93 // `F ≡ S` iff `¬(F ↔ S)` is unsatisfiable.
94 let neg_iff = ProofExpr::Not(Box::new(ProofExpr::Iff(
95 Box::new(a.clone()),
96 Box::new(b.clone()),
97 )));
98 match prove_unsat(&neg_iff) {
99 UnsatOutcome::Refuted => EquivOutcome::Equivalent,
100 UnsatOutcome::Sat(model) => EquivOutcome::Differ(model),
101 UnsatOutcome::Unsupported => EquivOutcome::Unsupported,
102 }
103}
104
105/// Is `e` unsatisfiable? One CDCL solve decides it: `Sat` carries a witnessing model,
106/// `Unsat` is independently RUP-certified into `Refuted` (a refutation the trusted checker
107/// cannot replay yields `Unsupported`, never a false `Refuted`). This is the shared certified
108/// Does `e` reduce to an inconsistent mod-`m` linear system? Clausifies `e`, recovers the one-hot-encoded
109/// congruences ([`crate::modp::recover_from_cnf`]), and refutes them by certified modular Gaussian
110/// elimination — over the prime field `GF(p)` ([`crate::modp`]) when the recovered modulus is prime, or
111/// over `ℤ/m` by CRT across its prime factors ([`crate::modm`]) when it is composite. Fail-closed:
112/// recovery returns `None` on anything that is not a faithful mod-`m` encoding, and the produced linear
113/// refutation is independently re-checked, so a `true` is always a sound UNSAT. This is the engine behind
114/// the cascade's modular fast-path — the same lift `solve_structured` routes to `ModP`/`ModM`.
115fn refutes_modular(e: &ProofExpr) -> bool {
116 let mut cnf = Cnf::new();
117 if cnf.assert(e).is_none() {
118 return false;
119 }
120 let Some(rec) = crate::modp::recover_from_cnf(cnf.num_vars(), cnf.clauses()) else {
121 return false;
122 };
123 if crate::modp::is_prime(rec.modulus) {
124 match crate::modp::solve(&rec.equations, rec.num_vars, rec.modulus) {
125 crate::modp::ModpOutcome::Unsat(combo) => {
126 crate::modp::is_refutation(&rec.equations, rec.num_vars, rec.modulus, &combo)
127 }
128 _ => false,
129 }
130 } else {
131 match crate::modm::solve(&rec.equations, rec.num_vars, rec.modulus) {
132 Some(crate::modm::ModmOutcome::Unsat { modulus, combo }) => {
133 crate::modm::is_refutation(&rec.equations, rec.num_vars, modulus, &combo)
134 }
135 _ => false,
136 }
137 }
138}
139
140/// Does `e` collapse under an auto-discovered covering symmetry, an asymmetric cardinality cover, or a
141/// parity structure? Clausifies `e` and runs [`crate::lyapunov::auto_collapse`], which synthesizes a
142/// Lyapunov measure and certifies the descent to a contradiction. This catches the *irregularly-encoded*
143/// coverings and counting cores that the strict structural recognizers (pigeonhole, cutting-planes) miss
144/// — the small-`n` census finds 756 such minimal-UNSAT families at n=4 alone. Fail-closed: each
145/// `auto_collapse` variant is returned only once its contradiction is actually reached/checked
146/// (`ranked.refuted` / `reached_goal`), and its XOR/covering recovery is conservative, so a `true` is a
147/// sound UNSAT. Runs after the cheap structural cuts, before search.
148fn refutes_by_collapse(e: &ProofExpr) -> bool {
149 let mut cnf = Cnf::new();
150 if cnf.assert(e).is_none() {
151 return false;
152 }
153 !matches!(
154 crate::lyapunov::auto_collapse(cnf.num_vars(), cnf.clauses()),
155 crate::lyapunov::AutoCollapse::None
156 )
157}
158
159/// Does `e` have a low-degree **Nullstellensatz** refutation over GF(2)? Clausifies `e` and asks whether
160/// `1` lies in the degree-`d` GF(2)-span of the clause polynomials (`crate::polycalc`) — the universal
161/// algebraic cut that *subsumes* the narrow ones (parity is its degree-1 fragment) and, at degree ≥ 2,
162/// certifies the irregular low-degree-algebraic cores no structural recognizer matches. The degree is
163/// **size-gated**: we take the largest `d` whose monomial basis `Σ_{i≤d} C(n,i)` fits a fixed budget —
164/// degree 1 (cheap, general GF(2)-affine) for large formulas, higher degree only when `n` is small — so
165/// the cut never threatens the hot path. Nullstellensatz refutability is monotone in `d`, so the single
166/// call at the gated degree decides it. Sound: a certificate exists only for an unsatisfiable formula.
167/// The largest algebraic degree whose monomial basis `Σ_{i≤d} C(n,i)` fits a fixed budget — the
168/// size-gate shared by the Nullstellensatz and Polynomial-Calculus cuts. Degree 1 (cheap, general
169/// GF(2)-affine) for large formulas, higher degree only when `n` is small; `0` when even degree 1 is too
170/// wide (leave it to search). Keeps the algebraic cuts off the hot path.
171fn gated_algebraic_degree(n: usize) -> usize {
172 const MONO_BUDGET: u128 = 4000;
173 let mut dmax = 0usize;
174 let mut monos: u128 = 1; // the constant monomial (degree 0)
175 let mut binom: u128 = 1; // C(n, d), updated as C(n,d) = C(n,d-1)·(n-d+1)/d
176 for d in 1..=n {
177 binom = binom * (n - d + 1) as u128 / d as u128;
178 if monos + binom > MONO_BUDGET {
179 break;
180 }
181 monos += binom;
182 dmax = d;
183 }
184 dmax
185}
186
187fn refutes_by_nullstellensatz(e: &ProofExpr) -> bool {
188 let mut cnf = Cnf::new();
189 if cnf.assert(e).is_none() {
190 return false;
191 }
192 let n = cnf.num_vars();
193 let d = gated_algebraic_degree(n);
194 if d == 0 {
195 return false;
196 }
197 crate::polycalc::nullstellensatz_refutes(n, cnf.clauses(), d)
198}
199
200/// Does `e` have a low-degree **Polynomial Calculus** refutation over GF(2)? PC is the dynamic
201/// strengthening of Nullstellensatz — it closes the clause polynomials under linear combination *and*
202/// multiply-by-variable, so an intermediate cancellation lets it certify at the gated degree a strict
203/// superset of what the NS cut above reaches (measured: real families at n=3). Same size-gate, runs only
204/// when the cheaper NS cut has already declined, sound (`1` derivable ⟹ unsatisfiable).
205fn refutes_by_polynomial_calculus(e: &ProofExpr) -> bool {
206 let mut cnf = Cnf::new();
207 if cnf.assert(e).is_none() {
208 return false;
209 }
210 let n = cnf.num_vars();
211 let d = gated_algebraic_degree(n);
212 if d == 0 {
213 return false;
214 }
215 crate::polycalc::polynomial_calculus_refutes(n, cnf.clauses(), d)
216}
217
218/// Variable-count ceiling under which `prove_unsat` runs the certified recursive symmetry breaker as its
219/// terminal rung. The symmetric-UNSAT instances that survive every algebraic cut and reach that rung are
220/// small; above the cap the automorphism re-detection is not worth its cost, so the cheap single-pass row
221/// break takes over.
222const CERTIFIED_SYMMETRY_VAR_CAP: usize = 64;
223
224/// Conflict budget for the first, cheap pass of the complete search in [`prove_unsat`]. Easy
225/// instances finish far below it; a hard one exhausts it and thereby EARNS the expensive terminal
226/// certified-symmetry rung. Keeping the rung escalation-only is the invariant that keeps
227/// microsecond obligations at microseconds (the rung costs ~50ms even on tiny formulas).
228const EASY_SEARCH_CONFLICTS: u64 = 2_000;
229
230/// Refute `e` with the **certified recursive symmetry breaker** and return the proof. Each round
231/// re-detects the residual automorphism group, certifies ONE lex-leader lead clause as a PR step (its
232/// generator a fresh automorphism of the current database), and re-detects — looping to a fixpoint that
233/// breaks the COMPLETE group, not just the adjacent positive-row swaps [`crate::symmetry::break_symmetries`]
234/// sees. The returned stream is the certified PR breaks followed by the closing RUP learned clauses, and
235/// it is already `check_pr_refutation`-verified against `e`'s CNF. `None` if `e` is not CNF-assertable, is
236/// satisfiable, or the breaker did not certify a refutation — the wired, checkable companion of
237/// [`prove_unsat`]'s coarse verdict.
238pub fn prove_unsat_certified(e: &ProofExpr) -> Option<Vec<crate::proof::ProofStep>> {
239 let mut cnf = Cnf::new();
240 cnf.assert(e)?;
241 let r = crate::sym_certify::certified_unsat_auto(cnf.num_vars(), cnf.clauses());
242 r.refuted.then_some(r.steps)
243}
244
245/// core for equivalence, bounded model checking, k-induction, and vacuity.
246pub fn prove_unsat(e: &ProofExpr) -> UnsatOutcome {
247 // Pigeonhole fast-path: a conjunction of at-least-one rows + fully-encoded at-most-one columns is
248 // a bipartite-matching question that costs CDCL *exponentially* many resolution steps, but the
249 // matching reasoner decides in *polynomial* time with a re-verified Hall witness — a sound UNSAT
250 // certificate. Fires only on a faithfully-recognized, infeasible pigeonhole structure (never a
251 // false `Refuted`); everything else falls through to the certified CDCL core below.
252 if crate::pigeonhole::decide_pigeonhole_unsat(e) {
253 return UnsatOutcome::Refuted;
254 }
255 // Ordering-principle fast-path: a complete GT(n) core — a strict total order (totality +
256 // antisymmetry + transitivity) with no maximal element — is unsatisfiable, since a finite strict
257 // total order always has a maximum. Recognized structurally and certified from that structure in
258 // polynomial time, where the general cascade searches super-polynomially. Faithful/fail-closed
259 // (never a false `Refuted`); falls through for anything that is not a complete ordering core.
260 if crate::ordering::refutes_ordering_principle(e) {
261 return UnsatOutcome::Refuted;
262 }
263 // Cutting-planes fast-path: recover the at-most-one cardinality from the exclusion cliques and
264 // sum-refute to `0 ≥ 1` — a POLYNOMIAL cutting-plane proof of a pigeonhole/cardinality CNF that
265 // resolution (CDCL) refutes only exponentially. Sound (each step a cutting-plane inference over
266 // verified at-most-one cliques, never a false `Refuted`); falls through for anything else.
267 if crate::pseudo_boolean::refute_clausal(e) {
268 return UnsatOutcome::Refuted;
269 }
270 // Parity (GF(2)) fast-path: a Tseitin/XOR cover is a linear system over GF(2). Its CNF encoding is
271 // resolution-hard (CDCL blows up exponentially on expander instances), but Gaussian elimination
272 // decides it in POLYNOMIAL time. We recognize the wrong-parity clause bundles, recover the XOR
273 // equations they imply, and refute the linear subsystem — sound (each equation a consequence of
274 // `e`, the refutation re-checkable), falling through for anything without inconsistent parity.
275 if crate::xorsat::refute_via_parity(e) {
276 return UnsatOutcome::Refuted;
277 }
278 // Modular (GF(p) / ℤ-mod-m) fast-path: the parity cut above speaks only GF(2), but a mod-`m`
279 // counting/Tseitin obstruction is a linear system over ℤ/m. Its one-hot CNF encoding is
280 // resolution-hard — CDCL blows up *exponentially* (and Z3/Kissat time out), and the GF(2) cut is
281 // blind to odd characteristic — yet Gaussian elimination over the *right* modulus decides it in
282 // polynomial time. We recover the congruence system and refute it over the prime field `GF(p)` when
283 // `m` is prime, or over ℤ/m by CRT across its prime factors when it is composite. Sound and
284 // fail-closed: recovery declines on non-encodings and every modular refutation is re-checked, so a
285 // `Refuted` here is never false. This carries the parity cut to every modulus.
286 if refutes_modular(e) {
287 return UnsatOutcome::Refuted;
288 }
289 // The apex algebraic rung — Sum-of-Squares / Positivstellensatz over the ordered field
290 // (`crate::sos`) — would slot here, certifying integrality gaps the GF(2) cuts above structurally
291 // cannot. It is *deliberately not wired*: with `sos::MAX_VARS = 6` it only runs where the
292 // Nullstellensatz cut already runs at full degree (`2ⁿ ≤ MONO_BUDGET`, i.e. `n ≤ 11`), and there NS
293 // is *complete* — so SoS would never fire. It earns a slot only once its reach extends into the
294 // `n ≥ 12` band where NS is gated below full degree, which needs the symmetry-reduced / PSD SoS that
295 // scales past the Fourier–Motzkin wall. The wiring is then one size-gated `sos::sos_refutes` call.
296 // Symmetry breaking: a symmetric formula forces CDCL to re-derive the same conflict once per
297 // symmetric copy (the pigeon symmetry multiplies the refutation by `n!`). Augmenting with sound,
298 // *verified-automorphism* lex-leader SBPs collapses each orbit so the solver searches the
299 // quotient. The SBPs preserve satisfiability, so refuting `e ∧ SBP` refutes `e`; the model decode
300 // and the `e` reported on `Sat` stay over the original atoms (auxiliaries are skipped).
301 let augmented = crate::symmetry::break_symmetries(e);
302 let mut cnf = Cnf::new();
303 if cnf.assert(&augmented).is_none() {
304 return UnsatOutcome::Unsupported;
305 }
306 let num_vars = cnf.num_vars();
307 // Move the atom table out alongside the solver (the RUP checker reads the original clauses
308 // back from the solver via `original_clauses()`, so no clone of the CNF is needed).
309 let (mut solver, atom_of) = cnf.into_solver_with_atoms();
310 // The complete search runs FIRST, conflict-budgeted: an easy instance — the overwhelming
311 // majority of equivalence/BMC obligations — gets its verdict in microseconds and never pays
312 // for the terminal rung below. Only an instance that exhausts the budget (the exponential
313 // families the rung exists for) escalates.
314 let first = match solver.solve_budgeted(EASY_SEARCH_CONFLICTS) {
315 BudgetedResult::Sat(model) => SolveResult::Sat(model),
316 BudgetedResult::Unsat => SolveResult::Unsat,
317 BudgetedResult::Budget => {
318 // The instance has EARNED escalation: the cheap complete search could not decide it
319 // within the budget, so it is exactly the class the expensive certified cuts exist
320 // for. Easy obligations never reach any of these.
321 //
322 // Covering / cardinality / algebraic collapse: auto-discover a covering *symmetry* (the
323 // pigeonhole class beyond the strict recognizer), an asymmetric cardinality cover (the
324 // mutilated-chessboard class), or a parity collapse, and certify it via a synthesized
325 // Lyapunov descent. The census proves it closes 756 minimal-UNSAT families at n=4 that
326 // every narrow upfront cut is blind to. Fail-closed.
327 if refutes_by_collapse(e) {
328 return UnsatOutcome::Refuted;
329 }
330 // Nullstellensatz: the universal *algebraic* cut. Where the structural recognizers each
331 // match one shape, this asks the shape-free question — is `1` in the low-degree
332 // GF(2)-span of the clause polynomials? — and certifies any instance with a low-degree
333 // algebraic refutation, the census's "rigid" residue that nonetheless has a degree-2/3
334 // certificate. Size-gated; sound (a certificate implies UNSAT).
335 if refutes_by_nullstellensatz(e) {
336 return UnsatOutcome::Refuted;
337 }
338 // Polynomial Calculus: the *dynamic* algebraic cut, one rung above Nullstellensatz. By
339 // closing the clause polynomials under linear combination AND multiply-by-variable
340 // (intermediate cancellations and all), it certifies at the same gated degree a strict
341 // superset of NS — the thin but real sliver NS leaves behind. Runs only after NS
342 // declines; size-gated; sound.
343 if refutes_by_polynomial_calculus(e) {
344 return UnsatOutcome::Refuted;
345 }
346 // Certified RECURSIVE symmetry break (the terminal certified rung). Detect the residual
347 // automorphism group of the current database, certify ONE lex-leader lead clause as a PR
348 // step, re-detect, and loop to a fixpoint — breaking the COMPLETE group rather than the
349 // adjacent positive-row swaps the single-pass break above sees (e.g. clique-coloring's
350 // COLOR-permutation symmetry, a column swap the row break is structurally blind to). The
351 // composed stream (certified PR breaks + closing RUP learned clauses) is
352 // `check_pr_refutation`-verified against this formula's CNF inside `certified_unsat_auto`,
353 // so a `refuted` here is a fully checked refutation. Size-gated — larger instances fall to
354 // the cheap single-pass row break — and only claims UNSAT (a satisfiable formula leaves
355 // `refuted == false`, so its model is recovered by resuming the search).
356 let mut base = Cnf::new();
357 if base.assert(e).is_some()
358 && base.num_vars() <= CERTIFIED_SYMMETRY_VAR_CAP
359 && crate::sym_certify::certified_unsat_auto(base.num_vars(), base.clauses()).refuted
360 {
361 return UnsatOutcome::Refuted;
362 }
363 // Complete the search on a FRESH solver over the original clauses: a budget-stopped
364 // solver is not re-enterable (inprocessing assumes a virgin DB), and rebuilding keeps
365 // the refutation certificate self-contained — the RUP checker replays only clauses
366 // this solve derived, against only the formula's own clauses.
367 let originals: Vec<Vec<Lit>> = solver.original_clauses().to_vec();
368 solver = Solver::new(num_vars);
369 for c in originals {
370 solver.add_clause(c);
371 }
372 solver.solve()
373 }
374 };
375 match first {
376 SolveResult::Sat(model) => UnsatOutcome::Sat(decode_model_from(&atom_of, &model, &[e])),
377 SolveResult::Unsat => {
378 let learned: Vec<Vec<Lit>> = solver.learned().iter().map(|c| c.lits.clone()).collect();
379 if rup::check_refutation(num_vars, solver.original_clauses(), &learned) {
380 UnsatOutcome::Refuted
381 } else {
382 UnsatOutcome::Unsupported
383 }
384 }
385 }
386}
387
388/// Decode a SAT `model` back to `(atom, value)` bindings for every source atom appearing in
389/// `exprs` (Tseitin auxiliaries carry no source meaning and are skipped), sorted by name.
390pub fn decode_model(cnf: &Cnf, model: &[bool], exprs: &[&ProofExpr]) -> Vec<(String, bool)> {
391 let mut atoms = BTreeSet::new();
392 for e in exprs {
393 collect_atoms(e, &mut atoms);
394 }
395 atoms
396 .into_iter()
397 .filter_map(|name| {
398 cnf.atom_value(&ProofExpr::Atom(name.clone()), model)
399 .map(|v| (name, v))
400 })
401 .collect()
402}
403
404/// Decode a SAT `model` from a pre-extracted atom→variable map (the table `Cnf` already holds),
405/// so a solve need not clone the whole clause database just to read its model back. Equivalent to
406/// [`decode_model`] for the propositional atoms `collect_atoms` gathers.
407pub fn decode_model_from(
408 atom_of: &HashMap<String, Var>,
409 model: &[bool],
410 exprs: &[&ProofExpr],
411) -> Vec<(String, bool)> {
412 let mut atoms = BTreeSet::new();
413 for e in exprs {
414 collect_atoms(e, &mut atoms);
415 }
416 atoms
417 .into_iter()
418 .filter_map(|name| {
419 // Atoms are interned under the `atom:` key (see `cnf::atom_key`).
420 atom_of
421 .get(&format!("atom:{name}"))
422 .and_then(|&v| model.get(v as usize).copied())
423 .map(|val| (name, val))
424 })
425 .collect()
426}
427
428/// Collect the names of every propositional [`ProofExpr::Atom`] reachable through the
429/// Boolean fragment (`∧ ∨ ¬ → ↔`). Non-Boolean nodes are ignored — they cannot appear in a
430/// bounded hardware obligation, and silently skipping them keeps the decode total.
431fn collect_atoms(e: &ProofExpr, out: &mut BTreeSet<String>) {
432 match e {
433 ProofExpr::Atom(name) => {
434 out.insert(name.clone());
435 }
436 ProofExpr::Not(p) => collect_atoms(p, out),
437 ProofExpr::And(p, q)
438 | ProofExpr::Or(p, q)
439 | ProofExpr::Implies(p, q)
440 | ProofExpr::Iff(p, q) => {
441 collect_atoms(p, out);
442 collect_atoms(q, out);
443 }
444 _ => {}
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 fn atom(s: &str) -> ProofExpr {
453 ProofExpr::Atom(s.to_string())
454 }
455 fn not(e: ProofExpr) -> ProofExpr {
456 ProofExpr::Not(Box::new(e))
457 }
458 fn and(a: ProofExpr, b: ProofExpr) -> ProofExpr {
459 ProofExpr::And(Box::new(a), Box::new(b))
460 }
461 fn or(a: ProofExpr, b: ProofExpr) -> ProofExpr {
462 ProofExpr::Or(Box::new(a), Box::new(b))
463 }
464 fn implies(a: ProofExpr, b: ProofExpr) -> ProofExpr {
465 ProofExpr::Implies(Box::new(a), Box::new(b))
466 }
467
468 /// Evaluate a Boolean `ProofExpr` under an assignment — the independent oracle that
469 /// proves a returned counterexample genuinely distinguishes two formulas (robust-to-
470 /// absurdity: we never trust the solver's model without re-checking it ourselves).
471 fn eval(e: &ProofExpr, env: &[(String, bool)]) -> bool {
472 match e {
473 ProofExpr::Atom(n) => env.iter().find(|(k, _)| k == n).map(|(_, v)| *v).unwrap_or(false),
474 ProofExpr::Not(p) => !eval(p, env),
475 ProofExpr::And(p, q) => eval(p, env) && eval(q, env),
476 ProofExpr::Or(p, q) => eval(p, env) || eval(q, env),
477 ProofExpr::Implies(p, q) => !eval(p, env) || eval(q, env),
478 ProofExpr::Iff(p, q) => eval(p, env) == eval(q, env),
479 _ => panic!("non-boolean node in test eval"),
480 }
481 }
482
483 #[test]
484 fn reflexive_equivalence_is_certified() {
485 // `req |-> ack` at t0 against itself — the trivial but load-bearing identity.
486 let f = implies(atom("req@0"), atom("ack@0"));
487 assert_eq!(prove_equivalence(&f, &f), EquivOutcome::Equivalent);
488 }
489
490 #[test]
491 fn de_morgan_is_equivalent() {
492 // ¬(a ∧ b) ≡ (¬a ∨ ¬b)
493 let lhs = not(and(atom("a@0"), atom("b@0")));
494 let rhs = or(not(atom("a@0")), not(atom("b@0")));
495 assert_eq!(prove_equivalence(&lhs, &rhs), EquivOutcome::Equivalent);
496 }
497
498 #[test]
499 fn distributivity_is_equivalent() {
500 // a ∧ (b ∨ c) ≡ (a ∧ b) ∨ (a ∧ c)
501 let lhs = and(atom("a@0"), or(atom("b@0"), atom("c@0")));
502 let rhs = or(and(atom("a@0"), atom("b@0")), and(atom("a@0"), atom("c@0")));
503 assert_eq!(prove_equivalence(&lhs, &rhs), EquivOutcome::Equivalent);
504 }
505
506 #[test]
507 fn distinct_tautologies_are_equivalent() {
508 // Two excluded-middle tautologies over different atoms are both constantly true,
509 // so they are equivalent even with no shared variables.
510 let lhs = or(atom("p@0"), not(atom("p@0")));
511 let rhs = or(atom("q@0"), not(atom("q@0")));
512 assert_eq!(prove_equivalence(&lhs, &rhs), EquivOutcome::Equivalent);
513 }
514
515 #[test]
516 fn implication_is_not_its_consequent() {
517 // `req → ack` vs `ack` differ: at req=0, ack=0 the implication holds but ack does
518 // not. The verdict must be Differ AND the counterexample must genuinely distinguish.
519 let f = implies(atom("req@0"), atom("ack@0"));
520 let s = atom("ack@0");
521 match prove_equivalence(&f, &s) {
522 EquivOutcome::Differ(model) => {
523 assert_ne!(
524 eval(&f, &model),
525 eval(&s, &model),
526 "counterexample {:?} must distinguish the two formulas",
527 model
528 );
529 }
530 other => panic!("expected Differ, got {:?}", other),
531 }
532 }
533
534 #[test]
535 fn implication_is_not_its_converse() {
536 // `req → ack` vs `ack → req` differ; verify the witness is real.
537 let f = implies(atom("req@0"), atom("ack@0"));
538 let s = implies(atom("ack@0"), atom("req@0"));
539 match prove_equivalence(&f, &s) {
540 EquivOutcome::Differ(model) => {
541 assert_ne!(eval(&f, &model), eval(&s, &model));
542 }
543 other => panic!("expected Differ, got {:?}", other),
544 }
545 }
546
547 #[test]
548 fn find_model_of_contradiction_is_unsat() {
549 assert_eq!(find_model(&and(atom("a@0"), not(atom("a@0")))), ModelOutcome::Unsat);
550 }
551
552 #[test]
553 fn find_model_of_satisfiable_returns_witness() {
554 // a ∧ (a → b) forces a=true, b=true.
555 let e = and(atom("a@0"), implies(atom("a@0"), atom("b@0")));
556 match find_model(&e) {
557 ModelOutcome::Sat(model) => {
558 assert!(eval(&e, &model), "returned model must actually satisfy the formula");
559 assert!(model.iter().any(|(k, v)| k == "a@0" && *v));
560 assert!(model.iter().any(|(k, v)| k == "b@0" && *v));
561 }
562 other => panic!("expected Sat, got {:?}", other),
563 }
564 }
565}