Skip to main content

logicaffeine_proof/
bmc.rs

1//! Bounded model checking, k-induction, and vacuity over `ProofExpr` — pure-Rust, certified,
2//! Z3-free, browser-ready.
3//!
4//! A transition system is given as three closures over `signal@t` atoms:
5//! - `init` — the constraint at `t = 0`,
6//! - `trans(t)` — relates state `@t` to `@t+1`,
7//! - `property(t)` — the safety property that must hold at `@t`.
8//!
9//! Every question reduces to one unsatisfiability query discharged by
10//! [`crate::sat::prove_unsat`] (CDCL + RUP certification):
11//! - **BMC** ([`find_counterexample`]) — is a violating state reachable within `k` steps?
12//!   `init ∧ ⋀ trans ∧ ¬property(k)` satisfiable ⇒ a counterexample trace.
13//! - **k-induction** ([`prove_invariant`]) — base (no violation in the first `k` steps) +
14//!   step (`property` is `k`-inductive); both certified-UNSAT ⇒ the property holds for ALL
15//!   reachable states (an unbounded proof, not merely bounded).
16//! - **Vacuity** ([`check_vacuity`]) — can the antecedent ever fire? Unsatisfiable ⇒ a dead
17//!   trigger and a vacuously-true property.
18
19use crate::cdcl::SolveResult;
20use crate::cnf::Cnf;
21use crate::sat::{decode_model, prove_unsat, UnsatOutcome};
22use crate::ProofExpr;
23
24fn not(e: ProofExpr) -> ProofExpr {
25    ProofExpr::Not(Box::new(e))
26}
27
28/// Conjoin a list of obligations. An empty list is the constant `true` (a tautology over a
29/// reserved atom, since `ProofExpr` has no Boolean literal).
30fn conj(mut parts: Vec<ProofExpr>) -> ProofExpr {
31    match parts.len() {
32        0 => {
33            let c = ProofExpr::Atom("__bmc_true".to_string());
34            ProofExpr::Or(Box::new(c.clone()), Box::new(not(c)))
35        }
36        1 => parts.pop().unwrap(),
37        _ => {
38            let mut acc = parts.pop().unwrap();
39            while let Some(p) = parts.pop() {
40                acc = ProofExpr::And(Box::new(p), Box::new(acc));
41            }
42            acc
43        }
44    }
45}
46
47/// `init ∧ trans(0) ∧ … ∧ trans(k-1)` — the symbolic paths of length `k` from an initial
48/// state.
49fn unrolled_path(
50    init: &ProofExpr,
51    trans: &dyn Fn(u32) -> ProofExpr,
52    k: u32,
53) -> Vec<ProofExpr> {
54    let mut parts = vec![init.clone()];
55    for i in 0..k {
56        parts.push(trans(i));
57    }
58    parts
59}
60
61/// The verdict of bounded model checking.
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub enum BmcOutcome {
64    /// A reachable state at depth `k` violates the property, with a witnessing trace.
65    CounterexampleAt { k: u32, trace: Vec<(String, bool)> },
66    /// No violating state is reachable within `max_k` transitions (a bounded guarantee).
67    NoneWithin(u32),
68    /// An obligation left the propositional fragment — escalate (e.g. bit-blast).
69    Unsupported,
70}
71
72/// Bounded model checking: search for a reachable state violating `property` within `max_k`
73/// transitions, returning the shallowest counterexample (and its trace) if one exists.
74pub fn find_counterexample(
75    init: &ProofExpr,
76    trans: &dyn Fn(u32) -> ProofExpr,
77    property: &dyn Fn(u32) -> ProofExpr,
78    max_k: u32,
79) -> BmcOutcome {
80    for k in 0..=max_k {
81        let mut parts = unrolled_path(init, trans, k);
82        parts.push(not(property(k)));
83        match prove_unsat(&conj(parts)) {
84            UnsatOutcome::Sat(trace) => return BmcOutcome::CounterexampleAt { k, trace },
85            UnsatOutcome::Refuted => continue,
86            UnsatOutcome::Unsupported => return BmcOutcome::Unsupported,
87        }
88    }
89    BmcOutcome::NoneWithin(max_k)
90}
91
92/// Bounded model checking, **incrementally**: the unrolling is clausified ONCE into a single
93/// persistent solver, and each depth's violation `¬property(k)` is checked by
94/// [`crate::cdcl::Solver::solve_under_assumptions`]. Every clause learned while ruling out a
95/// shallow depth is reused at the next — the IPASIR amortisation that makes deep BMC fast.
96///
97/// All transitions are asserted up front, so this is sound for **total** transition relations
98/// — every hardware next-state function is total (a prefix always extends to a full path), so
99/// the result matches the one-clause-per-call [`find_counterexample`]. For a partial /
100/// over-constrained transition relation, prefer [`find_counterexample`].
101pub fn find_counterexample_incremental(
102    init: &ProofExpr,
103    trans: &dyn Fn(u32) -> ProofExpr,
104    property: &dyn Fn(u32) -> ProofExpr,
105    max_k: u32,
106) -> BmcOutcome {
107    let mut cnf = Cnf::new();
108    if cnf.assert(init).is_none() {
109        return BmcOutcome::Unsupported;
110    }
111    for i in 0..max_k {
112        if cnf.assert(&trans(i)).is_none() {
113            return BmcOutcome::Unsupported;
114        }
115    }
116    // Encode each depth's violation to an activation literal (defining clauses only — the
117    // violation is asserted per query, via the assumption), and remember the exprs whose
118    // atoms make up a decoded trace.
119    let mut bad = Vec::with_capacity(max_k as usize + 1);
120    let mut atom_exprs: Vec<ProofExpr> = vec![init.clone()];
121    for i in 0..max_k {
122        atom_exprs.push(trans(i));
123    }
124    for k in 0..=max_k {
125        let violation = not(property(k));
126        match cnf.encode(&violation) {
127            Some(lit) => bad.push(lit),
128            None => return BmcOutcome::Unsupported,
129        }
130        atom_exprs.push(violation);
131    }
132
133    let decode_cnf = cnf.clone();
134    let mut solver = cnf.into_solver();
135    let refs: Vec<&ProofExpr> = atom_exprs.iter().collect();
136    for (k, &activation) in bad.iter().enumerate() {
137        match solver.solve_under_assumptions(&[activation]) {
138            SolveResult::Sat(model) => {
139                return BmcOutcome::CounterexampleAt {
140                    k: k as u32,
141                    trace: decode_model(&decode_cnf, &model, &refs),
142                }
143            }
144            SolveResult::Unsat => continue,
145        }
146    }
147    BmcOutcome::NoneWithin(max_k)
148}
149
150// ── Temporal symmetry ──────────────────────────────────────────────────────────────────────────
151//
152// A `signal@t` atom names a base signal observed at a time frame. A permutation of the BASE signals
153// that preserves the initial constraint, the (uniform) transition relation, and the property is a
154// symmetry of the system at EVERY frame — so applying it uniformly across the whole unrolling is an
155// automorphism of the BMC formula. Detecting it on the three single-frame obligations (not the giant
156// flat unrolling) is the temporal, model-checking-specific move; breaking it at the initial frame
157// prunes symmetric trajectories without changing whether a counterexample exists.
158
159/// Split a `signal@t` atom into `(base, frame)`; `None` for atoms carrying no frame.
160fn split_frame(a: &str) -> Option<(&str, &str)> {
161    a.rsplit_once('@')
162}
163
164/// Rename a formula by swapping two base signals, preserving each atom's time frame.
165fn swap_signals(e: &ProofExpr, a: &str, b: &str) -> ProofExpr {
166    match e {
167        ProofExpr::Atom(s) => match split_frame(s) {
168            Some((base, frame)) => {
169                let nb = if base == a {
170                    b
171                } else if base == b {
172                    a
173                } else {
174                    base
175                };
176                ProofExpr::Atom(format!("{nb}@{frame}"))
177            }
178            None => e.clone(),
179        },
180        ProofExpr::Not(x) => not(swap_signals(x, a, b)),
181        ProofExpr::And(x, y) => {
182            ProofExpr::And(Box::new(swap_signals(x, a, b)), Box::new(swap_signals(y, a, b)))
183        }
184        ProofExpr::Or(x, y) => {
185            ProofExpr::Or(Box::new(swap_signals(x, a, b)), Box::new(swap_signals(y, a, b)))
186        }
187        ProofExpr::Iff(x, y) => {
188            ProofExpr::Iff(Box::new(swap_signals(x, a, b)), Box::new(swap_signals(y, a, b)))
189        }
190        ProofExpr::Implies(x, y) => {
191            ProofExpr::Implies(Box::new(swap_signals(x, a, b)), Box::new(swap_signals(y, a, b)))
192        }
193        other => other.clone(),
194    }
195}
196
197/// Are two propositional obligations logically equivalent? (Their XOR is certified-UNSAT.) Semantic, so
198/// it recognises symmetries a purely syntactic clause-set comparison would miss.
199fn equivalent(e: &ProofExpr, f: &ProofExpr) -> bool {
200    let xor = ProofExpr::Or(
201        Box::new(ProofExpr::And(Box::new(e.clone()), Box::new(not(f.clone())))),
202        Box::new(ProofExpr::And(Box::new(f.clone()), Box::new(not(e.clone())))),
203    );
204    matches!(prove_unsat(&xor), UnsatOutcome::Refuted)
205}
206
207/// Accumulate the base signals appearing in a formula.
208fn base_signals(e: &ProofExpr, out: &mut std::collections::BTreeSet<String>) {
209    match e {
210        ProofExpr::Atom(s) => {
211            if let Some((base, _)) = split_frame(s) {
212                out.insert(base.to_string());
213            }
214        }
215        ProofExpr::Not(x) => base_signals(x, out),
216        ProofExpr::And(x, y)
217        | ProofExpr::Or(x, y)
218        | ProofExpr::Iff(x, y)
219        | ProofExpr::Implies(x, y) => {
220            base_signals(x, out);
221            base_signals(y, out);
222        }
223        _ => {}
224    }
225}
226
227/// The **temporal symmetries** of a transition system: pairs of base signals interchangeable across all
228/// time — a swap that preserves the initial constraint, the transition relation (checked on the generic
229/// frame `trans(0)`, hence on every frame by uniformity), and the property. Each surviving pair is a
230/// genuine system automorphism when lifted uniformly through the unrolling.
231pub fn temporal_symmetry_pairs(
232    init: &ProofExpr,
233    trans0: &ProofExpr,
234    property0: &ProofExpr,
235) -> Vec<(String, String)> {
236    let mut set = std::collections::BTreeSet::new();
237    base_signals(init, &mut set);
238    base_signals(trans0, &mut set);
239    base_signals(property0, &mut set);
240    let sigs: Vec<String> = set.into_iter().collect();
241    let mut pairs = Vec::new();
242    for i in 0..sigs.len() {
243        for j in (i + 1)..sigs.len() {
244            let (a, b) = (sigs[i].as_str(), sigs[j].as_str());
245            if equivalent(init, &swap_signals(init, a, b))
246                && equivalent(trans0, &swap_signals(trans0, a, b))
247                && equivalent(property0, &swap_signals(property0, a, b))
248            {
249                pairs.push((a.to_string(), b.to_string()));
250            }
251        }
252    }
253    pairs
254}
255
256/// Bounded model checking with **temporal symmetry breaking**. Interchangeable signals
257/// ([`temporal_symmetry_pairs`]) give a system automorphism that holds at every frame, so a swap lifted
258/// uniformly through the unrolling permutes counterexample trajectories. We order each pair at the initial
259/// frame (`a@0 ≤ b@0`, i.e. `¬a@0 ∨ b@0`): a sound partial lex-leader that keeps at least one trajectory
260/// per symmetry orbit. Hence a counterexample exists under the ordering iff one exists at all, and the
261/// shallowest violating depth is unchanged — the verdict matches [`find_counterexample`], with the
262/// symmetric search space pruned.
263pub fn find_counterexample_symmetric(
264    init: &ProofExpr,
265    trans: &dyn Fn(u32) -> ProofExpr,
266    property: &dyn Fn(u32) -> ProofExpr,
267    max_k: u32,
268) -> BmcOutcome {
269    let pairs = temporal_symmetry_pairs(init, &trans(0), &property(0));
270    let breaks: Vec<ProofExpr> = pairs
271        .iter()
272        .map(|(a, b)| {
273            ProofExpr::Or(
274                Box::new(not(ProofExpr::Atom(format!("{a}@0")))),
275                Box::new(ProofExpr::Atom(format!("{b}@0"))),
276            )
277        })
278        .collect();
279    for k in 0..=max_k {
280        let mut parts = unrolled_path(init, trans, k);
281        parts.push(not(property(k)));
282        parts.extend(breaks.iter().cloned());
283        match prove_unsat(&conj(parts)) {
284            UnsatOutcome::Sat(trace) => return BmcOutcome::CounterexampleAt { k, trace },
285            UnsatOutcome::Refuted => continue,
286            UnsatOutcome::Unsupported => return BmcOutcome::Unsupported,
287        }
288    }
289    BmcOutcome::NoneWithin(max_k)
290}
291
292/// The verdict of k-induction.
293#[derive(Clone, Debug, PartialEq, Eq)]
294pub enum InductionOutcome {
295    /// Base and step both certified — the property holds for ALL reachable states (unbounded).
296    Proven,
297    /// The base case found a real violation within the first `k` steps.
298    CounterexampleAt { k: u32, trace: Vec<(String, bool)> },
299    /// The base holds but the property is not `k`-inductive — try a larger `k` (or strengthen).
300    NotInductive,
301    /// An obligation left the propositional fragment — escalate.
302    Unsupported,
303}
304
305/// k-induction: prove `property` is an invariant of the transition system.
306///
307/// - **Base**: for each `j < k`, `init ∧ path(j) ∧ ¬property(j)` is certified-UNSAT (no
308///   violation in the first `k` steps; a SAT here is a genuine counterexample).
309/// - **Step**: `property(0..k) ∧ trans(0..k) ∧ ¬property(k)` is certified-UNSAT (the property
310///   is `k`-inductive).
311///
312/// Both certified ⇒ [`InductionOutcome::Proven`] for the unbounded system.
313pub fn prove_invariant(
314    init: &ProofExpr,
315    trans: &dyn Fn(u32) -> ProofExpr,
316    property: &dyn Fn(u32) -> ProofExpr,
317    k: u32,
318) -> InductionOutcome {
319    // Base case: no violation reachable in the first k steps.
320    for j in 0..k {
321        let mut parts = unrolled_path(init, trans, j);
322        parts.push(not(property(j)));
323        match prove_unsat(&conj(parts)) {
324            UnsatOutcome::Refuted => {}
325            UnsatOutcome::Sat(trace) => {
326                return InductionOutcome::CounterexampleAt { k: j, trace }
327            }
328            UnsatOutcome::Unsupported => return InductionOutcome::Unsupported,
329        }
330    }
331
332    // Step case: property holds for k consecutive states ⇒ it holds at the next.
333    let mut parts = Vec::new();
334    for i in 0..k {
335        parts.push(property(i));
336        parts.push(trans(i));
337    }
338    parts.push(not(property(k)));
339    match prove_unsat(&conj(parts)) {
340        UnsatOutcome::Refuted => InductionOutcome::Proven,
341        UnsatOutcome::Sat(_) => InductionOutcome::NotInductive,
342        UnsatOutcome::Unsupported => InductionOutcome::Unsupported,
343    }
344}
345
346/// The verdict of a vacuity check.
347#[derive(Clone, Debug, PartialEq, Eq)]
348pub enum VacuityOutcome {
349    /// The antecedent is unsatisfiable — a dead trigger; the property holds vacuously.
350    Vacuous,
351    /// The antecedent can fire, with a witnessing assignment.
352    Reachable(Vec<(String, bool)>),
353    /// Not propositional — escalate.
354    Unsupported,
355}
356
357/// Vacuity: can `antecedent` ever be satisfied? An unsatisfiable antecedent means the
358/// property is never actually exercised (a vacuous pass / dead trigger).
359pub fn check_vacuity(antecedent: &ProofExpr) -> VacuityOutcome {
360    match prove_unsat(antecedent) {
361        UnsatOutcome::Refuted => VacuityOutcome::Vacuous,
362        UnsatOutcome::Sat(witness) => VacuityOutcome::Reachable(witness),
363        UnsatOutcome::Unsupported => VacuityOutcome::Unsupported,
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    fn atom(s: &str) -> ProofExpr {
372        ProofExpr::Atom(s.to_string())
373    }
374    fn iff(a: ProofExpr, b: ProofExpr) -> ProofExpr {
375        ProofExpr::Iff(Box::new(a), Box::new(b))
376    }
377
378    // ── A latched-true register: x@0 = true, x@(t+1) ↔ x@t. Property: x is always true.
379    fn latched_init() -> ProofExpr {
380        atom("x@0")
381    }
382    fn latched_trans(t: u32) -> ProofExpr {
383        iff(atom(&format!("x@{}", t + 1)), atom(&format!("x@{}", t)))
384    }
385    fn latched_prop(t: u32) -> ProofExpr {
386        atom(&format!("x@{}", t))
387    }
388
389    // ── A toggle: q@0 = false, q@(t+1) ↔ ¬q@t. The (false) property "q is always false".
390    fn toggle_init() -> ProofExpr {
391        not(atom("q@0"))
392    }
393    fn toggle_trans(t: u32) -> ProofExpr {
394        iff(atom(&format!("q@{}", t + 1)), not(atom(&format!("q@{}", t))))
395    }
396    fn toggle_always_false(t: u32) -> ProofExpr {
397        not(atom(&format!("q@{}", t)))
398    }
399
400    #[test]
401    fn bmc_finds_toggle_violation_at_step_one() {
402        // "q always false" is violated: q@1 = ¬q@0 = true. BMC must find it at depth 1.
403        let out = find_counterexample(&toggle_init(), &toggle_trans, &toggle_always_false, 5);
404        match out {
405            BmcOutcome::CounterexampleAt { k, trace } => {
406                assert_eq!(k, 1, "shallowest violation is at step 1");
407                assert!(
408                    trace.iter().any(|(n, v)| n == "q@1" && *v),
409                    "trace must show q@1 high: {trace:?}"
410                );
411            }
412            other => panic!("expected a counterexample, got {other:?}"),
413        }
414    }
415
416    #[test]
417    fn bmc_no_counterexample_for_latched_invariant() {
418        let out = find_counterexample(&latched_init(), &latched_trans, &latched_prop, 6);
419        assert_eq!(out, BmcOutcome::NoneWithin(6));
420    }
421
422    #[test]
423    fn k_induction_proves_latched_invariant() {
424        // x is latched true and starts true ⇒ always true. 1-inductive.
425        let out = prove_invariant(&latched_init(), &latched_trans, &latched_prop, 1);
426        assert_eq!(out, InductionOutcome::Proven);
427    }
428
429    #[test]
430    fn k_induction_finds_toggle_counterexample_in_base() {
431        // "q always false" is false; the base case must surface the violation, not "Proven".
432        let out = prove_invariant(&toggle_init(), &toggle_trans, &toggle_always_false, 3);
433        match out {
434            InductionOutcome::CounterexampleAt { k, .. } => assert_eq!(k, 1),
435            other => panic!("expected a base-case counterexample, got {other:?}"),
436        }
437    }
438
439    #[test]
440    fn vacuity_detects_dead_trigger() {
441        // An antecedent that contradicts itself never fires → vacuous.
442        let dead = ProofExpr::And(Box::new(atom("req@0")), Box::new(not(atom("req@0"))));
443        assert_eq!(check_vacuity(&dead), VacuityOutcome::Vacuous);
444    }
445
446    #[test]
447    fn vacuity_accepts_a_live_trigger() {
448        match check_vacuity(&atom("req@0")) {
449            VacuityOutcome::Reachable(w) => {
450                assert!(w.iter().any(|(n, v)| n == "req@0" && *v));
451            }
452            other => panic!("a live trigger must be Reachable, got {other:?}"),
453        }
454    }
455
456    // ── Incremental BMC (reuses learned clauses across depths via assumptions). ──
457
458    #[test]
459    fn incremental_bmc_finds_toggle_violation_at_step_one() {
460        match find_counterexample_incremental(&toggle_init(), &toggle_trans, &toggle_always_false, 5) {
461            BmcOutcome::CounterexampleAt { k, trace } => {
462                assert_eq!(k, 1);
463                assert!(trace.iter().any(|(n, v)| n == "q@1" && *v), "trace: {trace:?}");
464            }
465            other => panic!("expected a counterexample, got {other:?}"),
466        }
467    }
468
469    #[test]
470    fn temporal_symmetry_detected_and_broken_in_bmc() {
471        // Two interchangeable latches s0, s1. init: at least one on. property: not both on. The system is
472        // symmetric under swapping s0 ↔ s1 at EVERY time frame.
473        let sym_init = ProofExpr::Or(Box::new(atom("s0@0")), Box::new(atom("s1@0")));
474        let sym_trans = |t: u32| {
475            conj(vec![
476                iff(atom(&format!("s0@{}", t + 1)), atom(&format!("s0@{}", t))),
477                iff(atom(&format!("s1@{}", t + 1)), atom(&format!("s1@{}", t))),
478            ])
479        };
480        let not_both = |t: u32| {
481            not(ProofExpr::And(
482                Box::new(atom(&format!("s0@{}", t))),
483                Box::new(atom(&format!("s1@{}", t))),
484            ))
485        };
486
487        // The temporal symmetry is detected: s0, s1 are interchangeable across time.
488        let pairs = temporal_symmetry_pairs(&sym_init, &sym_trans(0), &not_both(0));
489        assert_eq!(pairs, vec![("s0".to_string(), "s1".to_string())], "s0 ↔ s1 is a temporal symmetry");
490
491        // Counterexample (both latches on at t=0 violates "not both") — symmetric BMC agrees with plain BMC.
492        let plain = find_counterexample(&sym_init, &sym_trans, &not_both, 4);
493        let broken = find_counterexample_symmetric(&sym_init, &sym_trans, &not_both, 4);
494        assert!(matches!(plain, BmcOutcome::CounterexampleAt { k: 0, .. }), "plain: {plain:?}");
495        assert!(matches!(broken, BmcOutcome::CounterexampleAt { k: 0, .. }), "broken: {broken:?}");
496
497        // No counterexample for the same symmetric system with a satisfied property ("at least one on"):
498        // the symmetry break must not turn a NoneWithin into a spurious counterexample.
499        let at_least_one =
500            |t: u32| ProofExpr::Or(Box::new(atom(&format!("s0@{}", t))), Box::new(atom(&format!("s1@{}", t))));
501        assert_eq!(
502            find_counterexample_symmetric(&sym_init, &sym_trans, &at_least_one, 5),
503            find_counterexample(&sym_init, &sym_trans, &at_least_one, 5),
504            "symmetric BMC matches plain BMC on a held invariant"
505        );
506
507        // An asymmetric system (the single-signal toggle) has no interchangeable signals, and the symmetric
508        // search then coincides exactly with plain BMC.
509        assert!(
510            temporal_symmetry_pairs(&toggle_init(), &toggle_trans(0), &toggle_always_false(0)).is_empty(),
511            "a one-signal system has no signal-swap symmetry"
512        );
513        assert_eq!(
514            find_counterexample_symmetric(&toggle_init(), &toggle_trans, &toggle_always_false, 5),
515            find_counterexample(&toggle_init(), &toggle_trans, &toggle_always_false, 5),
516            "with no symmetry, symmetric BMC = plain BMC"
517        );
518    }
519
520    #[test]
521    fn incremental_bmc_no_counterexample_for_latched_invariant() {
522        assert_eq!(
523            find_counterexample_incremental(&latched_init(), &latched_trans, &latched_prop, 6),
524            BmcOutcome::NoneWithin(6)
525        );
526    }
527
528    fn sig(i: usize, t: u32) -> ProofExpr {
529        ProofExpr::Atom(format!("s{i}@{t}"))
530    }
531    fn lit_b(b: bool, e: ProofExpr) -> ProofExpr {
532        if b {
533            e
534        } else {
535            ProofExpr::Not(Box::new(e))
536        }
537    }
538
539    #[test]
540    fn incremental_matches_nonincremental_and_simulation() {
541        // Random DETERMINISTIC functional systems over `n` boolean signals: a fixed initial
542        // state and `next_i = (maybe ¬) current_{src_i}`. The unique trace is computed by
543        // direct simulation (the oracle); BOTH BMC variants must report the first depth it
544        // violates the property — and agree with each other.
545        let mut state = 0xb5ad_4ece_da1c_e2a9u64;
546        let mut next = || {
547            state ^= state << 13;
548            state ^= state >> 7;
549            state ^= state << 17;
550            state
551        };
552        let n = 3usize;
553        let max_k = 5u32;
554        for _trial in 0..120 {
555            let init_bits: Vec<bool> = (0..n).map(|_| next() & 1 == 0).collect();
556            let src: Vec<usize> = (0..n).map(|_| (next() % n as u64) as usize).collect();
557            let inv: Vec<bool> = (0..n).map(|_| next() & 1 == 0).collect();
558            let p = (next() % n as u64) as usize;
559            let want = next() & 1 == 0;
560
561            let init_expr = conj((0..n).map(|i| lit_b(init_bits[i], sig(i, 0))).collect());
562            let (src_t, inv_t) = (src.clone(), inv.clone());
563            let trans = move |t: u32| {
564                conj((0..n)
565                    .map(|i| {
566                        // s_i@(t+1) ↔ (inv_i ? : ¬) s_{src_i}@t
567                        let rhs = lit_b(inv_t[i], sig(src_t[i], t));
568                        ProofExpr::Iff(Box::new(sig(i, t + 1)), Box::new(rhs))
569                    })
570                    .collect())
571            };
572            let prop = move |t: u32| lit_b(want, sig(p, t));
573
574            // Oracle: simulate the unique trace, find the first violating depth.
575            let mut cur = init_bits.clone();
576            let mut expected: Option<u32> = None;
577            for k in 0..=max_k {
578                if (cur[p] == want) == false {
579                    expected = Some(k);
580                    break;
581                }
582                let nxt: Vec<bool> = (0..n)
583                    .map(|i| if inv[i] { cur[src[i]] } else { !cur[src[i]] })
584                    .collect();
585                cur = nxt;
586            }
587
588            let k_of = |o: &BmcOutcome| match o {
589                BmcOutcome::CounterexampleAt { k, .. } => Some(*k),
590                BmcOutcome::NoneWithin(_) => None,
591                BmcOutcome::Unsupported => panic!("unexpected Unsupported"),
592            };
593            let ni = find_counterexample(&init_expr, &trans, &prop, max_k);
594            let inc = find_counterexample_incremental(&init_expr, &trans, &prop, max_k);
595            assert_eq!(k_of(&ni), expected, "non-incremental BMC disagrees with simulation");
596            assert_eq!(k_of(&inc), expected, "incremental BMC disagrees with simulation");
597        }
598    }
599}