Skip to main content

synth_verify/
expansion_validator.rs

1//! Expansion-level certifying validation for the i64 pseudo-ops (#667 move 2).
2//!
3//! The [`crate::validator_pattern`] validator certifies `(WasmOp, [ArmOp])`
4//! selections — but for the i64 *pseudo-ops* (`I64Mul`, the `I64SetCond`
5//! family, `I64Clz`/`I64Ctz`/`I64Popcnt`, the i64 shifts and rotates) the
6//! `ArmOp` is itself a compound: the **encoder**
7//! (`synth-backend/src/arm_encoder.rs::encode_thumb`) expands each into a
8//! multi-instruction Thumb-2 sequence (UMULL + MLA cross products, IT-block
9//! flag materialisation, CMP/BEQ diamonds, the HAKMEM popcount fold with its
10//! scratch push/pop). Modeling the pseudo-op with its reference semantics —
11//! what `validator_pattern` does — certifies the register *wiring* but is
12//! circular about the expansion itself. The expansion is exactly where the
13//! #615 (A32 silent NOP), #632 (popcnt result clobbered by the scratch
14//! restore `POP`), and #633 (missing i64 `div_s` overflow guard) miscompiles
15//! lived.
16//!
17//! This module closes that gap for the **branch-free and forward-branch**
18//! pseudo-ops: it takes the *literal bytes the shipped encoder emitted*,
19//! decodes them with a small Thumb-2 decoder (loud `Err` on anything outside
20//! the modeled subset — never a silent skip), symbolically executes the
21//! decoded sequence over QF_BV terms (path conditions for forward branches,
22//! IT-block predication, a concrete-offset stack model for the scratch
23//! push/pop), and discharges
24//!
25//! ```text
26//! UNSAT( wasm_op_semantics(inputs) != sequence_semantics(inputs) )
27//! ```
28//!
29//! through [`crate::solver::BvSolver`] (ordeal by default — every `Unsat`
30//! carries an LRAT certificate checked before it is reported; Z3 remains the
31//! feature-gated differential oracle).
32//!
33//! # Covered (validated at the emitted-sequence level)
34//!
35//! | pseudo-op | expansion shape |
36//! |---|---|
37//! | `I64Mul` | MUL + MLA cross products + UMULL + ADD (branch-free) |
38//! | `I64SetCond` (EQ/NE/LT/LE/GT/GE/LO/LS/HI/HS) | CMP (+IT CMP) / SBCS + ITE + MOV pair |
39//! | `I64SetCondZ` (`i64.eqz`) | ORR.W + CMP + ITE + MOV pair |
40//! | `I64Clz` / `I64Ctz` | CMP.W + BEQ diamond, CLZ / RBIT+CLZ, +32 arm |
41//! | `I64Popcnt` | HAKMEM fold ×2 with PUSH/POP scratch discipline (#632) |
42//! | `I64Shl` / `I64ShrU` / `I64ShrS` | small/large split, one BPL diamond |
43//! | `I64Rotl` / `I64Rotr` | #610 fixed-ABI wrapper (stack marshaling) + diamond |
44//!
45//! # Held out, honestly
46//!
47//! - **`I64DivS` / `I64DivU` / `I64RemS` / `I64RemU`** — the Thumb-2
48//!   expansions contain a 64-round binary long-division **loop** (backward
49//!   branches). The symbolic executor here is a forward-only DAG executor;
50//!   sound validation of the division cores needs bounded loop unrolling (64
51//!   iterations of a ~10-instruction body is far past the bit-blasting
52//!   budget) or loop-invariant reasoning. The decoder rejects backward
53//!   branches with a loud error, so these can never be silently
54//!   green-washed. This also means the **#633 missing-overflow-guard shape is
55//!   not expressible here yet**: the guard sits inside the div expansion, and
56//!   guard/trap equivalence additionally needs UDF-reachability (trap-state)
57//!   modeling, not just value-domain equivalence. Documented as future work
58//!   in `docs/validator-pattern.md`.
59//! - **Trap guards in general** — like `validator_pattern`, this module
60//!   certifies value-domain equivalence; trap paths are control flow.
61//!
62//! # Trusted base
63//!
64//! The decoder (~25 instruction forms, each a direct transcription of the
65//! ARM ARM encoding diagram), the micro-op semantics below, and the solver.
66//! The *encoder* — the thing that has actually been wrong (#615/#632/#633) —
67//! is **not** trusted: its output bytes are the artifact being checked, so
68//! encoder/validator drift is structurally impossible (there is no second
69//! hand-maintained copy of the expansion to drift).
70
71#![cfg(feature = "arm")]
72
73use crate::solver::{CheckOutcome, new_solver};
74use crate::term::{BV, Bool};
75use crate::validator_pattern::{
76    Flags, I64Pair, SolverResultKind, bool_to_i32, i64_lt_s, i64_lt_u, i64_rotl, i64_rotr, i64_shl,
77    i64_shr_s, i64_shr_u, k32, shift_amount64, sym32,
78};
79use std::collections::HashMap;
80use synth_core::WasmOp;
81use synth_synthesis::{ArmOp, Reg};
82use thiserror::Error;
83
84/// Witness that an emitted expansion was certified: `Unsat` of the negated
85/// equivalence, discharged through the certificate-checked solver.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ExpansionWitness {
88    /// Human-readable label for the WASM op (e.g. `"I64Mul"`).
89    pub wasm_op_label: String,
90    /// Number of decoded Thumb-2 instructions in the validated sequence.
91    pub instr_count: usize,
92    /// Byte length of the emitted sequence.
93    pub byte_len: usize,
94    /// Always `Unsat` for accepted expansions.
95    pub solver_result: SolverResultKind,
96}
97
98/// Expansion validation failure reasons. Everything is loud: an instruction
99/// or shape outside the modeled subset is an error, never a silent accept.
100#[derive(Debug, Error)]
101pub enum ExpansionError {
102    /// The byte sequence contains an instruction (or branch shape) outside
103    /// the decoder's subset. Includes backward branches — loops (the i64
104    /// div/rem cores) are *held out*, not silently skipped.
105    #[error("expansion decode failed at byte offset {offset}: {reason}")]
106    Decode { offset: usize, reason: String },
107
108    /// The `(WasmOp, pseudo ArmOp)` pair is not in the covered surface.
109    #[error("expansion validator does not support: {0}")]
110    Unsupported(String),
111
112    /// The sequence is semantically wrong — a concrete counterexample input
113    /// distinguishes the WASM reference from the emitted sequence.
114    #[error("counterexample for {wasm_op_label}: {description}")]
115    Counterexample {
116        wasm_op_label: String,
117        description: String,
118    },
119
120    /// Solver returned `unknown` (conflict budget exceeded).
121    #[error("solver returned unknown for {0}")]
122    SolverUnknown(String),
123
124    /// Internal invariant violation (e.g. malformed IT block).
125    #[error("internal expansion-validator error: {0}")]
126    Internal(String),
127}
128
129// ===========================================================================
130// Thumb-2 decoder (subset)
131// ===========================================================================
132
133/// Shift kinds for the immediate / register shift forms.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135enum ShiftKind {
136    Lsl,
137    Lsr,
138    Asr,
139}
140
141/// Register-to-register data-processing kinds (T32 shifted-register class,
142/// used with shift amount 0 — plain register operands).
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144enum DpKind {
145    And,
146    Orr,
147    Add,
148    Sub,
149    /// SBC with S=1 — the only flag-setting member the expansions use.
150    Sbcs,
151}
152
153/// Decoded micro-op. Registers are 0..=15 indices.
154#[derive(Debug, Clone)]
155enum T2Op {
156    Nop,
157    /// IT/ITE block header: `firstcond` + mask (predicates the next 1..4
158    /// instructions).
159    It {
160        firstcond: u8,
161        mask: u8,
162    },
163    /// CMP (register), both the T1 low-reg and T2 high-reg forms.
164    CmpReg {
165        rn: u8,
166        rm: u8,
167    },
168    /// CMP (immediate), 16-bit T1 or 32-bit T2 (`rd == PC` marker form).
169    CmpImm {
170        rn: u8,
171        imm: u32,
172    },
173    /// 16-bit MOVS Rd, #imm8 (flag-setting outside IT blocks).
174    MovsImm {
175        rd: u8,
176        imm: u32,
177    },
178    /// 32-bit MOV.W Rd, #imm (modified immediate, S=0 — never sets flags).
179    MovImm {
180        rd: u8,
181        imm: u32,
182    },
183    /// MOV (register), 16-bit high-register form.
184    MovReg {
185        rd: u8,
186        rm: u8,
187    },
188    /// 16-bit ADD Rdn, Rm (high-register form; does not set flags).
189    AddReg16 {
190        rdn: u8,
191        rm: u8,
192    },
193    /// 16-bit ADDS Rd, Rn, Rm (T1 — sets NZCV). Only reachable from
194    /// deliberately-broken shapes (#632 red test); the shipped expansions
195    /// route their final add through ADD.W.
196    AddsReg16 {
197        rd: u8,
198        rn: u8,
199        rm: u8,
200    },
201    /// Conditional branch, resolved to an absolute byte target.
202    BCond {
203        cc: u8,
204        target: i64,
205    },
206    /// Unconditional branch (T2), absolute byte target.
207    B {
208        target: i64,
209    },
210    /// PUSH {list} (bit i set = Ri saved).
211    Push {
212        list: u16,
213    },
214    /// POP {list}.
215    Pop {
216        list: u16,
217    },
218    /// STR Rt, [SP, #-4]! (the fixed-ABI marshaling store).
219    StrPreDec {
220        rt: u8,
221    },
222    /// ADD SP, #imm (discard stack slots).
223    AddSpImm {
224        imm: u32,
225    },
226    /// Modified-immediate data processing: AND/ADD/SUB(S)/RSB.
227    AndImm {
228        rd: u8,
229        rn: u8,
230        imm: u32,
231    },
232    AddImm {
233        rd: u8,
234        rn: u8,
235        imm: u32,
236    },
237    /// SUBS.W Rd, Rn, #imm (S=1 — sets NZCV like CMP).
238    SubsImm {
239        rd: u8,
240        rn: u8,
241        imm: u32,
242    },
243    RsbImm {
244        rd: u8,
245        rn: u8,
246        imm: u32,
247    },
248    /// MOVW: Rd = imm16 (zero-extended).
249    MovwImm {
250        rd: u8,
251        imm16: u32,
252    },
253    /// MOVT: Rd[31:16] = imm16.
254    MovtImm {
255        rd: u8,
256        imm16: u32,
257    },
258    /// Register-register data processing (shift amount 0).
259    DpReg {
260        kind: DpKind,
261        rd: u8,
262        rn: u8,
263        rm: u8,
264    },
265    /// Immediate shift (T32 MOV-shifted-register form).
266    ShiftImm {
267        kind: ShiftKind,
268        rd: u8,
269        rm: u8,
270        imm: u32,
271    },
272    /// Register shift (LSL.W/LSR.W/ASR.W Rd, Rn, Rm).
273    ShiftReg {
274        kind: ShiftKind,
275        rd: u8,
276        rn: u8,
277        rm: u8,
278    },
279    Mul {
280        rd: u8,
281        rn: u8,
282        rm: u8,
283    },
284    Mla {
285        rd: u8,
286        rn: u8,
287        rm: u8,
288        ra: u8,
289    },
290    Umull {
291        rdlo: u8,
292        rdhi: u8,
293        rn: u8,
294        rm: u8,
295    },
296    Clz {
297        rd: u8,
298        rm: u8,
299    },
300    Rbit {
301        rd: u8,
302        rm: u8,
303    },
304}
305
306/// A decoded instruction with its byte offset and length.
307#[derive(Debug, Clone)]
308struct Decoded {
309    offset: usize,
310    len: usize,
311    op: T2Op,
312}
313
314fn derr<T>(offset: usize, reason: impl Into<String>) -> Result<T, ExpansionError> {
315    Err(ExpansionError::Decode {
316        offset,
317        reason: reason.into(),
318    })
319}
320
321/// ThumbExpandImm for the subset the expansions use: `i:imm3:imm8` with the
322/// top four bits zero (plain 0..=255 immediates). Anything else is a loud
323/// decode error.
324fn thumb_expand_imm(offset: usize, i: u32, imm3: u32, imm8: u32) -> Result<u32, ExpansionError> {
325    let imm12 = (i << 11) | (imm3 << 8) | imm8;
326    if imm12 & 0xF00 == 0 {
327        Ok(imm8)
328    } else {
329        derr(
330            offset,
331            format!("modified immediate 0x{imm12:03x} outside the plain-imm8 subset"),
332        )
333    }
334}
335
336/// Decode a Thumb-2 byte sequence into micro-ops. Anything outside the
337/// modeled subset is a loud error.
338fn decode_thumb2(code: &[u8]) -> Result<Vec<Decoded>, ExpansionError> {
339    if !code.len().is_multiple_of(2) {
340        return derr(0, "odd byte length");
341    }
342    let mut out = Vec::new();
343    let mut o = 0usize;
344    while o < code.len() {
345        let hw1 = u16::from_le_bytes([code[o], code[o + 1]]);
346        // 32-bit encodings: top 5 bits 11101 / 11110 / 11111.
347        let is32 = (hw1 & 0xE000) == 0xE000 && (hw1 & 0x1800) != 0;
348        if !is32 {
349            let op = decode16(o, hw1)?;
350            out.push(Decoded {
351                offset: o,
352                len: 2,
353                op,
354            });
355            o += 2;
356        } else {
357            if o + 4 > code.len() {
358                return derr(o, "truncated 32-bit instruction");
359            }
360            let hw2 = u16::from_le_bytes([code[o + 2], code[o + 3]]);
361            let op = decode32(o, hw1, hw2)?;
362            out.push(Decoded {
363                offset: o,
364                len: 4,
365                op,
366            });
367            o += 4;
368        }
369    }
370    Ok(out)
371}
372
373fn decode16(o: usize, hw: u16) -> Result<T2Op, ExpansionError> {
374    if hw == 0xBF00 {
375        return Ok(T2Op::Nop);
376    }
377    if hw & 0xFF00 == 0xBF00 {
378        return Ok(T2Op::It {
379            firstcond: ((hw >> 4) & 0xF) as u8,
380            mask: (hw & 0xF) as u8,
381        });
382    }
383    if hw & 0xFFC0 == 0x4280 {
384        return Ok(T2Op::CmpReg {
385            rn: (hw & 7) as u8,
386            rm: ((hw >> 3) & 7) as u8,
387        });
388    }
389    if hw & 0xFF00 == 0x4500 {
390        return Ok(T2Op::CmpReg {
391            rn: ((((hw >> 7) & 1) << 3) | (hw & 7)) as u8,
392            rm: ((hw >> 3) & 0xF) as u8,
393        });
394    }
395    if hw & 0xF800 == 0x2800 {
396        return Ok(T2Op::CmpImm {
397            rn: ((hw >> 8) & 7) as u8,
398            imm: (hw & 0xFF) as u32,
399        });
400    }
401    if hw & 0xF800 == 0x2000 {
402        return Ok(T2Op::MovsImm {
403            rd: ((hw >> 8) & 7) as u8,
404            imm: (hw & 0xFF) as u32,
405        });
406    }
407    if hw & 0xFF00 == 0x4600 {
408        return Ok(T2Op::MovReg {
409            rd: ((((hw >> 7) & 1) << 3) | (hw & 7)) as u8,
410            rm: ((hw >> 3) & 0xF) as u8,
411        });
412    }
413    if hw & 0xFF00 == 0x4400 {
414        return Ok(T2Op::AddReg16 {
415            rdn: ((((hw >> 7) & 1) << 3) | (hw & 7)) as u8,
416            rm: ((hw >> 3) & 0xF) as u8,
417        });
418    }
419    if hw & 0xFE00 == 0x1800 {
420        return Ok(T2Op::AddsReg16 {
421            rd: (hw & 7) as u8,
422            rn: ((hw >> 3) & 7) as u8,
423            rm: ((hw >> 6) & 7) as u8,
424        });
425    }
426    if hw & 0xF000 == 0xD000 {
427        let cc = ((hw >> 8) & 0xF) as u8;
428        if cc >= 0xE {
429            return derr(o, format!("conditional branch with cc={cc:#x}"));
430        }
431        let imm8 = (hw & 0xFF) as i64;
432        let simm = if imm8 >= 0x80 { imm8 - 0x100 } else { imm8 };
433        return Ok(T2Op::BCond {
434            cc,
435            target: o as i64 + 4 + 2 * simm,
436        });
437    }
438    if hw & 0xF800 == 0xE000 {
439        let imm11 = (hw & 0x7FF) as i64;
440        let simm = if imm11 >= 0x400 { imm11 - 0x800 } else { imm11 };
441        return Ok(T2Op::B {
442            target: o as i64 + 4 + 2 * simm,
443        });
444    }
445    if hw & 0xFE00 == 0xB400 {
446        if hw & 0x0100 != 0 {
447            return derr(o, "PUSH with LR outside the modeled subset");
448        }
449        return Ok(T2Op::Push { list: hw & 0xFF });
450    }
451    if hw & 0xFE00 == 0xBC00 {
452        if hw & 0x0100 != 0 {
453            return derr(o, "POP with PC outside the modeled subset");
454        }
455        return Ok(T2Op::Pop { list: hw & 0xFF });
456    }
457    if hw & 0xFF80 == 0xB000 {
458        return Ok(T2Op::AddSpImm {
459            imm: ((hw & 0x7F) as u32) * 4,
460        });
461    }
462    derr(
463        o,
464        format!("16-bit instruction {hw:#06x} outside the modeled subset"),
465    )
466}
467
468fn decode32(o: usize, hw1: u16, hw2: u16) -> Result<T2Op, ExpansionError> {
469    let hw1 = hw1 as u32;
470    let hw2 = hw2 as u32;
471
472    // STR Rt, [SP, #-4]! — the fixed-ABI marshaling store.
473    if hw1 == 0xF84D && hw2 & 0x0FFF == 0x0D04 {
474        return Ok(T2Op::StrPreDec {
475            rt: ((hw2 >> 12) & 0xF) as u8,
476        });
477    }
478
479    // CLZ / RBIT (before the shifted-register class; distinct hw1 space).
480    if hw1 & 0xFFF0 == 0xFAB0 && hw2 & 0xF0F0 == 0xF080 {
481        return Ok(T2Op::Clz {
482            rd: ((hw2 >> 8) & 0xF) as u8,
483            rm: (hw2 & 0xF) as u8,
484        });
485    }
486    if hw1 & 0xFFF0 == 0xFA90 && hw2 & 0xF0F0 == 0xF0A0 {
487        return Ok(T2Op::Rbit {
488            rd: ((hw2 >> 8) & 0xF) as u8,
489            rm: (hw2 & 0xF) as u8,
490        });
491    }
492
493    // Register shifts: LSL.W / LSR.W / ASR.W Rd, Rn, Rm.
494    if hw1 & 0xFF80 == 0xFA00 && hw2 & 0xF0F0 == 0xF000 {
495        let kind = match (hw1 >> 5) & 3 {
496            0 => ShiftKind::Lsl,
497            1 => ShiftKind::Lsr,
498            2 => ShiftKind::Asr,
499            _ => return derr(o, "ROR.W (register) outside the modeled subset"),
500        };
501        return Ok(T2Op::ShiftReg {
502            kind,
503            rd: ((hw2 >> 8) & 0xF) as u8,
504            rn: (hw1 & 0xF) as u8,
505            rm: (hw2 & 0xF) as u8,
506        });
507    }
508
509    // MUL / MLA.
510    if hw1 & 0xFFF0 == 0xFB00 && hw2 & 0x00F0 == 0x0000 {
511        let ra = ((hw2 >> 12) & 0xF) as u8;
512        let rd = ((hw2 >> 8) & 0xF) as u8;
513        let rn = (hw1 & 0xF) as u8;
514        let rm = (hw2 & 0xF) as u8;
515        return Ok(if ra == 0xF {
516            T2Op::Mul { rd, rn, rm }
517        } else {
518            T2Op::Mla { rd, rn, rm, ra }
519        });
520    }
521
522    // UMULL.
523    if hw1 & 0xFFF0 == 0xFBA0 && hw2 & 0x00F0 == 0x0000 {
524        return Ok(T2Op::Umull {
525            rdlo: ((hw2 >> 12) & 0xF) as u8,
526            rdhi: ((hw2 >> 8) & 0xF) as u8,
527            rn: (hw1 & 0xF) as u8,
528            rm: (hw2 & 0xF) as u8,
529        });
530    }
531
532    // MOVW / MOVT (checked before the modified-immediate class; they have
533    // hw1 bit 9 set, which the DP-modified-imm mask excludes).
534    if hw1 & 0xFBF0 == 0xF240 || hw1 & 0xFBF0 == 0xF2C0 {
535        let i = (hw1 >> 10) & 1;
536        let imm4 = hw1 & 0xF;
537        let imm3 = (hw2 >> 12) & 7;
538        let rd = ((hw2 >> 8) & 0xF) as u8;
539        let imm8 = hw2 & 0xFF;
540        let imm16 = (imm4 << 12) | (i << 11) | (imm3 << 8) | imm8;
541        return Ok(if hw1 & 0xFBF0 == 0xF240 {
542            T2Op::MovwImm { rd, imm16 }
543        } else {
544            T2Op::MovtImm { rd, imm16 }
545        });
546    }
547
548    // Data processing, modified immediate.
549    if hw1 & 0xFA00 == 0xF000 && hw2 & 0x8000 == 0 {
550        let i = (hw1 >> 10) & 1;
551        let op = (hw1 >> 5) & 0xF;
552        let s = (hw1 >> 4) & 1;
553        let rn = (hw1 & 0xF) as u8;
554        let imm3 = (hw2 >> 12) & 7;
555        let rd = ((hw2 >> 8) & 0xF) as u8;
556        let imm8 = hw2 & 0xFF;
557        let imm = thumb_expand_imm(o, i, imm3, imm8)?;
558        return match (op, s) {
559            (0b0000, 0) => Ok(T2Op::AndImm { rd, rn, imm }),
560            (0b0010, 0) if rn == 0xF => Ok(T2Op::MovImm { rd, imm }),
561            (0b1000, 0) => Ok(T2Op::AddImm { rd, rn, imm }),
562            (0b1101, 1) if rd == 0xF => Ok(T2Op::CmpImm { rn, imm }),
563            (0b1101, 1) => Ok(T2Op::SubsImm { rd, rn, imm }),
564            (0b1110, 0) => Ok(T2Op::RsbImm { rd, rn, imm }),
565            _ => derr(
566                o,
567                format!("modified-immediate DP op={op:#06b} S={s} outside the modeled subset"),
568            ),
569        };
570    }
571
572    // Data processing, shifted register (used with shift amount 0), plus the
573    // MOV-shifted-register immediate-shift form (rn == PC, op == ORR).
574    if hw1 & 0xFE00 == 0xEA00 {
575        let op = (hw1 >> 5) & 0xF;
576        let s = (hw1 >> 4) & 1;
577        let rn = (hw1 & 0xF) as u8;
578        let imm3 = (hw2 >> 12) & 7;
579        let rd = ((hw2 >> 8) & 0xF) as u8;
580        let imm2 = (hw2 >> 6) & 3;
581        let ty = (hw2 >> 4) & 3;
582        let rm = (hw2 & 0xF) as u8;
583        let imm5 = (imm3 << 2) | imm2;
584
585        if rn == 0xF && op == 0b0010 && s == 0 {
586            // MOV (shifted register): LSL/LSR/ASR immediate.
587            let kind = match ty {
588                0 if imm5 == 0 => return Ok(T2Op::MovReg { rd, rm }),
589                0 => ShiftKind::Lsl,
590                1 => ShiftKind::Lsr,
591                2 => ShiftKind::Asr,
592                _ => return derr(o, "ROR-immediate outside the modeled subset"),
593            };
594            if imm5 == 0 {
595                return derr(
596                    o,
597                    "shift-by-32 encoding (imm5=0) outside the modeled subset",
598                );
599            }
600            return Ok(T2Op::ShiftImm {
601                kind,
602                rd,
603                rm,
604                imm: imm5,
605            });
606        }
607
608        if imm5 != 0 || ty != 0 {
609            return derr(
610                o,
611                format!(
612                    "shifted-register operand (type={ty}, imm5={imm5}) outside the modeled subset"
613                ),
614            );
615        }
616        let kind = match (op, s) {
617            (0b0000, 0) => DpKind::And,
618            (0b0010, 0) => DpKind::Orr,
619            (0b1000, 0) => DpKind::Add,
620            (0b1101, 0) => DpKind::Sub,
621            (0b1011, 1) => DpKind::Sbcs,
622            _ => {
623                return derr(
624                    o,
625                    format!("register DP op={op:#06b} S={s} outside the modeled subset"),
626                );
627            }
628        };
629        return Ok(T2Op::DpReg { kind, rd, rn, rm });
630    }
631
632    derr(
633        o,
634        format!("32-bit instruction {hw1:#06x} {hw2:#06x} outside the modeled subset"),
635    )
636}
637
638// ===========================================================================
639// Symbolic executor (forward-branch DAG, IT predication, concrete stack)
640// ===========================================================================
641
642/// Boolean if-then-else (the term API only has BV ite).
643fn bool_ite(c: &Bool, t: &Bool, e: &Bool) -> Bool {
644    Bool::or(&[&Bool::and(&[c, t]), &Bool::and(&[&c.not(), e])])
645}
646
647/// Evaluate a 4-bit ARM condition code against the flags.
648fn cc_holds(cc: u8, f: &Flags) -> Result<Bool, ExpansionError> {
649    Ok(match cc {
650        0x0 => f.z.clone(),                             // EQ
651        0x1 => f.z.not(),                               // NE
652        0x2 => f.c.clone(),                             // HS/CS
653        0x3 => f.c.not(),                               // LO/CC
654        0x4 => f.n.clone(),                             // MI
655        0x5 => f.n.not(),                               // PL
656        0x6 => f.v.clone(),                             // VS
657        0x7 => f.v.not(),                               // VC
658        0x8 => Bool::and(&[&f.c, &f.z.not()]),          // HI
659        0x9 => Bool::or(&[&f.c.not(), &f.z]),           // LS
660        0xA => f.n.eq(&f.v),                            // GE
661        0xB => f.n.eq(&f.v).not(),                      // LT
662        0xC => Bool::and(&[&f.z.not(), &f.n.eq(&f.v)]), // GT
663        0xD => Bool::or(&[&f.z, &f.n.eq(&f.v).not()]),  // LE
664        _ => {
665            return Err(ExpansionError::Internal(format!(
666                "condition code {cc:#x} is not evaluatable"
667            )));
668        }
669    })
670}
671
672/// CLZ as a QF_BV expression: leading-zero count of a 32-bit value.
673fn clz32(x: &BV) -> BV {
674    // Fold from the MSB: the first set bit at position i gives 31 - i.
675    let mut r = k32(32);
676    for i in 0..32u32 {
677        // i ascending: bit 0 checked last ⇒ outermost ite is bit 31.
678        let bit = x.extract(i, i).eq(BV::from_i64(1, 1));
679        r = bit.ite(k32((31 - i) as i64), &r);
680    }
681    r
682}
683
684/// CTZ as a QF_BV expression: trailing-zero count (the WASM reference — the
685/// ARM side computes CLZ(RBIT(x)) instead, so the two encodings are
686/// independent).
687fn ctz32(x: &BV) -> BV {
688    let mut r = k32(32);
689    for i in (0..32u32).rev() {
690        // i descending: bit 31 checked last ⇒ outermost ite is bit 0.
691        let bit = x.extract(i, i).eq(BV::from_i64(1, 1));
692        r = bit.ite(k32(i as i64), &r);
693    }
694    r
695}
696
697/// POPCNT as a QF_BV expression: the sum of the 32 bits (the textbook truth,
698/// used to PIN the HAKMEM reference — see [`popcnt32_hakmem`]; test-only by
699/// construction, the runtime queries go through the pinned HAKMEM form).
700#[cfg(test)]
701fn popcnt32(x: &BV) -> BV {
702    let mut acc = k32(0);
703    for i in 0..32u32 {
704        acc = acc.bvadd(x.extract(i, i).zero_ext(31));
705    }
706    acc
707}
708
709/// The HAKMEM parallel-count fold — the algorithm the shipped `I64Popcnt`
710/// expansion implements per word.
711///
712/// Used as the sequence-side reference in a two-link proof chain:
713///
714/// 1. `popcnt_hakmem_formula_is_popcnt` (unit test below) proves
715///    `∀w. popcnt32_hakmem(w) == popcnt32(w)` symbolically (~7 s).
716/// 2. `validate_expansion(I64Popcnt, …)` proves the emitted sequence equals
717///    `popcnt32_hakmem(a_lo) + popcnt32_hakmem(a_hi)` — a structural query
718///    (the reference's multiply/mask atoms match the instructions'
719///    semantics), which discharges the routing/clobber correctness that
720///    #632 was about.
721///
722/// Transitivity gives `sequence == bit-sum popcount`. The direct one-link
723/// query (sequence vs the bit-sum over both words) is NOT used: it couples
724/// two multiplier folds against 64 single-bit adders and does not converge
725/// within any practical conflict budget (measured: > 570 s), while both
726/// links above are seconds.
727fn popcnt32_hakmem(x: &BV) -> BV {
728    let m1 = k32(0x5555_5555);
729    let m2 = k32(0x3333_3333);
730    let m4 = k32(0x0F0F_0F0F);
731    let x1 = x.bvsub(x.bvlshr(k32(1)).bvand(&m1));
732    let x2 = x1.bvand(&m2).bvadd(x1.bvlshr(k32(2)).bvand(&m2));
733    let x3 = x2.bvadd(x2.bvlshr(k32(4))).bvand(&m4);
734    x3.bvmul(k32(0x0101_0101)).bvlshr(k32(24))
735}
736
737/// The textbook 32-bit-limb (schoolbook) form of `i64.mul`:
738///
739/// ```text
740/// full  = zext64(a_lo) * zext64(b_lo)      (the exact 32×32→64 product)
741/// lo    = full[31:0]
742/// hi    = full[63:32] + a_lo*b_hi + a_hi*b_lo   (cross terms, mod 2^32)
743/// ```
744///
745/// `validator_pattern`'s reference uses the native 64-bit `bvmul` on the
746/// joined limbs. That form is used here deliberately NOT: proving the 64-bit
747/// `bvmul` equal to the UMULL/MUL/MLA sequence makes the solver reason about
748/// two *structurally different* multiplier circuits, which bit-blasts past
749/// any practical budget (measured: no convergence in 400 s — the same
750/// tractability line as the documented i32 `rem_s` MLS scope-out). The
751/// schoolbook form's multiply atoms (`zext(a_lo)*zext(b_lo)`, `a_lo*b_hi`,
752/// `a_hi*b_lo`) structurally match the instructions' semantics, so the query
753/// discharges the *routing and accumulation* of the expansion — exactly the
754/// #615/#632 bug classes — in milliseconds. The schoolbook↔`bvmul` identity
755/// itself is pinned by exhaustive-edge concrete tests below
756/// (`schoolbook_mul_matches_u64_mul`).
757fn i64_mul_schoolbook(a: &I64Pair, b: &I64Pair) -> I64Pair {
758    let full = a.lo.zero_ext(32).bvmul(b.lo.zero_ext(32));
759    let lo = full.extract(31, 0);
760    let hi = full
761        .extract(63, 32)
762        .bvadd(a.lo.bvmul(&b.hi))
763        .bvadd(a.hi.bvmul(&b.lo));
764    I64Pair { lo, hi }
765}
766
767/// RBIT: bit reversal, by concatenating the bits LSB-first.
768fn rbit32(x: &BV) -> BV {
769    let mut r = x.extract(0, 0);
770    for i in 1..32u32 {
771        r = r.concat(x.extract(i, i));
772    }
773    r
774}
775
776/// The machine state threaded through the guarded execution.
777struct MachineState {
778    regs: Vec<BV>,
779    flags: Flags,
780    /// Stack memory at concrete offsets from the entry SP (which is 0).
781    mem: HashMap<i64, BV>,
782    sp_off: i64,
783    fresh: usize,
784}
785
786impl MachineState {
787    fn new() -> Self {
788        Self {
789            regs: (0..16).map(|i| sym32(&format!("init_r{i}"))).collect(),
790            flags: Flags::unconstrained(),
791            mem: HashMap::new(),
792            sp_off: 0,
793            fresh: 0,
794        }
795    }
796
797    fn fresh32(&mut self, label: &str) -> BV {
798        self.fresh += 1;
799        sym32(&format!("fresh_{label}_{}", self.fresh))
800    }
801
802    /// Guarded register write.
803    fn set_reg(&mut self, g: &Bool, rd: u8, v: BV) -> Result<(), ExpansionError> {
804        let rd = rd as usize;
805        if rd == 13 || rd == 15 {
806            return Err(ExpansionError::Internal(
807                "direct SP/PC register write outside the modeled subset".into(),
808            ));
809        }
810        self.regs[rd] = g.ite(&v, &self.regs[rd]);
811        Ok(())
812    }
813
814    fn get_reg(&self, r: u8) -> Result<BV, ExpansionError> {
815        let r = r as usize;
816        if r == 13 || r == 15 {
817            return Err(ExpansionError::Internal(
818                "direct SP/PC register read outside the modeled subset".into(),
819            ));
820        }
821        Ok(self.regs[r].clone())
822    }
823
824    /// Guarded flag update.
825    fn set_flags(&mut self, g: &Bool, new: Flags) {
826        self.flags = Flags {
827            n: bool_ite(g, &new.n, &self.flags.n),
828            z: bool_ite(g, &new.z, &self.flags.z),
829            c: bool_ite(g, &new.c, &self.flags.c),
830            v: bool_ite(g, &new.v, &self.flags.v),
831        };
832    }
833
834    fn mem_read(&mut self, addr: i64) -> BV {
835        if let Some(v) = self.mem.get(&addr) {
836            return v.clone();
837        }
838        // Unwritten stack slot: unconstrained garbage (exactly what a POP of
839        // a never-pushed slot yields on hardware).
840        let v = self.fresh32("stack");
841        self.mem.insert(addr, v.clone());
842        v
843    }
844
845    fn mem_write(&mut self, g: &Bool, addr: i64, v: BV) {
846        let old = self.mem_read(addr);
847        self.mem.insert(addr, g.ite(&v, &old));
848    }
849}
850
851/// Flags for ADDS (rd = rn + rm).
852fn flags_from_adds(rn: &BV, rm: &BV, result: &BV) -> Flags {
853    let n = result.bvslt(k32(0));
854    let z = result.eq(k32(0));
855    let c = result.bvult(rn); // unsigned wrap ⇒ carry out
856    let rn_neg = rn.bvslt(k32(0));
857    let rm_neg = rm.bvslt(k32(0));
858    let r_neg = result.bvslt(k32(0));
859    let v = Bool::and(&[&rn_neg.eq(&rm_neg), &r_neg.eq(&rn_neg).not()]);
860    Flags { n, z, c, v }
861}
862
863/// SBCS: rd = rn - rm - (1 - C_in), full NZCV via 33-bit arithmetic.
864fn exec_sbcs(rn: &BV, rm: &BV, c_in: &Bool) -> (BV, Flags) {
865    let one33 = BV::from_i64(1, 33);
866    let zero33 = BV::from_i64(0, 33);
867    let borrow = c_in.ite(&zero33, &one33);
868    let u = rn.zero_ext(1).bvsub(rm.zero_ext(1)).bvsub(&borrow);
869    let result = u.extract(31, 0);
870    let c = u.extract(32, 32).eq(BV::from_i64(0, 1)); // no borrow
871    let s = rn.sign_ext(1).bvsub(rm.sign_ext(1)).bvsub(&borrow);
872    let v = s.extract(32, 32).eq(s.extract(31, 31)).not();
873    let n = result.bvslt(k32(0));
874    let z = result.eq(k32(0));
875    (result, Flags { n, z, c, v })
876}
877
878/// Symbolically execute a decoded forward-branch-only sequence.
879///
880/// Guarded (if-converted) execution over the address-ordered instruction
881/// list: every instruction carries the disjunction of the path conditions
882/// that reach it, register/flag/memory effects are predicated on that guard,
883/// and branches route guards forward. Backward branches were already
884/// rejected by the caller. This is sound for DAG control flow because on any
885/// concrete input exactly the guard-true instructions execute, in address
886/// order.
887fn execute(instrs: &[Decoded], state: &mut MachineState) -> Result<(), ExpansionError> {
888    let total_len = instrs.last().map(|d| d.offset + d.len).unwrap_or(0);
889
890    let mut incoming: HashMap<usize, Bool> = HashMap::new();
891    incoming.insert(0, Bool::from_bool(true));
892    // Open forward-branch join points: while any is ahead of the cursor we
893    // are inside a conditional region (SP discipline is enforced there).
894    let mut pending_joins: Vec<usize> = Vec::new();
895    // IT block: remaining condition codes for the next predicated instrs.
896    let mut it_queue: Vec<u8> = Vec::new();
897
898    let merge = |incoming: &mut HashMap<usize, Bool>, at: usize, g: Bool| {
899        let cur = incoming
900            .get(&at)
901            .cloned()
902            .unwrap_or_else(|| Bool::from_bool(false));
903        incoming.insert(at, Bool::or(&[&cur, &g]));
904    };
905
906    for d in instrs {
907        let o = d.offset;
908        pending_joins.retain(|&j| j > o);
909        let g = incoming
910            .get(&o)
911            .cloned()
912            .unwrap_or_else(|| Bool::from_bool(false));
913        let in_it = !it_queue.is_empty();
914        // Effective guard: path condition ∧ IT predicate (if predicated).
915        let eff = if in_it {
916            let cc = it_queue.remove(0);
917            let c = cc_holds(cc, &state.flags)?;
918            Bool::and(&[&g, &c])
919        } else {
920            g.clone()
921        };
922        let next = o + d.len;
923        let in_cond_region = !pending_joins.is_empty();
924
925        // Control flow falls through for everything except unconditional B.
926        let mut fallthrough = true;
927
928        match &d.op {
929            T2Op::Nop => {}
930            T2Op::It { firstcond, mask } => {
931                if in_it {
932                    return Err(ExpansionError::Internal("nested IT block".into()));
933                }
934                if *mask == 0 {
935                    return Err(ExpansionError::Internal("IT with empty mask".into()));
936                }
937                // ITSTATE advance: entry j has cond = firstcond[3:1] : bit_j,
938                // where bit_0 = firstcond[0] and bit_{j>0} = mask[3-(j-1)].
939                let count = 4 - mask.trailing_zeros() as usize;
940                let base = firstcond & 0xE;
941                it_queue.push(*firstcond);
942                for j in 1..count {
943                    let bit = (mask >> (3 - (j - 1))) & 1;
944                    it_queue.push(base | bit);
945                }
946            }
947            T2Op::CmpReg { rn, rm } => {
948                let a = state.get_reg(*rn)?;
949                let b = state.get_reg(*rm)?;
950                let f = Flags::from_cmp(&a, &b);
951                state.set_flags(&eff, f);
952            }
953            T2Op::CmpImm { rn, imm } => {
954                let a = state.get_reg(*rn)?;
955                let f = Flags::from_cmp(&a, &k32(*imm as i64));
956                state.set_flags(&eff, f);
957            }
958            T2Op::MovsImm { rd, imm } => {
959                let v = k32(*imm as i64);
960                if !in_it {
961                    // MOVS sets N and Z (C/V unchanged).
962                    let f = Flags {
963                        n: v.bvslt(k32(0)),
964                        z: v.eq(k32(0)),
965                        c: state.flags.c.clone(),
966                        v: state.flags.v.clone(),
967                    };
968                    state.set_flags(&eff, f);
969                }
970                state.set_reg(&eff, *rd, v)?;
971            }
972            T2Op::MovImm { rd, imm } => {
973                state.set_reg(&eff, *rd, k32(*imm as i64))?;
974            }
975            T2Op::MovReg { rd, rm } => {
976                let v = state.get_reg(*rm)?;
977                state.set_reg(&eff, *rd, v)?;
978            }
979            T2Op::AddReg16 { rdn, rm } => {
980                let v = state.get_reg(*rdn)?.bvadd(&state.get_reg(*rm)?);
981                state.set_reg(&eff, *rdn, v)?;
982            }
983            T2Op::AddsReg16 { rd, rn, rm } => {
984                let a = state.get_reg(*rn)?;
985                let b = state.get_reg(*rm)?;
986                let v = a.bvadd(&b);
987                if !in_it {
988                    let f = flags_from_adds(&a, &b, &v);
989                    state.set_flags(&eff, f);
990                }
991                state.set_reg(&eff, *rd, v)?;
992            }
993            T2Op::BCond { cc, target } => {
994                if in_it {
995                    return Err(ExpansionError::Internal("branch inside IT block".into()));
996                }
997                if *target <= o as i64 {
998                    return Err(ExpansionError::Decode {
999                        offset: o,
1000                        reason: "backward branch (loop) — held out, needs loop unrolling"
1001                            .to_string(),
1002                    });
1003                }
1004                let t = *target as usize;
1005                if t > total_len {
1006                    return derr(o, "branch target beyond sequence end");
1007                }
1008                let c = cc_holds(*cc, &state.flags)?;
1009                merge(&mut incoming, t, Bool::and(&[&eff, &c]));
1010                merge(&mut incoming, next, Bool::and(&[&eff, &c.not()]));
1011                pending_joins.push(t);
1012                fallthrough = false; // already merged the fall-through edge
1013            }
1014            T2Op::B { target } => {
1015                if in_it {
1016                    return Err(ExpansionError::Internal("branch inside IT block".into()));
1017                }
1018                if *target <= o as i64 {
1019                    return Err(ExpansionError::Decode {
1020                        offset: o,
1021                        reason: "backward branch (loop) — held out, needs loop unrolling"
1022                            .to_string(),
1023                    });
1024                }
1025                let t = *target as usize;
1026                if t > total_len {
1027                    return derr(o, "branch target beyond sequence end");
1028                }
1029                merge(&mut incoming, t, eff.clone());
1030                pending_joins.push(t);
1031                fallthrough = false;
1032            }
1033            T2Op::Push { list } => {
1034                if in_cond_region {
1035                    return Err(ExpansionError::Internal(
1036                        "PUSH inside a conditional region — SP discipline unmodeled".into(),
1037                    ));
1038                }
1039                let n = list.count_ones() as i64;
1040                state.sp_off -= 4 * n;
1041                let mut slot = 0i64;
1042                for r in 0..8u8 {
1043                    if list & (1 << r) != 0 {
1044                        let v = state.get_reg(r)?;
1045                        let addr = state.sp_off + 4 * slot;
1046                        state.mem_write(&eff, addr, v);
1047                        slot += 1;
1048                    }
1049                }
1050            }
1051            T2Op::Pop { list } => {
1052                if in_cond_region {
1053                    return Err(ExpansionError::Internal(
1054                        "POP inside a conditional region — SP discipline unmodeled".into(),
1055                    ));
1056                }
1057                let mut slot = 0i64;
1058                for r in 0..8u8 {
1059                    if list & (1 << r) != 0 {
1060                        let addr = state.sp_off + 4 * slot;
1061                        let v = state.mem_read(addr);
1062                        state.set_reg(&eff, r, v)?;
1063                        slot += 1;
1064                    }
1065                }
1066                state.sp_off += 4 * slot;
1067            }
1068            T2Op::StrPreDec { rt } => {
1069                if in_cond_region {
1070                    return Err(ExpansionError::Internal(
1071                        "STR [SP,#-4]! inside a conditional region — SP discipline unmodeled"
1072                            .into(),
1073                    ));
1074                }
1075                state.sp_off -= 4;
1076                let v = state.get_reg(*rt)?;
1077                let addr = state.sp_off;
1078                state.mem_write(&eff, addr, v);
1079            }
1080            T2Op::AddSpImm { imm } => {
1081                if in_cond_region {
1082                    return Err(ExpansionError::Internal(
1083                        "ADD SP inside a conditional region — SP discipline unmodeled".into(),
1084                    ));
1085                }
1086                state.sp_off += *imm as i64;
1087            }
1088            T2Op::AndImm { rd, rn, imm } => {
1089                let v = state.get_reg(*rn)?.bvand(k32(*imm as i64));
1090                state.set_reg(&eff, *rd, v)?;
1091            }
1092            T2Op::AddImm { rd, rn, imm } => {
1093                let v = state.get_reg(*rn)?.bvadd(k32(*imm as i64));
1094                state.set_reg(&eff, *rd, v)?;
1095            }
1096            T2Op::SubsImm { rd, rn, imm } => {
1097                let a = state.get_reg(*rn)?;
1098                let b = k32(*imm as i64);
1099                let f = Flags::from_cmp(&a, &b);
1100                state.set_flags(&eff, f);
1101                state.set_reg(&eff, *rd, a.bvsub(&b))?;
1102            }
1103            T2Op::RsbImm { rd, rn, imm } => {
1104                let v = k32(*imm as i64).bvsub(&state.get_reg(*rn)?);
1105                state.set_reg(&eff, *rd, v)?;
1106            }
1107            T2Op::MovwImm { rd, imm16 } => {
1108                state.set_reg(&eff, *rd, k32(*imm16 as i64))?;
1109            }
1110            T2Op::MovtImm { rd, imm16 } => {
1111                let low = state.get_reg(*rd)?.bvand(k32(0xFFFF));
1112                let v = low.bvor(k32(((*imm16 as i64) << 16) & 0xFFFF_FFFF));
1113                state.set_reg(&eff, *rd, v)?;
1114            }
1115            T2Op::DpReg { kind, rd, rn, rm } => {
1116                let a = state.get_reg(*rn)?;
1117                let b = state.get_reg(*rm)?;
1118                match kind {
1119                    DpKind::And => state.set_reg(&eff, *rd, a.bvand(&b))?,
1120                    DpKind::Orr => state.set_reg(&eff, *rd, a.bvor(&b))?,
1121                    DpKind::Add => state.set_reg(&eff, *rd, a.bvadd(&b))?,
1122                    DpKind::Sub => state.set_reg(&eff, *rd, a.bvsub(&b))?,
1123                    DpKind::Sbcs => {
1124                        let (v, f) = exec_sbcs(&a, &b, &state.flags.c);
1125                        state.set_flags(&eff, f);
1126                        state.set_reg(&eff, *rd, v)?;
1127                    }
1128                }
1129            }
1130            T2Op::ShiftImm { kind, rd, rm, imm } => {
1131                let a = state.get_reg(*rm)?;
1132                let s = k32(*imm as i64);
1133                let v = match kind {
1134                    ShiftKind::Lsl => a.bvshl(&s),
1135                    ShiftKind::Lsr => a.bvlshr(&s),
1136                    ShiftKind::Asr => a.bvashr(&s),
1137                };
1138                state.set_reg(&eff, *rd, v)?;
1139            }
1140            T2Op::ShiftReg { kind, rd, rn, rm } => {
1141                let a = state.get_reg(*rn)?;
1142                // ARM register shifts use the low byte of Rm; amounts >= 32
1143                // saturate (0 for LSL/LSR, sign-fill for ASR) — exactly the
1144                // SMT bvshl/bvlshr/bvashr semantics once masked to 8 bits.
1145                let s = state.get_reg(*rm)?.bvand(k32(0xFF));
1146                let v = match kind {
1147                    ShiftKind::Lsl => a.bvshl(&s),
1148                    ShiftKind::Lsr => a.bvlshr(&s),
1149                    ShiftKind::Asr => a.bvashr(&s),
1150                };
1151                state.set_reg(&eff, *rd, v)?;
1152            }
1153            T2Op::Mul { rd, rn, rm } => {
1154                let v = state.get_reg(*rn)?.bvmul(&state.get_reg(*rm)?);
1155                state.set_reg(&eff, *rd, v)?;
1156            }
1157            T2Op::Mla { rd, rn, rm, ra } => {
1158                let v = state
1159                    .get_reg(*ra)?
1160                    .bvadd(state.get_reg(*rn)?.bvmul(&state.get_reg(*rm)?));
1161                state.set_reg(&eff, *rd, v)?;
1162            }
1163            T2Op::Umull { rdlo, rdhi, rn, rm } => {
1164                let full = state
1165                    .get_reg(*rn)?
1166                    .zero_ext(32)
1167                    .bvmul(state.get_reg(*rm)?.zero_ext(32));
1168                let lo = full.extract(31, 0);
1169                let hi = full.extract(63, 32);
1170                state.set_reg(&eff, *rdlo, lo)?;
1171                state.set_reg(&eff, *rdhi, hi)?;
1172            }
1173            T2Op::Clz { rd, rm } => {
1174                let v = clz32(&state.get_reg(*rm)?);
1175                state.set_reg(&eff, *rd, v)?;
1176            }
1177            T2Op::Rbit { rd, rm } => {
1178                let v = rbit32(&state.get_reg(*rm)?);
1179                state.set_reg(&eff, *rd, v)?;
1180            }
1181        }
1182
1183        if fallthrough {
1184            merge(&mut incoming, next, g);
1185        }
1186    }
1187
1188    if !it_queue.is_empty() {
1189        return Err(ExpansionError::Internal(
1190            "IT block extends past the end of the sequence".into(),
1191        ));
1192    }
1193    Ok(())
1194}
1195
1196// ===========================================================================
1197// WASM references and the per-pseudo-op contract
1198// ===========================================================================
1199
1200/// What the validated result looks like.
1201enum RefResult {
1202    Word(BV),
1203    Pair(I64Pair),
1204}
1205
1206/// WASM reference semantics for the covered i64 pseudo-op surface.
1207fn wasm_reference(op: &WasmOp, a: &I64Pair, b: &I64Pair) -> Option<RefResult> {
1208    use WasmOp::*;
1209    let word = |bv: BV| Some(RefResult::Word(bv));
1210    let pair = |p: I64Pair| Some(RefResult::Pair(p));
1211    match op {
1212        I64Mul => pair(i64_mul_schoolbook(a, b)),
1213        I64Shl => pair(i64_shl(a, &shift_amount64(b))),
1214        I64ShrU => pair(i64_shr_u(a, &shift_amount64(b))),
1215        I64ShrS => pair(i64_shr_s(a, &shift_amount64(b))),
1216        I64Rotl => pair(i64_rotl(a, &shift_amount64(b))),
1217        I64Rotr => pair(i64_rotr(a, &shift_amount64(b))),
1218        I64Eq => word(bool_to_i32(&a.eq_pair(b))),
1219        I64Ne => word(bool_to_i32(&a.eq_pair(b).not())),
1220        I64LtU => word(bool_to_i32(&i64_lt_u(a, b))),
1221        I64LtS => word(bool_to_i32(&i64_lt_s(a, b))),
1222        I64GtU => word(bool_to_i32(&i64_lt_u(b, a))),
1223        I64GtS => word(bool_to_i32(&i64_lt_s(b, a))),
1224        I64LeU => word(bool_to_i32(&i64_lt_u(b, a).not())),
1225        I64LeS => word(bool_to_i32(&i64_lt_s(b, a).not())),
1226        I64GeU => word(bool_to_i32(&i64_lt_u(a, b).not())),
1227        I64GeS => word(bool_to_i32(&i64_lt_s(a, b).not())),
1228        I64Eqz => word(bool_to_i32(&Bool::and(&[
1229            &a.lo.eq(k32(0)),
1230            &a.hi.eq(k32(0)),
1231        ]))),
1232        // Bit-counting ops produce an i64 whose low limb is the count.
1233        I64Clz => pair(I64Pair {
1234            lo: a
1235                .hi
1236                .eq(k32(0))
1237                .ite(clz32(&a.lo).bvadd(k32(32)), clz32(&a.hi)),
1238            hi: k32(0),
1239        }),
1240        I64Ctz => pair(I64Pair {
1241            lo: a
1242                .lo
1243                .eq(k32(0))
1244                .ite(ctz32(&a.hi).bvadd(k32(32)), ctz32(&a.lo)),
1245            hi: k32(0),
1246        }),
1247        // Two-link chain: the sequence is checked against the HAKMEM fold
1248        // (structural, fast); the HAKMEM fold is separately PROVED equal to
1249        // the bit-sum popcount for all inputs (see `popcnt32_hakmem`).
1250        I64Popcnt => pair(I64Pair {
1251            lo: popcnt32_hakmem(&a.lo).bvadd(popcnt32_hakmem(&a.hi)),
1252            hi: k32(0),
1253        }),
1254        _ => None,
1255    }
1256}
1257
1258/// Where the sequence's inputs and result live, derived from the pseudo-op's
1259/// register fields (the contract the selector relies on).
1260struct Contract {
1261    /// (lo, hi) registers holding operand `a`.
1262    a: (u8, u8),
1263    /// (lo, hi) registers holding operand `b`. `hi: None` when the pseudo-op
1264    /// only consumes the low limb (rotate amounts).
1265    b: Option<(u8, Option<u8>)>,
1266    /// Result location: single register or (lo, hi) pair.
1267    result: ContractResult,
1268}
1269
1270enum ContractResult {
1271    Word(u8),
1272    Pair(u8, u8),
1273}
1274
1275fn ri(r: &Reg) -> u8 {
1276    crate::validator_pattern::reg_index(r) as u8
1277}
1278
1279/// Derive the register contract from the pseudo-op. Returns `None` for
1280/// pseudo-ops outside the covered surface (i64 div/rem — held out).
1281fn contract_of(pseudo: &ArmOp) -> Option<Contract> {
1282    match pseudo {
1283        ArmOp::I64Mul {
1284            rd_lo,
1285            rd_hi,
1286            rn_lo,
1287            rn_hi,
1288            rm_lo,
1289            rm_hi,
1290        } => Some(Contract {
1291            a: (ri(rn_lo), ri(rn_hi)),
1292            b: Some((ri(rm_lo), Some(ri(rm_hi)))),
1293            result: ContractResult::Pair(ri(rd_lo), ri(rd_hi)),
1294        }),
1295        ArmOp::I64Shl {
1296            rd_lo,
1297            rd_hi,
1298            rn_lo,
1299            rn_hi,
1300            rm_lo,
1301            rm_hi,
1302        }
1303        | ArmOp::I64ShrU {
1304            rd_lo,
1305            rd_hi,
1306            rn_lo,
1307            rn_hi,
1308            rm_lo,
1309            rm_hi,
1310        }
1311        | ArmOp::I64ShrS {
1312            rd_lo,
1313            rd_hi,
1314            rn_lo,
1315            rn_hi,
1316            rm_lo,
1317            rm_hi,
1318        } => Some(Contract {
1319            a: (ri(rn_lo), ri(rn_hi)),
1320            // rm_hi is a scratch register in the expansion; the amount's
1321            // high limb is semantically dead (WASM masks mod 64), so the
1322            // contract still seeds it — the expansion may clobber it freely.
1323            b: Some((ri(rm_lo), Some(ri(rm_hi)))),
1324            result: ContractResult::Pair(ri(rd_lo), ri(rd_hi)),
1325        }),
1326        ArmOp::I64Rotl {
1327            rdlo,
1328            rdhi,
1329            rnlo,
1330            rnhi,
1331            shift,
1332        }
1333        | ArmOp::I64Rotr {
1334            rdlo,
1335            rdhi,
1336            rnlo,
1337            rnhi,
1338            shift,
1339        } => Some(Contract {
1340            a: (ri(rnlo), ri(rnhi)),
1341            b: Some((ri(shift), None)),
1342            result: ContractResult::Pair(ri(rdlo), ri(rdhi)),
1343        }),
1344        ArmOp::I64SetCond {
1345            rd,
1346            rn_lo,
1347            rn_hi,
1348            rm_lo,
1349            rm_hi,
1350            ..
1351        } => Some(Contract {
1352            a: (ri(rn_lo), ri(rn_hi)),
1353            b: Some((ri(rm_lo), Some(ri(rm_hi)))),
1354            result: ContractResult::Word(ri(rd)),
1355        }),
1356        ArmOp::I64SetCondZ { rd, rn_lo, rn_hi } => Some(Contract {
1357            a: (ri(rn_lo), ri(rn_hi)),
1358            b: None,
1359            result: ContractResult::Word(ri(rd)),
1360        }),
1361        // The bit-counting expansions leave the count in `rd` and clear the
1362        // high limb IN PLACE in `rnhi` — the result pair is (rd, rnhi).
1363        ArmOp::I64Clz { rd, rnlo, rnhi }
1364        | ArmOp::I64Ctz { rd, rnlo, rnhi }
1365        | ArmOp::I64Popcnt { rd, rnlo, rnhi } => Some(Contract {
1366            a: (ri(rnlo), ri(rnhi)),
1367            b: None,
1368            result: ContractResult::Pair(ri(rd), ri(rnhi)),
1369        }),
1370        _ => None,
1371    }
1372}
1373
1374// ===========================================================================
1375// The validation entry point
1376// ===========================================================================
1377
1378/// Validate that the encoder's emitted `code` for `pseudo` implements `wasm`.
1379///
1380/// `code` must be the literal bytes the shipped Thumb-2 encoder produced for
1381/// `pseudo` (`ArmEncoder::encode`), so the artifact being certified is the
1382/// binary's actual instruction sequence. Returns an [`ExpansionWitness`]
1383/// (with a certificate-checked `Unsat`) or a loud error — including a
1384/// concrete counterexample when the sequence is wrong.
1385pub fn validate_expansion(
1386    wasm: &WasmOp,
1387    pseudo: &ArmOp,
1388    code: &[u8],
1389) -> Result<ExpansionWitness, ExpansionError> {
1390    let label = format!("{wasm:?}");
1391
1392    let contract = contract_of(pseudo)
1393        .ok_or_else(|| ExpansionError::Unsupported(format!("pseudo-op {pseudo:?}")))?;
1394
1395    let instrs = decode_thumb2(code)?;
1396
1397    // Symbolic operands (limb pairs, exactly the ARM register-pair layout).
1398    let a = I64Pair {
1399        lo: sym32("a_lo"),
1400        hi: sym32("a_hi"),
1401    };
1402    let b = I64Pair {
1403        lo: sym32("b_lo"),
1404        hi: sym32("b_hi"),
1405    };
1406
1407    let reference = wasm_reference(wasm, &a, &b)
1408        .ok_or_else(|| ExpansionError::Unsupported(format!("wasm op {wasm:?}")))?;
1409
1410    // Seed the machine per the contract and execute the emitted sequence.
1411    let mut state = MachineState::new();
1412    let g_true = Bool::from_bool(true);
1413    state.set_reg(&g_true, contract.a.0, a.lo.clone())?;
1414    state.set_reg(&g_true, contract.a.1, a.hi.clone())?;
1415    if let Some((blo, bhi)) = &contract.b {
1416        state.set_reg(&g_true, *blo, b.lo.clone())?;
1417        if let Some(bhi) = bhi {
1418            state.set_reg(&g_true, *bhi, b.hi.clone())?;
1419        }
1420    }
1421    execute(&instrs, &mut state)?;
1422
1423    // Assert ¬(wasm == sequence); unsat ⇒ equivalent for all inputs.
1424    let differ = match (&reference, &contract.result) {
1425        (RefResult::Word(w), ContractResult::Word(rd)) => w.eq(&state.get_reg(*rd)?).not(),
1426        (RefResult::Pair(w), ContractResult::Pair(rd_lo, rd_hi)) => {
1427            let got = I64Pair {
1428                lo: state.get_reg(*rd_lo)?,
1429                hi: state.get_reg(*rd_hi)?,
1430            };
1431            w.eq_pair(&got).not()
1432        }
1433        _ => {
1434            return Err(ExpansionError::Internal(format!(
1435                "result-shape mismatch for {label}"
1436            )));
1437        }
1438    };
1439
1440    let mut solver = new_solver();
1441    solver.assert(&differ);
1442    match solver.check() {
1443        CheckOutcome::Unsat => Ok(ExpansionWitness {
1444            wasm_op_label: label,
1445            instr_count: instrs.len(),
1446            byte_len: code.len(),
1447            solver_result: SolverResultKind::Unsat,
1448        }),
1449        CheckOutcome::Sat => {
1450            let mut parts = Vec::new();
1451            for (name, bv) in [
1452                ("a_lo", &a.lo),
1453                ("a_hi", &a.hi),
1454                ("b_lo", &b.lo),
1455                ("b_hi", &b.hi),
1456            ] {
1457                if let Some(v) = solver.value(bv) {
1458                    parts.push(format!("{name}={v:#x}"));
1459                }
1460            }
1461            let description = if parts.is_empty() {
1462                "no model available".to_string()
1463            } else {
1464                parts.join(", ")
1465            };
1466            Err(ExpansionError::Counterexample {
1467                wasm_op_label: label,
1468                description,
1469            })
1470        }
1471        CheckOutcome::Unknown(reason) => {
1472            Err(ExpansionError::SolverUnknown(format!("{label}: {reason}")))
1473        }
1474    }
1475}
1476
1477/// The covered `(WasmOp, pseudo ArmOp)` surface with the canonical
1478/// selector-shaped register assignment: operand `a` in (R0, R1), operand `b`
1479/// / amount in (R2, R3), result in R0 / (R0, R1). Callers (the `synth
1480/// verify` driver and the oracle tests) encode each pseudo-op through the
1481/// shipped `ArmEncoder` and feed the bytes to [`validate_expansion`].
1482pub fn covered_i64_pseudo_selections() -> Vec<(WasmOp, ArmOp)> {
1483    use synth_synthesis::rules::Condition;
1484    let mut v: Vec<(WasmOp, ArmOp)> = Vec::new();
1485
1486    v.push((
1487        WasmOp::I64Mul,
1488        ArmOp::I64Mul {
1489            rd_lo: Reg::R0,
1490            rd_hi: Reg::R1,
1491            rn_lo: Reg::R0,
1492            rn_hi: Reg::R1,
1493            rm_lo: Reg::R2,
1494            rm_hi: Reg::R3,
1495        },
1496    ));
1497
1498    let setcond = |cond: Condition| ArmOp::I64SetCond {
1499        rd: Reg::R0,
1500        rn_lo: Reg::R0,
1501        rn_hi: Reg::R1,
1502        rm_lo: Reg::R2,
1503        rm_hi: Reg::R3,
1504        cond,
1505    };
1506    v.push((WasmOp::I64Eq, setcond(Condition::EQ)));
1507    v.push((WasmOp::I64Ne, setcond(Condition::NE)));
1508    v.push((WasmOp::I64LtS, setcond(Condition::LT)));
1509    v.push((WasmOp::I64LeS, setcond(Condition::LE)));
1510    v.push((WasmOp::I64GtS, setcond(Condition::GT)));
1511    v.push((WasmOp::I64GeS, setcond(Condition::GE)));
1512    v.push((WasmOp::I64LtU, setcond(Condition::LO)));
1513    v.push((WasmOp::I64LeU, setcond(Condition::LS)));
1514    v.push((WasmOp::I64GtU, setcond(Condition::HI)));
1515    v.push((WasmOp::I64GeU, setcond(Condition::HS)));
1516
1517    v.push((
1518        WasmOp::I64Eqz,
1519        ArmOp::I64SetCondZ {
1520            rd: Reg::R0,
1521            rn_lo: Reg::R0,
1522            rn_hi: Reg::R1,
1523        },
1524    ));
1525
1526    for (op, mk) in [
1527        (
1528            WasmOp::I64Shl,
1529            (|| ArmOp::I64Shl {
1530                rd_lo: Reg::R0,
1531                rd_hi: Reg::R1,
1532                rn_lo: Reg::R0,
1533                rn_hi: Reg::R1,
1534                rm_lo: Reg::R2,
1535                rm_hi: Reg::R3,
1536            }) as fn() -> ArmOp,
1537        ),
1538        (WasmOp::I64ShrU, || ArmOp::I64ShrU {
1539            rd_lo: Reg::R0,
1540            rd_hi: Reg::R1,
1541            rn_lo: Reg::R0,
1542            rn_hi: Reg::R1,
1543            rm_lo: Reg::R2,
1544            rm_hi: Reg::R3,
1545        }),
1546        (WasmOp::I64ShrS, || ArmOp::I64ShrS {
1547            rd_lo: Reg::R0,
1548            rd_hi: Reg::R1,
1549            rn_lo: Reg::R0,
1550            rn_hi: Reg::R1,
1551            rm_lo: Reg::R2,
1552            rm_hi: Reg::R3,
1553        }),
1554    ] {
1555        v.push((op, mk()));
1556    }
1557
1558    v.push((
1559        WasmOp::I64Rotl,
1560        ArmOp::I64Rotl {
1561            rdlo: Reg::R0,
1562            rdhi: Reg::R1,
1563            rnlo: Reg::R0,
1564            rnhi: Reg::R1,
1565            shift: Reg::R2,
1566        },
1567    ));
1568    v.push((
1569        WasmOp::I64Rotr,
1570        ArmOp::I64Rotr {
1571            rdlo: Reg::R0,
1572            rdhi: Reg::R1,
1573            rnlo: Reg::R0,
1574            rnhi: Reg::R1,
1575            shift: Reg::R2,
1576        },
1577    ));
1578
1579    for (op, mk) in [
1580        (
1581            WasmOp::I64Clz,
1582            (|| ArmOp::I64Clz {
1583                rd: Reg::R0,
1584                rnlo: Reg::R0,
1585                rnhi: Reg::R1,
1586            }) as fn() -> ArmOp,
1587        ),
1588        (WasmOp::I64Ctz, || ArmOp::I64Ctz {
1589            rd: Reg::R0,
1590            rnlo: Reg::R0,
1591            rnhi: Reg::R1,
1592        }),
1593        (WasmOp::I64Popcnt, || ArmOp::I64Popcnt {
1594            rd: Reg::R0,
1595            rnlo: Reg::R0,
1596            rnhi: Reg::R1,
1597        }),
1598    ] {
1599        v.push((op, mk()));
1600    }
1601
1602    v
1603}
1604
1605// ===========================================================================
1606// Unit tests (encoder-independent: decoder + executor mechanics + red
1607// shapes built from literal bytes). The green-on-shipped-encoder oracle
1608// lives in `synth-backend/tests/i64_expansion_certification.rs`, where the
1609// real `ArmEncoder` is available.
1610// ===========================================================================
1611
1612#[cfg(test)]
1613mod tests {
1614    use super::*;
1615    use crate::with_verification_context;
1616
1617    fn halfwords(hws: &[u16]) -> Vec<u8> {
1618        let mut b = Vec::new();
1619        for hw in hws {
1620            b.extend_from_slice(&hw.to_le_bytes());
1621        }
1622        b
1623    }
1624
1625    /// The bit-count references agree with native 64-bit truths on the
1626    /// joined value — pins the hand-rolled ite chains.
1627    #[test]
1628    fn bitcount_references_sanity() {
1629        with_verification_context(|| {
1630            // clz32(1) == 31, clz32(0) == 32, ctz32(8) == 3, popcnt(0xF0F) == 8
1631            for (v, clz, ctz, pop) in [
1632                (0u32, 32i64, 32i64, 0i64),
1633                (1, 31, 0, 1),
1634                (8, 28, 3, 1),
1635                (0x8000_0000, 0, 31, 1),
1636                (0xF0F, 20, 0, 8),
1637                (0xFFFF_FFFF, 0, 0, 32),
1638            ] {
1639                let x = k32(v as i64);
1640                let mut s = new_solver();
1641                let bad = Bool::or(&[
1642                    &clz32(&x).eq(k32(clz)).not(),
1643                    &ctz32(&x).eq(k32(ctz)).not(),
1644                    &popcnt32(&x).eq(k32(pop)).not(),
1645                ]);
1646                s.assert(&bad);
1647                assert_eq!(s.check(), CheckOutcome::Unsat, "bitcount refs for {v:#x}");
1648            }
1649        });
1650    }
1651
1652    /// The schoolbook mul reference agrees with Rust's `u64` wrapping
1653    /// multiply on an edge-heavy concrete grid (the solver evaluates the
1654    /// formula on constants; Rust's `wrapping_mul` is the independent
1655    /// truth). This pins the reference that the symbolic i64.mul query is
1656    /// discharged against — the full symbolic schoolbook↔bvmul identity is
1657    /// past the bit-blasting budget (see `i64_mul_schoolbook`).
1658    #[test]
1659    fn schoolbook_mul_matches_u64_mul() {
1660        with_verification_context(|| {
1661            let grid: &[u64] = &[
1662                0,
1663                1,
1664                2,
1665                3,
1666                0x7FFF_FFFF,
1667                0x8000_0000,
1668                0xFFFF_FFFF,
1669                0x1_0000_0000,
1670                0xDEAD_BEEF_CAFE_F00D,
1671                0x8000_0000_0000_0000,
1672                0xFFFF_FFFF_FFFF_FFFF,
1673                0x0000_0001_0000_0001,
1674            ];
1675            for &x in grid {
1676                for &y in grid {
1677                    let truth = x.wrapping_mul(y);
1678                    let a = I64Pair {
1679                        lo: k32((x & 0xFFFF_FFFF) as i64),
1680                        hi: k32((x >> 32) as i64),
1681                    };
1682                    let b = I64Pair {
1683                        lo: k32((y & 0xFFFF_FFFF) as i64),
1684                        hi: k32((y >> 32) as i64),
1685                    };
1686                    let got = i64_mul_schoolbook(&a, &b);
1687                    let want = I64Pair {
1688                        lo: k32((truth & 0xFFFF_FFFF) as i64),
1689                        hi: k32((truth >> 32) as i64),
1690                    };
1691                    let mut s = new_solver();
1692                    s.assert(&got.eq_pair(&want).not());
1693                    assert_eq!(
1694                        s.check(),
1695                        CheckOutcome::Unsat,
1696                        "schoolbook mul wrong for {x:#x} * {y:#x}"
1697                    );
1698                }
1699            }
1700        });
1701    }
1702
1703    /// rbit32 followed by clz32 equals ctz32 — the independence argument for
1704    /// the Ctz validation (the ARM side computes CLZ(RBIT(x))).
1705    #[test]
1706    fn rbit_clz_is_ctz() {
1707        with_verification_context(|| {
1708            let x = sym32("x");
1709            let mut s = new_solver();
1710            s.assert(&clz32(&rbit32(&x)).eq(ctz32(&x)).not());
1711            assert_eq!(s.check(), CheckOutcome::Unsat);
1712        });
1713    }
1714
1715    /// Link 1 of the popcnt proof chain: the HAKMEM fold equals the bit-sum
1716    /// popcount for ALL 2^32 inputs — proved symbolically. Link 2 (the
1717    /// emitted sequence equals the HAKMEM fold per word, summed) is the
1718    /// `validate_expansion` query the certification oracle runs against the
1719    /// shipped encoder. Together: sequence == popcount.
1720    #[test]
1721    fn popcnt_hakmem_formula_is_popcnt() {
1722        with_verification_context(|| {
1723            let w = sym32("w");
1724            let mut s = new_solver();
1725            s.assert(&popcnt32_hakmem(&w).eq(popcnt32(&w)).not());
1726            assert_eq!(
1727                s.check(),
1728                CheckOutcome::Unsat,
1729                "HAKMEM fold must equal bit-sum popcount for all inputs"
1730            );
1731        });
1732    }
1733
1734    /// Decoder rejects instructions outside the subset loudly.
1735    #[test]
1736    fn decoder_rejects_unknown_loudly() {
1737        // 0xDE00 is a permanently-undefined 16-bit encoding (cc=0xE).
1738        let r = decode_thumb2(&halfwords(&[0xDE00]));
1739        assert!(matches!(r, Err(ExpansionError::Decode { .. })), "{r:?}");
1740        // UDF.W (F7F0 A000) — 32-bit, unmodeled.
1741        let r = decode_thumb2(&halfwords(&[0xF7F0, 0xA000]));
1742        assert!(matches!(r, Err(ExpansionError::Decode { .. })), "{r:?}");
1743    }
1744
1745    /// Backward branches (loops — the div/rem cores) are rejected loudly,
1746    /// never silently accepted: the held-out surface cannot green-wash.
1747    #[test]
1748    fn backward_branch_is_held_out_loudly() {
1749        with_verification_context(|| {
1750            // CMP R0, #0 ; BNE -4 (backward) — a minimal loop shape.
1751            let code = halfwords(&[0x2800, 0xD1FC]);
1752            let pseudo = ArmOp::I64SetCondZ {
1753                rd: Reg::R0,
1754                rn_lo: Reg::R0,
1755                rn_hi: Reg::R1,
1756            };
1757            let r = validate_expansion(&WasmOp::I64Eqz, &pseudo, &code);
1758            match r {
1759                Err(ExpansionError::Decode { reason, .. }) => {
1760                    assert!(reason.contains("backward branch"), "{reason}");
1761                }
1762                other => panic!("expected backward-branch decode error, got {other:?}"),
1763            }
1764        });
1765    }
1766
1767    /// i64 div/rem pseudo-ops are outside the covered surface (held out).
1768    #[test]
1769    fn div_rem_pseudo_ops_are_unsupported() {
1770        let pseudo = ArmOp::I64DivU {
1771            rdlo: Reg::R0,
1772            rdhi: Reg::R1,
1773            rnlo: Reg::R0,
1774            rnhi: Reg::R1,
1775            rmlo: Reg::R2,
1776            rmhi: Reg::R3,
1777            elide_zero_guard: false,
1778        };
1779        let r = validate_expansion(&WasmOp::I64DivU, &pseudo, &[]);
1780        assert!(matches!(r, Err(ExpansionError::Unsupported(_))), "{r:?}");
1781    }
1782
1783    /// A hand-built minimal I64SetCondZ sequence certifies: the executor's
1784    /// ORR/CMP/ITE/MOV modeling is exercised without the encoder.
1785    /// (ORR.W R0, R0, R1; CMP R0, #0; ITE EQ; MOV R0,#1; MOV R0,#0.)
1786    #[test]
1787    fn hand_built_eqz_sequence_certifies() {
1788        with_verification_context(|| {
1789            let code = halfwords(&[
1790                0xEA40, 0x0001, // ORR.W R0, R0, R1
1791                0x2800, // CMP R0, #0
1792                0xBF0C, // ITE EQ
1793                0x2001, // MOV R0, #1
1794                0x2000, // MOV R0, #0
1795            ]);
1796            let pseudo = ArmOp::I64SetCondZ {
1797                rd: Reg::R0,
1798                rn_lo: Reg::R0,
1799                rn_hi: Reg::R1,
1800            };
1801            let w = validate_expansion(&WasmOp::I64Eqz, &pseudo, &code)
1802                .expect("eqz sequence must certify");
1803            assert_eq!(w.solver_result, SolverResultKind::Unsat);
1804            assert_eq!(w.instr_count, 5);
1805        });
1806    }
1807
1808    /// The same sequence with the ITE polarity flipped (ITE NE) is rejected
1809    /// with a counterexample — the executor's IT modeling is not vacuous.
1810    #[test]
1811    fn hand_built_eqz_wrong_polarity_rejected() {
1812        with_verification_context(|| {
1813            let code = halfwords(&[
1814                0xEA40, 0x0001, // ORR.W R0, R0, R1
1815                0x2800, // CMP R0, #0
1816                0xBF14, // ITE NE (firstcond=1, mask=0x4)
1817                0x2001, // MOV R0, #1
1818                0x2000, // MOV R0, #0
1819            ]);
1820            let pseudo = ArmOp::I64SetCondZ {
1821                rd: Reg::R0,
1822                rn_lo: Reg::R0,
1823                rn_hi: Reg::R1,
1824            };
1825            match validate_expansion(&WasmOp::I64Eqz, &pseudo, &code) {
1826                Err(ExpansionError::Counterexample { .. }) => {}
1827                other => panic!("expected Counterexample, got {other:?}"),
1828            }
1829        });
1830    }
1831}