Skip to main content

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}