Skip to main content

ordeal/
trap.rs

1//! WASM trap / partiality semantics (issue #59 / TR-019).
2//!
3//! WASM operations like `div_s`, `load`, `call_indirect`, and `unreachable` are
4//! **partial**: they trap on some inputs. A verifier that proves only *value*
5//! equivalence over a model in which every op is total cannot see a
6//! transformation that *drops a trap* — deleting a trapping op looks value-equal
7//! (loom#273/#274/#278, synth#633/#666/#665/#642). This module builds the
8//! **trap condition** of each partial op as a QF_BV [`BoolTerm`] over its
9//! operand/pointer *bits*, and composes trap-preservation verification
10//! conditions, so trap-equivalence becomes a checkable obligation.
11//!
12//! **Boundary held:** ordeal *classifies* bits — it never models op *values*
13//! (those are consumer-supplied) and never does floating-point arithmetic. Every
14//! builder here is `BoolTerm`/`BvTerm` over the existing closed fragment: no new
15//! operations, no FP theory. Soundness is unchanged — a trap-equivalence VC is
16//! decided by the normal certificate-checked pipeline, so `Unsat` is
17//! LRAT-validated and re-checkable ([`crate::Certificate::recheck`]).
18//!
19//! Consumers: synth's `translation_validator` (VCR-VER-002) gates div/rem,
20//! `call_indirect`, and `unreachable` on the full [`trap_equivalence_vc`], and
21//! memory ops on [`trap_condition_equivalence`] (trap-clause only, since synth
22//! models no memory *contents*); loom (loom#279) uses the same builders.
23
24use crate::eval;
25use crate::solver::{CheckResult, Solver};
26use crate::term::{BoolTerm, BvTerm, Sort};
27
28fn bx(t: BvTerm) -> Box<BvTerm> {
29    Box::new(t)
30}
31fn bb(t: BoolTerm) -> Box<BoolTerm> {
32    Box::new(t)
33}
34
35/// A trivially-true `BoolTerm`. The fragment has no boolean constant, so truth
36/// is encoded as a constant equality (`0 == 0` at width 8, which the AIG folds
37/// to the `TRUE` literal).
38fn bool_true() -> BoolTerm {
39    let z = || {
40        bx(BvTerm::Const {
41            value: 0,
42            sort: Sort::new(8),
43        })
44    };
45    BoolTerm::Eq(z(), z())
46}
47
48/// A trivially-false `BoolTerm` (`¬true`).
49fn bool_false() -> BoolTerm {
50    BoolTerm::Not(bb(bool_true()))
51}
52
53/// A zero constant matching `t`'s width (width taken from the sort oracle; a
54/// width-8 fallback is harmless because an ill-sorted input makes the whole
55/// query `Unknown` regardless).
56fn zero_like(t: &BvTerm) -> BvTerm {
57    let width = eval::bv_sort(t).map(|s| s.width).unwrap_or(8);
58    BvTerm::Const {
59        value: 0,
60        sort: Sort::new(width),
61    }
62}
63
64/// A value paired with the condition under which the op **traps** instead of
65/// producing it. `value` is supplied by the caller — ordeal models no op values
66/// — and `may_trap` is built by the helpers in this module.
67#[derive(Clone, Debug)]
68pub struct DefineOrTrap {
69    /// The op's result value (consumer-supplied `BvTerm`).
70    pub value: BvTerm,
71    /// The condition under which the op traps.
72    pub may_trap: BoolTerm,
73}
74
75/// Which division/remainder op, for [`trap_div`].
76#[derive(Clone, Copy, Debug)]
77pub enum DivOp {
78    /// `i32.div_u` / `i64.div_u`.
79    DivU,
80    /// `i32.div_s` / `i64.div_s`.
81    DivS,
82    /// `i32.rem_u` / `i64.rem_u`.
83    RemU,
84    /// `i32.rem_s` / `i64.rem_s`.
85    RemS,
86}
87
88impl DivOp {
89    /// Whether this op traps on the `INT_MIN / -1` overflow pair.
90    ///
91    /// **Only `div_s` does.** Per WASM Core §4.4.1 `idiv_s` traps there because
92    /// the true quotient `2^(N-1)` is not representable, but `irem_s` is
93    /// *defined*: `irem_s(INT_MIN, -1) = 0` (ordeal's own `bvsrem` reference
94    /// agrees). `rem_s` therefore traps on divisor-zero and nothing else.
95    ///
96    /// Getting this wrong was ordeal#72: the clause was gated on
97    /// "is the op signed", which wrongly swept in `RemS` and made the library
98    /// demand a trap WASM does not have — rejecting correct `rem_s` lowerings
99    /// (and blessing spuriously-trapping ones). It hid because both sides of a
100    /// trap-equivalence VC used this same builder: consistent wrongness is
101    /// invisible to a consistency gate. synth caught it by deriving the ARM side
102    /// independently, from the emitted guard structure.
103    fn traps_on_overflow(self) -> bool {
104        matches!(self, DivOp::DivS)
105    }
106}
107
108/// Trap condition for a division/remainder op: divide-by-zero for all four,
109/// plus the `INT_MIN / -1` overflow for **`div_s` only** (see
110/// [`DivOp::traps_on_overflow`] — `rem_s` does NOT trap there).
111/// Pure compares — `Eq(divisor, 0)` and `And(Eq(dividend, INT_MIN), Eq(divisor, -1))`.
112pub fn trap_div(op: DivOp, dividend: &BvTerm, divisor: &BvTerm, width: u32) -> BoolTerm {
113    let sort = Sort::new(width);
114    let zero = BvTerm::Const { value: 0, sort };
115    let div_by_zero = BoolTerm::Eq(bx(divisor.clone()), bx(zero));
116    if !op.traps_on_overflow() {
117        return div_by_zero;
118    }
119    // Signed overflow: dividend == INT_MIN and divisor == -1 (all ones).
120    let int_min = 1u128 << (width - 1);
121    let all_ones = if width >= 128 {
122        u128::MAX
123    } else {
124        (1u128 << width) - 1
125    };
126    let overflow = BoolTerm::And(
127        bb(BoolTerm::Eq(
128            bx(dividend.clone()),
129            bx(BvTerm::Const {
130                value: int_min,
131                sort,
132            }),
133        )),
134        bb(BoolTerm::Eq(
135            bx(divisor.clone()),
136            bx(BvTerm::Const {
137                value: all_ones,
138                sort,
139            }),
140        )),
141    );
142    BoolTerm::Or(bb(div_by_zero), bb(overflow))
143}
144
145/// Trap condition for `unreachable`: an unconditional trap.
146pub fn trap_always() -> BoolTerm {
147    bool_true()
148}
149
150/// Trap condition for an OOB `load`/`store`: a `size`-byte access at `addr`
151/// exceeds `mem_bound` (`addr + size >u mem_bound`). **Wraparound-safe** — each
152/// operand is zero-extended by one bit before the add, so `addr + size` cannot
153/// alias a small value. `addr`, `size`, and `mem_bound` must share a width;
154/// `mem_bound` is the caller's symbolic linear-memory extent.
155pub fn trap_mem_oob(addr: &BvTerm, size: &BvTerm, mem_bound: &BvTerm) -> BoolTerm {
156    let ext = |t: &BvTerm| {
157        bx(BvTerm::ZeroExt {
158            by: 1,
159            arg: bx(t.clone()),
160        })
161    };
162    let end = BvTerm::Add(ext(addr), ext(size));
163    BoolTerm::Ugt(bx(end), ext(mem_bound))
164}
165
166/// The type-check mode of a `call_indirect`, per its table.
167pub enum TypeTrap<'a> {
168    /// Heterogeneous table: the element type is checked at runtime against the
169    /// call's expected type id — traps on `Ne(actual_type_id, expected_id)`.
170    Runtime {
171        /// The table element's runtime type-id term.
172        actual_type_id: &'a BvTerm,
173        /// The call site's expected type-id term.
174        expected_id: &'a BvTerm,
175    },
176    /// Closed-world / homogeneous table: the signature is discharged at compile
177    /// time by the selector, so there is no runtime type-id and the type clause
178    /// contributes `false` (the VC never demands a term that does not exist).
179    StaticallyDischarged,
180}
181
182/// The operands of a `call_indirect` trap check (WASM §4.4.8).
183pub struct CallIndirect<'a> {
184    /// The table index operand.
185    pub index: &'a BvTerm,
186    /// The table's element count.
187    pub table_size: &'a BvTerm,
188    /// The loaded funcref word; a null (zero) slot traps before the call.
189    pub slot_ptr: &'a BvTerm,
190    /// How the element's type is checked.
191    pub type_trap: TypeTrap<'a>,
192}
193
194/// Trap condition for `call_indirect`: `bounds ∨ null-slot ∨ type`
195/// (`Uge(index, table_size)`, `Eq(slot_ptr, 0)`, and the [`TypeTrap`] clause).
196pub fn trap_call_indirect(ci: &CallIndirect) -> BoolTerm {
197    let bounds = BoolTerm::Uge(bx(ci.index.clone()), bx(ci.table_size.clone()));
198    let null_slot = BoolTerm::Eq(bx(ci.slot_ptr.clone()), bx(zero_like(ci.slot_ptr)));
199    let type_clause = match &ci.type_trap {
200        TypeTrap::Runtime {
201            actual_type_id,
202            expected_id,
203        } => BoolTerm::Ne(bx((*actual_type_id).clone()), bx((*expected_id).clone())),
204        TypeTrap::StaticallyDischarged => bool_false(),
205    };
206    BoolTerm::Or(bb(BoolTerm::Or(bb(bounds), bb(null_slot))), bb(type_clause))
207}
208
209// ---------------------------------------------------------------------------
210// Float→int truncation traps (Phase B, synth #709).
211//
212// WASM `iN.trunc_fM_s/u` traps on NaN, ±∞, and when the round-toward-zero
213// truncation falls outside the target integer range. ordeal stays QF_BV: the
214// float enters ONLY as its bit pattern (BV32/BV64) and is *classified* with
215// extracts and unsigned compares — no FP theory, no new ops. The key fact is
216// IEEE-754's monotonic bit order: for a fixed sign, a float's magnitude is
217// strictly monotonic in the unsigned integer value of its sign-stripped bit
218// pattern, so "|x| ≥ threshold" is a single `Uge` against a constant pattern.
219// ---------------------------------------------------------------------------
220
221/// An IEEE-754 binary interchange format, for the trunc-trap classifiers.
222#[derive(Clone, Copy, Debug, PartialEq, Eq)]
223pub enum FpFmt {
224    /// binary32: 1 sign / 8 exponent / 23 mantissa bits.
225    F32,
226    /// binary64: 1 sign / 11 exponent / 52 mantissa bits.
227    F64,
228}
229
230impl FpFmt {
231    /// Total width of the bit pattern (32 / 64).
232    pub const fn total_bits(self) -> u32 {
233        match self {
234            FpFmt::F32 => 32,
235            FpFmt::F64 => 64,
236        }
237    }
238    /// Width of the biased-exponent field (8 / 11).
239    pub const fn exp_bits(self) -> u32 {
240        match self {
241            FpFmt::F32 => 8,
242            FpFmt::F64 => 11,
243        }
244    }
245    /// Width of the trailing-significand (mantissa) field (23 / 52).
246    pub const fn mant_bits(self) -> u32 {
247        match self {
248            FpFmt::F32 => 23,
249            FpFmt::F64 => 52,
250        }
251    }
252    /// Exponent bias (127 / 1023).
253    const fn bias(self) -> u32 {
254        match self {
255            FpFmt::F32 => 127,
256            FpFmt::F64 => 1023,
257        }
258    }
259    /// The all-ones exponent-field value (NaN/∞ marker: 0xFF / 0x7FF).
260    const fn exp_all_ones(self) -> u128 {
261        (1u128 << self.exp_bits()) - 1
262    }
263}
264
265/// The integer target of a truncation, for [`fp_trunc_out_of_range`].
266#[derive(Clone, Copy, Debug, PartialEq, Eq)]
267pub enum IntTarget {
268    /// `i32` (WASM `i32.trunc_f*`).
269    I32,
270    /// `i64` (WASM `i64.trunc_f*`).
271    I64,
272}
273
274impl IntTarget {
275    /// Bit width of the target integer (32 / 64).
276    pub const fn width(self) -> u32 {
277        match self {
278            IntTarget::I32 => 32,
279            IntTarget::I64 => 64,
280        }
281    }
282}
283
284/// Extract the biased-exponent field `bits[total-2 : mant]`.
285fn fp_exp_field(bits: &BvTerm, fmt: FpFmt) -> BvTerm {
286    BvTerm::Extract {
287        hi: fmt.total_bits() - 2,
288        lo: fmt.mant_bits(),
289        arg: bx(bits.clone()),
290    }
291}
292
293/// Extract the trailing-significand field `bits[mant-1 : 0]`.
294fn fp_mant_field(bits: &BvTerm, fmt: FpFmt) -> BvTerm {
295    BvTerm::Extract {
296        hi: fmt.mant_bits() - 1,
297        lo: 0,
298        arg: bx(bits.clone()),
299    }
300}
301
302/// Extract the sign bit `bits[total-1]` (1-bit term).
303fn fp_sign_bit(bits: &BvTerm, fmt: FpFmt) -> BvTerm {
304    let hi = fmt.total_bits() - 1;
305    BvTerm::Extract {
306        hi,
307        lo: hi,
308        arg: bx(bits.clone()),
309    }
310}
311
312/// Extract the sign-stripped magnitude pattern `bits[total-2 : 0]`. For a fixed
313/// sign, IEEE float magnitude is monotonic in this unsigned value.
314fn fp_magnitude(bits: &BvTerm, fmt: FpFmt) -> BvTerm {
315    BvTerm::Extract {
316        hi: fmt.total_bits() - 2,
317        lo: 0,
318        arg: bx(bits.clone()),
319    }
320}
321
322/// `exp field == all-ones` (the NaN/∞ exponent marker).
323fn fp_exp_is_all_ones(bits: &BvTerm, fmt: FpFmt) -> BoolTerm {
324    BoolTerm::Eq(
325        bx(fp_exp_field(bits, fmt)),
326        bx(BvTerm::Const {
327            value: fmt.exp_all_ones(),
328            sort: Sort::new(fmt.exp_bits()),
329        }),
330    )
331}
332
333/// The float's bits encode a NaN: exponent all-ones AND mantissa ≠ 0.
334pub fn fp_is_nan(bits: &BvTerm, fmt: FpFmt) -> BoolTerm {
335    let mant_nonzero = BoolTerm::Ne(
336        bx(fp_mant_field(bits, fmt)),
337        bx(BvTerm::Const {
338            value: 0,
339            sort: Sort::new(fmt.mant_bits()),
340        }),
341    );
342    BoolTerm::And(bb(fp_exp_is_all_ones(bits, fmt)), bb(mant_nonzero))
343}
344
345/// The float's bits encode ±∞: exponent all-ones AND mantissa == 0.
346pub fn fp_is_inf(bits: &BvTerm, fmt: FpFmt) -> BoolTerm {
347    let mant_zero = BoolTerm::Eq(
348        bx(fp_mant_field(bits, fmt)),
349        bx(BvTerm::Const {
350            value: 0,
351            sort: Sort::new(fmt.mant_bits()),
352        }),
353    );
354    BoolTerm::And(bb(fp_exp_is_all_ones(bits, fmt)), bb(mant_zero))
355}
356
357/// Sign-stripped bit pattern of `2^k` in `fmt`: exponent field `bias + k`,
358/// mantissa 0. Exact for every `k` used here (k ≤ 64 ≪ exponent range).
359fn pow2_magnitude_pattern(fmt: FpFmt, k: u32) -> u128 {
360    ((fmt.bias() + k) as u128) << fmt.mant_bits()
361}
362
363/// Sign-stripped bit pattern of the **smallest** float of `fmt` whose value is
364/// `≥ 2^k + 1` — the negative-side trap threshold for a signed target of width
365/// `k + 1` (trap iff `x ≤ -(2^k + 1)`, i.e. `|x| ≥ 2^k + 1`).
366///
367/// If the format has ≥ `k` mantissa bits, `2^k + 1` is exactly representable:
368/// pattern of `2^k` with mantissa bit `mant - k` set. Otherwise the next float
369/// above `2^k` (pattern + 1, one ULP) is the smallest one `≥ 2^k + 1`.
370fn min_pattern_ge_pow2_plus_1(fmt: FpFmt, k: u32) -> u128 {
371    let p2 = pow2_magnitude_pattern(fmt, k);
372    if fmt.mant_bits() >= k {
373        p2 | (1u128 << (fmt.mant_bits() - k))
374    } else {
375        p2 + 1
376    }
377}
378
379/// The float's truncation (round-toward-zero) falls outside the target integer
380/// range — **finite values only** (NaN/∞ are `false` here; they are separate
381/// disjuncts of [`trap_trunc`]). Matches WASM `iN.trunc_fM_s/u` (synth #709):
382///
383/// - signed:   in-range iff `trunc(x) ∈ [-2^(N-1), 2^(N-1)-1]`, i.e.
384///   `-(2^(N-1)+1) < x < 2^(N-1)`. Positive trap: `|x| ≥ 2^(N-1)` (so
385///   `x == 2^(N-1)` traps). Negative trap: `|x| ≥ 2^(N-1)+1` (so
386///   `x == -2^(N-1)` converts, and any float in `(-(2^(N-1)+1), -2^(N-1)]`
387///   truncates to `-2^(N-1)`).
388/// - unsigned: in-range iff `trunc(x) ∈ [0, 2^N-1]`, i.e. `-1 < x < 2^N`.
389///   Positive trap: `|x| ≥ 2^N`. Negative trap: `|x| ≥ 1` (so `x == -1.0`
390///   traps but `-1 < x < 0`, incl. `-0.0`, truncates to 0).
391///
392/// Each magnitude bound is one unsigned compare against a constant bit pattern
393/// (IEEE monotonic bit order), split on the sign bit.
394pub fn fp_trunc_out_of_range(
395    bits: &BvTerm,
396    fmt: FpFmt,
397    target: IntTarget,
398    signed: bool,
399) -> BoolTerm {
400    let mag_sort = Sort::new(fmt.total_bits() - 1);
401    let mag = fp_magnitude(bits, fmt);
402    let is_neg = BoolTerm::Eq(
403        bx(fp_sign_bit(bits, fmt)),
404        bx(BvTerm::Const {
405            value: 1,
406            sort: Sort::new(1),
407        }),
408    );
409    let finite = BoolTerm::Not(bb(fp_exp_is_all_ones(bits, fmt)));
410
411    // Positive side: trap iff x ≥ 2^k, k = N (unsigned) / N-1 (signed).
412    let k = target.width() - u32::from(signed);
413    let pos_thresh = pow2_magnitude_pattern(fmt, k);
414    // Negative side: trap iff |x| ≥ 2^(N-1)+1 (signed) / ≥ 1.0 (unsigned).
415    let neg_thresh = if signed {
416        min_pattern_ge_pow2_plus_1(fmt, target.width() - 1)
417    } else {
418        pow2_magnitude_pattern(fmt, 0) // bit pattern of 1.0
419    };
420
421    let uge_const = |t: BvTerm, value: u128| {
422        BoolTerm::Uge(
423            bx(t),
424            bx(BvTerm::Const {
425                value,
426                sort: mag_sort,
427            }),
428        )
429    };
430    let pos_oob = BoolTerm::And(
431        bb(BoolTerm::Not(bb(is_neg.clone()))),
432        bb(uge_const(mag.clone(), pos_thresh)),
433    );
434    let neg_oob = BoolTerm::And(bb(is_neg), bb(uge_const(mag, neg_thresh)));
435    BoolTerm::And(bb(finite), bb(BoolTerm::Or(bb(pos_oob), bb(neg_oob))))
436}
437
438/// Trap condition for WASM `iN.trunc_fM_s/u` (synth #709):
439/// `NaN ∨ ±∞ ∨ out-of-range` over the float's bit pattern.
440pub fn trap_trunc(bits: &BvTerm, fmt: FpFmt, target: IntTarget, signed: bool) -> BoolTerm {
441    BoolTerm::Or(
442        bb(BoolTerm::Or(
443            bb(fp_is_nan(bits, fmt)),
444            bb(fp_is_inf(bits, fmt)),
445        )),
446        bb(fp_trunc_out_of_range(bits, fmt, target, signed)),
447    )
448}
449
450/// Compose a block's trap condition from its partial ops: `may_trap` holds iff
451/// **any** of `conds` holds (an `Or`-fold; empty ⇒ never traps). Sound for
452/// straight-line code — control-flow sequencing is the consumer's VC's job.
453pub fn trap_any(conds: &[BoolTerm]) -> BoolTerm {
454    match conds.split_first() {
455        None => bool_false(),
456        Some((first, rest)) => rest
457            .iter()
458            .fold(first.clone(), |acc, c| BoolTerm::Or(bb(acc), bb(c.clone()))),
459    }
460}
461
462/// Material biconditional `a ⇔ b`, desugared to `And/Or/Not` (the fragment has
463/// no boolean XOR/iff).
464fn iff(a: &BoolTerm, b: &BoolTerm) -> BoolTerm {
465    let imp =
466        |x: &BoolTerm, y: &BoolTerm| BoolTerm::Or(bb(BoolTerm::Not(bb(x.clone()))), bb(y.clone()));
467    BoolTerm::And(bb(imp(a, b)), bb(imp(b, a)))
468}
469
470/// **Trap-condition equivalence** (conjunct 1 only): `orig.may_trap ⇔ opt.may_trap`.
471/// Lets a consumer that does not model an op's *value* still prove the trap was
472/// not dropped or spuriously added — the whole win for memory ops (OOB/null),
473/// where synth has the trap clause but no memory-contents model. Returns the
474/// goal to prove **valid**.
475pub fn trap_condition_equivalence(orig_may_trap: &BoolTerm, opt_may_trap: &BoolTerm) -> BoolTerm {
476    iff(orig_may_trap, opt_may_trap)
477}
478
479/// **Trap-preservation VC** (both conjuncts): the lowering preserves traps *and*
480/// values —
481/// `(orig.may_trap ⇔ opt.may_trap) ∧ (¬orig.may_trap ⇒ orig.value == opt.value)`.
482/// Returns the goal to prove **valid** (assert its negation and check; `Unsat`
483/// ⟹ preserved).
484pub fn trap_equivalence_vc(orig: &DefineOrTrap, opt: &DefineOrTrap) -> BoolTerm {
485    let trap_eq = iff(&orig.may_trap, &opt.may_trap);
486    // ¬orig.may_trap ⇒ value_eq  ==  orig.may_trap ∨ (orig.value == opt.value)
487    let value_eq = BoolTerm::Eq(bx(orig.value.clone()), bx(opt.value.clone()));
488    let guarded_value = BoolTerm::Or(bb(orig.may_trap.clone()), bb(value_eq));
489    BoolTerm::And(bb(trap_eq), bb(guarded_value))
490}
491
492/// One-call trap-preservation gate over the full VC ([`trap_equivalence_vc`]).
493/// `Unsat` ⟹ the lowering preserves traps and values.
494///
495/// Delegates to the public [`Solver::prove_valid`] (issue #66): this module's
496/// gate *is* "prove the trap-equivalence VC valid", so it uses the shared
497/// primitive rather than its own copy.
498pub fn prove_trap_equivalence(orig: &DefineOrTrap, opt: &DefineOrTrap) -> CheckResult {
499    Solver::prove_valid(trap_equivalence_vc(orig, opt))
500}
501
502/// One-call trap-drop gate over conjunct 1 only ([`trap_condition_equivalence`]).
503/// `Unsat` ⟹ the lowering neither drops nor spuriously adds a trap (value clause
504/// not considered — for consumers without a value model on this op).
505pub fn prove_trap_condition_equivalence(
506    orig_may_trap: &BoolTerm,
507    opt_may_trap: &BoolTerm,
508) -> CheckResult {
509    Solver::prove_valid(trap_condition_equivalence(orig_may_trap, opt_may_trap))
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use crate::eval::Env;
516
517    fn v(name: &str, w: u32) -> BvTerm {
518        BvTerm::Var {
519            name: name.into(),
520            sort: Sort::new(w),
521        }
522    }
523    fn c(value: u128, w: u32) -> BvTerm {
524        BvTerm::Const {
525            value,
526            sort: Sort::new(w),
527        }
528    }
529    fn env2(a: u128, b: u128) -> Env {
530        let mut e = Env::new();
531        e.insert("a".into(), a);
532        e.insert("b".into(), b);
533        e
534    }
535
536    // ---- trap-condition builders: eval-equivalence against the reference ----
537
538    /// Spec-grounded reference for WASM Core §4.4.1 div/rem trapping, derived
539    /// from **result definability** — NOT from `trap_div`'s own predicates.
540    ///
541    /// That independence is the whole point: ordeal#72 hid because this test
542    /// previously reused `DivOp::is_signed()`, the very predicate that was
543    /// wrong, so it mirrored the bug instead of catching it. Here the answer
544    /// comes from the spec's actual reason to trap — "the exact result is not
545    /// representable" — computed in `i128`.
546    fn wasm_div_traps(op: DivOp, x: u128, y: u128, w: u32) -> bool {
547        // Divisor zero traps for all four ops.
548        if y == 0 {
549            return true;
550        }
551        let to_signed = |v: u128| -> i128 {
552            let half = 1i128 << (w - 1);
553            let val = v as i128;
554            if val >= half { val - (1i128 << w) } else { val }
555        };
556        match op {
557            // Unsigned div/rem are total once the divisor is non-zero.
558            DivOp::DivU | DivOp::RemU => false,
559            // irem_s is TOTAL for a non-zero divisor: |rem| < |divisor|, so the
560            // result is always representable — including irem_s(INT_MIN, -1) = 0.
561            DivOp::RemS => false,
562            // idiv_s traps exactly when the exact quotient does not fit.
563            DivOp::DivS => {
564                let q = to_signed(x) / to_signed(y);
565                let (lo, hi) = (-(1i128 << (w - 1)), (1i128 << (w - 1)) - 1);
566                q < lo || q > hi
567            }
568        }
569    }
570
571    #[test]
572    fn trap_div_matches_wasm_semantics() {
573        let w = 8u32;
574        let (a, b) = (v("a", w), v("b", w));
575        for op in [DivOp::DivU, DivOp::DivS, DivOp::RemU, DivOp::RemS] {
576            let cond = trap_div(op, &a, &b, w);
577            for av in 0u128..256 {
578                for bv in 0u128..256 {
579                    let got = eval::eval_bool(&cond, &env2(av, bv)).unwrap();
580                    let want = wasm_div_traps(op, av, bv, w);
581                    assert_eq!(got, want, "{op:?} a={av:#04x} b={bv:#04x}");
582                }
583            }
584        }
585    }
586
587    #[test]
588    fn rem_s_does_not_trap_on_the_int_min_over_minus_one_pair() {
589        // The ordeal#72 regression, pinned explicitly: div_s traps on the
590        // overflow pair (quotient 2^(w-1) is unrepresentable) but rem_s does
591        // NOT — irem_s(INT_MIN, -1) = 0. Found by synth's derived-ARM-trap gate
592        // (synth#166) rejecting a CORRECT shipped rem_s lowering.
593        for w in [8u32, 32] {
594            let (a, b) = (v("a", w), v("b", w));
595            let int_min = 1u128 << (w - 1);
596            let minus_one = if w >= 128 {
597                u128::MAX
598            } else {
599                (1u128 << w) - 1
600            };
601            let at = |op: DivOp| {
602                let mut e = Env::new();
603                e.insert("a".into(), int_min);
604                e.insert("b".into(), minus_one);
605                eval::eval_bool(&trap_div(op, &a, &b, w), &e).unwrap()
606            };
607            assert!(at(DivOp::DivS), "div_s MUST trap on INT_MIN/-1 (w{w})");
608            assert!(!at(DivOp::RemS), "rem_s must NOT trap on INT_MIN/-1 (w{w})");
609            assert!(!at(DivOp::RemU), "rem_u must NOT trap on INT_MIN/-1 (w{w})");
610            // …and every op still traps on divisor zero.
611            for op in [DivOp::DivU, DivOp::DivS, DivOp::RemU, DivOp::RemS] {
612                let mut e = Env::new();
613                e.insert("a".into(), int_min);
614                e.insert("b".into(), 0);
615                assert!(
616                    eval::eval_bool(&trap_div(op, &a, &b, w), &e).unwrap(),
617                    "{op:?} must trap on divisor zero (w{w})"
618                );
619            }
620        }
621    }
622
623    #[test]
624    fn trap_always_is_true() {
625        assert!(eval::eval_bool(&trap_always(), &Env::new()).unwrap());
626    }
627
628    #[test]
629    fn trap_mem_oob_matches_reference_and_is_wraparound_safe() {
630        // 8-bit address space; access size 4. OOB iff addr + 4 > bound.
631        let addr = v("a", 8);
632        let bound = v("b", 8);
633        let size = c(4, 8);
634        let cond = trap_mem_oob(&addr, &size, &bound);
635        for a in 0u128..256 {
636            for b in 0u128..256 {
637                let got = eval::eval_bool(&cond, &env2(a, b)).unwrap();
638                // Reference in wide arithmetic (no 8-bit wraparound).
639                assert_eq!(got, a + 4 > b, "addr={a} bound={b}");
640            }
641        }
642        // Explicit wraparound guard: addr=254, size=4 → end 258 > any 8-bit
643        // bound, must be OOB even though 254+4 wraps to 2 in 8-bit modular add.
644        assert!(eval::eval_bool(&cond, &env2(254, 255)).unwrap());
645    }
646
647    #[test]
648    fn trap_call_indirect_covers_bounds_null_and_type() {
649        let index = v("a", 32);
650        let table_size = c(10, 32);
651        let slot = v("b", 32);
652        // Runtime type check against expected id 7.
653        let actual = BvTerm::Var {
654            name: "t".into(),
655            sort: Sort::new(32),
656        };
657        let expected = c(7, 32);
658        let ci = CallIndirect {
659            index: &index,
660            table_size: &table_size,
661            slot_ptr: &slot,
662            type_trap: TypeTrap::Runtime {
663                actual_type_id: &actual,
664                expected_id: &expected,
665            },
666        };
667        let cond = trap_call_indirect(&ci);
668        let eval = |idx: u128, slotv: u128, t: u128| {
669            let mut e = Env::new();
670            e.insert("a".into(), idx);
671            e.insert("b".into(), slotv);
672            e.insert("t".into(), t);
673            eval::eval_bool(&cond, &e).unwrap()
674        };
675        assert!(eval(10, 1, 7), "index == size is out of bounds");
676        assert!(eval(3, 0, 7), "null slot traps");
677        assert!(eval(3, 1, 9), "type mismatch traps");
678        assert!(
679            !eval(3, 1, 7),
680            "in-bounds, non-null, matching type: no trap"
681        );
682    }
683
684    #[test]
685    fn statically_discharged_type_never_contributes_a_trap() {
686        let index = v("a", 32);
687        let table_size = c(10, 32);
688        let slot = v("b", 32);
689        let ci = CallIndirect {
690            index: &index,
691            table_size: &table_size,
692            slot_ptr: &slot,
693            type_trap: TypeTrap::StaticallyDischarged,
694        };
695        let cond = trap_call_indirect(&ci);
696        // In-bounds + non-null ⇒ no trap, regardless of any (absent) type id.
697        let mut e = Env::new();
698        e.insert("a".into(), 3);
699        e.insert("b".into(), 1);
700        assert!(!eval::eval_bool(&cond, &e).unwrap());
701    }
702
703    #[test]
704    fn trap_any_is_the_or_fold() {
705        assert!(!eval::eval_bool(&trap_any(&[]), &Env::new()).unwrap());
706        let a_zero = BoolTerm::Eq(Box::new(v("a", 8)), Box::new(c(0, 8)));
707        let b_zero = BoolTerm::Eq(Box::new(v("b", 8)), Box::new(c(0, 8)));
708        let any = trap_any(&[a_zero, b_zero]);
709        assert!(eval::eval_bool(&any, &env2(0, 5)).unwrap());
710        assert!(eval::eval_bool(&any, &env2(5, 0)).unwrap());
711        assert!(!eval::eval_bool(&any, &env2(5, 5)).unwrap());
712    }
713
714    // ---- float→int truncation traps (Phase B, synth #709) ----
715    //
716    // The proof style: build the BoolTerm classifier, then evaluate it with
717    // `eval::eval_bool` against a reference predicate computed on the REAL
718    // Rust float (`f32::from_bits` / `f64::from_bits`, `is_nan`,
719    // `is_infinite`, `trunc`). The reference does all range math in f64,
720    // which is exact for every case here: f32→f64 is exact, `trunc` of a
721    // finite float is an exactly-representable integer-valued f64, the
722    // bounds ±2^31, ±2^63, 2^32, 2^64, 0 are exact f64 values, and for an
723    // integer t, `t ≤ 2^N - 1 ⟺ t < 2^N` (so the unrepresentable 2^63-1 /
724    // 2^64-1 bounds are never materialized).
725
726    /// The float value (widened to f64, exactly) of a bit pattern.
727    fn fval(fmt: FpFmt, p: u128) -> f64 {
728        match fmt {
729            FpFmt::F32 => f32::from_bits(p as u32) as f64,
730            FpFmt::F64 => f64::from_bits(p as u64),
731        }
732    }
733
734    /// Reference: finite `x` truncates (round-toward-zero) outside the target
735    /// range. Caller guards non-finite inputs.
736    fn ref_out_of_range(x: f64, target: IntTarget, signed: bool) -> bool {
737        let t = x.trunc();
738        let n = target.width() as i32;
739        if signed {
740            !(t >= -(2f64.powi(n - 1)) && t < 2f64.powi(n - 1))
741        } else {
742            !(t >= 0.0 && t < 2f64.powi(n))
743        }
744    }
745
746    /// Reference: the exact WASM `iN.trunc_fM_s/u` trap predicate.
747    fn ref_trap_trunc(x: f64, target: IntTarget, signed: bool) -> bool {
748        !x.is_finite() || ref_out_of_range(x, target, signed)
749    }
750
751    /// The derived magnitude thresholds must be the bit patterns of the IEEE
752    /// values the WASM spec bounds are stated in. Cross-checked against the
753    /// host float's `to_bits`, plus the one-ULP semantics of the negative
754    /// signed threshold (smallest float ≥ 2^k + 1).
755    #[test]
756    fn derived_threshold_constants_match_ieee_bit_patterns() {
757        for k in [0u32, 31, 32, 63, 64] {
758            assert_eq!(
759                pow2_magnitude_pattern(FpFmt::F32, k),
760                2f32.powi(k as i32).to_bits() as u128,
761                "f32 2^{k}"
762            );
763            assert_eq!(
764                pow2_magnitude_pattern(FpFmt::F64, k),
765                2f64.powi(k as i32).to_bits() as u128,
766                "f64 2^{k}"
767            );
768        }
769        // Spelled-out constants, for clean-room comparison.
770        assert_eq!(pow2_magnitude_pattern(FpFmt::F32, 31), 0x4F00_0000);
771        assert_eq!(pow2_magnitude_pattern(FpFmt::F32, 32), 0x4F80_0000);
772        assert_eq!(pow2_magnitude_pattern(FpFmt::F32, 63), 0x5F00_0000);
773        assert_eq!(pow2_magnitude_pattern(FpFmt::F32, 64), 0x5F80_0000);
774        assert_eq!(pow2_magnitude_pattern(FpFmt::F32, 0), 0x3F80_0000);
775        assert_eq!(
776            pow2_magnitude_pattern(FpFmt::F64, 31),
777            0x41E0_0000_0000_0000
778        );
779        assert_eq!(
780            pow2_magnitude_pattern(FpFmt::F64, 32),
781            0x41F0_0000_0000_0000
782        );
783        assert_eq!(
784            pow2_magnitude_pattern(FpFmt::F64, 63),
785            0x43E0_0000_0000_0000
786        );
787        assert_eq!(
788            pow2_magnitude_pattern(FpFmt::F64, 64),
789            0x43F0_0000_0000_0000
790        );
791        assert_eq!(pow2_magnitude_pattern(FpFmt::F64, 0), 0x3FF0_0000_0000_0000);
792        // Negative-side signed thresholds: smallest float ≥ 2^k + 1.
793        assert_eq!(min_pattern_ge_pow2_plus_1(FpFmt::F32, 31), 0x4F00_0001);
794        assert_eq!(min_pattern_ge_pow2_plus_1(FpFmt::F32, 63), 0x5F00_0001);
795        assert_eq!(
796            min_pattern_ge_pow2_plus_1(FpFmt::F64, 31),
797            0x41E0_0000_0020_0000
798        );
799        assert_eq!(
800            min_pattern_ge_pow2_plus_1(FpFmt::F64, 63),
801            0x43E0_0000_0000_0001
802        );
803        // One-ULP semantics of each: value at the threshold is ≥ 2^k + 1,
804        // one ULP below is < 2^k + 1.
805        for (fmt, k, thresh) in [
806            (FpFmt::F32, 31u32, 0x4F00_0001u128),
807            (FpFmt::F32, 63, 0x5F00_0001),
808            (FpFmt::F64, 31, 0x41E0_0000_0020_0000),
809            (FpFmt::F64, 63, 0x43E0_0000_0000_0001),
810        ] {
811            if k <= 52 {
812                // 2^k + 1 is an exact f64; floats near 2^k may be fractional
813                // (f64 spacing < 1 there) but every one is f64-exact.
814                let bound = 2f64.powi(k as i32) + 1.0;
815                assert!(fval(fmt, thresh) >= bound, "{fmt:?} 2^{k}+1 at threshold");
816                assert!(fval(fmt, thresh - 1) < bound, "{fmt:?} 2^{k}+1 one below");
817            } else {
818                // 2^63 + 1 is NOT an f64 (needs 64 significand bits) — but
819                // every float near 2^63 is an integer (spacing ≥ 2048), so
820                // compare exactly in u128.
821                let bound = (1u128 << k) + 1;
822                assert!(
823                    fval(fmt, thresh) as u128 >= bound,
824                    "{fmt:?} 2^{k}+1 at threshold"
825                );
826                assert!(
827                    (fval(fmt, thresh - 1) as u128) < bound,
828                    "{fmt:?} 2^{k}+1 one below"
829                );
830            }
831        }
832    }
833
834    /// `fp_is_nan` / `fp_is_inf` ⇔ the host float's `is_nan` / `is_infinite`,
835    /// over every exponent value × structured mantissa samples × both signs.
836    #[test]
837    fn nan_inf_classifiers_match_ieee_reference() {
838        for fmt in [FpFmt::F32, FpFmt::F64] {
839            let f = v("f", fmt.total_bits());
840            let nan_t = fp_is_nan(&f, fmt);
841            let inf_t = fp_is_inf(&f, fmt);
842            let mut env = Env::new();
843            for p in structured_patterns(fmt) {
844                env.insert("f".into(), p);
845                let x = fval(fmt, p);
846                assert_eq!(
847                    eval::eval_bool(&nan_t, &env).unwrap(),
848                    x.is_nan(),
849                    "is_nan {fmt:?} pattern {p:#x}"
850                );
851                assert_eq!(
852                    eval::eval_bool(&inf_t, &env).unwrap(),
853                    x.is_infinite(),
854                    "is_inf {fmt:?} pattern {p:#x}"
855                );
856            }
857        }
858    }
859
860    /// Structured pattern sweep for a format: every exponent value × mantissa
861    /// samples (0..=3, top three, thirds, and every single-bit mantissa) ×
862    /// both signs. Covers all exponent boundaries and every mantissa bit
863    /// position — 15,872 patterns for f32 (256 × 31 × 2), 245,760 for f64
864    /// (2048 × 60 × 2).
865    fn structured_patterns(fmt: FpFmt) -> Vec<u128> {
866        let m = fmt.mant_bits();
867        let mant_max = (1u128 << m) - 1;
868        let mut mants = vec![
869            0,
870            1,
871            2,
872            3,
873            mant_max,
874            mant_max - 1,
875            mant_max - 2,
876            mant_max / 3,
877            mant_max / 2,
878            2 * (mant_max / 3),
879        ];
880        for i in 2..m {
881            mants.push(1u128 << i);
882        }
883        let mut out = Vec::new();
884        for exp in 0..=fmt.exp_all_ones() {
885            for &mant in &mants {
886                for sign in [0u128, 1] {
887                    out.push((sign << (fmt.total_bits() - 1)) | (exp << m) | mant);
888                }
889            }
890        }
891        out
892    }
893
894    /// One trunc variant, proven two ways against the real-float reference:
895    /// the structured sweep of [`structured_patterns`], plus a ±64-ULP
896    /// magnitude sweep (both signs) around BOTH derived threshold patterns —
897    /// i.e. the ±2^31 / ±2^32 / ±2^63 / ±2^64 / ±1.0 neighborhoods at ULP
898    /// granularity.
899    fn sweep_trunc_variant(fmt: FpFmt, target: IntTarget, signed: bool) {
900        let f = v("f", fmt.total_bits());
901        let oor_t = fp_trunc_out_of_range(&f, fmt, target, signed);
902        let trap_t = trap_trunc(&f, fmt, target, signed);
903        let mut env = Env::new();
904        let mut check = |p: u128| {
905            env.insert("f".into(), p);
906            let x = fval(fmt, p);
907            assert_eq!(
908                eval::eval_bool(&oor_t, &env).unwrap(),
909                x.is_finite() && ref_out_of_range(x, target, signed),
910                "out_of_range {fmt:?}->{target:?} signed={signed} pattern {p:#x} value {x:e}"
911            );
912            assert_eq!(
913                eval::eval_bool(&trap_t, &env).unwrap(),
914                ref_trap_trunc(x, target, signed),
915                "trap_trunc {fmt:?}->{target:?} signed={signed} pattern {p:#x} value {x:e}"
916            );
917        };
918        for p in structured_patterns(fmt) {
919            check(p);
920        }
921        let k = target.width() - u32::from(signed);
922        let pos_thresh = pow2_magnitude_pattern(fmt, k);
923        let neg_thresh = if signed {
924            min_pattern_ge_pow2_plus_1(fmt, target.width() - 1)
925        } else {
926            pow2_magnitude_pattern(fmt, 0)
927        };
928        for thresh in [pos_thresh, neg_thresh] {
929            for mag in (thresh - 64)..=(thresh + 64) {
930                for sign in [0u128, 1] {
931                    check((sign << (fmt.total_bits() - 1)) | mag);
932                }
933            }
934        }
935    }
936
937    #[test]
938    fn trunc_f32_to_i32_signed_matches_reference() {
939        sweep_trunc_variant(FpFmt::F32, IntTarget::I32, true);
940    }
941    #[test]
942    fn trunc_f32_to_i32_unsigned_matches_reference() {
943        sweep_trunc_variant(FpFmt::F32, IntTarget::I32, false);
944    }
945    #[test]
946    fn trunc_f32_to_i64_signed_matches_reference() {
947        sweep_trunc_variant(FpFmt::F32, IntTarget::I64, true);
948    }
949    #[test]
950    fn trunc_f32_to_i64_unsigned_matches_reference() {
951        sweep_trunc_variant(FpFmt::F32, IntTarget::I64, false);
952    }
953    #[test]
954    fn trunc_f64_to_i32_signed_matches_reference() {
955        sweep_trunc_variant(FpFmt::F64, IntTarget::I32, true);
956    }
957    #[test]
958    fn trunc_f64_to_i32_unsigned_matches_reference() {
959        sweep_trunc_variant(FpFmt::F64, IntTarget::I32, false);
960    }
961    #[test]
962    fn trunc_f64_to_i64_signed_matches_reference() {
963        sweep_trunc_variant(FpFmt::F64, IntTarget::I64, true);
964    }
965    #[test]
966    fn trunc_f64_to_i64_unsigned_matches_reference() {
967        sweep_trunc_variant(FpFmt::F64, IntTarget::I64, false);
968    }
969
970    /// The synth #709 boundary cases, spelled out one by one.
971    #[test]
972    fn trunc_boundary_cases_synth_709() {
973        #[track_caller]
974        fn t(fmt: FpFmt, target: IntTarget, signed: bool, p: u128, want: bool, label: &str) {
975            let f = v("f", fmt.total_bits());
976            let term = trap_trunc(&f, fmt, target, signed);
977            let mut e = Env::new();
978            e.insert("f".into(), p);
979            assert_eq!(eval::eval_bool(&term, &e).unwrap(), want, "{label}");
980        }
981        let b32 = |x: f32| x.to_bits() as u128;
982        let b64 = |x: f64| x.to_bits() as u128;
983        use FpFmt::{F32, F64};
984        use IntTarget::{I32, I64};
985
986        // --- i32.trunc_f32_s ---
987        t(
988            F32,
989            I32,
990            true,
991            b32(2f32.powi(31)),
992            true,
993            "f32→i32_s: 2^31 traps",
994        );
995        #[allow(clippy::approx_constant)]
996        {
997            // (2^31 - 1) is not an f32; the literal rounds UP to 2^31 — traps.
998            assert_eq!((2_147_483_647f32).to_bits(), 0x4F00_0000);
999        }
1000        t(
1001            F32,
1002            I32,
1003            true,
1004            b32(-(2f32.powi(31))),
1005            false,
1006            "f32→i32_s: -2^31 is in range",
1007        );
1008        t(
1009            F32,
1010            I32,
1011            true,
1012            b32(2_147_483_520.0), // 2^31 - 128: largest f32 below 2^31
1013            false,
1014            "f32→i32_s: largest f32 below 2^31 converts",
1015        );
1016        t(
1017            F32,
1018            I32,
1019            true,
1020            0xCF00_0001, // -(2^31 + 256): next f32 below -2^31
1021            true,
1022            "f32→i32_s: -(2^31+256) traps",
1023        );
1024        t(F32, I32, true, b32(f32::NAN), true, "f32→i32_s: NaN traps");
1025        t(
1026            F32,
1027            I32,
1028            true,
1029            b32(f32::INFINITY),
1030            true,
1031            "f32→i32_s: +∞ traps",
1032        );
1033        t(
1034            F32,
1035            I32,
1036            true,
1037            b32(f32::NEG_INFINITY),
1038            true,
1039            "f32→i32_s: -∞ traps",
1040        );
1041        t(F32, I32, true, b32(0.5), false, "f32→i32_s: 0.5 → 0");
1042        t(F32, I32, true, b32(-0.5), false, "f32→i32_s: -0.5 → 0");
1043
1044        // --- i32.trunc_f32_u ---
1045        t(F32, I32, false, b32(-1.0), true, "f32→i32_u: -1.0 traps");
1046        t(F32, I32, false, b32(0.5), false, "f32→i32_u: 0.5 → 0");
1047        t(F32, I32, false, b32(-0.5), false, "f32→i32_u: -0.5 → 0");
1048        t(F32, I32, false, b32(-0.0), false, "f32→i32_u: -0.0 → 0");
1049        t(
1050            F32,
1051            I32,
1052            false,
1053            b32(f32::from_bits(0xBF7F_FFFF)), // -(1 - 2^-24): just above -1
1054            false,
1055            "f32→i32_u: -(1-ε) → 0",
1056        );
1057        t(
1058            F32,
1059            I32,
1060            false,
1061            b32(2f32.powi(32)),
1062            true,
1063            "f32→i32_u: 2^32 traps",
1064        );
1065        t(
1066            F32,
1067            I32,
1068            false,
1069            b32(4_294_967_040.0), // 2^32 - 256: largest f32 below 2^32
1070            false,
1071            "f32→i32_u: largest f32 below 2^32 converts",
1072        );
1073        t(F32, I32, false, b32(f32::NAN), true, "f32→i32_u: NaN traps");
1074
1075        // --- i32.trunc_f64_s ---
1076        t(
1077            F64,
1078            I32,
1079            true,
1080            b64(2f64.powi(31)),
1081            true,
1082            "f64→i32_s: 2^31 traps",
1083        );
1084        t(
1085            F64,
1086            I32,
1087            true,
1088            b64(2_147_483_647.5),
1089            false,
1090            "f64→i32_s: 2^31-0.5 → 2^31-1",
1091        );
1092        t(
1093            F64,
1094            I32,
1095            true,
1096            b64(-(2f64.powi(31))),
1097            false,
1098            "f64→i32_s: -2^31 is in range",
1099        );
1100        t(
1101            F64,
1102            I32,
1103            true,
1104            b64(-2_147_483_648.5),
1105            false,
1106            "f64→i32_s: -(2^31+0.5) → -2^31",
1107        );
1108        t(
1109            F64,
1110            I32,
1111            true,
1112            b64(-2_147_483_649.0),
1113            true,
1114            "f64→i32_s: -(2^31+1) traps",
1115        );
1116        t(F64, I32, true, b64(f64::NAN), true, "f64→i32_s: NaN traps");
1117
1118        // --- i32.trunc_f64_u ---
1119        t(F64, I32, false, b64(-1.0), true, "f64→i32_u: -1.0 traps");
1120        t(
1121            F64,
1122            I32,
1123            false,
1124            b64(-0.999_999_999),
1125            false,
1126            "f64→i32_u: just above -1 → 0",
1127        );
1128        t(
1129            F64,
1130            I32,
1131            false,
1132            b64(2f64.powi(32)),
1133            true,
1134            "f64→i32_u: 2^32 traps",
1135        );
1136        t(
1137            F64,
1138            I32,
1139            false,
1140            b64(4_294_967_295.5),
1141            false,
1142            "f64→i32_u: 2^32-0.5 → 2^32-1",
1143        );
1144
1145        // --- i64.trunc_f32_s ---
1146        t(
1147            F32,
1148            I64,
1149            true,
1150            b32(2f32.powi(63)),
1151            true,
1152            "f32→i64_s: 2^63 traps",
1153        );
1154        t(
1155            F32,
1156            I64,
1157            true,
1158            b32(f32::from_bits(0x5EFF_FFFF)), // largest f32 below 2^63
1159            false,
1160            "f32→i64_s: largest f32 below 2^63 converts",
1161        );
1162        t(
1163            F32,
1164            I64,
1165            true,
1166            b32(-(2f32.powi(63))),
1167            false,
1168            "f32→i64_s: -2^63 is in range",
1169        );
1170        t(
1171            F32,
1172            I64,
1173            true,
1174            0xDF00_0001, // next f32 below -2^63
1175            true,
1176            "f32→i64_s: below -2^63 traps",
1177        );
1178
1179        // --- i64.trunc_f32_u ---
1180        t(
1181            F32,
1182            I64,
1183            false,
1184            b32(2f32.powi(64)),
1185            true,
1186            "f32→i64_u: 2^64 traps",
1187        );
1188        t(
1189            F32,
1190            I64,
1191            false,
1192            b32(f32::from_bits(0x5F7F_FFFF)), // largest f32 below 2^64
1193            false,
1194            "f32→i64_u: largest f32 below 2^64 converts",
1195        );
1196        t(F32, I64, false, b32(-1.0), true, "f32→i64_u: -1.0 traps");
1197
1198        // --- i64.trunc_f64_s ---
1199        t(
1200            F64,
1201            I64,
1202            true,
1203            b64(2f64.powi(63)),
1204            true,
1205            "f64→i64_s: 2^63 traps",
1206        );
1207        t(
1208            F64,
1209            I64,
1210            true,
1211            0x43DF_FFFF_FFFF_FFFF, // 2^63 - 1024: largest f64 below 2^63
1212            false,
1213            "f64→i64_s: largest f64 below 2^63 converts",
1214        );
1215        t(
1216            F64,
1217            I64,
1218            true,
1219            b64(-(2f64.powi(63))),
1220            false,
1221            "f64→i64_s: -2^63 is in range",
1222        );
1223        t(
1224            F64,
1225            I64,
1226            true,
1227            0xC3E0_0000_0000_0001, // -(2^63 + 2048): next f64 below -2^63
1228            true,
1229            "f64→i64_s: below -2^63 traps",
1230        );
1231
1232        // --- i64.trunc_f64_u ---
1233        t(
1234            F64,
1235            I64,
1236            false,
1237            b64(2f64.powi(64)),
1238            true,
1239            "f64→i64_u: 2^64 traps",
1240        );
1241        t(
1242            F64,
1243            I64,
1244            false,
1245            0x43EF_FFFF_FFFF_FFFF, // 2^64 - 2048: largest f64 below 2^64
1246            false,
1247            "f64→i64_u: largest f64 below 2^64 converts",
1248        );
1249        t(F64, I64, false, b64(-1.0), true, "f64→i64_u: -1.0 traps");
1250        t(
1251            F64,
1252            I64,
1253            false,
1254            0xBFEF_FFFF_FFFF_FFFF, // -(1 - 2^-53): just above -1
1255            false,
1256            "f64→i64_u: -(1-ε) → 0",
1257        );
1258    }
1259
1260    /// Exhaustive proof for f32→i32, both signednesses: ALL 2^32 bit patterns
1261    /// evaluated against the real-float reference. ~8.6e9 term evaluations —
1262    /// ignored by default; run explicitly in release:
1263    /// `cargo test -p ordeal --release --lib trap:: -- --ignored`
1264    #[test]
1265    #[ignore = "exhaustive 2^32 sweep; run with --release --ignored"]
1266    fn exhaustive_f32_to_i32_all_bit_patterns() {
1267        let threads = std::thread::available_parallelism()
1268            .map(|n| n.get())
1269            .unwrap_or(8);
1270        let chunk = (1u64 << 32).div_ceil(threads as u64);
1271        std::thread::scope(|s| {
1272            for tid in 0..threads {
1273                s.spawn(move || {
1274                    let f = v("f", 32);
1275                    let signed_t = trap_trunc(&f, FpFmt::F32, IntTarget::I32, true);
1276                    let unsigned_t = trap_trunc(&f, FpFmt::F32, IntTarget::I32, false);
1277                    let mut env = Env::new();
1278                    let lo = tid as u64 * chunk;
1279                    let hi = ((tid as u64 + 1) * chunk).min(1u64 << 32);
1280                    for p in lo..hi {
1281                        env.insert("f".into(), p as u128);
1282                        let x = f32::from_bits(p as u32) as f64;
1283                        assert_eq!(
1284                            eval::eval_bool(&signed_t, &env).unwrap(),
1285                            ref_trap_trunc(x, IntTarget::I32, true),
1286                            "i32.trunc_f32_s pattern {p:#010x}"
1287                        );
1288                        assert_eq!(
1289                            eval::eval_bool(&unsigned_t, &env).unwrap(),
1290                            ref_trap_trunc(x, IntTarget::I32, false),
1291                            "i32.trunc_f32_u pattern {p:#010x}"
1292                        );
1293                    }
1294                });
1295            }
1296        });
1297    }
1298
1299    // ---- VC helpers: preservation proves, trap-drop is caught ----
1300
1301    #[test]
1302    fn dropped_trunc_trap_is_caught_and_preserved_lowering_proves() {
1303        // End-to-end through the certificate-checked solver: `i32.trunc_f32_s`
1304        // whose lowering DROPS the trap (may_trap = false) must be caught with
1305        // a counterexample that really traps; the preserving lowering must
1306        // prove Unsat with a re-checkable certificate. The #709 shape.
1307        let f = v("f", 32);
1308        let trap = trap_trunc(&f, FpFmt::F32, IntTarget::I32, true);
1309        let orig = DefineOrTrap {
1310            value: f.clone(),
1311            may_trap: trap.clone(),
1312        };
1313        let dropped = DefineOrTrap {
1314            value: f.clone(),
1315            may_trap: bool_false(),
1316        };
1317        match prove_trap_equivalence(&orig, &dropped) {
1318            CheckResult::Sat(m) => {
1319                let p = m
1320                    .assignments
1321                    .iter()
1322                    .find(|(n, _)| n == "f")
1323                    .map(|(_, x)| *x)
1324                    .expect("model must assign f");
1325                let x = f32::from_bits(p as u32) as f64;
1326                assert!(
1327                    ref_trap_trunc(x, IntTarget::I32, true),
1328                    "counterexample {p:#010x} must be a genuinely trapping input"
1329                );
1330            }
1331            other => panic!("dropped trunc trap must be Sat, got {other:?}"),
1332        }
1333        let preserved = DefineOrTrap {
1334            value: f.clone(),
1335            may_trap: trap,
1336        };
1337        match prove_trap_equivalence(&orig, &preserved) {
1338            CheckResult::Unsat(cert) => cert.recheck().expect("trunc-trap cert re-checks"),
1339            other => panic!("preserved trunc trap must be Unsat, got {other:?}"),
1340        }
1341    }
1342
1343    #[test]
1344    fn preserved_div_lowering_proves_unsat() {
1345        // orig and opt: same trap (÷0) and same value ⇒ trap-equivalent.
1346        let (a, b) = (v("a", 8), v("b", 8));
1347        let value = BvTerm::Udiv(Box::new(a.clone()), Box::new(b.clone()));
1348        let d = |val: BvTerm, t: BoolTerm| DefineOrTrap {
1349            value: val,
1350            may_trap: t,
1351        };
1352        let orig = d(value.clone(), trap_div(DivOp::DivU, &a, &b, 8));
1353        let opt = d(value, trap_div(DivOp::DivU, &a, &b, 8));
1354        match prove_trap_equivalence(&orig, &opt) {
1355            CheckResult::Unsat(cert) => cert.recheck().expect("trap-equiv cert re-checks"),
1356            other => panic!("preserved lowering must be Unsat, got {other:?}"),
1357        }
1358    }
1359
1360    #[test]
1361    fn dropped_trap_is_caught_with_counterexample() {
1362        // opt drops the ÷0 trap (may_trap = false) but keeps the value: the
1363        // #633/#666 shape. Must be SAT with divisor 0.
1364        let (a, b) = (v("a", 8), v("b", 8));
1365        let value = BvTerm::Udiv(Box::new(a.clone()), Box::new(b.clone()));
1366        let orig = DefineOrTrap {
1367            value: value.clone(),
1368            may_trap: trap_div(DivOp::DivU, &a, &b, 8),
1369        };
1370        let opt = DefineOrTrap {
1371            value,
1372            may_trap: bool_false(),
1373        };
1374        match prove_trap_equivalence(&orig, &opt) {
1375            CheckResult::Sat(m) => {
1376                let b_val = m
1377                    .assignments
1378                    .iter()
1379                    .find(|(n, _)| n == "b")
1380                    .map(|(_, x)| *x);
1381                assert_eq!(b_val, Some(0), "counterexample must set divisor to 0");
1382            }
1383            other => panic!("dropped trap must be Sat, got {other:?}"),
1384        }
1385    }
1386
1387    #[test]
1388    fn dropped_bounds_check_caught_by_conjunct1_gate() {
1389        // Memory op: synth gates on conjunct-1 only (no value model). opt drops
1390        // the OOB trap ⇒ trap-condition-equivalence must be SAT.
1391        let addr = v("a", 8);
1392        let bound = v("b", 8);
1393        let size = c(4, 8);
1394        let orig_trap = trap_mem_oob(&addr, &size, &bound);
1395        let opt_trap = bool_false(); // lowering dropped the bounds check
1396        match prove_trap_condition_equivalence(&orig_trap, &opt_trap) {
1397            CheckResult::Sat(_) => {}
1398            other => panic!("dropped bounds check must be Sat, got {other:?}"),
1399        }
1400        // Preserved (same trap) proves Unsat.
1401        match prove_trap_condition_equivalence(&orig_trap, &orig_trap) {
1402            CheckResult::Unsat(cert) => cert.recheck().expect("re-check"),
1403            other => panic!("preserved bounds check must be Unsat, got {other:?}"),
1404        }
1405    }
1406}