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: ordeal decides under a conflict budget (`check_with_limit`) so an
20//! adversarial query degrades to a clean conservative `Unknown` instead of
21//! hanging. Override with `SYNTH_ORDEAL_MAX_CONFLICTS` (0 = unbounded).
22
23use crate::term::{BV, Bool};
24use ordeal::{BoolTerm, CheckResult};
25
26/// Default conflict budget for the ordeal CDCL core. The synth burn-in corpus
27/// (pulseengine/ordeal#29) decides at a median of ~1 ms and well under this;
28/// the budget exists to bound adversarial shapes (e.g. non-canonicalized
29/// commuted multiplies) to a conservative `Unknown`.
30const DEFAULT_MAX_CONFLICTS: u64 = 1_000_000;
31
32/// Outcome of a one-shot `check` of the asserted conjunction.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum CheckOutcome {
35    /// The assertions are unsatisfiable (for a negated equivalence query:
36    /// the equivalence is proven).
37    Unsat,
38    /// The assertions are satisfiable; a model is available via
39    /// [`BvSolver::value`].
40    Sat,
41    /// The solver could not decide (budget/timeout). Callers must treat this
42    /// conservatively.
43    Unknown(String),
44}
45
46/// The thin solver interface: assert boolean terms, check once, read the
47/// model back on `Sat`. One instance = one query (no incremental solving —
48/// synth's translation-validation fragment is one-shot by design).
49pub trait BvSolver {
50    /// Engine name (diagnostics).
51    fn name(&self) -> &'static str;
52
53    /// Add `cond` to the asserted conjunction.
54    fn assert(&mut self, cond: &Bool);
55
56    /// Decide satisfiability of the asserted conjunction.
57    fn check(&mut self) -> CheckOutcome;
58
59    /// After [`CheckOutcome::Sat`]: the model value of a free variable
60    /// (with model completion — unconstrained variables read as 0, matching
61    /// z3's `eval(_, true)`). `None` if there is no model or `var` is not a
62    /// free variable.
63    fn value(&self, var: &BV) -> Option<u128>;
64}
65
66/// Construct the configured solver:
67///
68/// - default: [`OrdealSolver`];
69/// - with the `z3-solver` feature compiled in **and** `SYNTH_SOLVER_DIFF=1`:
70///   the differential solver (ordeal checked against Z3 on every query).
71pub fn new_solver() -> Box<dyn BvSolver> {
72    #[cfg(feature = "z3-solver")]
73    if std::env::var("SYNTH_SOLVER_DIFF").as_deref() == Ok("1") {
74        return Box::new(z3_backend::DifferentialSolver::new());
75    }
76    Box::new(OrdealSolver::new())
77}
78
79// ---------------------------------------------------------------------------
80// ordeal backend (default)
81// ---------------------------------------------------------------------------
82
83/// The default engine: pure-Rust `ordeal` with a conflict budget.
84pub struct OrdealSolver {
85    assertions: Vec<BoolTerm>,
86    max_conflicts: u64,
87    model: Option<ordeal::Model>,
88}
89
90impl OrdealSolver {
91    /// New solver with the budget from `SYNTH_ORDEAL_MAX_CONFLICTS`
92    /// (default [`DEFAULT_MAX_CONFLICTS`]; 0 = unbounded).
93    pub fn new() -> Self {
94        let max_conflicts = std::env::var("SYNTH_ORDEAL_MAX_CONFLICTS")
95            .ok()
96            .and_then(|v| v.parse().ok())
97            .unwrap_or(DEFAULT_MAX_CONFLICTS);
98        Self {
99            assertions: Vec::new(),
100            max_conflicts,
101            model: None,
102        }
103    }
104}
105
106impl Default for OrdealSolver {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112impl BvSolver for OrdealSolver {
113    fn name(&self) -> &'static str {
114        "ordeal"
115    }
116
117    fn assert(&mut self, cond: &Bool) {
118        self.assertions.push(cond.term().clone());
119    }
120
121    fn check(&mut self) -> CheckOutcome {
122        let mut solver = ordeal::Solver::new();
123        for a in &self.assertions {
124            solver.assert(a.clone());
125        }
126        let result = if self.max_conflicts == 0 {
127            solver.check()
128        } else {
129            solver.check_with_limit(self.max_conflicts)
130        };
131        match result {
132            CheckResult::Unsat(_certificate) => CheckOutcome::Unsat,
133            CheckResult::Sat(model) => {
134                self.model = Some(model);
135                CheckOutcome::Sat
136            }
137            CheckResult::Unknown => CheckOutcome::Unknown(format!(
138                "ordeal returned unknown (conflict budget {})",
139                self.max_conflicts
140            )),
141        }
142    }
143
144    fn value(&self, var: &BV) -> Option<u128> {
145        let model = self.model.as_ref()?;
146        let name = var.var_name()?;
147        Some(
148            model
149                .assignments
150                .iter()
151                .find(|(n, _)| n == name)
152                .map(|(_, v)| *v)
153                // Model completion: a variable the SAT core never saw is
154                // unconstrained; any value witnesses — use 0 (z3 does too).
155                .unwrap_or(0),
156        )
157    }
158}
159
160// ---------------------------------------------------------------------------
161// Z3 backend (feature-gated differential oracle)
162// ---------------------------------------------------------------------------
163
164#[cfg(feature = "z3-solver")]
165mod z3_backend {
166    use super::*;
167    use ordeal::BvTerm;
168    use z3::SatResult;
169    use z3::ast::Ast;
170
171    /// Translate an ordeal `BvTerm` into a Z3 AST (the two backends consume
172    /// the identical canonicalized query).
173    fn bv_to_z3(t: &BvTerm) -> z3::ast::BV {
174        match t {
175            BvTerm::Const { value, sort } => {
176                if sort.width <= 64 {
177                    z3::ast::BV::from_u64(*value as u64, sort.width)
178                } else {
179                    // Split a wide constant into two 64-bit halves.
180                    let hi = z3::ast::BV::from_u64((value >> 64) as u64, sort.width - 64);
181                    let lo = z3::ast::BV::from_u64(*value as u64, 64);
182                    hi.concat(&lo)
183                }
184            }
185            BvTerm::Var { name, sort } => z3::ast::BV::new_const(name.clone(), sort.width),
186            BvTerm::Add(a, b) => bv_to_z3(a).bvadd(&bv_to_z3(b)),
187            BvTerm::Sub(a, b) => bv_to_z3(a).bvsub(&bv_to_z3(b)),
188            BvTerm::Mul(a, b) => bv_to_z3(a).bvmul(&bv_to_z3(b)),
189            BvTerm::Udiv(a, b) => bv_to_z3(a).bvudiv(&bv_to_z3(b)),
190            BvTerm::And(a, b) => bv_to_z3(a).bvand(&bv_to_z3(b)),
191            BvTerm::Or(a, b) => bv_to_z3(a).bvor(&bv_to_z3(b)),
192            BvTerm::Xor(a, b) => bv_to_z3(a).bvxor(&bv_to_z3(b)),
193            BvTerm::Shl(a, b) => bv_to_z3(a).bvshl(&bv_to_z3(b)),
194            BvTerm::Lshr(a, b) => bv_to_z3(a).bvlshr(&bv_to_z3(b)),
195            BvTerm::Ashr(a, b) => bv_to_z3(a).bvashr(&bv_to_z3(b)),
196            BvTerm::Rotr(a, b) => bv_to_z3(a).bvrotr(&bv_to_z3(b)),
197            BvTerm::Extract { hi, lo, arg } => bv_to_z3(arg).extract(*hi, *lo),
198            BvTerm::Concat(a, b) => bv_to_z3(a).concat(&bv_to_z3(b)),
199            BvTerm::ZeroExt { by, arg } => bv_to_z3(arg).zero_ext(*by),
200            BvTerm::SignExt { by, arg } => bv_to_z3(arg).sign_ext(*by),
201            BvTerm::Ite { cond, then_, else_ } => {
202                bool_to_z3(cond).ite(&bv_to_z3(then_), &bv_to_z3(else_))
203            }
204        }
205    }
206
207    fn bool_to_z3(t: &BoolTerm) -> z3::ast::Bool {
208        match t {
209            BoolTerm::Eq(a, b) => bv_to_z3(a).eq(&bv_to_z3(b)),
210            BoolTerm::Ne(a, b) => bv_to_z3(a).eq(&bv_to_z3(b)).not(),
211            BoolTerm::Ult(a, b) => bv_to_z3(a).bvult(&bv_to_z3(b)),
212            BoolTerm::Ule(a, b) => bv_to_z3(a).bvule(&bv_to_z3(b)),
213            BoolTerm::Ugt(a, b) => bv_to_z3(a).bvugt(&bv_to_z3(b)),
214            BoolTerm::Uge(a, b) => bv_to_z3(a).bvuge(&bv_to_z3(b)),
215            BoolTerm::Slt(a, b) => bv_to_z3(a).bvslt(&bv_to_z3(b)),
216            BoolTerm::Sle(a, b) => bv_to_z3(a).bvsle(&bv_to_z3(b)),
217            BoolTerm::Sgt(a, b) => bv_to_z3(a).bvsgt(&bv_to_z3(b)),
218            BoolTerm::Sge(a, b) => bv_to_z3(a).bvsge(&bv_to_z3(b)),
219            BoolTerm::Not(a) => bool_to_z3(a).not(),
220            BoolTerm::And(a, b) => z3::ast::Bool::and(&[&bool_to_z3(a), &bool_to_z3(b)]),
221            BoolTerm::Or(a, b) => z3::ast::Bool::or(&[&bool_to_z3(a), &bool_to_z3(b)]),
222        }
223    }
224
225    /// The former engine, now the differential oracle.
226    pub struct Z3Solver {
227        assertions: Vec<BoolTerm>,
228        model: Option<z3::Model>,
229    }
230
231    impl Z3Solver {
232        pub fn new() -> Self {
233            Self {
234                assertions: Vec::new(),
235                model: None,
236            }
237        }
238    }
239
240    impl BvSolver for Z3Solver {
241        fn name(&self) -> &'static str {
242            "z3"
243        }
244
245        fn assert(&mut self, cond: &Bool) {
246            self.assertions.push(cond.term().clone());
247        }
248
249        fn check(&mut self) -> CheckOutcome {
250            let solver = z3::Solver::new();
251            for a in &self.assertions {
252                solver.assert(bool_to_z3(a));
253            }
254            match solver.check() {
255                SatResult::Unsat => CheckOutcome::Unsat,
256                SatResult::Sat => {
257                    self.model = solver.get_model();
258                    if self.model.is_some() {
259                        CheckOutcome::Sat
260                    } else {
261                        CheckOutcome::Unknown("z3: SAT but no model available".to_string())
262                    }
263                }
264                SatResult::Unknown => CheckOutcome::Unknown("z3 returned unknown".to_string()),
265            }
266        }
267
268        fn value(&self, var: &BV) -> Option<u128> {
269            let model = self.model.as_ref()?;
270            let z3_var = bv_to_z3(var.term());
271            // z3::Model::eval is SMT model-value lookup (with completion),
272            // not code evaluation.
273            model
274                .eval(&z3_var, true)
275                .and_then(|v| v.as_u64())
276                .map(u128::from)
277        }
278    }
279
280    /// Which backend produced the authoritative model for the last `Sat`.
281    #[derive(Clone, Copy, PartialEq)]
282    enum ModelSource {
283        Ordeal,
284        Z3,
285    }
286
287    /// Runs every query through **both** engines (`SYNTH_SOLVER_DIFF=1`).
288    ///
289    /// Disagreement on a decided verdict is a hard error: it means one of
290    /// the solvers is unsound on this query, and no downstream use of either
291    /// answer is safe. ordeal `Unknown` falls through to Z3's verdict.
292    pub struct DifferentialSolver {
293        ordeal: OrdealSolver,
294        z3: Z3Solver,
295        model_source: ModelSource,
296    }
297
298    impl DifferentialSolver {
299        pub fn new() -> Self {
300            Self {
301                ordeal: OrdealSolver::new(),
302                z3: Z3Solver::new(),
303                model_source: ModelSource::Ordeal,
304            }
305        }
306    }
307
308    impl BvSolver for DifferentialSolver {
309        fn name(&self) -> &'static str {
310            "ordeal+z3-differential"
311        }
312
313        fn assert(&mut self, cond: &Bool) {
314            self.ordeal.assert(cond);
315            self.z3.assert(cond);
316        }
317
318        fn check(&mut self) -> CheckOutcome {
319            let ordeal_verdict = self.ordeal.check();
320            let z3_verdict = self.z3.check();
321            match (&ordeal_verdict, &z3_verdict) {
322                // ordeal could not decide: conservative fall-through to the
323                // oracle's verdict (logged — this is the measurable residue
324                // the ordeal budget/normalization roadmap tracks).
325                (CheckOutcome::Unknown(reason), _) => {
326                    eprintln!(
327                        "[synth-verify differential] ordeal unknown ({reason}); \
328                         falling through to z3 verdict: {z3_verdict:?}"
329                    );
330                    self.model_source = ModelSource::Z3;
331                    z3_verdict
332                }
333                // Oracle could not decide but ordeal did: verdicts do not
334                // disagree; keep ordeal's decided answer.
335                (_, CheckOutcome::Unknown(reason)) => {
336                    eprintln!(
337                        "[synth-verify differential] z3 unknown ({reason}); \
338                         keeping ordeal verdict: {ordeal_verdict:?}"
339                    );
340                    self.model_source = ModelSource::Ordeal;
341                    ordeal_verdict
342                }
343                (CheckOutcome::Unsat, CheckOutcome::Unsat) => CheckOutcome::Unsat,
344                (CheckOutcome::Sat, CheckOutcome::Sat) => {
345                    self.model_source = ModelSource::Ordeal;
346                    CheckOutcome::Sat
347                }
348                // Decided disagreement: one solver is wrong. Hard error.
349                (o, z) => panic!(
350                    "SOLVER DISAGREEMENT (#553 differential oracle): \
351                     ordeal={o:?} z3={z:?} on the same query — one engine is \
352                     unsound on this fragment; refusing to proceed"
353                ),
354            }
355        }
356
357        fn value(&self, var: &BV) -> Option<u128> {
358            match self.model_source {
359                ModelSource::Ordeal => self.ordeal.value(var),
360                ModelSource::Z3 => self.z3.value(var),
361            }
362        }
363    }
364}
365
366#[cfg(feature = "z3-solver")]
367pub use z3_backend::{DifferentialSolver, Z3Solver};
368
369// ---------------------------------------------------------------------------
370// Tests
371// ---------------------------------------------------------------------------
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    fn x32() -> BV {
378        BV::new_const("x", 32)
379    }
380    fn y32() -> BV {
381        BV::new_const("y", 32)
382    }
383
384    /// Small equivalence corpus: (label, query, expect_unsat).
385    /// Every query is a negated equivalence, exactly the validator's shape.
386    fn corpus() -> Vec<(&'static str, Bool, bool)> {
387        let x = x32();
388        let y = y32();
389        let one = BV::from_i64(1, 32);
390        vec![
391            ("add-comm", x.bvadd(&y).eq(y.bvadd(&x)).not(), true),
392            ("mul-comm", x.bvmul(&y).eq(y.bvmul(&x)).not(), true),
393            ("and-comm", x.bvand(&y).eq(y.bvand(&x)).not(), true),
394            (
395                "shl1-vs-mul2",
396                x.bvshl(&one).eq(x.bvmul(BV::from_i64(2, 32))).not(),
397                true,
398            ),
399            (
400                "sub-not-comm",
401                x.bvsub(&y).eq(y.bvsub(&x)).not(),
402                false, // SAT: x-y != y-x has witnesses
403            ),
404            (
405                "ite-select",
406                {
407                    let c = x.eq(BV::from_i64(0, 32)).not();
408                    c.ite(&y, &one).eq(c.ite(&y, &one)).not()
409                },
410                true,
411            ),
412            (
413                "add-vs-sub-bug",
414                x.bvadd(&y).eq(x.bvsub(&y)).not(),
415                false, // SAT: differs whenever y != 0
416            ),
417        ]
418    }
419
420    #[test]
421    fn ordeal_decides_the_corpus() {
422        for (label, query, expect_unsat) in corpus() {
423            let mut solver = OrdealSolver::new();
424            solver.assert(&query);
425            let outcome = solver.check();
426            if expect_unsat {
427                assert_eq!(outcome, CheckOutcome::Unsat, "{label}");
428            } else {
429                assert_eq!(outcome, CheckOutcome::Sat, "{label}");
430                // Model readback must produce usable witness values.
431                assert!(solver.value(&x32()).is_some(), "{label}: no model value");
432            }
433        }
434    }
435
436    #[test]
437    fn ordeal_finds_counterexample_with_model() {
438        // x + 1 == x - 1 is UNSAT-free: always differs -> negation is... the
439        // direct assertion x+1 == x-1 is unsatisfiable; assert the EQUALITY
440        // and expect Unsat, then assert a satisfiable disequality and read
441        // the model back.
442        let x = x32();
443        let mut solver = OrdealSolver::new();
444        solver.assert(
445            &x.bvadd(BV::from_i64(1, 32))
446                .eq(x.bvsub(BV::from_i64(1, 32))),
447        );
448        assert_eq!(solver.check(), CheckOutcome::Unsat);
449
450        let mut solver = OrdealSolver::new();
451        solver.assert(&x.eq(BV::from_i64(7, 32)));
452        assert_eq!(solver.check(), CheckOutcome::Sat);
453        assert_eq!(solver.value(&x), Some(7));
454    }
455
456    #[test]
457    fn bool_var_bridge_is_symbolic() {
458        // Bool::new_const must be genuinely free: both polarities satisfiable.
459        let flag = Bool::new_const("flag");
460        let one = BV::from_i64(1, 32);
461        let zero = BV::from_i64(0, 32);
462        // `out` selects on the flag; constraining the flag's polarity must
463        // force the selected value — in both directions.
464        for (cond, expected) in [(flag.clone(), 1u128), (flag.not(), 0u128)] {
465            let mut solver = OrdealSolver::new();
466            let out = flag.ite(&one, &zero);
467            let probe = BV::new_const("probe", 32);
468            solver.assert(&probe.eq(&out));
469            solver.assert(&cond);
470            assert_eq!(solver.check(), CheckOutcome::Sat);
471            assert_eq!(solver.value(&probe), Some(expected));
472        }
473    }
474
475    /// The differential oracle itself: run the corpus through BOTH engines.
476    /// Any decided disagreement panics inside `DifferentialSolver::check`.
477    /// This runs in the CI Z3 job (`--features z3-solver,arm`) regardless of
478    /// the SYNTH_SOLVER_DIFF env, so the cross-check is always exercised
479    /// where Z3 is available.
480    #[cfg(feature = "z3-solver")]
481    #[test]
482    fn differential_zero_disagreements_on_corpus() {
483        crate::with_z3_context(|| {
484            for (label, query, expect_unsat) in corpus() {
485                let mut solver = DifferentialSolver::new();
486                solver.assert(&query);
487                let outcome = solver.check();
488                if expect_unsat {
489                    assert_eq!(outcome, CheckOutcome::Unsat, "{label}");
490                } else {
491                    assert_eq!(outcome, CheckOutcome::Sat, "{label}");
492                }
493            }
494        });
495    }
496}