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            // ordeal ≥0.17 seals the term enums (`#[non_exhaustive]`,
294            // ordeal#104). A differential oracle must never guess a
295            // translation — an unknown variant means an ordeal bump added an
296            // op nobody taught this backend; refuse loudly (see the term.rs
297            // catch-alls, which fire first on any such term).
298            other => panic!(
299                "synth-verify z3 oracle: unmodeled ordeal BvTerm variant {other:?} — \
300                 extend bv_to_z3 before trusting any differential verdict"
301            ),
302        }
303    }
304
305    fn bool_to_z3(t: &BoolTerm) -> z3::ast::Bool {
306        match t {
307            BoolTerm::Eq(a, b) => bv_to_z3(a).eq(&bv_to_z3(b)),
308            BoolTerm::Ne(a, b) => bv_to_z3(a).eq(&bv_to_z3(b)).not(),
309            BoolTerm::Ult(a, b) => bv_to_z3(a).bvult(&bv_to_z3(b)),
310            BoolTerm::Ule(a, b) => bv_to_z3(a).bvule(&bv_to_z3(b)),
311            BoolTerm::Ugt(a, b) => bv_to_z3(a).bvugt(&bv_to_z3(b)),
312            BoolTerm::Uge(a, b) => bv_to_z3(a).bvuge(&bv_to_z3(b)),
313            BoolTerm::Slt(a, b) => bv_to_z3(a).bvslt(&bv_to_z3(b)),
314            BoolTerm::Sle(a, b) => bv_to_z3(a).bvsle(&bv_to_z3(b)),
315            BoolTerm::Sgt(a, b) => bv_to_z3(a).bvsgt(&bv_to_z3(b)),
316            BoolTerm::Sge(a, b) => bv_to_z3(a).bvsge(&bv_to_z3(b)),
317            BoolTerm::Not(a) => bool_to_z3(a).not(),
318            BoolTerm::And(a, b) => z3::ast::Bool::and(&[&bool_to_z3(a), &bool_to_z3(b)]),
319            BoolTerm::Or(a, b) => z3::ast::Bool::or(&[&bool_to_z3(a), &bool_to_z3(b)]),
320            // Sealed enum (ordeal#104) — see the `bv_to_z3` catch-all.
321            other => panic!(
322                "synth-verify z3 oracle: unmodeled ordeal BoolTerm variant {other:?} — \
323                 extend bool_to_z3 before trusting any differential verdict"
324            ),
325        }
326    }
327
328    /// The former engine, now the differential oracle.
329    pub struct Z3Solver {
330        assertions: Vec<BoolTerm>,
331        model: Option<z3::Model>,
332    }
333
334    impl Z3Solver {
335        pub fn new() -> Self {
336            Self {
337                assertions: Vec::new(),
338                model: None,
339            }
340        }
341    }
342
343    impl BvSolver for Z3Solver {
344        fn name(&self) -> &'static str {
345            "z3"
346        }
347
348        fn assert(&mut self, cond: &Bool) {
349            self.assertions.push(cond.term().clone());
350        }
351
352        fn check(&mut self) -> CheckOutcome {
353            let solver = z3::Solver::new();
354            // Same wall-clock floor as the ordeal path (#848/#849): the Z3
355            // Verification job hung for 4-6 h on the very same 64-bit
356            // bvsrem/bvurem queries, so the oracle gets the identical budget.
357            // Z3's `timeout` param is milliseconds; on expiry it answers
358            // `Unknown`, which is the conservative non-answer here too.
359            let deadline_ms = super::configured_deadline_ms();
360            if deadline_ms > 0 {
361                let mut params = z3::Params::new();
362                params.set_u32("timeout", u32::try_from(deadline_ms).unwrap_or(u32::MAX));
363                solver.set_params(&params);
364            }
365            for a in &self.assertions {
366                solver.assert(bool_to_z3(a));
367            }
368            match solver.check() {
369                SatResult::Unsat => CheckOutcome::Unsat,
370                SatResult::Sat => {
371                    self.model = solver.get_model();
372                    if self.model.is_some() {
373                        CheckOutcome::Sat
374                    } else {
375                        CheckOutcome::Unknown("z3: SAT but no model available".to_string())
376                    }
377                }
378                SatResult::Unknown => CheckOutcome::Unknown("z3 returned unknown".to_string()),
379            }
380        }
381
382        fn value(&self, var: &BV) -> Option<u128> {
383            let model = self.model.as_ref()?;
384            let z3_var = bv_to_z3(var.term());
385            // z3::Model::eval is SMT model-value lookup (with completion),
386            // not code evaluation.
387            model
388                .eval(&z3_var, true)
389                .and_then(|v| v.as_u64())
390                .map(u128::from)
391        }
392    }
393
394    /// Which backend produced the authoritative model for the last `Sat`.
395    #[derive(Clone, Copy, PartialEq)]
396    enum ModelSource {
397        Ordeal,
398        Z3,
399    }
400
401    /// Runs every query through **both** engines (`SYNTH_SOLVER_DIFF=1`).
402    ///
403    /// Disagreement on a decided verdict is a hard error: it means one of
404    /// the solvers is unsound on this query, and no downstream use of either
405    /// answer is safe. ordeal `Unknown` falls through to Z3's verdict.
406    pub struct DifferentialSolver {
407        ordeal: OrdealSolver,
408        z3: Z3Solver,
409        model_source: ModelSource,
410    }
411
412    impl DifferentialSolver {
413        pub fn new() -> Self {
414            Self {
415                ordeal: OrdealSolver::new(),
416                z3: Z3Solver::new(),
417                model_source: ModelSource::Ordeal,
418            }
419        }
420    }
421
422    impl BvSolver for DifferentialSolver {
423        fn name(&self) -> &'static str {
424            "ordeal+z3-differential"
425        }
426
427        fn assert(&mut self, cond: &Bool) {
428            self.ordeal.assert(cond);
429            self.z3.assert(cond);
430        }
431
432        fn check(&mut self) -> CheckOutcome {
433            let ordeal_verdict = self.ordeal.check();
434            let z3_verdict = self.z3.check();
435            match (&ordeal_verdict, &z3_verdict) {
436                // ordeal could not decide: conservative fall-through to the
437                // oracle's verdict (logged — this is the measurable residue
438                // the ordeal budget/normalization roadmap tracks).
439                (CheckOutcome::Unknown(reason), _) => {
440                    eprintln!(
441                        "[synth-verify differential] ordeal unknown ({reason}); \
442                         falling through to z3 verdict: {z3_verdict:?}"
443                    );
444                    self.model_source = ModelSource::Z3;
445                    z3_verdict
446                }
447                // Oracle could not decide but ordeal did: verdicts do not
448                // disagree; keep ordeal's decided answer.
449                (_, CheckOutcome::Unknown(reason)) => {
450                    eprintln!(
451                        "[synth-verify differential] z3 unknown ({reason}); \
452                         keeping ordeal verdict: {ordeal_verdict:?}"
453                    );
454                    self.model_source = ModelSource::Ordeal;
455                    ordeal_verdict
456                }
457                (CheckOutcome::Unsat, CheckOutcome::Unsat) => CheckOutcome::Unsat,
458                (CheckOutcome::Sat, CheckOutcome::Sat) => {
459                    self.model_source = ModelSource::Ordeal;
460                    CheckOutcome::Sat
461                }
462                // Decided disagreement: one solver is wrong. Hard error.
463                (o, z) => panic!(
464                    "SOLVER DISAGREEMENT (#553 differential oracle): \
465                     ordeal={o:?} z3={z:?} on the same query — one engine is \
466                     unsound on this fragment; refusing to proceed"
467                ),
468            }
469        }
470
471        fn value(&self, var: &BV) -> Option<u128> {
472            match self.model_source {
473                ModelSource::Ordeal => self.ordeal.value(var),
474                ModelSource::Z3 => self.z3.value(var),
475            }
476        }
477    }
478}
479
480#[cfg(feature = "z3-solver")]
481pub use z3_backend::{DifferentialSolver, Z3Solver};
482
483// ---------------------------------------------------------------------------
484// Tests
485// ---------------------------------------------------------------------------
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    fn x32() -> BV {
492        BV::new_const("x", 32)
493    }
494    fn y32() -> BV {
495        BV::new_const("y", 32)
496    }
497
498    /// Small equivalence corpus: (label, query, expect_unsat).
499    /// Every query is a negated equivalence, exactly the validator's shape.
500    fn corpus() -> Vec<(&'static str, Bool, bool)> {
501        let x = x32();
502        let y = y32();
503        let one = BV::from_i64(1, 32);
504        vec![
505            ("add-comm", x.bvadd(&y).eq(y.bvadd(&x)).not(), true),
506            ("mul-comm", x.bvmul(&y).eq(y.bvmul(&x)).not(), true),
507            ("and-comm", x.bvand(&y).eq(y.bvand(&x)).not(), true),
508            (
509                "shl1-vs-mul2",
510                x.bvshl(&one).eq(x.bvmul(BV::from_i64(2, 32))).not(),
511                true,
512            ),
513            (
514                "sub-not-comm",
515                x.bvsub(&y).eq(y.bvsub(&x)).not(),
516                false, // SAT: x-y != y-x has witnesses
517            ),
518            (
519                "ite-select",
520                {
521                    let c = x.eq(BV::from_i64(0, 32)).not();
522                    c.ite(&y, &one).eq(c.ite(&y, &one)).not()
523                },
524                true,
525            ),
526            (
527                "add-vs-sub-bug",
528                x.bvadd(&y).eq(x.bvsub(&y)).not(),
529                false, // SAT: differs whenever y != 0
530            ),
531        ]
532    }
533
534    #[test]
535    fn ordeal_decides_the_corpus() {
536        for (label, query, expect_unsat) in corpus() {
537            let mut solver = OrdealSolver::new();
538            solver.assert(&query);
539            let outcome = solver.check();
540            if expect_unsat {
541                assert_eq!(outcome, CheckOutcome::Unsat, "{label}");
542            } else {
543                assert_eq!(outcome, CheckOutcome::Sat, "{label}");
544                // Model readback must produce usable witness values.
545                assert!(solver.value(&x32()).is_some(), "{label}: no model value");
546            }
547        }
548    }
549
550    #[test]
551    fn ordeal_finds_counterexample_with_model() {
552        // x + 1 == x - 1 is UNSAT-free: always differs -> negation is... the
553        // direct assertion x+1 == x-1 is unsatisfiable; assert the EQUALITY
554        // and expect Unsat, then assert a satisfiable disequality and read
555        // the model back.
556        let x = x32();
557        let mut solver = OrdealSolver::new();
558        solver.assert(
559            &x.bvadd(BV::from_i64(1, 32))
560                .eq(x.bvsub(BV::from_i64(1, 32))),
561        );
562        assert_eq!(solver.check(), CheckOutcome::Unsat);
563
564        let mut solver = OrdealSolver::new();
565        solver.assert(&x.eq(BV::from_i64(7, 32)));
566        assert_eq!(solver.check(), CheckOutcome::Sat);
567        assert_eq!(solver.value(&x), Some(7));
568    }
569
570    #[test]
571    fn bool_var_bridge_is_symbolic() {
572        // Bool::new_const must be genuinely free: both polarities satisfiable.
573        let flag = Bool::new_const("flag");
574        let one = BV::from_i64(1, 32);
575        let zero = BV::from_i64(0, 32);
576        // `out` selects on the flag; constraining the flag's polarity must
577        // force the selected value — in both directions.
578        for (cond, expected) in [(flag.clone(), 1u128), (flag.not(), 0u128)] {
579            let mut solver = OrdealSolver::new();
580            let out = flag.ite(&one, &zero);
581            let probe = BV::new_const("probe", 32);
582            solver.assert(&probe.eq(&out));
583            solver.assert(&cond);
584            assert_eq!(solver.check(), CheckOutcome::Sat);
585            assert_eq!(solver.value(&probe), Some(expected));
586        }
587    }
588
589    // --- per-query wall-clock deadline (#848/#849) ---
590
591    /// The negated multiplication-**associativity** goal at a chosen width:
592    /// `¬((x·y)·z == x·(y·z))`. Valid at every width (so a decided verdict is
593    /// always `Unsat`), but *hard* for a CDCL core: commutativity is folded
594    /// away by canonicalization, associativity is not, so proving it needs a
595    /// real multiplier blast + search. This is exactly the shape the deadline
596    /// exists to bound.
597    ///
598    /// Cost scales viciously — measured unbounded on a dev host (debug build,
599    /// ordeal 0.16.1): width 8, 16 and 32 all run **>30 s**, width 3 decides
600    /// in milliseconds. Hence the two widths below.
601    fn mul_assoc_goal(width: u32) -> Bool {
602        let x = BV::new_const("ha_x", width);
603        let y = BV::new_const("ha_y", width);
604        let z = BV::new_const("ha_z", width);
605        x.bvmul(&y).bvmul(&z).eq(x.bvmul(y.bvmul(&z))).not()
606    }
607
608    /// Wide enough that no 1 ms budget can finish it (>30 s unbounded).
609    const HARD_WIDTH: u32 = 8;
610    /// Narrow enough to decide in milliseconds — the cheap validity control.
611    const EASY_WIDTH: u32 = 3;
612
613    /// SOUNDNESS GATE: a query that exceeds its wall-clock deadline must
614    /// degrade to [`CheckOutcome::Unknown`] — **never** `Unsat`, which every
615    /// caller reads as "proven". This is the mechanism that would have turned
616    /// the #849 six-hour hang into a bounded, loud non-answer.
617    ///
618    /// Non-vacuity comes from the narrow control: the SAME construction at
619    /// [`EASY_WIDTH`] decides `Unsat` in milliseconds, so the `Unknown` above
620    /// is the deadline biting a well-formed, in-fragment, *valid* obligation —
621    /// not a query builder bug or an out-of-fragment term. (Associativity is a
622    /// mathematical identity at every width; what the control rules out is a
623    /// coding error in `mul_assoc_goal`, which is the only way this gate could
624    /// go vacuous.) Re-proving the width-8 instance unbounded would cost the
625    /// suite >30 s for no extra information.
626    #[test]
627    fn deadline_degrades_to_unknown_never_to_proven() {
628        // 1 ms: orders of magnitude below what the wide instance needs.
629        let mut bounded = OrdealSolver::with_deadline_ms(1);
630        bounded.assert(&mul_assoc_goal(HARD_WIDTH));
631        let outcome = bounded.check();
632        assert!(
633            matches!(outcome, CheckOutcome::Unknown(_)),
634            "an undecided query must be Unknown, got {outcome:?}"
635        );
636        assert_ne!(
637            outcome,
638            CheckOutcome::Unsat,
639            "SOUNDNESS: a timed-out query must never be reported as proven"
640        );
641        // And it must say *why* — a deadline, not a silent shrug.
642        let CheckOutcome::Unknown(reason) = &outcome else {
643            unreachable!("asserted above")
644        };
645        assert!(
646            reason.contains("deadline"),
647            "the non-answer must name the bound that produced it, got {reason:?}"
648        );
649
650        // Non-vacuity control: same construction, narrow, given the time.
651        let mut control = OrdealSolver::with_deadline_ms(0);
652        control.assert(&mul_assoc_goal(EASY_WIDTH));
653        assert_eq!(
654            control.check(),
655            CheckOutcome::Unsat,
656            "control: the mul-associativity goal is well-formed and valid"
657        );
658    }
659
660    /// The deadline must not cost decidability on ordinary queries: the whole
661    /// equivalence corpus still decides under the shipped default budget.
662    #[test]
663    fn default_deadline_decides_the_corpus() {
664        for (label, query, expect_unsat) in corpus() {
665            let mut solver = OrdealSolver::new();
666            solver.assert(&query);
667            let outcome = solver.check();
668            assert_ne!(
669                outcome,
670                CheckOutcome::Unknown(format!(
671                    "ordeal returned unknown (wall-clock deadline {DEFAULT_DEADLINE_MS} ms)"
672                )),
673                "{label}: default budget must not starve an ordinary query"
674            );
675            if expect_unsat {
676                assert_eq!(outcome, CheckOutcome::Unsat, "{label}");
677            } else {
678                assert_eq!(outcome, CheckOutcome::Sat, "{label}");
679            }
680        }
681    }
682
683    /// The differential oracle itself: run the corpus through BOTH engines.
684    /// Any decided disagreement panics inside `DifferentialSolver::check`.
685    /// This runs in the CI Z3 job (`--features z3-solver,arm`) regardless of
686    /// the SYNTH_SOLVER_DIFF env, so the cross-check is always exercised
687    /// where Z3 is available.
688    #[cfg(feature = "z3-solver")]
689    #[test]
690    fn differential_zero_disagreements_on_corpus() {
691        crate::with_z3_context(|| {
692            for (label, query, expect_unsat) in corpus() {
693                let mut solver = DifferentialSolver::new();
694                solver.assert(&query);
695                let outcome = solver.check();
696                if expect_unsat {
697                    assert_eq!(outcome, CheckOutcome::Unsat, "{label}");
698                } else {
699                    assert_eq!(outcome, CheckOutcome::Sat, "{label}");
700                }
701            }
702        });
703    }
704}