Skip to main content

synth_verify/
solver.rs

1//! The thin solver trait behind which the SMT engines sit (#553).
2//!
3//! Queries are built once as solver-agnostic terms ([`crate::term`]) and
4//! discharged through [`BvSolver`]:
5//!
6//! - [`OrdealSolver`] — the **default engine**: `ordeal`, the pure-Rust,
7//!   certificate-checked QF_BV solver (every `Unsat` carries an LRAT proof
8//!   the trusted `ordeal-lrat` checker validated before it is reported).
9//!   No C++ toolchain, no `z3-sys` build.
10//! - `Z3Solver` (feature `z3-solver`) — the former engine, retained as the
11//!   **differential oracle**.
12//! - `DifferentialSolver` — used when both backends are compiled in and
13//!   `SYNTH_SOLVER_DIFF=1`: every query runs through both engines. A verdict
14//!   disagreement (`Sat` vs `Unsat`) is a **hard error** (panic — one of the
15//!   solvers is wrong, and nothing downstream may proceed on either answer).
16//!   An ordeal `Unknown` falls through to Z3's verdict (logged, not fatal —
17//!   `Unknown` is the conservative non-answer, not a verdict).
18//!
19//! Budget: every query runs under a **wall-clock deadline**
20//! (`Solver::check_with_deadline`, ordeal ≥0.15) so an adversarial query
21//! degrades to a clean conservative `Unknown` instead of hanging. See
22//! [`DEFAULT_DEADLINE_MS`] for the budget, the env overrides and the
23//! documented limitation.
24
25use crate::term::{BV, Bool};
26use ordeal::{BoolTerm, CheckResult};
27
28/// Default conflict budget for the ordeal CDCL core. The synth burn-in corpus
29/// (pulseengine/ordeal#29) decides at a median of ~1 ms and well under this;
30/// the budget exists to bound adversarial shapes (e.g. non-canonicalized
31/// commuted multiplies) to a conservative `Unknown`.
32///
33/// Only consulted when the wall-clock deadline is explicitly disabled
34/// (`SYNTH_ORDEAL_DEADLINE_MS=0`) — ordeal's `Bound` is one-of, so a query
35/// carries either a deadline or a conflict cap, never both.
36const DEFAULT_MAX_CONFLICTS: u64 = 1_000_000;
37
38/// Default **wall-clock** per-query budget, in milliseconds (#848/#849).
39///
40/// This is the real insurance against a solver cliff. A conflict budget does
41/// not map to wall-clock time: the 4–6 h CI hangs of #849 burned hours inside
42/// a single `check` that never came near 1 M conflicts, because there was no
43/// wall-clock floor anywhere in the stack.
44///
45/// # Why 5 minutes and not 15 seconds
46///
47/// The budget must clear the slowest **legitimate** query with real headroom,
48/// or it converts a sound proof into a spurious `Unknown`. Measured pacer on
49/// a dev host (debug build, ordeal 0.16.1): the pre-existing
50/// `expansion_validator` popcnt-HAKMEM link-1 equivalence decides in
51/// **40.6 s** — a 15 s budget cut it off (it is genuinely hard, not a cliff).
52/// The re-landed 64-bit `bvurem`/`bvsrem` i64-rem value VCs, by contrast,
53/// decide in **2.4 s** for all four tests combined. 5 min is ~7× the pacer,
54/// enough that a slower/parallel-loaded CI runner cannot flake it, while
55/// still turning a solver cliff from "6 hours" into "5 minutes".
56///
57/// The per-query deadline is the INNER floor; the outer one is
58/// `timeout-minutes` on the CI `Test` / `Z3 Verification` jobs (also added in
59/// #848 — neither had one, which is why #849 cost days rather than minutes).
60/// Together they bound both a single cliff and the aggregate.
61///
62/// A query that exceeds the deadline yields [`CheckOutcome::Unknown`], which
63/// every caller treats conservatively — it is **never** reported as
64/// `Verified`. Soundness is unaffected: the deadline bounds only
65/// completeness (ordeal's certificate gate and model self-check are
66/// untouched, so an abandoned search can never produce a wrong verdict).
67///
68/// **Documented limitation:** `check_with_deadline` bounds the *SAT search*,
69/// not bit-blasting/canonicalization. A query whose blast alone is
70/// pathological can still exceed the budget before the clock is ever
71/// consulted, so this is a strong bound on the dominant cost — not a
72/// universal wall-clock guard. Treat it as the floor it is, and keep the
73/// hard-query classes (64-bit `bvsrem`/`bvurem`) under CI timing watch.
74const DEFAULT_DEADLINE_MS: u64 = 300_000;
75
76/// Read the configured per-query wall-clock budget.
77///
78/// `SYNTH_ORDEAL_DEADLINE_MS` overrides [`DEFAULT_DEADLINE_MS`]. The value
79/// `0` **disables** the deadline and falls back to the conflict budget — note
80/// this deliberately differs from ordeal's own `check_with_deadline(0)`
81/// (which admits only queries decided before the first conflict); disabling
82/// re-opens the #849 hang class and exists only as a debugging escape hatch.
83pub(crate) fn configured_deadline_ms() -> u64 {
84    std::env::var("SYNTH_ORDEAL_DEADLINE_MS")
85        .ok()
86        .and_then(|v| v.parse().ok())
87        .unwrap_or(DEFAULT_DEADLINE_MS)
88}
89
90/// Outcome of a one-shot `check` of the asserted conjunction.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum CheckOutcome {
93    /// The assertions are unsatisfiable (for a negated equivalence query:
94    /// the equivalence is proven).
95    Unsat,
96    /// The assertions are satisfiable; a model is available via
97    /// [`BvSolver::value`].
98    Sat,
99    /// The solver could not decide (budget/timeout). Callers must treat this
100    /// conservatively.
101    Unknown(String),
102}
103
104/// The thin solver interface: assert boolean terms, check once, read the
105/// model back on `Sat`. One instance = one query (no incremental solving —
106/// synth's translation-validation fragment is one-shot by design).
107pub trait BvSolver {
108    /// Engine name (diagnostics).
109    fn name(&self) -> &'static str;
110
111    /// Add `cond` to the asserted conjunction.
112    fn assert(&mut self, cond: &Bool);
113
114    /// Decide satisfiability of the asserted conjunction.
115    fn check(&mut self) -> CheckOutcome;
116
117    /// After [`CheckOutcome::Sat`]: the model value of a free variable
118    /// (with model completion — unconstrained variables read as 0, matching
119    /// z3's `eval(_, true)`). `None` if there is no model or `var` is not a
120    /// free variable.
121    fn value(&self, var: &BV) -> Option<u128>;
122}
123
124/// Construct the configured solver:
125///
126/// - default: [`OrdealSolver`];
127/// - with the `z3-solver` feature compiled in **and** `SYNTH_SOLVER_DIFF=1`:
128///   the differential solver (ordeal checked against Z3 on every query).
129pub fn new_solver() -> Box<dyn BvSolver> {
130    #[cfg(feature = "z3-solver")]
131    if std::env::var("SYNTH_SOLVER_DIFF").as_deref() == Ok("1") {
132        return Box::new(z3_backend::DifferentialSolver::new());
133    }
134    Box::new(OrdealSolver::new())
135}
136
137// ---------------------------------------------------------------------------
138// ordeal backend (default)
139// ---------------------------------------------------------------------------
140
141/// The default engine: pure-Rust `ordeal` under a per-query wall-clock
142/// deadline (with a conflict budget as the disabled-deadline fallback).
143pub struct OrdealSolver {
144    assertions: Vec<BoolTerm>,
145    /// Wall-clock budget in ms; `0` = deadline disabled (use `max_conflicts`).
146    deadline_ms: u64,
147    max_conflicts: u64,
148    model: Option<ordeal::Model>,
149}
150
151impl OrdealSolver {
152    /// New solver bounded by `SYNTH_ORDEAL_DEADLINE_MS` (default
153    /// [`DEFAULT_DEADLINE_MS`]; 0 = deadline off), falling back to the
154    /// `SYNTH_ORDEAL_MAX_CONFLICTS` conflict budget (default
155    /// [`DEFAULT_MAX_CONFLICTS`]; 0 = unbounded) when the deadline is off.
156    pub fn new() -> Self {
157        let max_conflicts = std::env::var("SYNTH_ORDEAL_MAX_CONFLICTS")
158            .ok()
159            .and_then(|v| v.parse().ok())
160            .unwrap_or(DEFAULT_MAX_CONFLICTS);
161        Self {
162            assertions: Vec::new(),
163            deadline_ms: configured_deadline_ms(),
164            max_conflicts,
165            model: None,
166        }
167    }
168
169    /// New solver with an explicit wall-clock budget in ms (`0` = deadline
170    /// off). Env-independent, so tests can pin a budget without racing the
171    /// process-global environment.
172    pub fn with_deadline_ms(deadline_ms: u64) -> Self {
173        Self {
174            deadline_ms,
175            ..Self::new()
176        }
177    }
178}
179
180impl Default for OrdealSolver {
181    fn default() -> Self {
182        Self::new()
183    }
184}
185
186impl BvSolver for OrdealSolver {
187    fn name(&self) -> &'static str {
188        "ordeal"
189    }
190
191    fn assert(&mut self, cond: &Bool) {
192        self.assertions.push(cond.term().clone());
193    }
194
195    fn check(&mut self) -> CheckOutcome {
196        let mut solver = ordeal::Solver::new();
197        for a in &self.assertions {
198            solver.assert(a.clone());
199        }
200        // ordeal's `Bound` is one-of (None | Conflicts | Deadline), so a query
201        // carries EITHER the wall-clock deadline (the default, #848/#849) or
202        // the conflict cap — the deadline wins because it is the bound that
203        // actually maps to "CI must not hang".
204        let (result, bound) = if self.deadline_ms > 0 {
205            (
206                solver.check_with_deadline(self.deadline_ms),
207                format!("wall-clock deadline {} ms", self.deadline_ms),
208            )
209        } else if self.max_conflicts == 0 {
210            (solver.check(), "unbounded".to_string())
211        } else {
212            (
213                solver.check_with_limit(self.max_conflicts),
214                format!("conflict budget {}", self.max_conflicts),
215            )
216        };
217        match result {
218            CheckResult::Unsat(_certificate) => CheckOutcome::Unsat,
219            CheckResult::Sat(model) => {
220                self.model = Some(model);
221                CheckOutcome::Sat
222            }
223            // Budget/deadline exhaustion (or an out-of-fragment query) is the
224            // conservative non-answer. Callers MUST NOT read it as proven —
225            // `Unknown` never becomes `Verified` anywhere downstream.
226            CheckResult::Unknown => {
227                CheckOutcome::Unknown(format!("ordeal returned unknown ({bound})"))
228            }
229        }
230    }
231
232    fn value(&self, var: &BV) -> Option<u128> {
233        let model = self.model.as_ref()?;
234        let name = var.var_name()?;
235        Some(
236            model
237                .assignments
238                .iter()
239                .find(|(n, _)| n == name)
240                .map(|(_, v)| *v)
241                // Model completion: a variable the SAT core never saw is
242                // unconstrained; any value witnesses — use 0 (z3 does too).
243                .unwrap_or(0),
244        )
245    }
246}
247
248// ---------------------------------------------------------------------------
249// Z3 backend (feature-gated differential oracle)
250// ---------------------------------------------------------------------------
251
252#[cfg(feature = "z3-solver")]
253mod z3_backend {
254    use super::*;
255    use ordeal::BvTerm;
256    use z3::SatResult;
257    use z3::ast::Ast;
258
259    /// Translate an ordeal `BvTerm` into a Z3 AST (the two backends consume
260    /// the identical canonicalized query).
261    fn bv_to_z3(t: &BvTerm) -> z3::ast::BV {
262        match t {
263            BvTerm::Const { value, sort } => {
264                if sort.width <= 64 {
265                    z3::ast::BV::from_u64(*value as u64, sort.width)
266                } else {
267                    // Split a wide constant into two 64-bit halves.
268                    let hi = z3::ast::BV::from_u64((value >> 64) as u64, sort.width - 64);
269                    let lo = z3::ast::BV::from_u64(*value as u64, 64);
270                    hi.concat(&lo)
271                }
272            }
273            BvTerm::Var { name, sort } => z3::ast::BV::new_const(name.clone(), sort.width),
274            BvTerm::Add(a, b) => bv_to_z3(a).bvadd(&bv_to_z3(b)),
275            BvTerm::Sub(a, b) => bv_to_z3(a).bvsub(&bv_to_z3(b)),
276            BvTerm::Mul(a, b) => bv_to_z3(a).bvmul(&bv_to_z3(b)),
277            BvTerm::Udiv(a, b) => bv_to_z3(a).bvudiv(&bv_to_z3(b)),
278            BvTerm::Urem(a, b) => bv_to_z3(a).bvurem(&bv_to_z3(b)),
279            BvTerm::And(a, b) => bv_to_z3(a).bvand(&bv_to_z3(b)),
280            BvTerm::Or(a, b) => bv_to_z3(a).bvor(&bv_to_z3(b)),
281            BvTerm::Xor(a, b) => bv_to_z3(a).bvxor(&bv_to_z3(b)),
282            BvTerm::Shl(a, b) => bv_to_z3(a).bvshl(&bv_to_z3(b)),
283            BvTerm::Lshr(a, b) => bv_to_z3(a).bvlshr(&bv_to_z3(b)),
284            BvTerm::Ashr(a, b) => bv_to_z3(a).bvashr(&bv_to_z3(b)),
285            BvTerm::Rotr(a, b) => bv_to_z3(a).bvrotr(&bv_to_z3(b)),
286            BvTerm::Extract { hi, lo, arg } => bv_to_z3(arg).extract(*hi, *lo),
287            BvTerm::Concat(a, b) => bv_to_z3(a).concat(&bv_to_z3(b)),
288            BvTerm::ZeroExt { by, arg } => bv_to_z3(arg).zero_ext(*by),
289            BvTerm::SignExt { by, arg } => bv_to_z3(arg).sign_ext(*by),
290            BvTerm::Ite { cond, then_, else_ } => {
291                bool_to_z3(cond).ite(&bv_to_z3(then_), &bv_to_z3(else_))
292            }
293        }
294    }
295
296    fn bool_to_z3(t: &BoolTerm) -> z3::ast::Bool {
297        match t {
298            BoolTerm::Eq(a, b) => bv_to_z3(a).eq(&bv_to_z3(b)),
299            BoolTerm::Ne(a, b) => bv_to_z3(a).eq(&bv_to_z3(b)).not(),
300            BoolTerm::Ult(a, b) => bv_to_z3(a).bvult(&bv_to_z3(b)),
301            BoolTerm::Ule(a, b) => bv_to_z3(a).bvule(&bv_to_z3(b)),
302            BoolTerm::Ugt(a, b) => bv_to_z3(a).bvugt(&bv_to_z3(b)),
303            BoolTerm::Uge(a, b) => bv_to_z3(a).bvuge(&bv_to_z3(b)),
304            BoolTerm::Slt(a, b) => bv_to_z3(a).bvslt(&bv_to_z3(b)),
305            BoolTerm::Sle(a, b) => bv_to_z3(a).bvsle(&bv_to_z3(b)),
306            BoolTerm::Sgt(a, b) => bv_to_z3(a).bvsgt(&bv_to_z3(b)),
307            BoolTerm::Sge(a, b) => bv_to_z3(a).bvsge(&bv_to_z3(b)),
308            BoolTerm::Not(a) => bool_to_z3(a).not(),
309            BoolTerm::And(a, b) => z3::ast::Bool::and(&[&bool_to_z3(a), &bool_to_z3(b)]),
310            BoolTerm::Or(a, b) => z3::ast::Bool::or(&[&bool_to_z3(a), &bool_to_z3(b)]),
311        }
312    }
313
314    /// The former engine, now the differential oracle.
315    pub struct Z3Solver {
316        assertions: Vec<BoolTerm>,
317        model: Option<z3::Model>,
318    }
319
320    impl Z3Solver {
321        pub fn new() -> Self {
322            Self {
323                assertions: Vec::new(),
324                model: None,
325            }
326        }
327    }
328
329    impl BvSolver for Z3Solver {
330        fn name(&self) -> &'static str {
331            "z3"
332        }
333
334        fn assert(&mut self, cond: &Bool) {
335            self.assertions.push(cond.term().clone());
336        }
337
338        fn check(&mut self) -> CheckOutcome {
339            let solver = z3::Solver::new();
340            // Same wall-clock floor as the ordeal path (#848/#849): the Z3
341            // Verification job hung for 4-6 h on the very same 64-bit
342            // bvsrem/bvurem queries, so the oracle gets the identical budget.
343            // Z3's `timeout` param is milliseconds; on expiry it answers
344            // `Unknown`, which is the conservative non-answer here too.
345            let deadline_ms = super::configured_deadline_ms();
346            if deadline_ms > 0 {
347                let mut params = z3::Params::new();
348                params.set_u32("timeout", u32::try_from(deadline_ms).unwrap_or(u32::MAX));
349                solver.set_params(&params);
350            }
351            for a in &self.assertions {
352                solver.assert(bool_to_z3(a));
353            }
354            match solver.check() {
355                SatResult::Unsat => CheckOutcome::Unsat,
356                SatResult::Sat => {
357                    self.model = solver.get_model();
358                    if self.model.is_some() {
359                        CheckOutcome::Sat
360                    } else {
361                        CheckOutcome::Unknown("z3: SAT but no model available".to_string())
362                    }
363                }
364                SatResult::Unknown => CheckOutcome::Unknown("z3 returned unknown".to_string()),
365            }
366        }
367
368        fn value(&self, var: &BV) -> Option<u128> {
369            let model = self.model.as_ref()?;
370            let z3_var = bv_to_z3(var.term());
371            // z3::Model::eval is SMT model-value lookup (with completion),
372            // not code evaluation.
373            model
374                .eval(&z3_var, true)
375                .and_then(|v| v.as_u64())
376                .map(u128::from)
377        }
378    }
379
380    /// Which backend produced the authoritative model for the last `Sat`.
381    #[derive(Clone, Copy, PartialEq)]
382    enum ModelSource {
383        Ordeal,
384        Z3,
385    }
386
387    /// Runs every query through **both** engines (`SYNTH_SOLVER_DIFF=1`).
388    ///
389    /// Disagreement on a decided verdict is a hard error: it means one of
390    /// the solvers is unsound on this query, and no downstream use of either
391    /// answer is safe. ordeal `Unknown` falls through to Z3's verdict.
392    pub struct DifferentialSolver {
393        ordeal: OrdealSolver,
394        z3: Z3Solver,
395        model_source: ModelSource,
396    }
397
398    impl DifferentialSolver {
399        pub fn new() -> Self {
400            Self {
401                ordeal: OrdealSolver::new(),
402                z3: Z3Solver::new(),
403                model_source: ModelSource::Ordeal,
404            }
405        }
406    }
407
408    impl BvSolver for DifferentialSolver {
409        fn name(&self) -> &'static str {
410            "ordeal+z3-differential"
411        }
412
413        fn assert(&mut self, cond: &Bool) {
414            self.ordeal.assert(cond);
415            self.z3.assert(cond);
416        }
417
418        fn check(&mut self) -> CheckOutcome {
419            let ordeal_verdict = self.ordeal.check();
420            let z3_verdict = self.z3.check();
421            match (&ordeal_verdict, &z3_verdict) {
422                // ordeal could not decide: conservative fall-through to the
423                // oracle's verdict (logged — this is the measurable residue
424                // the ordeal budget/normalization roadmap tracks).
425                (CheckOutcome::Unknown(reason), _) => {
426                    eprintln!(
427                        "[synth-verify differential] ordeal unknown ({reason}); \
428                         falling through to z3 verdict: {z3_verdict:?}"
429                    );
430                    self.model_source = ModelSource::Z3;
431                    z3_verdict
432                }
433                // Oracle could not decide but ordeal did: verdicts do not
434                // disagree; keep ordeal's decided answer.
435                (_, CheckOutcome::Unknown(reason)) => {
436                    eprintln!(
437                        "[synth-verify differential] z3 unknown ({reason}); \
438                         keeping ordeal verdict: {ordeal_verdict:?}"
439                    );
440                    self.model_source = ModelSource::Ordeal;
441                    ordeal_verdict
442                }
443                (CheckOutcome::Unsat, CheckOutcome::Unsat) => CheckOutcome::Unsat,
444                (CheckOutcome::Sat, CheckOutcome::Sat) => {
445                    self.model_source = ModelSource::Ordeal;
446                    CheckOutcome::Sat
447                }
448                // Decided disagreement: one solver is wrong. Hard error.
449                (o, z) => panic!(
450                    "SOLVER DISAGREEMENT (#553 differential oracle): \
451                     ordeal={o:?} z3={z:?} on the same query — one engine is \
452                     unsound on this fragment; refusing to proceed"
453                ),
454            }
455        }
456
457        fn value(&self, var: &BV) -> Option<u128> {
458            match self.model_source {
459                ModelSource::Ordeal => self.ordeal.value(var),
460                ModelSource::Z3 => self.z3.value(var),
461            }
462        }
463    }
464}
465
466#[cfg(feature = "z3-solver")]
467pub use z3_backend::{DifferentialSolver, Z3Solver};
468
469// ---------------------------------------------------------------------------
470// Tests
471// ---------------------------------------------------------------------------
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    fn x32() -> BV {
478        BV::new_const("x", 32)
479    }
480    fn y32() -> BV {
481        BV::new_const("y", 32)
482    }
483
484    /// Small equivalence corpus: (label, query, expect_unsat).
485    /// Every query is a negated equivalence, exactly the validator's shape.
486    fn corpus() -> Vec<(&'static str, Bool, bool)> {
487        let x = x32();
488        let y = y32();
489        let one = BV::from_i64(1, 32);
490        vec![
491            ("add-comm", x.bvadd(&y).eq(y.bvadd(&x)).not(), true),
492            ("mul-comm", x.bvmul(&y).eq(y.bvmul(&x)).not(), true),
493            ("and-comm", x.bvand(&y).eq(y.bvand(&x)).not(), true),
494            (
495                "shl1-vs-mul2",
496                x.bvshl(&one).eq(x.bvmul(BV::from_i64(2, 32))).not(),
497                true,
498            ),
499            (
500                "sub-not-comm",
501                x.bvsub(&y).eq(y.bvsub(&x)).not(),
502                false, // SAT: x-y != y-x has witnesses
503            ),
504            (
505                "ite-select",
506                {
507                    let c = x.eq(BV::from_i64(0, 32)).not();
508                    c.ite(&y, &one).eq(c.ite(&y, &one)).not()
509                },
510                true,
511            ),
512            (
513                "add-vs-sub-bug",
514                x.bvadd(&y).eq(x.bvsub(&y)).not(),
515                false, // SAT: differs whenever y != 0
516            ),
517        ]
518    }
519
520    #[test]
521    fn ordeal_decides_the_corpus() {
522        for (label, query, expect_unsat) in corpus() {
523            let mut solver = OrdealSolver::new();
524            solver.assert(&query);
525            let outcome = solver.check();
526            if expect_unsat {
527                assert_eq!(outcome, CheckOutcome::Unsat, "{label}");
528            } else {
529                assert_eq!(outcome, CheckOutcome::Sat, "{label}");
530                // Model readback must produce usable witness values.
531                assert!(solver.value(&x32()).is_some(), "{label}: no model value");
532            }
533        }
534    }
535
536    #[test]
537    fn ordeal_finds_counterexample_with_model() {
538        // x + 1 == x - 1 is UNSAT-free: always differs -> negation is... the
539        // direct assertion x+1 == x-1 is unsatisfiable; assert the EQUALITY
540        // and expect Unsat, then assert a satisfiable disequality and read
541        // the model back.
542        let x = x32();
543        let mut solver = OrdealSolver::new();
544        solver.assert(
545            &x.bvadd(BV::from_i64(1, 32))
546                .eq(x.bvsub(BV::from_i64(1, 32))),
547        );
548        assert_eq!(solver.check(), CheckOutcome::Unsat);
549
550        let mut solver = OrdealSolver::new();
551        solver.assert(&x.eq(BV::from_i64(7, 32)));
552        assert_eq!(solver.check(), CheckOutcome::Sat);
553        assert_eq!(solver.value(&x), Some(7));
554    }
555
556    #[test]
557    fn bool_var_bridge_is_symbolic() {
558        // Bool::new_const must be genuinely free: both polarities satisfiable.
559        let flag = Bool::new_const("flag");
560        let one = BV::from_i64(1, 32);
561        let zero = BV::from_i64(0, 32);
562        // `out` selects on the flag; constraining the flag's polarity must
563        // force the selected value — in both directions.
564        for (cond, expected) in [(flag.clone(), 1u128), (flag.not(), 0u128)] {
565            let mut solver = OrdealSolver::new();
566            let out = flag.ite(&one, &zero);
567            let probe = BV::new_const("probe", 32);
568            solver.assert(&probe.eq(&out));
569            solver.assert(&cond);
570            assert_eq!(solver.check(), CheckOutcome::Sat);
571            assert_eq!(solver.value(&probe), Some(expected));
572        }
573    }
574
575    // --- per-query wall-clock deadline (#848/#849) ---
576
577    /// The negated multiplication-**associativity** goal at a chosen width:
578    /// `¬((x·y)·z == x·(y·z))`. Valid at every width (so a decided verdict is
579    /// always `Unsat`), but *hard* for a CDCL core: commutativity is folded
580    /// away by canonicalization, associativity is not, so proving it needs a
581    /// real multiplier blast + search. This is exactly the shape the deadline
582    /// exists to bound.
583    ///
584    /// Cost scales viciously — measured unbounded on a dev host (debug build,
585    /// ordeal 0.16.1): width 8, 16 and 32 all run **>30 s**, width 3 decides
586    /// in milliseconds. Hence the two widths below.
587    fn mul_assoc_goal(width: u32) -> Bool {
588        let x = BV::new_const("ha_x", width);
589        let y = BV::new_const("ha_y", width);
590        let z = BV::new_const("ha_z", width);
591        x.bvmul(&y).bvmul(&z).eq(x.bvmul(y.bvmul(&z))).not()
592    }
593
594    /// Wide enough that no 1 ms budget can finish it (>30 s unbounded).
595    const HARD_WIDTH: u32 = 8;
596    /// Narrow enough to decide in milliseconds — the cheap validity control.
597    const EASY_WIDTH: u32 = 3;
598
599    /// SOUNDNESS GATE: a query that exceeds its wall-clock deadline must
600    /// degrade to [`CheckOutcome::Unknown`] — **never** `Unsat`, which every
601    /// caller reads as "proven". This is the mechanism that would have turned
602    /// the #849 six-hour hang into a bounded, loud non-answer.
603    ///
604    /// Non-vacuity comes from the narrow control: the SAME construction at
605    /// [`EASY_WIDTH`] decides `Unsat` in milliseconds, so the `Unknown` above
606    /// is the deadline biting a well-formed, in-fragment, *valid* obligation —
607    /// not a query builder bug or an out-of-fragment term. (Associativity is a
608    /// mathematical identity at every width; what the control rules out is a
609    /// coding error in `mul_assoc_goal`, which is the only way this gate could
610    /// go vacuous.) Re-proving the width-8 instance unbounded would cost the
611    /// suite >30 s for no extra information.
612    #[test]
613    fn deadline_degrades_to_unknown_never_to_proven() {
614        // 1 ms: orders of magnitude below what the wide instance needs.
615        let mut bounded = OrdealSolver::with_deadline_ms(1);
616        bounded.assert(&mul_assoc_goal(HARD_WIDTH));
617        let outcome = bounded.check();
618        assert!(
619            matches!(outcome, CheckOutcome::Unknown(_)),
620            "an undecided query must be Unknown, got {outcome:?}"
621        );
622        assert_ne!(
623            outcome,
624            CheckOutcome::Unsat,
625            "SOUNDNESS: a timed-out query must never be reported as proven"
626        );
627        // And it must say *why* — a deadline, not a silent shrug.
628        let CheckOutcome::Unknown(reason) = &outcome else {
629            unreachable!("asserted above")
630        };
631        assert!(
632            reason.contains("deadline"),
633            "the non-answer must name the bound that produced it, got {reason:?}"
634        );
635
636        // Non-vacuity control: same construction, narrow, given the time.
637        let mut control = OrdealSolver::with_deadline_ms(0);
638        control.assert(&mul_assoc_goal(EASY_WIDTH));
639        assert_eq!(
640            control.check(),
641            CheckOutcome::Unsat,
642            "control: the mul-associativity goal is well-formed and valid"
643        );
644    }
645
646    /// The deadline must not cost decidability on ordinary queries: the whole
647    /// equivalence corpus still decides under the shipped default budget.
648    #[test]
649    fn default_deadline_decides_the_corpus() {
650        for (label, query, expect_unsat) in corpus() {
651            let mut solver = OrdealSolver::new();
652            solver.assert(&query);
653            let outcome = solver.check();
654            assert_ne!(
655                outcome,
656                CheckOutcome::Unknown(format!(
657                    "ordeal returned unknown (wall-clock deadline {DEFAULT_DEADLINE_MS} ms)"
658                )),
659                "{label}: default budget must not starve an ordinary query"
660            );
661            if expect_unsat {
662                assert_eq!(outcome, CheckOutcome::Unsat, "{label}");
663            } else {
664                assert_eq!(outcome, CheckOutcome::Sat, "{label}");
665            }
666        }
667    }
668
669    /// The differential oracle itself: run the corpus through BOTH engines.
670    /// Any decided disagreement panics inside `DifferentialSolver::check`.
671    /// This runs in the CI Z3 job (`--features z3-solver,arm`) regardless of
672    /// the SYNTH_SOLVER_DIFF env, so the cross-check is always exercised
673    /// where Z3 is available.
674    #[cfg(feature = "z3-solver")]
675    #[test]
676    fn differential_zero_disagreements_on_corpus() {
677        crate::with_z3_context(|| {
678            for (label, query, expect_unsat) in corpus() {
679                let mut solver = DifferentialSolver::new();
680                solver.assert(&query);
681                let outcome = solver.check();
682                if expect_unsat {
683                    assert_eq!(outcome, CheckOutcome::Unsat, "{label}");
684                } else {
685                    assert_eq!(outcome, CheckOutcome::Sat, "{label}");
686                }
687            }
688        });
689    }
690}