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