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(¶ms);
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}