Skip to main content

synth_verify/
trap.rs

1//! Trap-preservation obligations (VCR-VER-002, synth #166 / ordeal#59).
2//!
3//! WASM operations like `div_s`, `load`/`store`, `call_indirect`,
4//! `unreachable`, and the float→int truncations (`iN.trunc_fM_s/u`) are
5//! **partial**: they trap on some inputs. A validator that
6//! proves only *value* equivalence over a total model cannot see a lowering that
7//! **drops a trap** — deleting a trapping guard looks value-equal
8//! (synth#633/#666/#665/#642/#709). This module is the thin synth-facing layer
9//! over [`ordeal::trap`]: it maps synth's [`WasmOp`]s to trap conditions built
10//! over synth's own [`BV`]/[`Bool`] terms, and exposes the trap-preservation
11//! gate so "the trap survived the lowering" becomes a checkable obligation.
12//!
13//! # Boundary (unchanged from ordeal#59)
14//!
15//! ordeal *classifies* operand/pointer bits — it never models op *values* (those
16//! are consumer-supplied) and does no floating-point arithmetic. Every builder
17//! here is a `Bool`/`BV` over the existing closed QF_BV fragment. A verdict of
18//! [`TrapVerdict::Preserved`] is an ordeal `Unsat` whose LRAT certificate is
19//! re-checked before it is returned, so soundness is that of the normal
20//! certificate-checked pipeline.
21//!
22//! # Which VC for which op class
23//!
24//! - **div/rem** — the ARM lowering carries a value (the quotient/remainder), so
25//!   the full [`prove_trap_equivalence`] (trap clause **and** guarded value
26//!   clause) applies.
27//! - **load/store, call_indirect, unreachable** — synth models no memory
28//!   *contents* nor table *values*, so these use
29//!   [`prove_trap_condition_equivalence`] (trap clause only). This is the
30//!   ordeal#59 agreement.
31//! - **float→int trunc** (`i32/i64.trunc_f{32,64}_{s,u}`, Phase B) — the trap
32//!   predicate is a pure bit-pattern classifier over the float OPERAND's bits
33//!   (NaN/±∞ exponent patterns + sign-split monotonic magnitude thresholds,
34//!   ordeal 0.9.1's `trap_trunc`; floats enter as BV32/BV64, no FP theory).
35//!   synth's QF_BV model carries no float→int *value* function, so this class
36//!   uses [`prove_trap_condition_equivalence`] (trap clause only) — exactly
37//!   the #709 soundness surface: ARM `VCVT` saturates where WASM traps, so a
38//!   lowering that keeps the saturated value but drops the guard is the bug
39//!   shape this clause rejects.
40
41use crate::term::{BV, Bool};
42use ordeal::CheckResult;
43use ordeal::trap as ot;
44use synth_core::WasmOp;
45
46pub use ordeal::trap::{DivOp, FpFmt, IntTarget};
47
48/// A value paired with the condition under which the op **traps** instead of
49/// producing it — the synth-`BV`/`Bool` mirror of [`ordeal::trap::DefineOrTrap`].
50/// `value` is the op's result (e.g. the ARM quotient); `may_trap` is one of the
51/// trap-condition builders below.
52#[derive(Clone, Debug)]
53pub struct DefineOrTrap {
54    /// The op's result value.
55    pub value: BV,
56    /// The condition under which the op traps.
57    pub may_trap: Bool,
58}
59
60impl DefineOrTrap {
61    fn to_ordeal(&self) -> ot::DefineOrTrap {
62        ot::DefineOrTrap {
63            value: self.value.term().clone(),
64            may_trap: self.may_trap.term().clone(),
65        }
66    }
67}
68
69/// The type-check mode of a `call_indirect`, per its table — the synth-`BV`
70/// mirror of [`ordeal::trap::TypeTrap`].
71pub enum TypeTrap<'a> {
72    /// Heterogeneous table: the element type is checked at runtime against the
73    /// call's expected type id.
74    Runtime {
75        /// The table element's runtime type-id term.
76        actual_type_id: &'a BV,
77        /// The call site's expected type-id term.
78        expected_id: &'a BV,
79    },
80    /// Closed-world / homogeneous table: the signature is discharged at compile
81    /// time by the selector (synth's default — `ArmOp::CallIndirect.type_check`
82    /// is `None`), so there is no runtime type-id and the type clause is `false`.
83    StaticallyDischarged,
84}
85
86/// The operands of a `call_indirect` trap check (WASM §4.4.8) — the synth-`BV`
87/// mirror of [`ordeal::trap::CallIndirect`].
88pub struct CallIndirect<'a> {
89    /// The table index operand.
90    pub index: &'a BV,
91    /// The table's element count.
92    pub table_size: &'a BV,
93    /// The loaded funcref word; a null (zero) slot traps before the call.
94    pub slot_ptr: &'a BV,
95    /// How the element's type is checked.
96    pub type_trap: TypeTrap<'a>,
97}
98
99/// The verdict of a trap-preservation gate.
100#[derive(Clone, Debug, PartialEq, Eq)]
101pub enum TrapVerdict {
102    /// `Unsat` — the lowering preserves the trap (and, for the full VC, the
103    /// value). The underlying LRAT certificate re-checked successfully.
104    Preserved,
105    /// `Sat` — a counterexample input under which the trap was dropped (or
106    /// spuriously added). Carries the model's variable → value assignments.
107    Dropped(Vec<(String, u128)>),
108    /// `Unknown` — conservative: do **not** accept. Also returned if a
109    /// `Preserved` certificate fails to re-check (an internal soundness alarm).
110    Unknown,
111}
112
113// ---------------------------------------------------------------------------
114// Trap-condition builders (WasmOp → Bool over operand bits)
115// ---------------------------------------------------------------------------
116
117/// Map a division/remainder [`WasmOp`] (i32 or i64) to its [`DivOp`]; `None`
118/// for any non-div/rem op.
119pub fn div_op(op: &WasmOp) -> Option<DivOp> {
120    Some(match op {
121        WasmOp::I32DivU | WasmOp::I64DivU => DivOp::DivU,
122        WasmOp::I32DivS | WasmOp::I64DivS => DivOp::DivS,
123        WasmOp::I32RemU | WasmOp::I64RemU => DivOp::RemU,
124        WasmOp::I32RemS | WasmOp::I64RemS => DivOp::RemS,
125        _ => return None,
126    })
127}
128
129/// Trap condition for a div/rem op: divide-by-zero (all four) plus
130/// `INT_MIN / -1` signed overflow (`div_s` ONLY). The width is taken from
131/// `dividend` — pass 32-bit terms for i32 ops, 64-bit for i64.
132///
133/// # ordeal 0.9.1 divergence (upstream bug, reported as ordeal#72)
134///
135/// `ordeal::trap::trap_div(DivOp::RemS)` includes the `INT_MIN / -1`
136/// overflow clause, but WASM Core §4.4.1 `irem_s` does NOT trap there — it
137/// returns 0 (only `idiv_s` traps on overflow). synth's own models agree
138/// (`I32.rems` in `coq/Synth/Common/Integers.v` carries no overflow guard;
139/// the shipped `rem_s` lowering emits only the ÷0 guard). The divergence was
140/// invisible while BOTH sides of the VC used the builder (consistent
141/// wrongness); the #166 derived-ARM-trap gate exposed it by rejecting the
142/// CORRECT shipped `rem_s` lowering with an INT_MIN/-1 counterexample. Until
143/// the ordeal#72 fix ships, `RemS` is built through the zero-only path
144/// (`RemU`'s trap condition — the ÷0 test is a bit-pattern equality, so
145/// signedness does not change it).
146pub fn trap_div(op: DivOp, dividend: &BV, divisor: &BV) -> Bool {
147    // WASM rem_s traps ONLY on ÷0 — route around ordeal 0.9.1's spurious
148    // overflow clause (see the doc comment above).
149    let op = if matches!(op, DivOp::RemS) {
150        DivOp::RemU
151    } else {
152        op
153    };
154    Bool::from_ordeal(ot::trap_div(
155        op,
156        dividend.term(),
157        divisor.term(),
158        dividend.get_size(),
159    ))
160}
161
162/// Map a float→int truncation [`WasmOp`] to its
163/// `(float format, integer target, signedness)` triple; `None` for any
164/// non-trunc op. Covers all eight trunc variants synth's decoder produces
165/// (`i64.trunc_f32_s/u` gained `WasmOp` variants + an ARM lowering in #869).
166pub fn trunc_op(op: &WasmOp) -> Option<(FpFmt, IntTarget, bool)> {
167    Some(match op {
168        WasmOp::I32TruncF32S => (FpFmt::F32, IntTarget::I32, true),
169        WasmOp::I32TruncF32U => (FpFmt::F32, IntTarget::I32, false),
170        WasmOp::I32TruncF64S => (FpFmt::F64, IntTarget::I32, true),
171        WasmOp::I32TruncF64U => (FpFmt::F64, IntTarget::I32, false),
172        WasmOp::I64TruncF64S => (FpFmt::F64, IntTarget::I64, true),
173        WasmOp::I64TruncF64U => (FpFmt::F64, IntTarget::I64, false),
174        // #869: the f32-source i64-target pair — WasmOp variants (and an ARM
175        // lowering) exist now; the raw builder always covered the shape.
176        WasmOp::I64TruncF32S => (FpFmt::F32, IntTarget::I64, true),
177        WasmOp::I64TruncF32U => (FpFmt::F32, IntTarget::I64, false),
178        _ => return None,
179    })
180}
181
182/// Trap condition for `iN.trunc_fM_s/u` (WASM float→int truncation, #709):
183/// `NaN ∨ ±∞ ∨ out-of-range` classified purely over the float operand's
184/// **bit pattern** (`bits` is the BV32/BV64 the float travels as — no FP
185/// theory). Pass the triple from [`trunc_op`]. `bits` must be exactly
186/// `fmt.total_bits()` wide (32 for f32, 64 for f64) — a width mismatch is an
187/// internal bug, so it panics loud rather than returning an ill-sorted term.
188pub fn trap_trunc(bits: &BV, fmt: FpFmt, target: IntTarget, signed: bool) -> Bool {
189    assert_eq!(
190        bits.get_size(),
191        fmt.total_bits(),
192        "trap_trunc: float operand term must be {} bits wide for {:?}",
193        fmt.total_bits(),
194        fmt
195    );
196    Bool::from_ordeal(ot::trap_trunc(bits.term(), fmt, target, signed))
197}
198
199/// Trap condition for `unreachable`: an unconditional trap.
200pub fn trap_always() -> Bool {
201    Bool::from_ordeal(ot::trap_always())
202}
203
204/// Trap condition for an OOB `load`/`store`: a `size`-byte access at `addr`
205/// exceeds `mem_bound` (`addr + size >u mem_bound`, wraparound-safe). `addr`,
206/// `size`, and `mem_bound` must share a width; `mem_bound` is synth's symbolic
207/// native-pointer linear-memory extent.
208pub fn trap_mem_oob(addr: &BV, size: &BV, mem_bound: &BV) -> Bool {
209    Bool::from_ordeal(ot::trap_mem_oob(addr.term(), size.term(), mem_bound.term()))
210}
211
212/// Trap condition for `call_indirect`: `bounds ∨ null-slot ∨ type`.
213pub fn trap_call_indirect(ci: &CallIndirect) -> Bool {
214    // Bind the runtime type-id borrows so the `ot::TypeTrap` refs outlive the
215    // `trap_call_indirect` call.
216    let type_trap = match &ci.type_trap {
217        TypeTrap::Runtime {
218            actual_type_id,
219            expected_id,
220        } => ot::TypeTrap::Runtime {
221            actual_type_id: actual_type_id.term(),
222            expected_id: expected_id.term(),
223        },
224        TypeTrap::StaticallyDischarged => ot::TypeTrap::StaticallyDischarged,
225    };
226    let oci = ot::CallIndirect {
227        index: ci.index.term(),
228        table_size: ci.table_size.term(),
229        slot_ptr: ci.slot_ptr.term(),
230        type_trap,
231    };
232    Bool::from_ordeal(ot::trap_call_indirect(&oci))
233}
234
235// ---------------------------------------------------------------------------
236// The gate
237// ---------------------------------------------------------------------------
238
239fn verdict(result: CheckResult) -> TrapVerdict {
240    match result {
241        CheckResult::Unsat(cert) => match cert.recheck() {
242            Ok(()) => TrapVerdict::Preserved,
243            // A certificate that does not re-check is an internal soundness
244            // alarm — never report it as preserved.
245            Err(_) => TrapVerdict::Unknown,
246        },
247        CheckResult::Sat(model) => TrapVerdict::Dropped(model.assignments),
248        CheckResult::Unknown => TrapVerdict::Unknown,
249    }
250}
251
252/// Discharge a trap VC under the **same per-query wall-clock deadline** as the
253/// [`crate::solver`] seam (#848/#849).
254///
255/// `ordeal::trap::prove_trap_{equivalence,condition_equivalence}` delegate to
256/// the UNBOUNDED `Solver::prove_valid`, so calling them directly would leave
257/// the hardest VC class in the whole validator — the 64-bit `bvsrem`/`bvurem`
258/// div/rem value VCs — with no wall-clock floor at all. That is precisely the
259/// #849 hang path (4–6 h CI runs). So synth builds the identical goal term
260/// (`ot::trap_equivalence_vc` / `ot::trap_condition_equivalence`) and runs the
261/// standard validity-as-UNSAT encoding itself, bounded by
262/// `SYNTH_ORDEAL_DEADLINE_MS`.
263///
264/// A deadline expiry yields [`TrapVerdict::Unknown`] — conservative, never
265/// `Preserved`. The certificate re-check in [`verdict`] is untouched, so the
266/// bound costs completeness only.
267///
268/// NOTE this path is ordeal-only by construction: it does NOT go through
269/// [`crate::solver::new_solver`], so the `SYNTH_SOLVER_DIFF` Z3 cross-check
270/// never sees these VCs. That is why the CI "Z3 Verification" job hung in
271/// #849 too — it runs the same ordeal-backed trap tests, not a Z3 solve.
272/// Routing trap VCs through the differential seam is a separate follow-up.
273///
274/// Limitation (same as the seam): the deadline governs the SAT *search*, not
275/// bit-blasting.
276fn prove_valid_bounded(goal: ordeal::BoolTerm) -> CheckResult {
277    let mut solver = ordeal::Solver::new();
278    solver.assert(ordeal::BoolTerm::Not(Box::new(goal)));
279    let deadline_ms = crate::solver::configured_deadline_ms();
280    if deadline_ms > 0 {
281        solver.check_with_deadline(deadline_ms)
282    } else {
283        solver.check()
284    }
285}
286
287/// Full trap-preservation gate (trap clause **and** guarded value clause) — for
288/// ops whose value synth models (div/rem). [`TrapVerdict::Preserved`] ⟹ the
289/// lowering preserves both traps and values.
290pub fn prove_trap_equivalence(orig: &DefineOrTrap, opt: &DefineOrTrap) -> TrapVerdict {
291    verdict(prove_valid_bounded(ot::trap_equivalence_vc(
292        &orig.to_ordeal(),
293        &opt.to_ordeal(),
294    )))
295}
296
297/// Trap-clause-only gate (`orig.may_trap ⇔ opt.may_trap`) — for ops whose value
298/// synth does not model (load/store, call_indirect, unreachable, float→int
299/// trunc).
300/// [`TrapVerdict::Preserved`] ⟹ the lowering neither drops nor spuriously adds
301/// the trap.
302pub fn prove_trap_condition_equivalence(orig_may_trap: &Bool, opt_may_trap: &Bool) -> TrapVerdict {
303    verdict(prove_valid_bounded(ot::trap_condition_equivalence(
304        orig_may_trap.term(),
305        opt_may_trap.term(),
306    )))
307}