Skip to main content

tensor_wasm_jit/
reject_list.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Reject-list detector for the Pliron `dialect-mir` lowering pipeline.
5//!
6//! [`scan_function`] walks a [`cranelift_codegen::ir::Function`] and returns
7//! every instruction whose opcode the wave-2 lowering cannot translate to
8//! the [`crate::lowered_ir::LoweredOp`] interim IR. The categories mirror
9//! the "Unsupported in v0.4" section of
10//! [`crate::pliron_dialect`](crate::pliron_dialect) one-for-one:
11//!
12//! - **Atomics** ([`RejectReason::Atomic`]): `atomic_load`, `atomic_store`,
13//!   `atomic_rmw`, `atomic_cas`. Deferred — Wasm threads + GPU atomics is a
14//!   memory-model alignment problem larger than the wave-2 scope.
15//! - **Strict-FP exception bits** ([`RejectReason::StrictFp`]):
16//!   `fcvt_to_sint_sat`, `fcvt_to_uint_sat`. PTX default rounding diverges
17//!   from Wasm-strict FP — these opcodes carry trap semantics the wave-2
18//!   lowering cannot honour.
19//! - **Host calls** ([`RejectReason::HostCall`]): `call`, `call_indirect`,
20//!   `return_call`, `return_call_indirect`. PTX has no host-callback path;
21//!   wave 3+ will distinguish device-side `func.call`s from host
22//!   round-trips, but for wave 2 every call is rejected.
23//!
24//! # Categories documented but absent from this Cranelift version
25//!
26//! The canonical reject list in [`crate::pliron_dialect`] also names:
27//!
28//! - **Wasm table ops** (`table.get` / `table.set`)
29//! - **Wasm GC / `ref.func`**
30//! - **`memory.grow` / `memory.size`**
31//! - **`memory.copy` / `memory.fill`**
32//!
33//! These are **not direct Cranelift opcodes in the pinned `0.111.9`
34//! workspace dependency**: they are intercepted by the Wasm-to-Cranelift
35//! translator (`cranelift-wasm` and Wasmtime), which lowers them into
36//! sequences of `load`/`store`/`call`-to-libcall before they reach the
37//! `ir::Function` this detector sees. The `call` reject above is therefore
38//! load-bearing: any host-routed lowering surfaces here as a `Call` and
39//! gets rejected on that ground. The dedicated [`RejectReason::TableOp`],
40//! [`RejectReason::GcOp`], [`RejectReason::MemoryResize`] and
41//! [`RejectReason::LargeMemcpy`] variants are pre-declared so the public
42//! API is stable when a future Cranelift bump (or a direct Wasm front-end)
43//! exposes those opcodes natively.
44//!
45//! # Why a separate pass
46//!
47//! The detector runs *before* the per-family `lower_*` modules. Returning
48//! an early `Vec<Rejection>` (rather than letting the lowerings fail one
49//! by one) is the contract that lets the auto-offload pipeline fall back
50//! to the blueprint detector cleanly: an admissible function gets the
51//! Pliron pipeline, an inadmissible one gets the legacy blueprint path —
52//! never a half-lowered, half-rejected mess.
53
54#![cfg(feature = "cuda-oxide-backend")]
55
56use std::collections::HashMap;
57
58use cranelift_codegen::ir::{Block, Function, Inst, Opcode};
59
60/// A reason a Cranelift instruction cannot be lowered to a
61/// [`crate::lowered_ir::LoweredOp`].
62///
63/// Each variant carries the offending opcode's mnemonic (`&'static str`)
64/// so error messages and logs are grep-able against the [mapping
65/// table](crate::pliron_dialect#mapping-table). The mnemonic is the same
66/// string Cranelift's own [`Opcode`] `Display` impl prints (e.g.
67/// `"atomic_rmw"`, not `"AtomicRmw"`), making it stable across the
68/// `cranelift-codegen` version bumps that periodically rename enum
69/// variants but keep the textual mnemonic.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum RejectReason {
72    /// Atomic memory operation. Deferred — Wasm-threads-on-GPU is a
73    /// memory-model problem out of scope for wave 2. Covers `atomic_load`,
74    /// `atomic_store`, `atomic_rmw`, `atomic_cas`.
75    Atomic(&'static str),
76
77    /// Floating-point opcode with strict-FP exception semantics PTX
78    /// default rounding cannot match. Covers `fcvt_to_sint_sat` and
79    /// `fcvt_to_uint_sat` — both saturate-on-out-of-range, behaviour the
80    /// PTX `cvt` does not provide by default. The full strict-FP scope
81    /// (NaN propagation, denormals-as-zero) is broader; wave 2 limits
82    /// itself to the trap-carrying conversions.
83    StrictFp(&'static str),
84
85    /// Wasm `table.get` / `table.set`. Tables live host-side; lowering
86    /// them would require device-resident table mirrors. Hard-rejected.
87    ///
88    /// **Not currently wired in the detector**: `cranelift-codegen` 0.111
89    /// has no direct `Opcode::TableGet` / `Opcode::TableSet` variant —
90    /// `cranelift-wasm` lowers these to `load`/`store`+libcall sequences
91    /// in the translator, so a Wasm module reaches us with the table
92    /// access already expanded into a `call` to a runtime helper (and
93    /// therefore is caught by [`RejectReason::HostCall`]). Pre-declared
94    /// here so a future direct Wasm front-end or a Cranelift bump that
95    /// introduces table opcodes natively does not have to reshuffle the
96    /// public enum.
97    TableOp(&'static str),
98
99    /// Wasm GC / reference-type opcode (`ref.func`, `ref.null`,
100    /// `ref.is_null` on a non-i31 ref). No device-side representation
101    /// possible. Hard-rejected.
102    ///
103    /// **Not currently wired** for the same reason as
104    /// [`RejectReason::TableOp`]: in `cranelift-codegen` 0.111 the GC
105    /// proposal opcodes either do not exist as direct enum variants
106    /// (`ref.func`) or are translated into other ops by `cranelift-wasm`
107    /// before the detector sees them. Pre-declared for forward
108    /// compatibility.
109    GcOp(&'static str),
110
111    /// `memory.grow` / `memory.size`. Linear-memory resizing requires a
112    /// host round-trip; PTX kernels must run with a fixed memory
113    /// snapshot.
114    ///
115    /// **Not currently wired**: `cranelift-codegen` 0.111 has no direct
116    /// `Opcode::MemoryGrow` / `Opcode::MemorySize` — Wasm `memory.grow`
117    /// reaches us as a libcall (a `Call` instruction targeting the
118    /// runtime helper) and is therefore caught by
119    /// [`RejectReason::HostCall`]. Pre-declared for the same forward-
120    /// compatibility reason as the other absent categories.
121    MemoryResize(&'static str),
122
123    /// `memory.copy` / `memory.fill` above the inline-copy threshold.
124    /// PTX has `cp.async.bulk` (sm_90+) but the wave-2 baseline is
125    /// sm_80 — the cuda-oxide PTX target version pinned via
126    /// [`crate::ptx_emit::DEFAULT_TARGET`](crate::ptx_emit::DEFAULT_TARGET).
127    ///
128    /// **Not currently wired**: same translator-expansion situation as
129    /// the other absent categories — `memory.copy` / `memory.fill` reach
130    /// us as `Call` instructions to the runtime, caught by
131    /// [`RejectReason::HostCall`]. Pre-declared for forward compatibility.
132    /// Wave 3+ will refine [`RejectReason::HostCall`] to distinguish
133    /// device-internal calls from runtime-helper calls and may at that
134    /// point route small `memory.copy` libcalls back to an inline
135    /// `Load`/`Store` sequence instead of a hard reject.
136    LargeMemcpy(&'static str),
137
138    /// `call` / `call_indirect` / `return_call` / `return_call_indirect`.
139    /// Host-callback prohibition: PTX has no path back into the Wasmtime
140    /// runtime, so every call is rejected at the wave-2 detector. Wave
141    /// 3+ will distinguish device-internal calls (legal: lowered to
142    /// `func.call`) from host round-trips (illegal: rejected).
143    HostCall(&'static str),
144
145    /// Any floating-point opcode (`fadd`, `fmul`, `fdiv`, `fma`, `sqrt`,
146    /// `fcmp`, the float conversions, …).
147    ///
148    /// jit MED fix (finding 4): the reject-list previously admitted
149    /// non-saturating FP ops. PTX default rounding (`add.rn.f32` etc.) is
150    /// bit-exact IEEE for the basic ops, but the broader strict-FP scope
151    /// (NaN payload propagation, denormal-as-zero behaviour, transcendental
152    /// approximations) is NOT yet proven equivalent to the Wasm/CPU
153    /// reference across this lowering path. Until a differential proof of
154    /// bit-exact rounding lands, ALL float opcodes are rejected so a float
155    /// function deopts to the CPU path rather than risk silent numerical
156    /// divergence. (The basic-op subset can be re-admitted once the
157    /// differential oracle proves equivalence — narrow this then.)
158    FloatOp(&'static str),
159
160    /// Integer divide / remainder (`sdiv`, `udiv`, `srem`, `urem`).
161    ///
162    /// jit MED fix (finding 4): Wasm `i32.div_u` etc. TRAP on divide-by-
163    /// zero (and signed div traps on `INT_MIN / -1` overflow). PTX `div`
164    /// has undefined behaviour on divide-by-zero — it does not trap. Until
165    /// the lowering emits an explicit divisor-zero guard that reproduces
166    /// the Wasm trap, integer div/rem is rejected so it stays on the CPU
167    /// path rather than producing a non-trapping (wrong) result on the GPU.
168    IntDivRem(&'static str),
169
170    /// A control-flow back-edge: a branch whose target is a block at or
171    /// before the branching block in layout order (i.e. a loop).
172    ///
173    /// jit MED fix (finding 4): the wave-2 lowering does not yet model loop
174    /// carried dependencies / convergence on the GPU, so any function
175    /// containing a loop back-edge is rejected. Straight-line and
176    /// forward-only (if/else, switch) control flow remains admissible.
177    BackEdge(&'static str),
178
179    /// An explicit trap or unreachable (`trap`, `trapz`, `trapnz`,
180    /// `resumable_trap`, `debugtrap`; Wasm `unreachable` lowers to `trap`).
181    ///
182    /// jit MED fix (finding 4): PTX has no equivalent of the Wasm trap
183    /// machinery (which unwinds back into the host with a trap code). A
184    /// function that can trap mid-kernel cannot be faithfully offloaded, so
185    /// it is rejected.
186    Trap(&'static str),
187}
188
189impl RejectReason {
190    /// The Cranelift opcode mnemonic that triggered this rejection.
191    ///
192    /// Convenience accessor for log lines / error formatting that want
193    /// the offending opcode name without `match`-ing on the variant.
194    pub fn opcode_mnemonic(&self) -> &'static str {
195        match self {
196            Self::Atomic(m)
197            | Self::StrictFp(m)
198            | Self::TableOp(m)
199            | Self::GcOp(m)
200            | Self::MemoryResize(m)
201            | Self::LargeMemcpy(m)
202            | Self::HostCall(m)
203            | Self::FloatOp(m)
204            | Self::IntDivRem(m)
205            | Self::BackEdge(m)
206            | Self::Trap(m) => m,
207        }
208    }
209}
210
211/// A single rejected instruction with its location in the function.
212///
213/// Returned by [`scan_function`]; the `(inst, block)` pair is enough for
214/// the caller to render a diagnostic with `cranelift_codegen`'s own
215/// pretty-printer (e.g. `func.dfg.display_inst(inst)`).
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct Rejection {
218    /// Cranelift instruction that triggered the rejection.
219    pub inst: Inst,
220    /// Block containing the offending instruction.
221    pub block: Block,
222    /// Reason for rejection (also carries the opcode mnemonic).
223    pub reason: RejectReason,
224}
225
226/// Walk `func` and return every rejection.
227///
228/// Empty `Vec` ⇔ the function is admissible to the Pliron pipeline modulo
229/// per-opcode lowering errors caught later. The detector is intentionally
230/// conservative: when in doubt, reject — it is cheaper to fall back to the
231/// blueprint detector than to half-lower a function and then bail.
232///
233/// The scan is `O(n)` in the number of instructions and allocates only the
234/// returned `Vec`. It does not run the Cranelift verifier and therefore
235/// makes no well-formedness guarantees on `func` beyond what the layout
236/// iterator exposes.
237pub fn scan_function(func: &Function) -> Vec<Rejection> {
238    let mut rejections = Vec::new();
239    let block_order = block_layout_order(func);
240    for block in func.layout.blocks() {
241        let block_idx = block_order.get(&block).copied().unwrap_or(usize::MAX);
242        for inst in func.layout.block_insts(block) {
243            if let Some(reason) = classify_opcode(func.dfg.insts[inst].opcode()) {
244                rejections.push(Rejection {
245                    inst,
246                    block,
247                    reason,
248                });
249            } else if let Some(reason) = back_edge_reason(func, inst, block_idx, &block_order) {
250                // jit MED fix (finding 4): a branch whose target is at or
251                // before this block in layout order is a loop back-edge.
252                rejections.push(Rejection {
253                    inst,
254                    block,
255                    reason,
256                });
257            }
258        }
259    }
260    rejections
261}
262
263/// Convenience wrapper: return the **first** rejection or `None`.
264///
265/// Useful for the call-site shortcut "is this function admissible?" —
266/// avoids the `Vec` allocation when the caller does not need the full
267/// list. Equivalent to `scan_function(func).into_iter().next()` but does
268/// not walk the rest of the function once a rejection is found.
269pub fn check_function(func: &Function) -> Option<Rejection> {
270    let block_order = block_layout_order(func);
271    for block in func.layout.blocks() {
272        let block_idx = block_order.get(&block).copied().unwrap_or(usize::MAX);
273        for inst in func.layout.block_insts(block) {
274            if let Some(reason) = classify_opcode(func.dfg.insts[inst].opcode()) {
275                return Some(Rejection {
276                    inst,
277                    block,
278                    reason,
279                });
280            }
281            if let Some(reason) = back_edge_reason(func, inst, block_idx, &block_order) {
282                return Some(Rejection {
283                    inst,
284                    block,
285                    reason,
286                });
287            }
288        }
289    }
290    None
291}
292
293/// Assign each block its position in layout order. A branch to a block
294/// whose index is `<=` the branching block's index is a back-edge (loop).
295fn block_layout_order(func: &Function) -> HashMap<Block, usize> {
296    func.layout
297        .blocks()
298        .enumerate()
299        .map(|(idx, block)| (block, idx))
300        .collect()
301}
302
303/// If `inst` is a branch with a destination at or before `block_idx` in
304/// layout order, return a [`RejectReason::BackEdge`]. Returns `None` for
305/// non-branch instructions and purely forward branches.
306///
307/// jit MED fix (finding 4): the wave-2 lowering does not model loop-carried
308/// dependencies, so any loop (detected here as a back-edge) is rejected.
309fn back_edge_reason(
310    func: &Function,
311    inst: Inst,
312    block_idx: usize,
313    block_order: &HashMap<Block, usize>,
314) -> Option<RejectReason> {
315    let data = &func.dfg.insts[inst];
316    let opcode = data.opcode();
317    if !opcode.is_branch() {
318        return None;
319    }
320    let mut is_back_edge = false;
321    for block_call in data.branch_destination(&func.dfg.jump_tables) {
322        let target = block_call.block(&func.dfg.value_lists);
323        if let Some(&target_idx) = block_order.get(&target) {
324            if target_idx <= block_idx {
325                is_back_edge = true;
326            }
327        }
328    }
329    if is_back_edge {
330        Some(RejectReason::BackEdge(branch_mnemonic(opcode)))
331    } else {
332        None
333    }
334}
335
336/// Static mnemonic for a branch opcode (diagnostics only).
337fn branch_mnemonic(op: Opcode) -> &'static str {
338    match op {
339        Opcode::Jump => "jump",
340        Opcode::Brif => "brif",
341        Opcode::BrTable => "br_table",
342        _ => "branch",
343    }
344}
345
346/// Map a Cranelift [`Opcode`] to a [`RejectReason`] if it is on the
347/// reject list, otherwise `None`.
348///
349/// Centralised match so the two scanners stay in lock-step. The mnemonic
350/// strings are hard-coded `&'static str` literals (rather than
351/// `opcode_name(op)` / `Display`) to keep [`RejectReason`] `Copy`-able
352/// and avoid the runtime cost of stringification in the hot path; they
353/// are kept identical to Cranelift's own mnemonics so the two are
354/// interchangeable for log output.
355fn classify_opcode(op: Opcode) -> Option<RejectReason> {
356    match op {
357        // Atomics — see RejectReason::Atomic.
358        Opcode::AtomicLoad => Some(RejectReason::Atomic("atomic_load")),
359        Opcode::AtomicStore => Some(RejectReason::Atomic("atomic_store")),
360        Opcode::AtomicRmw => Some(RejectReason::Atomic("atomic_rmw")),
361        Opcode::AtomicCas => Some(RejectReason::Atomic("atomic_cas")),
362
363        // Strict-FP saturating conversions — see RejectReason::StrictFp.
364        // PTX `cvt.f32.s32` / `cvt.f32.u32` do not saturate; the wave-2
365        // lowering cannot honour the Wasm-strict saturation semantics
366        // without an explicit min/max clamp the detector chooses not to
367        // synthesize for v0.4.
368        Opcode::FcvtToSintSat => Some(RejectReason::StrictFp("fcvt_to_sint_sat")),
369        Opcode::FcvtToUintSat => Some(RejectReason::StrictFp("fcvt_to_uint_sat")),
370
371        // Host-call prohibition — see RejectReason::HostCall. This also
372        // catches the libcall-expanded forms of `memory.grow`,
373        // `memory.size`, `memory.copy`, `memory.fill`, `table.get`, and
374        // `table.set` that `cranelift-wasm` emits in pinned Cranelift
375        // 0.111. Wave 3+ will refine this match to peek into `func_ref`
376        // and distinguish device-internal calls from runtime helpers.
377        Opcode::Call => Some(RejectReason::HostCall("call")),
378        Opcode::CallIndirect => Some(RejectReason::HostCall("call_indirect")),
379        Opcode::ReturnCall => Some(RejectReason::HostCall("return_call")),
380        Opcode::ReturnCallIndirect => Some(RejectReason::HostCall("return_call_indirect")),
381
382        // Integer divide / remainder — see RejectReason::IntDivRem. These
383        // trap on divide-by-zero in Wasm but PTX `div`/`rem` do not, so
384        // they are rejected until the trap is emulated (jit MED finding 4).
385        Opcode::Sdiv => Some(RejectReason::IntDivRem("sdiv")),
386        Opcode::Udiv => Some(RejectReason::IntDivRem("udiv")),
387        Opcode::Srem => Some(RejectReason::IntDivRem("srem")),
388        Opcode::Urem => Some(RejectReason::IntDivRem("urem")),
389
390        // Trap / unreachable — see RejectReason::Trap. No PTX equivalent of
391        // the Wasm trap unwind (jit MED finding 4). `unreachable` lowers to
392        // `trap` in Cranelift, so this also covers Wasm `unreachable`.
393        Opcode::Trap => Some(RejectReason::Trap("trap")),
394        Opcode::Trapz => Some(RejectReason::Trap("trapz")),
395        Opcode::Trapnz => Some(RejectReason::Trap("trapnz")),
396        Opcode::Debugtrap => Some(RejectReason::Trap("debugtrap")),
397
398        // All floating-point opcodes — see RejectReason::FloatOp. Rejected
399        // wholesale until bit-exact PTX rounding equivalence is proven by
400        // the differential oracle (jit MED finding 4). The two saturating
401        // conversions keep their more specific `StrictFp` reason above for
402        // diagnostic continuity; everything else float lands here.
403        op if is_float_opcode(op) => Some(RejectReason::FloatOp(float_mnemonic(op))),
404
405        // Everything else is provisionally admissible; the per-family
406        // lowerings may still reject in their own pass.
407        _ => None,
408    }
409}
410
411/// True for any floating-point Cranelift opcode the wave-2 reject-list
412/// refuses (jit MED finding 4). Kept as an explicit allow/deny list rather
413/// than a type-based check because `classify_opcode` only has the opcode in
414/// hand (the scanners deliberately avoid touching value types for speed).
415fn is_float_opcode(op: Opcode) -> bool {
416    matches!(
417        op,
418        Opcode::Fadd
419            | Opcode::Fsub
420            | Opcode::Fmul
421            | Opcode::Fdiv
422            | Opcode::Fma
423            | Opcode::Fneg
424            | Opcode::Fabs
425            | Opcode::Fcopysign
426            | Opcode::Fmin
427            | Opcode::Fmax
428            | Opcode::Sqrt
429            | Opcode::Ceil
430            | Opcode::Floor
431            | Opcode::Trunc
432            | Opcode::Nearest
433            | Opcode::Fcmp
434            | Opcode::Fpromote
435            | Opcode::Fdemote
436            | Opcode::FcvtFromSint
437            | Opcode::FcvtFromUint
438            | Opcode::FcvtToSint
439            | Opcode::FcvtToUint
440    )
441}
442
443/// Static mnemonic for a float opcode that `is_float_opcode` accepts.
444/// Falls back to a generic `"float_op"` label for any opcode not
445/// individually named — the reject decision is what matters, the mnemonic
446/// is only for diagnostics.
447fn float_mnemonic(op: Opcode) -> &'static str {
448    match op {
449        Opcode::Fadd => "fadd",
450        Opcode::Fsub => "fsub",
451        Opcode::Fmul => "fmul",
452        Opcode::Fdiv => "fdiv",
453        Opcode::Fma => "fma",
454        Opcode::Fneg => "fneg",
455        Opcode::Fabs => "fabs",
456        Opcode::Fcopysign => "fcopysign",
457        Opcode::Fmin => "fmin",
458        Opcode::Fmax => "fmax",
459        Opcode::Sqrt => "sqrt",
460        Opcode::Ceil => "ceil",
461        Opcode::Floor => "floor",
462        Opcode::Trunc => "trunc",
463        Opcode::Nearest => "nearest",
464        Opcode::Fcmp => "fcmp",
465        Opcode::Fpromote => "fpromote",
466        Opcode::Fdemote => "fdemote",
467        Opcode::FcvtFromSint => "fcvt_from_sint",
468        Opcode::FcvtFromUint => "fcvt_from_uint",
469        Opcode::FcvtToSint => "fcvt_to_sint",
470        Opcode::FcvtToUint => "fcvt_to_uint",
471        _ => "float_op",
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use cranelift_codegen::ir::immediates::Offset32;
479    use cranelift_codegen::ir::instructions::InstructionData;
480    use cranelift_codegen::ir::{
481        AtomicRmwOp, FuncRef, MemFlags, SigRef, Signature, UserFuncName, Value, ValueList,
482    };
483    use cranelift_codegen::isa::CallConv;
484
485    /// Build a fresh empty function with a single empty entry block.
486    /// The wave-2 detector never inspects values or types, only opcodes,
487    /// so the dummy `Value(0)` / `FuncRef(0)` / `SigRef(0)` placeholders
488    /// the tests use never need to be valid — `scan_function` does not
489    /// dereference them.
490    fn empty_func() -> (Function, Block) {
491        let mut func = Function::with_name_signature(
492            UserFuncName::user(0, 0),
493            Signature::new(CallConv::SystemV),
494        );
495        let block = func.dfg.make_block();
496        func.layout.append_block(block);
497        (func, block)
498    }
499
500    /// Convenience: place `data` at the end of `block` and return the
501    /// resulting `Inst`. Intentionally does NOT call `make_inst_results`
502    /// — the detector ignores result values, and skipping result wiring
503    /// keeps the test fixtures small and verifier-independent.
504    fn append(func: &mut Function, block: Block, data: InstructionData) -> Inst {
505        let inst = func.dfg.make_inst(data);
506        func.layout.append_inst(inst, block);
507        inst
508    }
509
510    /// A throwaway `Value` reference. Never dereferenced by the
511    /// detector, so any well-formed `Value` ID is fine.
512    fn dummy_val() -> Value {
513        Value::from_u32(0)
514    }
515
516    // ---- Positive (admissible) case -----------------------------------
517
518    /// A function with only admissible opcodes (`iadd` + `return`)
519    /// produces no rejections.
520    #[test]
521    fn admissible_iadd_return_yields_empty() {
522        let (mut func, block) = empty_func();
523        append(
524            &mut func,
525            block,
526            InstructionData::Binary {
527                opcode: Opcode::Iadd,
528                args: [dummy_val(), dummy_val()],
529            },
530        );
531        append(
532            &mut func,
533            block,
534            InstructionData::MultiAry {
535                opcode: Opcode::Return,
536                args: ValueList::new(),
537            },
538        );
539
540        assert_eq!(scan_function(&func), vec![]);
541        assert_eq!(check_function(&func), None);
542    }
543
544    // ---- Atomic family -------------------------------------------------
545
546    /// `atomic_load` is rejected with the `Atomic` reason.
547    #[test]
548    fn atomic_load_is_rejected() {
549        let (mut func, block) = empty_func();
550        append(
551            &mut func,
552            block,
553            InstructionData::LoadNoOffset {
554                opcode: Opcode::AtomicLoad,
555                flags: MemFlags::new(),
556                arg: dummy_val(),
557            },
558        );
559
560        let rejections = scan_function(&func);
561        assert_eq!(rejections.len(), 1);
562        assert_eq!(rejections[0].reason, RejectReason::Atomic("atomic_load"));
563        assert_eq!(rejections[0].block, block);
564    }
565
566    /// `atomic_store` is rejected with the `Atomic` reason.
567    #[test]
568    fn atomic_store_is_rejected() {
569        let (mut func, block) = empty_func();
570        append(
571            &mut func,
572            block,
573            InstructionData::StoreNoOffset {
574                opcode: Opcode::AtomicStore,
575                flags: MemFlags::new(),
576                args: [dummy_val(), dummy_val()],
577            },
578        );
579
580        let rejections = scan_function(&func);
581        assert_eq!(rejections.len(), 1);
582        assert_eq!(rejections[0].reason, RejectReason::Atomic("atomic_store"));
583    }
584
585    /// `atomic_rmw` is rejected with the `Atomic` reason.
586    #[test]
587    fn atomic_rmw_is_rejected() {
588        let (mut func, block) = empty_func();
589        append(
590            &mut func,
591            block,
592            InstructionData::AtomicRmw {
593                opcode: Opcode::AtomicRmw,
594                flags: MemFlags::new(),
595                op: AtomicRmwOp::Add,
596                args: [dummy_val(), dummy_val()],
597            },
598        );
599
600        let rejections = scan_function(&func);
601        assert_eq!(rejections.len(), 1);
602        assert_eq!(rejections[0].reason, RejectReason::Atomic("atomic_rmw"));
603    }
604
605    /// `atomic_cas` is rejected with the `Atomic` reason.
606    #[test]
607    fn atomic_cas_is_rejected() {
608        let (mut func, block) = empty_func();
609        append(
610            &mut func,
611            block,
612            InstructionData::AtomicCas {
613                opcode: Opcode::AtomicCas,
614                flags: MemFlags::new(),
615                args: [dummy_val(), dummy_val(), dummy_val()],
616            },
617        );
618
619        let rejections = scan_function(&func);
620        assert_eq!(rejections.len(), 1);
621        assert_eq!(rejections[0].reason, RejectReason::Atomic("atomic_cas"));
622    }
623
624    // ---- Strict-FP family ----------------------------------------------
625
626    /// `fcvt_to_sint_sat` is rejected with the `StrictFp` reason.
627    #[test]
628    fn fcvt_to_sint_sat_is_rejected() {
629        let (mut func, block) = empty_func();
630        append(
631            &mut func,
632            block,
633            InstructionData::Unary {
634                opcode: Opcode::FcvtToSintSat,
635                arg: dummy_val(),
636            },
637        );
638
639        let rejections = scan_function(&func);
640        assert_eq!(rejections.len(), 1);
641        assert_eq!(
642            rejections[0].reason,
643            RejectReason::StrictFp("fcvt_to_sint_sat")
644        );
645    }
646
647    /// `fcvt_to_uint_sat` is also rejected (sibling saturating
648    /// conversion). Covers the second variant of the `StrictFp` family
649    /// the detector recognises.
650    #[test]
651    fn fcvt_to_uint_sat_is_rejected() {
652        let (mut func, block) = empty_func();
653        append(
654            &mut func,
655            block,
656            InstructionData::Unary {
657                opcode: Opcode::FcvtToUintSat,
658                arg: dummy_val(),
659            },
660        );
661
662        let rejections = scan_function(&func);
663        assert_eq!(rejections.len(), 1);
664        assert_eq!(
665            rejections[0].reason,
666            RejectReason::StrictFp("fcvt_to_uint_sat")
667        );
668    }
669
670    // ---- Host-call family ----------------------------------------------
671
672    /// `call` is rejected with the `HostCall` reason. The dummy
673    /// `FuncRef(0)` is never resolved by the detector.
674    #[test]
675    fn call_is_rejected() {
676        let (mut func, block) = empty_func();
677        append(
678            &mut func,
679            block,
680            InstructionData::Call {
681                opcode: Opcode::Call,
682                func_ref: FuncRef::from_u32(0),
683                args: ValueList::new(),
684            },
685        );
686
687        let rejections = scan_function(&func);
688        assert_eq!(rejections.len(), 1);
689        assert_eq!(rejections[0].reason, RejectReason::HostCall("call"));
690    }
691
692    /// `call_indirect` is rejected with the `HostCall` reason.
693    #[test]
694    fn call_indirect_is_rejected() {
695        let (mut func, block) = empty_func();
696        append(
697            &mut func,
698            block,
699            InstructionData::CallIndirect {
700                opcode: Opcode::CallIndirect,
701                sig_ref: SigRef::from_u32(0),
702                args: ValueList::new(),
703            },
704        );
705
706        let rejections = scan_function(&func);
707        assert_eq!(rejections.len(), 1);
708        assert_eq!(
709            rejections[0].reason,
710            RejectReason::HostCall("call_indirect")
711        );
712    }
713
714    // ---- TableOp / GcOp / MemoryResize / LargeMemcpy -------------------
715    //
716    // These four categories are pre-declared in `RejectReason` for forward
717    // compatibility but are not wired to a specific Cranelift opcode in
718    // 0.111.9 (see module docs — the wasm-to-clif translator expands them
719    // into `call` libcalls before the detector sees them, so they are
720    // caught indirectly via `HostCall`). The unit tests below exercise
721    // the *variant* surface — constructing and inspecting each reason —
722    // so a future wiring patch only needs to add a `match` arm in
723    // `classify_opcode`, not redesign the public enum.
724
725    /// The `TableOp` variant round-trips through `opcode_mnemonic`.
726    /// Pre-declared variant; no opcode wired in 0.111.
727    #[test]
728    fn table_op_variant_is_constructible() {
729        let r = RejectReason::TableOp("table_get");
730        assert_eq!(r.opcode_mnemonic(), "table_get");
731    }
732
733    /// The `GcOp` variant round-trips through `opcode_mnemonic`.
734    /// Pre-declared variant; no opcode wired in 0.111.
735    #[test]
736    fn gc_op_variant_is_constructible() {
737        let r = RejectReason::GcOp("ref_func");
738        assert_eq!(r.opcode_mnemonic(), "ref_func");
739    }
740
741    /// The `MemoryResize` variant round-trips through `opcode_mnemonic`.
742    /// Pre-declared variant; no opcode wired in 0.111.
743    #[test]
744    fn memory_resize_variant_is_constructible() {
745        let r = RejectReason::MemoryResize("memory_grow");
746        assert_eq!(r.opcode_mnemonic(), "memory_grow");
747    }
748
749    /// The `LargeMemcpy` variant round-trips through `opcode_mnemonic`.
750    /// Pre-declared variant; no opcode wired in 0.111.
751    #[test]
752    fn large_memcpy_variant_is_constructible() {
753        let r = RejectReason::LargeMemcpy("memory_copy");
754        assert_eq!(r.opcode_mnemonic(), "memory_copy");
755    }
756
757    // ---- Multi-rejection / ordering ------------------------------------
758
759    /// A function with two rejected instructions returns both, in
760    /// layout order. Locks in the contract that the detector is a
761    /// *list* not a "first failure" — wave 3+ diagnostics will rely on
762    /// the full list to surface every issue at once.
763    #[test]
764    fn multiple_rejections_returned_in_layout_order() {
765        let (mut func, block) = empty_func();
766        append(
767            &mut func,
768            block,
769            InstructionData::LoadNoOffset {
770                opcode: Opcode::AtomicLoad,
771                flags: MemFlags::new(),
772                arg: dummy_val(),
773            },
774        );
775        append(
776            &mut func,
777            block,
778            InstructionData::Call {
779                opcode: Opcode::Call,
780                func_ref: FuncRef::from_u32(0),
781                args: ValueList::new(),
782            },
783        );
784
785        let rejections = scan_function(&func);
786        assert_eq!(rejections.len(), 2);
787        assert_eq!(rejections[0].reason, RejectReason::Atomic("atomic_load"));
788        assert_eq!(rejections[1].reason, RejectReason::HostCall("call"));
789    }
790
791    /// `check_function` returns the first rejection encountered (early
792    /// exit), not the full list.
793    #[test]
794    fn check_function_returns_first_rejection() {
795        let (mut func, block) = empty_func();
796        append(
797            &mut func,
798            block,
799            InstructionData::Call {
800                opcode: Opcode::Call,
801                func_ref: FuncRef::from_u32(0),
802                args: ValueList::new(),
803            },
804        );
805        append(
806            &mut func,
807            block,
808            InstructionData::LoadNoOffset {
809                opcode: Opcode::AtomicLoad,
810                flags: MemFlags::new(),
811                arg: dummy_val(),
812            },
813        );
814
815        let first = check_function(&func).expect("function has rejections");
816        assert_eq!(first.reason, RejectReason::HostCall("call"));
817    }
818
819    /// `Offset32` is included in the `cranelift_codegen::ir::immediates`
820    /// import set above so the test module compiles even when no test
821    /// directly references it; this assertion keeps the import live and
822    /// documents that the detector intentionally ignores instruction
823    /// offsets (only opcode identity matters).
824    #[test]
825    fn offset_field_is_irrelevant_to_detection() {
826        let _ = Offset32::new(0);
827    }
828
829    // ---- jit MED fix (finding 4): float / div-rem / trap / back-edge ----
830
831    /// All floating-point arithmetic is rejected with `FloatOp` until
832    /// bit-exact PTX rounding is proven.
833    #[test]
834    fn float_arith_is_rejected() {
835        for (opcode, mnemonic) in [
836            (Opcode::Fadd, "fadd"),
837            (Opcode::Fmul, "fmul"),
838            (Opcode::Fdiv, "fdiv"),
839        ] {
840            let (mut func, block) = empty_func();
841            append(
842                &mut func,
843                block,
844                InstructionData::Binary {
845                    opcode,
846                    args: [dummy_val(), dummy_val()],
847                },
848            );
849            let first = check_function(&func).expect("float op must be rejected");
850            assert_eq!(first.reason, RejectReason::FloatOp(mnemonic));
851        }
852    }
853
854    /// Float unary ops (`sqrt`) are rejected too.
855    #[test]
856    fn float_sqrt_is_rejected() {
857        let (mut func, block) = empty_func();
858        append(
859            &mut func,
860            block,
861            InstructionData::Unary {
862                opcode: Opcode::Sqrt,
863                arg: dummy_val(),
864            },
865        );
866        let first = check_function(&func).expect("sqrt must be rejected");
867        assert_eq!(first.reason, RejectReason::FloatOp("sqrt"));
868    }
869
870    /// Integer divide / remainder is rejected until divide-by-zero trap
871    /// emulation lands.
872    #[test]
873    fn int_div_rem_is_rejected() {
874        for (opcode, mnemonic) in [
875            (Opcode::Sdiv, "sdiv"),
876            (Opcode::Udiv, "udiv"),
877            (Opcode::Srem, "srem"),
878            (Opcode::Urem, "urem"),
879        ] {
880            let (mut func, block) = empty_func();
881            append(
882                &mut func,
883                block,
884                InstructionData::Binary {
885                    opcode,
886                    args: [dummy_val(), dummy_val()],
887                },
888            );
889            let first = check_function(&func).expect("int div/rem must be rejected");
890            assert_eq!(first.reason, RejectReason::IntDivRem(mnemonic));
891        }
892    }
893
894    /// `iadd` (a non-trapping integer op) stays admissible — the div/rem
895    /// reject must not over-reach to all integer arithmetic.
896    #[test]
897    fn plain_iadd_still_admissible() {
898        let (mut func, block) = empty_func();
899        append(
900            &mut func,
901            block,
902            InstructionData::Binary {
903                opcode: Opcode::Iadd,
904                args: [dummy_val(), dummy_val()],
905            },
906        );
907        assert_eq!(check_function(&func), None);
908    }
909
910    /// `trap` and friends are rejected.
911    #[test]
912    fn trap_is_rejected() {
913        let (mut func, block) = empty_func();
914        append(
915            &mut func,
916            block,
917            InstructionData::Trap {
918                opcode: Opcode::Trap,
919                code: cranelift_codegen::ir::TrapCode::User(1),
920            },
921        );
922        let first = check_function(&func).expect("trap must be rejected");
923        assert_eq!(first.reason, RejectReason::Trap("trap"));
924    }
925
926    /// A loop back-edge (a `jump` whose target is the entry block, which is
927    /// at or before the branching block in layout order) is rejected; a
928    /// forward jump is NOT.
929    #[test]
930    fn loop_back_edge_is_rejected_forward_jump_is_not() {
931        use cranelift_codegen::ir::BlockCall;
932
933        // Build entry -> body, with body jumping BACK to entry (a loop).
934        let mut func = Function::with_name_signature(
935            UserFuncName::user(0, 0),
936            Signature::new(CallConv::SystemV),
937        );
938        let entry = func.dfg.make_block();
939        let body = func.dfg.make_block();
940        func.layout.append_block(entry);
941        func.layout.append_block(body);
942
943        // entry: jump body  (forward — admissible)
944        let fwd_call = BlockCall::new(body, &[], &mut func.dfg.value_lists);
945        let fwd = func.dfg.make_inst(InstructionData::Jump {
946            opcode: Opcode::Jump,
947            destination: fwd_call,
948        });
949        func.layout.append_inst(fwd, entry);
950
951        // body: jump entry  (back-edge — rejected)
952        let back_call = BlockCall::new(entry, &[], &mut func.dfg.value_lists);
953        let back = func.dfg.make_inst(InstructionData::Jump {
954            opcode: Opcode::Jump,
955            destination: back_call,
956        });
957        func.layout.append_inst(back, body);
958
959        let rejections = scan_function(&func);
960        assert_eq!(
961            rejections.len(),
962            1,
963            "exactly the back-edge is rejected, not the forward jump"
964        );
965        assert!(matches!(rejections[0].reason, RejectReason::BackEdge(_)));
966        assert_eq!(rejections[0].block, body);
967    }
968
969    /// A self-loop (`jump` to the block's own block) is a back-edge.
970    #[test]
971    fn self_loop_is_back_edge() {
972        use cranelift_codegen::ir::BlockCall;
973        let (mut func, block) = empty_func();
974        let call = BlockCall::new(block, &[], &mut func.dfg.value_lists);
975        let jmp = func.dfg.make_inst(InstructionData::Jump {
976            opcode: Opcode::Jump,
977            destination: call,
978        });
979        func.layout.append_inst(jmp, block);
980        let first = check_function(&func).expect("self-loop must be rejected");
981        assert!(matches!(first.reason, RejectReason::BackEdge(_)));
982    }
983}