Skip to main content

aver/ir/mir/optimize/
bare_i64.rs

1//! Int "unboxing": pick a bare `i64` representation for provably-bounded,
2//! non-escaping `Int` values so the Rust backend emits native integer
3//! arithmetic instead of the default arbitrary-precision `aver_rt::AverInt`.
4//!
5//! This is a **read-only codegen analysis** — it never mutates the
6//! `MirProgram`. It produces a [`BareI64Facts`] table the Rust walker reads
7//! to select `i64` vs `AverInt` at each emit site, exactly the way the Rust
8//! walker reads `aliased_slots` (the `own_param` pass) to select owned vs
9//! borrowed collection params.
10//!
11//! ## What reuses the #511 interval domain
12//!
13//! The arithmetic-bound half reuses [`crate::ir::interval`] verbatim: the
14//! `Interval` lattice element (i128-saturating, never wraps), its
15//! `add`/`sub`/`mul`/`hull`/`fits_i64`, the `OpClass` verdict band, and the
16//! `raw_i64_eligible` gate. We only swap the LEAF source — instead of a
17//! refined-type carrier read, the leaf is an SSA-value (`LocalId`) range
18//! query over the `MirFn` body.
19//!
20//! ## What reuses the existing escape / use-flow machinery
21//!
22//! The escape half mirrors the NOTION the `own_param` pass already uses (a
23//! single use-flow scan over the body, defaulting every unrecognized
24//! position to "escapes"), but with a REPRESENTATION-escape predicate
25//! ("does this Int reach a general-Int context") rather than the ownership-
26//! escape predicate ("does this collection leave the frame"). The call
27//! graph used for the cross-frame summary is the `MirCallee::Fn` /
28//! `MirTailCall` edge set, the same edges `own_param` walks.
29//!
30//! ## Soundness — fail-closed (the C0 guard)
31//!
32//! A value is `Bare` ONLY when PROVEN `raw_i64_eligible` (interval `Some`,
33//! `fits_i64`, every participating op `OverflowFree`) AND non-escaping. Any
34//! missing or unknown fact ⇒ `Boxed` (`AverInt`), never `Bare`. So a bug
35//! here is a MISSED optimization (lost speed), never a wrong value — the
36//! opposite of the wasm-gc silent-wrong-value risk. A wrongly-bare value
37//! would reintroduce silent two's-complement wrapping, and additionally
38//! the emitted Rust would not type-check (a bare value reaching an
39//! `AverInt` slot is a `rustc` error), which is itself a backstop.
40
41use std::collections::{HashMap, HashSet};
42
43use crate::ast::{BinOp, Literal, Spanned, Type};
44use crate::ir::FnId;
45use crate::ir::interval::{Interval, OpClass, raw_i64_eligible};
46
47use super::super::expr::{MirCallee, MirExpr, MirPattern};
48use super::super::program::{LocalId, MirFn, MirProgram};
49
50/// ETAP-2 SLICE 0+1 — per-carrier-type proven bound, keyed by the opaque
51/// type's bare Aver name (the `MirParam.ty` string). Built once by
52/// [`crate::codegen::proof_lower::carrier_interval_table`] and threaded into
53/// [`analyze`]. The `bool` is the `interval_known` bit from
54/// [`crate::ir::interval::interval_of_invariant`]: only a recognized,
55/// `fits_i64` bound makes a carrier slot bare-eligible. An EMPTY table
56/// (the default at every non-carrier call site — the VM facts path, tests)
57/// reproduces the pre-slice all-`Int` behavior byte-for-byte.
58pub type CarrierIntervals = HashMap<String, (Interval, bool)>;
59
60/// ETAP-2 multi-field carrier-`i64` — per-`(record-type, field)` proven bound,
61/// keyed by the bare record name + field name. Built by
62/// [`crate::codegen::proof_lower::field_carrier_eligible_intervals`] (already
63/// tightened through the same demotion scans as the single-field set) and
64/// threaded into [`analyze`] alongside [`CarrierIntervals`]. Lets
65/// [`FnBareFacts::carrier_project_interval`] recognize a DIRECT bounded-field
66/// read — `Project(rec, "x")` where `rec`'s stamped type is a bounded record
67/// and field `x` is eligible — as a raw i64 leaf carrying `x`'s bound (#550
68/// stored the field as a native `i64`, so the `struct.get` yields i64). An
69/// EMPTY table (the default at every non-wasm-gc call site — the VM facts path,
70/// the Rust backend, tests) reproduces the pre-slice all-`Int` behavior.
71pub type FieldCarrierIntervals = HashMap<(String, String), (Interval, bool)>;
72
73/// The proven interval for a carrier whose Aver type name is `ty`, returned
74/// ONLY when the table holds a recognized bound (`interval_known`) that
75/// `fits_i64`. Any other case (`ty` not a carrier, an unrecognized invariant
76/// omitted upstream, or a bound too wide for `i64`) yields `None` — the
77/// fail-closed decline that keeps the carrier boxed.
78///
79/// `ty` is a `MirParam.ty` string, which the lowerer fills with
80/// `format!("{:?}", Type)` — so a named carrier type renders as the Debug
81/// form `Named { id: Some(TypeId(N)), name: "IntRange" }`, NOT the bare
82/// `"IntRange"`. We extract the bare `name:` to match the table key (which
83/// is keyed by the bare type name from `populate_refined_types`).
84///
85/// ## Seam gate (`CARRIER_BARE_ELIGIBLE`)
86///
87/// This is the analysis half of carrier-`i64` lowering. The codegen half —
88/// flipping a bare-carrier function slot to a native `i64` on the wasm-gc /
89/// Rust backends — is NOT in place yet: a carrier is a refinement-via-opaque
90/// single-field record that the wasm-gc registry already *newtype-erases* to
91/// its underlying `Int` ref (`$aint`), and the body-emit path (`Project` of
92/// the carrier field, `RecordCreate` of the carrier, the smart-constructor
93/// `Result.Ok(IntRange(..))` boundary, and the same on Rust which does NOT
94/// erase the carrier at all) still expects that ref. Flagging a carrier slot
95/// bare in `MirFnRepr` therefore desyncs the body from the (still-boxed)
96/// signature and emits invalid wasm. So the seam ships GATED OFF — exactly
97/// the discipline 2a used (`ENABLE_BARE_SLOTS = false`) before 2b flipped it.
98/// The table, threading, name-extraction, escape-coupling and summary
99/// integration below are all LIVE and tested; flipping this `const` to
100/// `true` (after the body-emit bridge lands) turns the slice on with no
101/// other change to this file.
102const CARRIER_BARE_ELIGIBLE: bool = true;
103
104fn carrier_interval(ty: &str, carrier: &CarrierIntervals) -> Option<Interval> {
105    if !CARRIER_BARE_ELIGIBLE {
106        return None;
107    }
108    let bare = bare_named_type(ty)?;
109    let (iv, known) = carrier.get(bare).copied()?;
110    if known && iv.fits_i64() {
111        Some(iv)
112    } else {
113        None
114    }
115}
116
117/// Extract the bare type name from a `MirParam.ty` Debug string for a
118/// `Type::Named`. Returns the `name: "X"` payload as `X`, or `None` when the
119/// string is not a `Named { … }` Debug form (e.g. `"Int"`, `"Result(…)"`).
120/// The Debug format of `Type::Named { id, name }` is stable
121/// (`Named { id: …, name: "X" }`); we slice out the quoted `name:` field.
122fn bare_named_type(ty: &str) -> Option<&str> {
123    let rest = ty.strip_prefix("Named {")?;
124    let after = rest.split("name:").nth(1)?;
125    let start = after.find('"')? + 1;
126    let end = after[start..].find('"')? + start;
127    Some(&after[start..end])
128}
129
130/// Representation chosen for a value: a native machine `i64` or the
131/// default arbitrary-precision `AverInt`.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum Repr {
134    /// Native `i64` — emitted only when proven sound.
135    Bare,
136    /// `aver_rt::AverInt` — the safe default.
137    Boxed,
138}
139
140/// Per-value representation-selection fact, keyed by `LocalId` within a
141/// `MirFn`. `repr == Bare` ⟺ `raw_i64_eligible(interval, ops) && !escapes`.
142#[derive(Debug, Clone)]
143pub struct ValueFact {
144    /// Derived interval over-approximation (`None` = the analysis could
145    /// not derive a bound, the conservative decline).
146    pub interval: Option<Interval>,
147    /// Worst-case op class over every arithmetic node the value flows
148    /// through. `OverflowFree` is the only band that may be `Bare`.
149    pub op_class: OpClass,
150    /// `true` when the value reaches a general-Int context (returned as a
151    /// general Int, passed to a general-Int param, stored in an aggregate,
152    /// or stringified). A `Bare` value never escapes by construction.
153    pub escapes: bool,
154    /// The chosen representation.
155    pub repr: Repr,
156}
157
158impl ValueFact {
159    /// The safe default — `Boxed`, unknown interval, declined.
160    fn boxed() -> Self {
161        Self {
162            interval: None,
163            op_class: OpClass::Unbounded,
164            escapes: true,
165            repr: Repr::Boxed,
166        }
167    }
168
169    pub fn is_bare(&self) -> bool {
170        self.repr == Repr::Bare
171    }
172}
173
174/// Per-`MirFn` representation facts the Rust walker consumes.
175#[derive(Debug, Clone, Default)]
176pub struct FnBareFacts {
177    /// Per-`LocalId` value fact. A slot absent from this map defaults to
178    /// `Boxed` (fail-closed).
179    pub values: HashMap<LocalId, ValueFact>,
180    /// Per-param-index representation: `true` ⟺ the param is emitted as a
181    /// bare `i64` in the fn signature (and every caller converts at the
182    /// boundary). Indexed by declaration order, same indexing
183    /// `own_param`'s `aliased_slots` uses.
184    pub bare_params: Vec<bool>,
185    /// `true` ⟺ the fn's return type is emitted as a bare `i64`.
186    pub bare_return: bool,
187    /// ETAP-2 carrier-`i64` (wasm-gc only): slots holding a bare carrier
188    /// value (an eligible refinement-via-opaque carrier whose wasm storage IS
189    /// a native `i64`) mapped to the carrier's PROVEN `fits_i64` interval.
190    /// A `Project(Local(slot), "value")` over such a slot reads the i64
191    /// directly (no `$AverInt` project bridge) and contributes the carrier's
192    /// interval to the surrounding arithmetic, so `c.value + c.value` over a
193    /// `[0,100]` carrier stays in the OverflowFree band. EMPTY on the Rust
194    /// backend (it keeps the carrier struct) and whenever no eligible carrier
195    /// is in scope — the byte-identical default.
196    pub carrier_slots: HashMap<LocalId, Interval>,
197    /// ETAP-2 carrier-`i64` (wasm-gc only): the eligible-carrier type table
198    /// (`bare type name → (proven interval, recognized)`), restricted to the
199    /// registry's eligible set. Lets `carrier_project_interval` recognize a
200    /// NESTED carrier-field read — `Project(Project(rec, "coord"), "value")`,
201    /// where the inner `rec.coord` is stamped an eligible carrier type — as a
202    /// raw i64 leaf carrying `coord`'s proven bound. The #550 storage erasure
203    /// already made the carrier field a native `i64`, so the inner field read
204    /// yields i64 and the outer `.value` is identity. EMPTY on the Rust
205    /// backend / whenever no eligible carrier is in scope — the byte-identical
206    /// default (the nested form then never fires, only the `Local`-base form).
207    pub carrier_types: CarrierIntervals,
208    /// ETAP-2 multi-field carrier-`i64` (wasm-gc only): the eligible
209    /// `(record, field)` bound table. Lets `carrier_project_interval` recognize
210    /// a DIRECT bounded-field read — `Project(rec, "x")` where `rec`'s stamped
211    /// type is a bounded record and `(record, "x")` is eligible — as a raw i64
212    /// leaf carrying `x`'s proven bound. The #550 storage erasure stored that
213    /// field as a native `i64`, so the `struct.get` yields i64 and the read is a
214    /// bare leaf for the surrounding arithmetic. EMPTY on the Rust backend /
215    /// whenever no bounded multi-field record is in scope — the byte-identical
216    /// default (the direct-field form then never fires).
217    pub field_carrier_intervals: FieldCarrierIntervals,
218}
219
220impl FnBareFacts {
221    /// Is the value bound to `slot` emitted as a bare `i64`?
222    pub fn is_bare(&self, slot: LocalId) -> bool {
223        self.values.get(&slot).is_some_and(ValueFact::is_bare)
224    }
225
226    /// Is param index `i` bare in the signature?
227    pub fn param_is_bare(&self, i: usize) -> bool {
228        self.bare_params.get(i).copied().unwrap_or(false)
229    }
230
231    /// The interval over-approximation of a value bound to `slot`, when the
232    /// analysis derived one and proved the slot `Bare`. `None` for a boxed
233    /// or unknown slot — the conservative decline.
234    fn bare_slot_interval(&self, slot: LocalId) -> Option<Interval> {
235        let fact = self.values.get(&slot)?;
236        if !fact.is_bare() {
237            return None;
238        }
239        fact.interval
240    }
241
242    /// ETAP-2 carrier-`i64`: the proven interval of a `.value` `Project` whose
243    /// BASE renders an eligible carrier value (wasm storage IS i64). Two base
244    /// shapes qualify, both reading a native i64:
245    ///   - a `Local` in `carrier_slots` — a bare carrier PARAM/local (the #551
246    ///     param-level form);
247    ///   - a NESTED carrier-field read — a `Project` whose result `ty()` is an
248    ///     eligible carrier type (`carrier_types`), e.g. `rec.coord` in
249    ///     `rec.coord.value`. The #550 storage erasure made `coord` a native
250    ///     `i64` field, so the inner `struct.get` yields i64 and the outer
251    ///     `.value` is identity. The general field-of-field-of-… case is
252    ///     covered: ANY base whose `ty()` is an eligible carrier renders i64.
253    ///
254    /// In both cases the `.value` read is a native i64 carrying the carrier's
255    /// smart-constructor bound, a bare leaf for the surrounding arithmetic.
256    /// `None` for any other base — the conservative, fail-closed decline (the
257    /// boxed `$AverInt` project bridge runs).
258    pub fn carrier_project_interval(&self, e: &MirExpr) -> Option<Interval> {
259        let MirExpr::Project(p) = e else {
260            return None;
261        };
262        // Param/local bare carrier slot (#551).
263        if let MirExpr::Local(local) = &p.node.base.node
264            && let Some(iv) = self.carrier_slots.get(&local.node.slot).copied()
265        {
266            return Some(iv);
267        }
268        // Multi-field direct bounded-field read: `Project(rec, "x")` where
269        // `rec`'s stamped type is a bounded record and `(record, "x")` is an
270        // eligible field. The #550 storage erasure stored `x` as a native `i64`,
271        // so the `struct.get` yields raw i64 — a bare leaf carrying `x`'s proven
272        // bound. (The base `rec` is a `Coord` struct ref, NOT itself an eligible
273        // carrier, so this does not overlap the single-field `.value` paths.)
274        if let Some(iv) = self.field_carrier_field_interval(&p.node.base, &p.node.field) {
275            return Some(iv);
276        }
277        // Nested carrier-field read: the base is any expression whose stamped
278        // type is an eligible carrier (a field read, or transitively a field of
279        // a field). The #550 storage erasure made that carrier a native `i64`,
280        // so the base renders raw i64 and its `.value` is identity. Fail-closed:
281        // if the base has no stamped type, or the type is not an eligible
282        // carrier, decline (boxed).
283        self.base_renders_eligible_carrier(&p.node.base)
284    }
285
286    /// ETAP-2 multi-field carrier-`i64`: the proven interval of field `field`
287    /// read off `base`, when `base`'s stamped type is a bounded record and
288    /// `(record, field)` is an eligible bounded field. #550 stored that field
289    /// as a native `i64`, so the `struct.get` reads raw i64 directly — a bare
290    /// leaf. `None` when `base` has no stamped record type, or the
291    /// `(record, field)` pair is not eligible / not `fits_i64` — fail-closed.
292    fn field_carrier_field_interval(
293        &self,
294        base: &Spanned<MirExpr>,
295        field: &str,
296    ) -> Option<Interval> {
297        let name = base.ty().and_then(Type::named_name)?;
298        let bare = name.rsplit_once('.').map_or(name, |(_, b)| b);
299        let (iv, known) = self
300            .field_carrier_intervals
301            .get(&(bare.to_string(), field.to_string()))
302            .copied()?;
303        (known && iv.fits_i64()).then_some(iv)
304    }
305
306    /// The proven interval of `base` when it renders an eligible carrier value
307    /// as a native `i64` — i.e. `base.ty()` is an eligible carrier type held in
308    /// `carrier_types`. This is the NESTED-field recognition: a carrier-typed
309    /// field read (`rec.coord`) was erased to an i64 field by #550, so reading
310    /// its `.value` is identity over that i64. We additionally require `base`
311    /// to be a `Project` so the recognition matches EXACTLY the i64-rendering
312    /// positions the wasm-gc emitter skips the project bridge for (a carrier
313    /// field read); a carrier-typed `Local`/`Call`/etc. base is NOT recognized
314    /// here (those go through `carrier_slots` or stay boxed) — fail-closed.
315    fn base_renders_eligible_carrier(&self, base: &Spanned<MirExpr>) -> Option<Interval> {
316        if !matches!(base.node, MirExpr::Project(_)) {
317            return None;
318        }
319        let name = base.ty().and_then(Type::named_name)?;
320        let bare = name.rsplit_once('.').map_or(name, |(_, b)| b);
321        let (iv, known) = self.carrier_types.get(bare).copied()?;
322        (known && iv.fits_i64()).then_some(iv)
323    }
324
325    /// ETAP-2 carrier-`i64`: does `e` read a bare carrier's i64 `.value`
326    /// (`Project(Local(bare_carrier_slot), _)`)? Such a read renders raw i64
327    /// on wasm-gc (the project bridge is skipped), so it is a bare leaf.
328    pub fn is_carrier_project(&self, e: &MirExpr) -> bool {
329        self.carrier_project_interval(e).is_some()
330    }
331
332    /// Compute the result interval of an EXPRESSION TREE built only from
333    /// bare leaves (`Local`s the analysis proved `Bare`, `Int` literals) and
334    /// `Add`/`Sub`/`Mul`/`Neg` nodes, using the saturating #511 interval
335    /// arithmetic. Returns `None` if any leaf is non-bare / unknown, the node
336    /// is an unsupported shape, OR any INTERMEDIATE sub-result leaves `i64` —
337    /// the conservative decline.
338    ///
339    /// This is the SINGLE SOURCE OF TRUTH for "what interval does this
340    /// inline compound evaluate to": both the analysis's `tail_value_is_bare`
341    /// and the Rust backend's `mir_expr_is_bare_i64` route a compound through
342    /// here, so neither can accept a tree whose result leaves `i64`.
343    ///
344    /// SOUNDNESS: gating only the WHOLE-TREE result is not enough — a
345    /// transient out-of-`i64` intermediate (`(n + i64::MAX) - i64::MAX`,
346    /// whose inner `Add` is `[MAX+1, …]` but whose final value narrows back
347    /// into range) would lower this node's raw-`i64` op and WRAP before the
348    /// enclosing op runs. So every node's result is checked against `i64`
349    /// here, mirroring `eval_interval`'s `worst`-join on the analysis side;
350    /// a single escaping sub-result declines the whole compound to boxed.
351    pub fn bare_expr_interval(&self, e: &MirExpr) -> Option<Interval> {
352        match e {
353            MirExpr::Literal(l) => match l.node {
354                Literal::Int(k) => Some(Interval::point(k as i128)),
355                _ => None,
356            },
357            MirExpr::Local(local) => self.bare_slot_interval(local.node.slot),
358            // ETAP-2 carrier-`i64`: a bare carrier's `.value` is a native i64
359            // leaf carrying the carrier's proven bound.
360            MirExpr::Project(_) => self.carrier_project_interval(e),
361            MirExpr::Neg(inner) => {
362                let r = Interval::point(0).sub(self.bare_expr_interval(&inner.node)?);
363                r.fits_i64().then_some(r)
364            }
365            MirExpr::BinOp(b) => {
366                let l = self.bare_expr_interval(&b.node.lhs.node)?;
367                let r = self.bare_expr_interval(&b.node.rhs.node)?;
368                let result = match b.node.op {
369                    BinOp::Add => l.add(r),
370                    BinOp::Sub => l.sub(r),
371                    BinOp::Mul => l.mul(r),
372                    _ => return None,
373                };
374                result.fits_i64().then_some(result)
375            }
376            _ => None,
377        }
378    }
379
380    /// Is `e` a bare-`i64`-eligible expression? A `Local`/`Int` leaf the
381    /// analysis proved `Bare`, or an `Add`/`Sub`/`Mul`/`Neg` tree over such
382    /// leaves WHOSE RESULT INTERVAL provably fits `i64` (every intermediate
383    /// stays `OverflowFree` under the saturating interval arithmetic).
384    ///
385    /// SOUNDNESS (BUG 2): a compound is bare ONLY when its result interval
386    /// ⊆ `i64`. An overflowing compound like `n + i64::MAX` (result
387    /// `[MAX+1, …]`, outside `i64`) is NOT bare, so codegen must emit the
388    /// boxed `AverInt` arithmetic with `from_i64` boundary conversions — the
389    /// raw-`i64` path would silently wrap (`overflow-checks = false`).
390    pub fn expr_is_bare_i64(&self, e: &MirExpr) -> bool {
391        match e {
392            // A direct bare leaf: `Local` proven `Bare`, or an `Int` literal
393            // (an exact point — its `i64`-fit is checked by the enclosing
394            // compound's interval; a standalone literal is always a sound
395            // `{N}i64` constant on a bare path the analysis already gated).
396            MirExpr::Literal(l) => matches!(l.node, Literal::Int(_)),
397            MirExpr::Local(local) => self.is_bare(local.node.slot),
398            // ETAP-2 carrier-`i64`: a bare carrier's `.value` reads raw i64.
399            MirExpr::Project(_) => self.is_carrier_project(e),
400            // A compound: require the WHOLE-TREE result interval to fit i64.
401            MirExpr::Neg(_) | MirExpr::BinOp(_) => self.bare_expr_interval(e).is_some_and(|iv| {
402                raw_i64_eligible(Some(iv), std::iter::once(&OpClass::OverflowFree))
403            }),
404            _ => false,
405        }
406    }
407}
408
409/// Whole-program representation-selection facts, keyed by `FnId`. Built
410/// once per compilation alongside the optimized `MirProgram`; the Rust
411/// backend reads a per-fn slice at signature + body emit.
412#[derive(Debug, Clone, Default)]
413pub struct BareI64Facts {
414    fns: HashMap<FnId, FnBareFacts>,
415}
416
417impl BareI64Facts {
418    /// Per-fn facts, or `None` when the fn is absent (fail-closed: the
419    /// caller then treats every value as `Boxed`).
420    pub fn for_fn(&self, id: FnId) -> Option<&FnBareFacts> {
421        self.fns.get(&id)
422    }
423
424    /// Total values proven `Bare` across the whole program — a diagnostic
425    /// counter (proof the recognizer fired), nothing in codegen keys off it.
426    pub fn bare_values(&self) -> usize {
427        self.fns
428            .values()
429            .flat_map(|f| f.values.values())
430            .filter(|v| v.is_bare())
431            .count()
432    }
433}
434
435/// Entry point: compute the bare-`i64` representation facts for `program`.
436///
437/// The analysis is whole-program: a tail-recursion counter that crosses a
438/// self-tail-call frame can only go bare if the param it crosses to is
439/// ALSO bare, so the per-fn param/return summary is computed against the
440/// visible `MirCallee::Fn` / `MirTailCall` call graph. A dependency-module
441/// fragment (callers unseen) bails to all-`Boxed`, exactly like
442/// `own_param`.
443pub fn analyze(program: &MirProgram, carrier: &CarrierIntervals) -> BareI64Facts {
444    analyze_with_fields(program, carrier, &FieldCarrierIntervals::new())
445}
446
447/// [`analyze`] plus the multi-field carrier table (`field_carrier`): the
448/// wasm-gc entry threads the eligible `(record, field)` bounds so a DIRECT
449/// bounded-field read renders as a raw i64 leaf. Every other caller goes
450/// through [`analyze`] (empty field table) and keeps the byte-identical
451/// pre-slice behavior.
452pub fn analyze_with_fields(
453    program: &MirProgram,
454    carrier: &CarrierIntervals,
455    field_carrier: &FieldCarrierIntervals,
456) -> BareI64Facts {
457    // Diagnostic / bench-differential escape hatch: skip the analysis so a
458    // run keeps the conservative all-Boxed baseline.
459    if std::env::var("AVER_NO_BARE_I64").is_ok() {
460        return BareI64Facts::default();
461    }
462    // Whole-program gate: a bare param/return changes a fn's Rust ABI, so
463    // EVERY caller must be visible to convert at the boundary. A
464    // dependency-module fragment is missing the entry/sibling call sites,
465    // so bail to all-Boxed.
466    if program.external_callers_possible || program.modules.len() > 1 {
467        return BareI64Facts::default();
468    }
469
470    // Param Int-typedness per fn (a bare param must itself be `Int`).
471    let mut int_params: HashMap<FnId, Vec<bool>> = HashMap::new();
472    for (id, f) in program.iter() {
473        int_params.insert(*id, f.params.iter().map(|p| ty_str_is_int(&p.ty)).collect());
474    }
475
476    // The minimal cross-frame summary: which params are bare, whether the
477    // return is bare. The field table only adds NEW bare leaves (a direct
478    // bounded-field read), which the body pass reads via the per-fn facts; the
479    // cross-frame param/return summary keys off `carrier` exactly as before.
480    let summary = compute_summary(program, &int_params, carrier, field_carrier);
481
482    let mut fns: HashMap<FnId, FnBareFacts> = HashMap::new();
483    for (id, f) in program.iter() {
484        fns.insert(*id, analyze_fn(f, &summary, carrier, field_carrier));
485    }
486
487    BareI64Facts { fns }
488}
489
490/// Cross-frame summary: per-fn, which params are bare and whether the
491/// return is bare. Computed as a monotone-descending fixpoint (a param /
492/// return starts optimistically bare, demoted to boxed when any
493/// constraint fails), mirroring `own_param`'s lattice.
494struct Summary {
495    bare_params: HashMap<FnId, Vec<bool>>,
496    bare_return: HashMap<FnId, bool>,
497    /// Per-param TIGHT recurrence interval (`compute_bare_param_intervals`),
498    /// so the body pass can seed a bare counter with its PROVEN range
499    /// (`[K-step, entry]`) instead of the full `i64` line. This is what keeps
500    /// `n - 1` / `acc * n` over a bare counter in the OverflowFree band: a
501    /// full-`i64` seed makes every `+`/`-`/`*` over the counter look like it
502    /// could overflow, demoting the fast decrement to a boxed round-trip. A
503    /// `None` entry (or a missing param) falls back to the full-`i64` seed.
504    bare_param_intervals: HashMap<FnId, Vec<Option<Interval>>>,
505}
506
507impl Summary {
508    fn param_bare(&self, id: FnId, i: usize) -> bool {
509        self.bare_params
510            .get(&id)
511            .and_then(|v| v.get(i).copied())
512            .unwrap_or(false)
513    }
514
515    fn return_bare(&self, id: FnId) -> bool {
516        self.bare_return.get(&id).copied().unwrap_or(false)
517    }
518
519    /// The proven recurrence interval for param `i` of `id`, if one was
520    /// derived. `None` ⇒ the body pass seeds the full `i64` range.
521    fn param_interval(&self, id: FnId, i: usize) -> Option<Interval> {
522        self.bare_param_intervals
523            .get(&id)
524            .and_then(|v| v.get(i).copied())
525            .flatten()
526    }
527}
528
529fn compute_summary(
530    program: &MirProgram,
531    int_params: &HashMap<FnId, Vec<bool>>,
532    carrier: &CarrierIntervals,
533    field_carrier: &FieldCarrierIntervals,
534) -> Summary {
535    // Address-taken fns (name appears as a `FnValue`) have callers we
536    // cannot attribute — pin all their params/return boxed.
537    let mut address_taken: HashSet<String> = HashSet::new();
538    for (_, f) in program.iter() {
539        collect_fn_values(&f.body.node, &mut address_taken);
540    }
541
542    // Seed: optimistic. A param is candidate-bare iff it is Int-typed and
543    // the fn is not externally reachable (main / address-taken). The
544    // return is candidate-bare iff Int-typed and not externally reachable.
545    let mut bare_params: HashMap<FnId, Vec<bool>> = HashMap::new();
546    let mut bare_return: HashMap<FnId, bool> = HashMap::new();
547    for (id, f) in program.iter() {
548        let externally_reachable = f.name == "main" || address_taken.contains(&f.name);
549        let ip = &int_params[id];
550        let seed: Vec<bool> = ip.iter().map(|&i| i && !externally_reachable).collect();
551        bare_params.insert(*id, seed);
552        bare_return.insert(*id, ty_str_is_int(&f.return_type) && !externally_reachable);
553    }
554
555    // Collect visible call edges (`Call(Fn)` + `TailCall`) with their args.
556    let mut edges: Vec<CallEdge> = Vec::new();
557    for (caller, f) in program.iter() {
558        collect_call_edges(*caller, &f.body.node, &mut edges);
559    }
560
561    // A fn's return may be bare only when it has at least one visible
562    // NON-tail (ordinary `Call(Fn)`) caller — a fn with no such caller is
563    // externally reachable (a library entry), so changing its return ABI
564    // to `i64` would break an unseen caller. (A self-tail-call is the
565    // recurrence edge, not an external consumer, so it does not count.)
566    let mut has_ordinary_caller: HashSet<FnId> = HashSet::new();
567    for edge in &edges {
568        if !edge.is_tail_self() {
569            has_ordinary_caller.insert(edge.target);
570        }
571    }
572    for (id, br) in bare_return.iter_mut() {
573        if !has_ordinary_caller.contains(id) {
574            *br = false;
575        }
576    }
577
578    // Tight per-param recurrence intervals, computed ONCE: they are a pure
579    // function of the recurrence shape + literal entry callers, independent
580    // of the fixpoint's escape/return state, so they stay valid across
581    // iterations. A bare counter is seeded with this proven range in the
582    // body pass instead of the full `i64` line, keeping `n - 1` / `acc * n`
583    // OverflowFree (see `Summary::bare_param_intervals`).
584    //
585    // Produced by a SOUND guard-seeded interval FIXPOINT over the call graph
586    // (`compute_bare_param_intervals`): the entry literals seed an interval
587    // per `(fn, param)`, every self-tail back-edge is JOINED as a recurrence
588    // transfer, an equality-decrement guard installs a convex floor, and
589    // widening guarantees termination. Any unrecognized / unfloored shape
590    // widens out of `i64` and maps to `None` (boxed). Drop-in: the output
591    // type and both readers (condition B + the `analyze_fn` seed) are
592    // unchanged.
593    let bare_param_intervals = compute_bare_param_intervals(program, &edges, &address_taken);
594
595    loop {
596        let mut changed = false;
597
598        // (A) A bare callee param stays bare only if every caller can
599        //     supply a bare/literal/bounded value at that position.
600        for edge in &edges {
601            let Some(callee_params) = bare_params.get(&edge.target).cloned() else {
602                continue;
603            };
604            for (i, arg) in edge.args.iter().enumerate() {
605                if !callee_params.get(i).copied().unwrap_or(false) {
606                    continue; // already boxed — skip
607                }
608                let ok =
609                    arg_supplies_bare(&arg.node, edge.caller, program, &bare_params, &bare_return);
610                if !ok {
611                    demote_param(&mut bare_params, edge.target, i, &mut changed);
612                }
613            }
614        }
615
616        // (B) The counter must remain in `i64` range across the recurrence
617        //     and the literal-bounded entry. A param is bare only when its
618        //     derived interval (precomputed in `bare_param_intervals`)
619        //     provably fits `i64`.
620        for (id, _f) in program.iter() {
621            let ip = &int_params[id];
622            for (i, &is_int) in ip.iter().enumerate() {
623                if !bare_params[id].get(i).copied().unwrap_or(false) {
624                    continue;
625                }
626                if !is_int {
627                    demote_param(&mut bare_params, *id, i, &mut changed);
628                    continue;
629                }
630                let bound = bare_param_intervals
631                    .get(id)
632                    .and_then(|v| v.get(i).copied())
633                    .flatten();
634                let eligible = bound.is_some_and(|iv| {
635                    raw_i64_eligible(Some(iv), std::iter::once(&OpClass::OverflowFree))
636                });
637                if !eligible {
638                    demote_param(&mut bare_params, *id, i, &mut changed);
639                }
640            }
641        }
642
643        // (B2) A bare PARAM must not ESCAPE in its own body. If the counter
644        //      flows into a general-Int context (a boxed callee param, a
645        //      builtin like `Float.fromInt(n)`, an aggregate, or a
646        //      stringify), its representation must be `AverInt` — emitting
647        //      a bare `i64` param whose body reads it through an `AverInt`
648        //      method is a `rustc` type error (and a missed conversion).
649        //      This couples the signature (driven by `bare_params`) to the
650        //      body's escape predicate so the two never disagree. The
651        //      escape set depends on the current bare-param seed (a bare
652        //      callee param does not escape its arg), so recompute it inside
653        //      the fixpoint; demotion is monotone.
654        let tmp = Summary {
655            bare_params: bare_params.clone(),
656            bare_return: bare_return.clone(),
657            bare_param_intervals: bare_param_intervals.clone(),
658        };
659        for (id, f) in program.iter() {
660            let mut escaping: HashSet<LocalId> = HashSet::new();
661            scan_escapes(&f.body.node, &tmp, &mut escaping);
662            for (i, p) in f.params.iter().enumerate() {
663                if bare_params[id].get(i).copied().unwrap_or(false) && escaping.contains(&p.local) {
664                    demote_param(&mut bare_params, *id, i, &mut changed);
665                }
666            }
667        }
668
669        // (C) The return is bare only when the body's tail value is itself
670        //     bare-eligible. Recompute the body facts against the current
671        //     seed and demote when the body says the return cannot be bare.
672        for (id, f) in program.iter() {
673            if !bare_return.get(id).copied().unwrap_or(false) {
674                continue;
675            }
676            let tmp = Summary {
677                bare_params: bare_params.clone(),
678                bare_return: bare_return.clone(),
679                bare_param_intervals: bare_param_intervals.clone(),
680            };
681            let facts = analyze_fn(f, &tmp, carrier, field_carrier);
682            if !facts.bare_return && bare_return.insert(*id, false) != Some(false) {
683                changed = true;
684            }
685        }
686
687        // (C2) A fn's return is bare only if EVERY caller consumes the
688        //      result in an i64-safe position. A bare `i64` return value
689        //      consumed in a general-Int context (an `AverInt`-method
690        //      arithmetic operand, a boxed callee param, an aggregate
691        //      field) would be a `rustc` type error and a missed
692        //      conversion. We do NOT insert per-call-site return
693        //      conversions in this slice, so the conservative rule is:
694        //      mark `bare_return` only when every call result is discarded,
695        //      stringified, or fed where a bare value is accepted (a bare
696        //      param). Any other use demotes the callee's return. This is
697        //      what keeps factorial/countdown (their results are discarded
698        //      or stringified by `main`) bare while a return flowing into
699        //      arithmetic stays boxed.
700        let unsafe_returns = collect_unsafe_return_consumers(program, &bare_params);
701        for id in &unsafe_returns {
702            if bare_return.get(id).copied().unwrap_or(false)
703                && bare_return.insert(*id, false) != Some(false)
704            {
705                changed = true;
706            }
707        }
708
709        // (C3) A bare-returning callee whose result is bound to a `let` is
710        //      safe ONLY when the binding slot is itself bare (a fresh `i64`
711        //      that stays raw). If the binding is BOXED — its later uses
712        //      escape into a general-Int context (`from.x + dx`, an aggregate,
713        //      a boxed param) so `analyze_fn` declined it — then storing the
714        //      raw `i64` call result into the `AverInt` slot is unsound: a
715        //      `rustc` type error on Rust, a wasm VALIDATION error on wasm-gc
716        //      (no per-store coercion). The C2 scan deliberately does NOT flag
717        //      a `let`-bound call (the binding's uses are scanned as their own
718        //      positions), but a `Local` use never flags its originating call,
719        //      so this binding-aware step closes that gap: recompute each
720        //      caller's per-slot facts and demote any bare-returning callee
721        //      bound to a non-bare slot. (Mirror of C2's discipline, keyed on
722        //      the binding repr instead of the consumer position.)
723        let tmp = Summary {
724            bare_params: bare_params.clone(),
725            bare_return: bare_return.clone(),
726            bare_param_intervals: bare_param_intervals.clone(),
727        };
728        for (_caller, f) in program.iter() {
729            let facts = analyze_fn(f, &tmp, carrier, field_carrier);
730            let mut demote: Vec<FnId> = Vec::new();
731            collect_let_bound_boxed_returns(&f.body.node, &facts, &bare_return, &mut demote);
732            for target in demote {
733                if bare_return.get(&target).copied().unwrap_or(false)
734                    && bare_return.insert(target, false) != Some(false)
735                {
736                    changed = true;
737                }
738            }
739        }
740
741        if !changed {
742            break;
743        }
744    }
745
746    Summary {
747        bare_params,
748        bare_return,
749        bare_param_intervals,
750    }
751}
752
753/// Walk `e`, collecting any `Call(Fn(target))` that is the immediate VALUE
754/// of a `Let` whose binding slot the caller's per-slot analysis (`facts`)
755/// declined to make bare, AND whose `target` the summary currently marks
756/// `bare_return`. Such a callee's raw `i64` result would be stored into a
757/// boxed `AverInt` binding slot with no boundary conversion — unsound. The
758/// fixpoint demotes each collected `target`'s `bare_return` (C3).
759fn collect_let_bound_boxed_returns(
760    e: &MirExpr,
761    facts: &FnBareFacts,
762    bare_return: &HashMap<FnId, bool>,
763    out: &mut Vec<FnId>,
764) {
765    if let MirExpr::Let(l) = e
766        && let MirExpr::Call(c) = &l.node.value.node
767        && let MirCallee::Fn(target) = c.node.callee
768        && bare_return.get(&target).copied().unwrap_or(false)
769        && !facts.is_bare(l.node.binding)
770    {
771        out.push(target);
772    }
773    visit_children(e, &mut |c| {
774        collect_let_bound_boxed_returns(c, facts, bare_return, out)
775    });
776}
777
778/// The set of callee `FnId`s whose return value is consumed in at least
779/// one i64-UNSAFE position by some caller. A bare `i64` return reaching
780/// such a position (an `AverInt` arithmetic operand, a boxed callee param,
781/// an aggregate field, a stringify-incompatible context) would not
782/// type-check, and this slice inserts no per-call-site return conversion —
783/// so any unsafe consumer demotes the callee's `bare_return`.
784///
785/// SAFE consumers (do NOT demote): a discarded result (a `Let` whose
786/// binding is never re-read as a boxed value — approximated as: the call
787/// is the whole RHS and the binding is a fresh i64), a stringify
788/// (`String.fromInt` / interpolation embed), the direct tail value of a
789/// fn whose own return is bare, and an argument at a bare callee param
790/// index. Everything else is unsafe.
791fn collect_unsafe_return_consumers(
792    program: &MirProgram,
793    bare_params: &HashMap<FnId, Vec<bool>>,
794) -> HashSet<FnId> {
795    let mut unsafe_set: HashSet<FnId> = HashSet::new();
796    for (_, f) in program.iter() {
797        scan_return_consumers(&f.body.node, program, bare_params, &mut unsafe_set);
798    }
799    unsafe_set
800}
801
802/// Walk `e`, flagging every `Call(Fn(target))` whose RESULT lands in an
803/// i64-unsafe position. A call reached as a direct child of a SAFE position
804/// (a discard statement, a stringify, a bare param arg, a tail/return that
805/// is itself bare) is not flagged; one reached anywhere else IS. We
806/// approximate by walking each node and classifying the position of any
807/// DIRECT `Call(Fn)` child; nested calls are handled when the walk reaches
808/// their own parent.
809fn scan_return_consumers(
810    e: &MirExpr,
811    program: &MirProgram,
812    bare_params: &HashMap<FnId, Vec<bool>>,
813    unsafe_set: &mut HashSet<FnId>,
814) {
815    // Helper: flag a direct `Call(Fn)` child as unsafe (its result is
816    // consumed in a general-Int position here).
817    let flag_if_call = |child: &MirExpr, unsafe_set: &mut HashSet<FnId>| {
818        if let MirExpr::Call(c) = child
819            && let MirCallee::Fn(t) = c.node.callee
820        {
821            unsafe_set.insert(t);
822        }
823    };
824
825    match e {
826        // Arithmetic / negation operands are i64-UNSAFE for a boxed result
827        // (the boxed-path emit calls `AverInt` methods on the operand).
828        MirExpr::BinOp(b) => {
829            flag_if_call(&b.node.lhs.node, unsafe_set);
830            flag_if_call(&b.node.rhs.node, unsafe_set);
831        }
832        MirExpr::Neg(inner) => flag_if_call(&inner.node, unsafe_set),
833        // Aggregate fields/elements are general-Int contexts.
834        MirExpr::List(items) | MirExpr::Tuple(items) => {
835            for it in items {
836                flag_if_call(&it.node, unsafe_set);
837            }
838        }
839        MirExpr::MapLiteral(pairs) => {
840            for (k, v) in pairs {
841                flag_if_call(&k.node, unsafe_set);
842                flag_if_call(&v.node, unsafe_set);
843            }
844        }
845        MirExpr::Construct(c) => {
846            for a in &c.node.args {
847                flag_if_call(&a.node, unsafe_set);
848            }
849        }
850        MirExpr::RecordCreate(r) => {
851            for fld in &r.node.fields {
852                flag_if_call(&fld.value.node, unsafe_set);
853            }
854        }
855        MirExpr::RecordUpdate(u) => {
856            flag_if_call(&u.node.base.node, unsafe_set);
857            for fld in &u.node.updates {
858                flag_if_call(&fld.value.node, unsafe_set);
859            }
860        }
861        // A call's args: a call result passed at a BOXED callee param index
862        // is unsafe; at a BARE index it is safe (the param accepts i64). A
863        // builtin/intrinsic/fn-value callee that takes Int args treats the
864        // result as a general Int (e.g. `String.fromInt` reads the value) —
865        // those are SAFE for stringify but UNSAFE for arithmetic builtins.
866        // We conservatively treat only `String.fromInt` and interpolation
867        // (handled below) as the stringify-safe sinks; every other builtin
868        // Int arg is unsafe.
869        MirExpr::Call(c) => match c.node.callee {
870            MirCallee::Fn(target) => {
871                for (i, a) in c.node.args.iter().enumerate() {
872                    let bare_param = bare_params
873                        .get(&target)
874                        .and_then(|v| v.get(i).copied())
875                        .unwrap_or(false);
876                    if !bare_param {
877                        flag_if_call(&a.node, unsafe_set);
878                    }
879                }
880            }
881            MirCallee::Builtin(bid) => {
882                let name = program.builtin_name(bid);
883                // `String.fromInt(x)` stringifies its arg — `x.to_string()`
884                // works on both `i64` and `AverInt`, so a bare return here
885                // is SAFE. Every other builtin reads the Int generally.
886                if name != "String.fromInt" {
887                    for a in &c.node.args {
888                        flag_if_call(&a.node, unsafe_set);
889                    }
890                }
891            }
892            MirCallee::Intrinsic(_) | MirCallee::LocalSlot { .. } => {
893                for a in &c.node.args {
894                    flag_if_call(&a.node, unsafe_set);
895                }
896            }
897        },
898        // A `TailCall`'s args carry the SAME consumer discipline as a
899        // `Call(Fn)`: a call result passed at the tail-callee's BOXED param
900        // index is consumed in a general-Int (`AverInt`) position and so is
901        // unsafe; at a BARE param it is safe. Without this arm a bare-
902        // returning call used as a tail-call arg at a boxed param
903        // (`loop(i - 1, signOf(i))`, `signOf` bare-return, `loop`'s `acc`
904        // boxed) was NEVER flagged, so `signOf.bare_return` stayed true and
905        // its raw `i64` result flowed into the `AverInt` param — a silent
906        // wrong value on Rust, a wasm VALIDATION error on wasm-gc (where
907        // there is no per-call coercion). Mirror of the `MirCallee::Fn` arm.
908        MirExpr::TailCall(tc) => {
909            for (i, a) in tc.node.args.iter().enumerate() {
910                let bare_param = bare_params
911                    .get(&tc.node.target)
912                    .and_then(|v| v.get(i).copied())
913                    .unwrap_or(false);
914                if !bare_param {
915                    flag_if_call(&a.node, unsafe_set);
916                }
917            }
918        }
919        // Interpolation embeds stringify their values — `to_string()` works
920        // on `i64` too, so a call result there is SAFE (do not flag).
921        // A `Let` whose VALUE is a call: the binding holds the result; we
922        // cannot cheaply prove the binding is only re-read safely, so be
923        // conservative and DO NOT flag here (the binding's later uses are
924        // scanned as their own positions). This keeps the discard /
925        // tail-return case bare while still flagging direct arithmetic /
926        // aggregate / boxed-param uses above.
927        _ => {}
928    }
929
930    // Recurse into all children to catch nested consumer positions.
931    visit_children(e, &mut |c| {
932        scan_return_consumers(c, program, bare_params, unsafe_set)
933    });
934}
935
936fn demote_param(
937    bare_params: &mut HashMap<FnId, Vec<bool>>,
938    id: FnId,
939    i: usize,
940    changed: &mut bool,
941) {
942    if let Some(v) = bare_params.get_mut(&id)
943        && let Some(slot) = v.get_mut(i)
944        && *slot
945    {
946        *slot = false;
947        *changed = true;
948    }
949}
950
951/// A visible call edge `target(args…)` made from `caller`. `tail_self` is
952/// `true` for a `MirTailCall` whose target is the caller itself (the
953/// recurrence edge).
954struct CallEdge {
955    target: FnId,
956    caller: FnId,
957    args: Vec<Spanned<MirExpr>>,
958    tail_self: bool,
959}
960
961impl CallEdge {
962    fn is_tail_self(&self) -> bool {
963        self.tail_self
964    }
965}
966
967fn collect_call_edges(caller: FnId, e: &MirExpr, out: &mut Vec<CallEdge>) {
968    match e {
969        MirExpr::Call(c) => {
970            if let MirCallee::Fn(target) = c.node.callee {
971                out.push(CallEdge {
972                    target,
973                    caller,
974                    args: c.node.args.clone(),
975                    tail_self: false,
976                });
977            }
978        }
979        MirExpr::TailCall(tc) => {
980            out.push(CallEdge {
981                target: tc.node.target,
982                caller,
983                args: tc.node.args.clone(),
984                tail_self: tc.node.target == caller,
985            });
986        }
987        _ => {}
988    }
989    visit_children(e, &mut |c| collect_call_edges(caller, c, out));
990}
991
992/// Can the call argument `arg` (evaluated in `caller`) supply a bare /
993/// bounded value at a bare callee param?
994///
995/// - A literal `Int` is always supplyable bare.
996/// - The recurrence arithmetic `n - 1`, `acc * n`, `acc + n` … over bare
997///   operands stays bare (the `i64`-bound is checked separately).
998/// - A read of a caller param that is itself bare is supplyable.
999/// - A call to another fn whose return is bare supplies bare.
1000/// - Anything else cannot be supplied bare ⇒ the callee param demotes.
1001fn arg_supplies_bare(
1002    arg: &MirExpr,
1003    caller: FnId,
1004    program: &MirProgram,
1005    bare_params: &HashMap<FnId, Vec<bool>>,
1006    bare_return: &HashMap<FnId, bool>,
1007) -> bool {
1008    match arg {
1009        MirExpr::Literal(l) => matches!(l.node, Literal::Int(_)),
1010        MirExpr::Neg(inner) => {
1011            arg_supplies_bare(&inner.node, caller, program, bare_params, bare_return)
1012        }
1013        MirExpr::Local(local) => local_supplies_bare(local.node.slot, caller, program, bare_params),
1014        MirExpr::BinOp(b) if matches!(b.node.op, BinOp::Add | BinOp::Sub | BinOp::Mul) => {
1015            arg_supplies_bare(&b.node.lhs.node, caller, program, bare_params, bare_return)
1016                && arg_supplies_bare(&b.node.rhs.node, caller, program, bare_params, bare_return)
1017        }
1018        MirExpr::Call(c) => match c.node.callee {
1019            MirCallee::Fn(target) => bare_return.get(&target).copied().unwrap_or(false),
1020            _ => false,
1021        },
1022        _ => false,
1023    }
1024}
1025
1026/// Is the caller's slot `slot` a bare param? (Let-bound locals are not
1027/// tracked across frames in this minimal summary — they demote.)
1028fn local_supplies_bare(
1029    slot: LocalId,
1030    caller: FnId,
1031    program: &MirProgram,
1032    bare_params: &HashMap<FnId, Vec<bool>>,
1033) -> bool {
1034    let Some(f) = program.fn_by_id(caller) else {
1035        return false;
1036    };
1037    for (i, p) in f.params.iter().enumerate() {
1038        if p.local == slot {
1039            return bare_params
1040                .get(&caller)
1041                .and_then(|v| v.get(i).copied())
1042                .unwrap_or(false);
1043        }
1044    }
1045    false
1046}
1047
1048// ── The guard-seeded interval FIXPOINT (the bound producer) ──────────────
1049//
1050// `compute_bare_param_intervals` replaces the hand-rolled closed-form
1051// recurrence recognizer with a SOUND per-`(FnId, param-index)` interval
1052// fixpoint over the call graph. The output type and both consumers are
1053// unchanged (drop-in): condition B and the `analyze_fn` seed read the same
1054// `HashMap<FnId, Vec<Option<Interval>>>`.
1055//
1056// SOUNDNESS (the C0 obligation). Every interval is built bottom-up from
1057// `Interval::unbounded()` / literal points using ONLY:
1058//   - `hull` (the entry-seed join and the per-round state join) — enlarging,
1059//   - `widen` (the termination operator) — enlarging (superset of `next`),
1060//   - the single `intersect` with the equality-decrement `floor` — the ONLY
1061//     narrowing, and it is narrowed against a convex bound that is
1062//     PATH-GUARANTEED-TRUE on every recursive iteration (a descending
1063//     counter whose reachability gate proves it lands exactly on `K`, so it
1064//     is `>= K-step` on every back-edge — see `guard_floor`).
1065// So a cell mapped to `Some(iv)` with `iv.fits_i64()` is a genuine superset
1066// of the param's real reachable value-set, hence `⊆ i64`; any unrecognized
1067// or unfloored shape widens to ±inf ⇒ `None` ⇒ boxed (lose speed, never a
1068// wrong value). The floor is the only trusted, locally-auditable obligation.
1069
1070/// The number of join rounds before widening kicks in. With the every-round
1071/// floor a countdown/factorial counter reaches its fixpoint within `UNROLL`
1072/// rounds, so widen only ever fires on a genuinely-unbounded endpoint.
1073const UNROLL: usize = 2;
1074
1075/// Per-`(FnId, param-index)` interval fixpoint. Replaces the closed-form
1076/// `param_recurrence_bound`. Produces the SAME output map (drop-in): for each
1077/// param cell, `Some(iv)` when the solved interval `fits_i64`, else `None`.
1078///
1079/// The mechanics, per non-pinned `(f, i)` (see `solve_scc`):
1080///   1. SEED = hull of every entry literal at index `i` over NON-self-tail
1081///      caller edges (the entry envelope). No literal entry, or a non-literal
1082///      entry arg, or no entry caller ⇒ ⊤ ⇒ `None` (boxed).
1083///   2. FLOOR = the gated equality-decrement convex bound (`guard_floor`),
1084///      or ⊤ when any of the four preconditions fails.
1085///   3. Iterate the SCC worklist: every round joins ALL self-tail back-edges
1086///      (so a second growing path can never be missed), meets the floor, and
1087///      widens once `round >= UNROLL`.
1088///
1089/// Pinned to ⊤ (→ `None`): every param of `main` / an address-taken fn (its
1090/// callers are unknowable, so its entry envelope is unknown).
1091fn compute_bare_param_intervals(
1092    program: &MirProgram,
1093    edges: &[CallEdge],
1094    address_taken: &HashSet<String>,
1095) -> HashMap<FnId, Vec<Option<Interval>>> {
1096    // State: one interval cell per param; ⊤ = `unbounded()`.
1097    let mut state: HashMap<FnId, Vec<Interval>> = HashMap::new();
1098    // Which `(fn, param)` cells are PINNED to ⊤ (externally-reachable fns —
1099    // callers unknowable, so the entry envelope is unknown). Mirrors the
1100    // existing seed at `compute_summary` (`f.name == "main" || address_taken`).
1101    let mut pinned: HashMap<FnId, Vec<bool>> = HashMap::new();
1102    for (id, f) in program.iter() {
1103        let externally_reachable = f.name == "main" || address_taken.contains(&f.name);
1104        state.insert(*id, vec![Interval::unbounded(); f.params.len()]);
1105        pinned.insert(*id, vec![externally_reachable; f.params.len()]);
1106    }
1107
1108    // Per-`(fn, param)` floor + entry seed, computed ONCE (pure functions of
1109    // the recurrence shape + literal entries, independent of the iteration).
1110    let mut floors: HashMap<FnId, Vec<Interval>> = HashMap::new();
1111    let mut seeds: HashMap<FnId, Vec<Interval>> = HashMap::new();
1112    for (id, f) in program.iter() {
1113        let mut fv = Vec::with_capacity(f.params.len());
1114        let mut sv = Vec::with_capacity(f.params.len());
1115        for i in 0..f.params.len() {
1116            fv.push(guard_floor(f, i, edges));
1117            sv.push(seed_interval(f, i, edges));
1118        }
1119        floors.insert(*id, fv);
1120        seeds.insert(*id, sv);
1121    }
1122
1123    // Initialise the iterate at the SEED (the entry envelope) for every
1124    // non-pinned cell — this is the BOTTOM of the ascending Kleene iteration:
1125    // the join (`hull`) only ENLARGES, so starting at ⊤ would stay ⊤ forever.
1126    // The recurrence transfer then joins the floored back-edge descent into
1127    // this seed each round. A cell whose seed is already ⊤ (unbounded entry)
1128    // stays ⊤ ⇒ `None` (boxed). Pinned cells keep their ⊤ seed.
1129    for (id, f) in program.iter() {
1130        for i in 0..f.params.len() {
1131            if !pinned[id][i] {
1132                state.get_mut(id).unwrap()[i] = seeds[id][i];
1133            }
1134        }
1135    }
1136
1137    // Iterate over SCCs of the static caller→callee graph, callee-before-
1138    // caller (each SCC runs its own local worklist to a fixpoint). Self-
1139    // recursive counters (countdown/factorial) are size-1 SCCs with a
1140    // self-edge; the transfer for index `i` reads only that cell's own state,
1141    // so the per-SCC fixpoint is self-contained.
1142    let nodes: Vec<FnId> = program.iter().map(|(id, _)| *id).collect();
1143    let mut graph: HashMap<FnId, Vec<FnId>> = HashMap::new();
1144    for edge in edges {
1145        graph.entry(edge.caller).or_default().push(edge.target);
1146    }
1147    // `tarjan_sccs` returns components ordered by least member; the static
1148    // call graph is a DAG across components, but the transfer never reads
1149    // another fn's cell in this lean scope, so any order converges. Process
1150    // each SCC's local fixpoint independently.
1151    for scc in crate::scc::tarjan_sccs::<FnId>(&nodes, &graph) {
1152        solve_scc(program, edges, &scc, &seeds, &floors, &pinned, &mut state);
1153    }
1154
1155    // Final mapping: `Some(iv)` ONLY for a param that carries a recognized
1156    // equality-decrement recurrence (a non-⊤ floor) AND whose solved interval
1157    // `fits_i64`; otherwise `None` (boxed). The floor gate is what keeps this
1158    // BYTE-IDENTICAL to the old closed-form producer: the old code returned a
1159    // bound only when `recurrence_for_param` recognized the recurrence AND the
1160    // reachability gate held — exactly the cells whose `guard_floor` is non-⊤.
1161    // A non-recurrent param with a finite literal seed (e.g. `twice(10)`,
1162    // `n + n`) would be a SOUND-but-NEW win; Phase A withholds it (boxes, as
1163    // today) so the swap is observably identical on every known shape.
1164    let top = Interval::unbounded();
1165    let mut out: HashMap<FnId, Vec<Option<Interval>>> = HashMap::new();
1166    for (id, f) in program.iter() {
1167        let cells = &state[id];
1168        let fl = &floors[id];
1169        let ivs: Vec<Option<Interval>> = (0..f.params.len())
1170            .map(|i| {
1171                let iv = cells[i];
1172                if fl[i] != top && iv.fits_i64() {
1173                    Some(iv)
1174                } else {
1175                    None
1176                }
1177            })
1178            .collect();
1179        out.insert(*id, ivs);
1180    }
1181    out
1182}
1183
1184/// Test-only: produce the bare-`i64` param intervals for `program`, rebuilding
1185/// the `edges` + `address_taken` inputs the same way `compute_summary` does.
1186/// Lets the golden tests assert the produced interval VALUE (byte-identity),
1187/// not just `param_is_bare`.
1188#[cfg(test)]
1189fn compute_param_intervals_for_test(program: &MirProgram) -> HashMap<FnId, Vec<Option<Interval>>> {
1190    let mut address_taken: HashSet<String> = HashSet::new();
1191    for (_, f) in program.iter() {
1192        collect_fn_values(&f.body.node, &mut address_taken);
1193    }
1194    let mut edges: Vec<CallEdge> = Vec::new();
1195    for (caller, f) in program.iter() {
1196        collect_call_edges(*caller, &f.body.node, &mut edges);
1197    }
1198    compute_bare_param_intervals(program, &edges, &address_taken)
1199}
1200
1201/// Run the per-`(fn, param)` interval worklist for one SCC to a fixpoint.
1202/// Copies the `compute_summary` `loop { changed=false; …; if !changed break }`
1203/// template. Pinned cells (externally-reachable fns) stay ⊤.
1204fn solve_scc(
1205    program: &MirProgram,
1206    edges: &[CallEdge],
1207    scc: &[FnId],
1208    seeds: &HashMap<FnId, Vec<Interval>>,
1209    floors: &HashMap<FnId, Vec<Interval>>,
1210    pinned: &HashMap<FnId, Vec<bool>>,
1211    state: &mut HashMap<FnId, Vec<Interval>>,
1212) {
1213    let mut round = 0usize;
1214    loop {
1215        let mut changed = false;
1216        for &fid in scc {
1217            let Some(f) = program.fn_by_id(fid) else {
1218                continue;
1219            };
1220            for i in 0..f.params.len() {
1221                if pinned[&fid][i] {
1222                    continue; // externally reachable — stays ⊤.
1223                }
1224                let floor = floors[&fid][i];
1225                let seed = seeds[&fid][i];
1226
1227                // The recurrence transfer: JOIN every self-tail back-edge's
1228                // arg at index `i`, evaluated under the caller env where each
1229                // counter slot is its CURRENT state interval, the floor
1230                // applied. Joining ALL back-edges is what makes the multi-
1231                // back-edge (#538) class structurally impossible — a second
1232                // growing path is hulled in, never skipped.
1233                let mut back: Option<Interval> = None;
1234                for edge in edges {
1235                    if edge.target != fid || !edge.is_tail_self() {
1236                        continue;
1237                    }
1238                    let Some(arg) = edge.args.get(i) else {
1239                        continue;
1240                    };
1241                    let env = caller_env(f, &state[&fid], floors[&fid].as_slice());
1242                    let iv = eval_interval_pub(&arg.node, &env);
1243                    back = Some(match back {
1244                        Some(prev) => prev.hull(iv),
1245                        None => iv,
1246                    });
1247                }
1248
1249                // incoming = (seed ⊔ back) ∩ floor — the floor meet EVERY
1250                // round caps the bounded side so widen never fires on it.
1251                let pre = match back {
1252                    Some(b) => seed.hull(b),
1253                    None => seed,
1254                };
1255                let incoming = pre.intersect(floor);
1256                let joined = state[&fid][i].hull(incoming);
1257                let next = if round < UNROLL {
1258                    joined
1259                } else {
1260                    state[&fid][i].widen(joined)
1261                };
1262                if next != state[&fid][i] {
1263                    state.get_mut(&fid).unwrap()[i] = next;
1264                    changed = true;
1265                }
1266            }
1267        }
1268        round += 1;
1269        if !changed {
1270            break;
1271        }
1272    }
1273}
1274
1275/// Build the caller env for the recurrence transfer. A FLOORED param (its
1276/// `guard_floor` is a real convex bound, not ⊤) is read at its FLOOR — the
1277/// path-guaranteed-true superset of that counter's value-set on the recursive
1278/// branch — so `counter - step` is evaluated over the bounded counter and the
1279/// descent caps at the floor in a single round (no per-round march that widen
1280/// would blow to ±inf). A param with NO floor (⊤) is read at its current
1281/// iterate `state[i]` (the seed-grown value); the floor-intersect there is a
1282/// no-op (⊤), so it stays the raw state.
1283fn caller_env(f: &MirFn, cells: &[Interval], floors: &[Interval]) -> HashMap<LocalId, Interval> {
1284    let top = Interval::unbounded();
1285    let mut env: HashMap<LocalId, Interval> = HashMap::new();
1286    for (i, p) in f.params.iter().enumerate() {
1287        // A FLOORED param is read at its floor — the path-guaranteed bound on
1288        // the recursive branch (`[K-step, entry]`) — so `counter - step`
1289        // evaluates over the full bounded range and the descent lands at the
1290        // floor in ONE round (no per-round march that widen would blow to
1291        // ±inf). An UNFLOORED param (floor ⊤) is read at its current iterate.
1292        let iv = if floors[i] == top {
1293            cells[i]
1294        } else {
1295            floors[i]
1296        };
1297        env.insert(p.local, iv);
1298    }
1299    env
1300}
1301
1302/// Wrap `eval_interval` with a fresh `worst` accumulator and return
1303/// `worst.hull(result)`, preserving the transient-out-of-`i64` demotion: a
1304/// back-edge arg whose computation passes through an out-of-`i64`
1305/// intermediate widens the counter out of `i64` ⇒ `!fits_i64` ⇒ boxed.
1306fn eval_interval_pub(e: &MirExpr, env: &HashMap<LocalId, Interval>) -> Interval {
1307    let mut worst = Interval::point(0);
1308    // The recurrence back-edge args are `counter - step` over Int counters; a
1309    // carrier `.value` projection never appears here, so the carrier facts are
1310    // empty (a `Project` evaluates to `unbounded()`, the safe decline).
1311    let no_carriers = FnBareFacts::default();
1312    let iv = eval_interval(e, env, &mut worst, &no_carriers);
1313    worst.hull(iv)
1314}
1315
1316/// The SEED interval for param `i` of `f`: the convex hull of every literal
1317/// entry value passed at index `i` by a NON-self-tail caller edge (the entry
1318/// envelope). A non-literal entry arg, or no entry caller at all, ⇒ ⊤ (the
1319/// param is unbounded, mapping to `None`). Self-tail edges are the recurrence
1320/// back-edges (handled by the transfer), never seeds.
1321fn seed_interval(f: &MirFn, i: usize, edges: &[CallEdge]) -> Interval {
1322    let mut entry: Option<Interval> = None;
1323    let mut saw_entry = false;
1324    for edge in edges {
1325        if edge.target != f.fn_id || edge.is_tail_self() {
1326            continue;
1327        }
1328        saw_entry = true;
1329        let Some(arg) = edge.args.get(i) else {
1330            return Interval::unbounded();
1331        };
1332        let Some(iv) = literal_interval(&arg.node) else {
1333            return Interval::unbounded();
1334        };
1335        entry = Some(match entry {
1336            Some(prev) => prev.hull(iv),
1337            None => iv,
1338        });
1339    }
1340    match (saw_entry, entry) {
1341        (true, Some(iv)) => iv,
1342        _ => Interval::unbounded(),
1343    }
1344}
1345
1346/// The equality-decrement FLOOR for param `i` of `f`: the convex interval
1347/// `between(K - step, entry_hi)`, installed ONLY when ALL FOUR of the
1348/// #538/#539/#541/#519 preconditions hold; otherwise ⊤ (no narrowing, the
1349/// joined descent widens, the param boxes — same as today).
1350///
1351/// The four preconditions (each reuses a kept helper):
1352///   (a) `guard_literal_for` finds an EQUALITY guard `K` on the counter
1353///       (comparison guards get NO floor in this phase — Phase A boxes them);
1354///   (b) the `K` base arm is TERMINAL (`!equality_guard_arm_recurses`) — the
1355///       #541 invariant: a self-recursing base arm never stops at `K`;
1356///   (c) every self-tail back-edge arg at index `i` is the SAME single
1357///       `counter - step` monotone decrement (`walk_self_tailcall_steps`
1358///       agrees on one `step`) — the #538 invariant: disagreeing or growing
1359///       back-edges leave the floor uninstalled;
1360///   (d) reachability: `entry >= K && (entry - K) % step == 0` for EVERY
1361///       entry literal (the #519/BUG-1 invariant) — the descent must LAND on
1362///       `K`, never step over it.
1363///
1364/// When all hold, the floor is `between(K - step, entry_hi)`, reproducing the
1365/// old `param_recurrence_bound`'s exact `[min(entry.lo, K-step), max(entry.hi,
1366/// K)]` (the gate guarantees `entry.lo >= K` so `min(entry.lo, K-step)=K-step`
1367/// and `entry.hi >= K` so `max(entry.hi, K)=entry.hi`) — BYTE-IDENTICAL on
1368/// every known shape.
1369fn guard_floor(f: &MirFn, i: usize, edges: &[CallEdge]) -> Interval {
1370    let unfloored = Interval::unbounded();
1371    let Some(param_slot) = f.params.get(i).map(|p| p.local) else {
1372        return unfloored;
1373    };
1374
1375    // (a) equality guard K on the counter.
1376    let Some((guard_k, guard_kind)) = guard_literal_for(&f.body.node, param_slot) else {
1377        return unfloored;
1378    };
1379    if guard_kind != GuardKind::Equality {
1380        return unfloored; // comparison guard ⇒ no floor in Phase A.
1381    }
1382
1383    // (a') the guard K must DOMINATE the recursion: its literal arm and a
1384    //      self-tail-call must be sibling arms of the SAME `match counter`.
1385    //      Otherwise `guard_literal_for` may have picked a `K` from an
1386    //      unrelated `match counter` (a dead binding, a different branch)
1387    //      while the recursion is stopped by a different base value, making
1388    //      `[K-step, entry]` fiction and the counter unbounded below.
1389    if !guard_dominates_recursion(&f.body.node, f.fn_id, param_slot, guard_k) {
1390        return unfloored;
1391    }
1392
1393    // (b) the K base arm must be TERMINAL (not self-recursing).
1394    if equality_guard_arm_recurses(&f.body.node, f.fn_id, param_slot, guard_k) {
1395        return unfloored;
1396    }
1397
1398    // (c) every self-tail back-edge at index `i` is the SAME single
1399    //     `counter - step` decrement.
1400    let mut step: Option<i128> = None;
1401    let mut saw_self_tail = false;
1402    let all_same_decrement = walk_self_tailcall_steps(
1403        &f.body.node,
1404        f.fn_id,
1405        param_slot,
1406        i,
1407        &mut step,
1408        &mut saw_self_tail,
1409    );
1410    if !saw_self_tail || !all_same_decrement {
1411        return unfloored;
1412    }
1413    let Some(step) = step else {
1414        return unfloored;
1415    };
1416
1417    // (d) reachability per entry literal: `entry >= K && (entry-K)%step == 0`.
1418    //     We also recompute the entry hull here (the floor's upper endpoint).
1419    let mut entry: Option<Interval> = None;
1420    let mut saw_entry = false;
1421    for edge in edges {
1422        if edge.target != f.fn_id || edge.is_tail_self() {
1423            continue;
1424        }
1425        saw_entry = true;
1426        let Some(arg) = edge.args.get(i) else {
1427            return unfloored;
1428        };
1429        let Some(iv) = literal_interval(&arg.node) else {
1430            return unfloored;
1431        };
1432        let v = match iv.lo {
1433            crate::ir::interval::Bound::Finite(v) => v,
1434            _ => return unfloored,
1435        };
1436        if v < guard_k || (v - guard_k) % step != 0 {
1437            // The decrement never lands on `K` from this entry — diverges.
1438            return unfloored;
1439        }
1440        entry = Some(match entry {
1441            Some(prev) => prev.hull(iv),
1442            None => iv,
1443        });
1444    }
1445    let entry = match (saw_entry, entry) {
1446        (true, Some(iv)) => iv,
1447        _ => return unfloored, // no entry caller ⇒ unbounded ⇒ no floor.
1448    };
1449
1450    // The convex floor: `[min(entry.lo, K-step), max(entry.hi, K)]`. The gate
1451    // guarantees every entry literal is `>= K`, so `entry.lo >= K > K-step`
1452    // and `entry.hi >= K`, hence this equals `[K-step, entry.hi]` — exactly
1453    // the old `param_recurrence_bound` combine (byte-identical).
1454    let lo = entry.lo.min(Interval::point(guard_k - step).lo);
1455    let hi = entry.hi.max(Interval::point(guard_k).hi);
1456    Interval { lo, hi }
1457}
1458
1459/// How the base-case guard `K` compares the counter against the literal.
1460/// ONLY an `Equality` guard (`match counter { K -> … }` / the lowered
1461/// `IfThenElse` over `counter == K`) lets the decrement sequence be proven
1462/// to LAND on `K`; every comparison-direction guard (`<`, `<=`, `>`, `>=`)
1463/// is a half-bounded test that does not pin the exact stopping value, so it
1464/// cannot be used to bound the recurrence's far endpoint — those DECLINE.
1465#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1466enum GuardKind {
1467    /// `counter == K` (a `Match` literal arm, or `IfThenElse` over `Eq`).
1468    Equality,
1469    /// `<`, `<=`, `>`, `>=` against `K` — a half-bounded comparison whose
1470    /// exact stopping value is unknown. The conservative decline.
1471    Comparison,
1472}
1473
1474/// Walk for a `Match`/`IfThenElse` that guards the counter against a
1475/// literal `K`. Returns the first literal guard `(K, kind)` found on the
1476/// counter — `kind` distinguishes an equality guard (the only kind that
1477/// pins the stopping value) from a comparison guard.
1478fn guard_literal_for(e: &MirExpr, counter: LocalId) -> Option<(i128, GuardKind)> {
1479    match e {
1480        MirExpr::Match(m) => {
1481            // A `match counter { K -> … }` literal arm is an EQUALITY guard:
1482            // the arm fires exactly when `counter == K`.
1483            if subject_is_local(&m.node.subject.node, counter) {
1484                for arm in &m.node.arms {
1485                    if let MirPattern::Literal(Literal::Int(k)) = &arm.pattern {
1486                        return Some((*k as i128, GuardKind::Equality));
1487                    }
1488                }
1489            }
1490            guard_literal_for(&m.node.subject.node, counter).or_else(|| {
1491                m.node
1492                    .arms
1493                    .iter()
1494                    .find_map(|arm| guard_literal_for(&arm.body.node, counter))
1495            })
1496        }
1497        MirExpr::IfThenElse(ite) => {
1498            if let MirExpr::BinOp(b) = &ite.node.cond.node
1499                && matches!(
1500                    b.node.op,
1501                    BinOp::Eq | BinOp::Lt | BinOp::Lte | BinOp::Gt | BinOp::Gte
1502                )
1503            {
1504                // Only `==` pins the exact stopping value; `<`/`<=`/`>`/`>=`
1505                // are half-bounded tests whose stopping value is unknown.
1506                let kind = if matches!(b.node.op, BinOp::Eq) {
1507                    GuardKind::Equality
1508                } else {
1509                    GuardKind::Comparison
1510                };
1511                if subject_is_local(&b.node.lhs.node, counter)
1512                    && let MirExpr::Literal(l) = &b.node.rhs.node
1513                    && let Literal::Int(k) = l.node
1514                {
1515                    return Some((k as i128, kind));
1516                }
1517                if subject_is_local(&b.node.rhs.node, counter)
1518                    && let MirExpr::Literal(l) = &b.node.lhs.node
1519                    && let Literal::Int(k) = l.node
1520                {
1521                    return Some((k as i128, kind));
1522                }
1523            }
1524            guard_literal_for(&ite.node.cond.node, counter)
1525                .or_else(|| guard_literal_for(&ite.node.then_branch.node, counter))
1526                .or_else(|| guard_literal_for(&ite.node.else_branch.node, counter))
1527        }
1528        MirExpr::Let(l) => guard_literal_for(&l.node.value.node, counter)
1529            .or_else(|| guard_literal_for(&l.node.body.node, counter)),
1530        _ => {
1531            let mut found = None;
1532            visit_children(e, &mut |c| {
1533                if found.is_none() {
1534                    found = guard_literal_for(c, counter);
1535                }
1536            });
1537            found
1538        }
1539    }
1540}
1541
1542/// Walk `e` and validate the arg at index `i` of EVERY self-`TailCall` to
1543/// `self_fn`. Each such arg MUST be the monotone `counter - step` decrement
1544/// (a positive literal `step`), and every path must agree on the SAME
1545/// `step` — the first one seen pins it (recorded in `step`). `saw_self_tail`
1546/// is set when at least one self-tail-call carries an arg at `i`.
1547///
1548/// Returns `false` (and stops contributing) the moment any self-tail-call's
1549/// arg at `i` is NOT the agreed decrement — a growing `counter + lit`, a
1550/// different/opposite step, or a non-`counter`-based expr. A `false` result
1551/// means the param is not a provably-bounded counter and must box
1552/// (fail-closed). A self-tail-call with too few args (no slot `i`) is
1553/// skipped: the param at `i` does not participate in that call.
1554fn walk_self_tailcall_steps(
1555    e: &MirExpr,
1556    self_fn: FnId,
1557    counter: LocalId,
1558    i: usize,
1559    step: &mut Option<i128>,
1560    saw_self_tail: &mut bool,
1561) -> bool {
1562    let mut ok = true;
1563    if let MirExpr::TailCall(tc) = e
1564        && tc.node.target == self_fn
1565        && let Some(arg) = tc.node.args.get(i)
1566    {
1567        *saw_self_tail = true;
1568        match decrement_step(&arg.node, counter) {
1569            // First decrement path pins the step; later paths must match it.
1570            Some(s) => match *step {
1571                None => *step = Some(s),
1572                Some(prev) if prev != s => ok = false,
1573                Some(_) => {}
1574            },
1575            // Not a `counter - lit` decrement (a growth path, a different
1576            // shape, or not counter-based) ⇒ unbounded recurrence.
1577            None => ok = false,
1578        }
1579    }
1580    visit_children(e, &mut |c| {
1581        if !walk_self_tailcall_steps(c, self_fn, counter, i, step, saw_self_tail) {
1582            ok = false;
1583        }
1584    });
1585    ok
1586}
1587
1588/// Does the equality-guard base-case arm (`match counter { K -> body }`, the
1589/// arm whose literal pattern equals `guard_k`) itself self-recurse?
1590///
1591/// `guard_literal_for` treats that literal arm as the counter's STOPPING
1592/// point, which only holds when the arm TERMINATES. If its body tail-calls
1593/// `self_fn`, the counter does NOT stop at `K` — it runs past the guard, so
1594/// the `[K - step, entry]` recurrence bound is fiction and the param can
1595/// leave `i64` (the recursive-base-arm hole). The caller declines (boxes)
1596/// when this returns `true`. Fail-closed: the common non-recursive base arm
1597/// (`match n { 0 -> acc; … }`) returns `false` and stays bare.
1598fn equality_guard_arm_recurses(
1599    e: &MirExpr,
1600    self_fn: FnId,
1601    counter: LocalId,
1602    guard_k: i128,
1603) -> bool {
1604    if let MirExpr::Match(m) = e
1605        && subject_is_local(&m.node.subject.node, counter)
1606    {
1607        for arm in &m.node.arms {
1608            if let MirPattern::Literal(Literal::Int(k)) = &arm.pattern
1609                && *k as i128 == guard_k
1610                && contains_self_tailcall(&arm.body.node, self_fn)
1611            {
1612                return true;
1613            }
1614        }
1615    }
1616    let mut found = false;
1617    visit_children(e, &mut |c| {
1618        if !found {
1619            found = equality_guard_arm_recurses(c, self_fn, counter, guard_k);
1620        }
1621    });
1622    found
1623}
1624
1625/// Does `e` contain a `TailCall` to `self_fn` anywhere in its subtree?
1626fn contains_self_tailcall(e: &MirExpr, self_fn: FnId) -> bool {
1627    if let MirExpr::TailCall(tc) = e
1628        && tc.node.target == self_fn
1629    {
1630        return true;
1631    }
1632    let mut found = false;
1633    visit_children(e, &mut |c| {
1634        if !found {
1635            found = contains_self_tailcall(c, self_fn);
1636        }
1637    });
1638    found
1639}
1640
1641/// Does the equality guard `K` DOMINATE the recursion — does the `K` base case
1642/// and a self-tail-call live in MUTUALLY-EXCLUSIVE branches of the SAME control
1643/// node on the counter?
1644///
1645/// `guard_literal_for` returns a `K` literal found ANYWHERE on the counter,
1646/// including a `match counter` that does not gate the recursion at all (a dead
1647/// `let` binding, an unrelated branch). If the recursion is actually stopped by
1648/// a DIFFERENT control node (a different base value — e.g. the real base case is
1649/// `i64::MAX`, not `0`), the `[K-step, entry]` floor is fiction and the counter
1650/// runs unbounded toward `-inf` (the guard-dominance hole). Requiring the
1651/// recursive self-tail-call to sit in the `counter != K` branch — and NOT in the
1652/// `counter == K` (base) branch — proves the counter genuinely descends toward
1653/// `K` and stops there. Two lowered forms count:
1654///   - `match counter { K -> base; … rec … }` — the `K` literal arm and a
1655///     self-tail-call are SIBLING arms.
1656///   - `if counter == K { base } else { … rec … }` (the lowering of
1657///     `match counter == K { true -> base; false -> rec }`) — `Eq` is symmetric,
1658///     so either operand order; the recursion must be in the `else`
1659///     (`counter != K`) branch and absent from the `then` (`== K`) branch.
1660///
1661/// Fail-closed: no dominating node ⇒ no floor ⇒ the descent widens ⇒ boxed.
1662fn guard_dominates_recursion(e: &MirExpr, self_fn: FnId, counter: LocalId, guard_k: i128) -> bool {
1663    if let MirExpr::Match(m) = e
1664        && subject_is_local(&m.node.subject.node, counter)
1665    {
1666        let mut has_k_arm = false;
1667        let mut has_sibling_recursion = false;
1668        for arm in &m.node.arms {
1669            let is_k = matches!(
1670                &arm.pattern,
1671                MirPattern::Literal(Literal::Int(k)) if *k as i128 == guard_k
1672            );
1673            if is_k {
1674                has_k_arm = true;
1675            } else if contains_self_tailcall(&arm.body.node, self_fn) {
1676                has_sibling_recursion = true;
1677            }
1678        }
1679        if has_k_arm && has_sibling_recursion {
1680            return true;
1681        }
1682    }
1683    // The lowered `match counter == K { true -> base; false -> rec }` form:
1684    // `IfThenElse { cond: counter == K, then = base, else = rec }`. The
1685    // recursion must be in the `else` (counter != K) branch and absent from the
1686    // `then` (counter == K) branch — a self-tail-call under `== K` would recurse
1687    // AT the base case (unbounded), exactly the recursive-base-arm hole.
1688    if let MirExpr::IfThenElse(ite) = e
1689        && let MirExpr::BinOp(b) = &ite.node.cond.node
1690        && matches!(b.node.op, BinOp::Eq)
1691        && cond_compares_counter_to_k(&b.node.lhs.node, &b.node.rhs.node, counter, guard_k)
1692        && contains_self_tailcall(&ite.node.else_branch.node, self_fn)
1693        && !contains_self_tailcall(&ite.node.then_branch.node, self_fn)
1694    {
1695        return true;
1696    }
1697    let mut found = false;
1698    visit_children(e, &mut |c| {
1699        if !found {
1700            found = guard_dominates_recursion(c, self_fn, counter, guard_k);
1701        }
1702    });
1703    found
1704}
1705
1706/// `true` when `lhs OP rhs` compares the `counter` local against the literal
1707/// `guard_k` (either operand order). Used by the `Eq`-guard dominance check.
1708fn cond_compares_counter_to_k(
1709    lhs: &MirExpr,
1710    rhs: &MirExpr,
1711    counter: LocalId,
1712    guard_k: i128,
1713) -> bool {
1714    fn is_k_lit(e: &MirExpr, guard_k: i128) -> bool {
1715        if let MirExpr::Literal(l) = e
1716            && let Literal::Int(k) = l.node
1717        {
1718            return k as i128 == guard_k;
1719        }
1720        false
1721    }
1722    (subject_is_local(lhs, counter) && is_k_lit(rhs, guard_k))
1723        || (subject_is_local(rhs, counter) && is_k_lit(lhs, guard_k))
1724}
1725
1726/// If `e` is `counter - K` (a positive literal `K`), return `K`. The only
1727/// monotone-decrement shape we recognize today.
1728fn decrement_step(e: &MirExpr, counter: LocalId) -> Option<i128> {
1729    if let MirExpr::BinOp(b) = e
1730        && matches!(b.node.op, BinOp::Sub)
1731        && subject_is_local(&b.node.lhs.node, counter)
1732        && let MirExpr::Literal(l) = &b.node.rhs.node
1733        && let Literal::Int(k) = l.node
1734        && k > 0
1735    {
1736        return Some(k as i128);
1737    }
1738    None
1739}
1740
1741fn subject_is_local(e: &MirExpr, slot: LocalId) -> bool {
1742    matches!(e, MirExpr::Local(l) if l.node.slot == slot)
1743}
1744
1745/// Extract a bounded literal interval from a call argument (a bare `Int`
1746/// literal, or a negated one). `None` for any non-literal — the
1747/// conservative decline.
1748fn literal_interval(arg: &MirExpr) -> Option<Interval> {
1749    match arg {
1750        MirExpr::Literal(l) => match l.node {
1751            Literal::Int(k) => Some(Interval::point(k as i128)),
1752            _ => None,
1753        },
1754        MirExpr::Neg(inner) => match &inner.node {
1755            MirExpr::Literal(l) => match l.node {
1756                Literal::Int(k) => Some(Interval::point(-(k as i128))),
1757                _ => None,
1758            },
1759            _ => None,
1760        },
1761        _ => None,
1762    }
1763}
1764
1765// ── Per-fn body analysis (range + escape) ───────────────────────────────
1766
1767/// Compute the per-`LocalId` value facts for `f` given the cross-frame
1768/// `summary` (which params/returns are bare). The body walk derives an
1769/// interval per slot (bottom-up over the `Let` chain), an escape predicate
1770/// (a single use-flow scan), and combines them via `raw_i64_eligible`.
1771fn analyze_fn(
1772    f: &MirFn,
1773    summary: &Summary,
1774    carrier: &CarrierIntervals,
1775    field_carrier: &FieldCarrierIntervals,
1776) -> FnBareFacts {
1777    let mut facts = FnBareFacts::default();
1778
1779    // 1. Range: seed param intervals from the summary. A bare param is
1780    //    seeded with its PROVEN recurrence interval (`[K-step, entry]`) when
1781    //    one was derived, so an in-body `n - 1` / `acc * n` over the counter
1782    //    stays in the OverflowFree band; a bare param with no tight interval
1783    //    falls back to the full-`i64` line (still sound — bare ⟹ confined to
1784    //    `i64` by construction); a boxed param is unbounded.
1785    //
1786    //    ETAP-2 carrier-`i64` — carrier params (wasm-gc only). A param whose
1787    //    annotated type is a refinement-via-opaque carrier
1788    //    (`carrier_interval(&p.ty, carrier)` is `Some`) has its wasm storage
1789    //    ALREADY erased to a native `i64` (the registry's `eligible_carriers`
1790    //    set, applied uniformly by `aver_to_wasm`), INDEPENDENT of this Int-
1791    //    bareness analysis. So a carrier param is NOT an Int-bare slot — the
1792    //    value `c` is the carrier (a record), never a raw Int operand — and we
1793    //    deliberately do NOT mark it `bare_params` / `Repr::Bare`: a carrier
1794    //    value flows to a carrier param with NO conversion (both are i64), so
1795    //    flagging it bare would make the call site spuriously `Unbox` the i64.
1796    //    What the carrier param DOES enable is a raw `.value` read: a
1797    //    `Project(Local(carrier_slot), "value")` reads the i64 directly (no
1798    //    `$AverInt` project bridge) and contributes the carrier's PROVEN bound
1799    //    to the surrounding arithmetic. We record that bound in `carrier_slots`
1800    //    so `bare_expr_interval` / `expr_is_bare_i64` treat the `.value` read
1801    //    as a bare leaf — `c.value + c.value` over a `[0,100]` carrier then
1802    //    stays OverflowFree, while `c.value * c.value` over a `[0,2^40]`
1803    //    carrier overflows i64 and the compound declines (boxed) — the C0
1804    //    soundness gate is the SAME interval fixpoint, just with a carrier-
1805    //    projection leaf source. The bound is sound by construction: the type
1806    //    is opaque + the only constructor is the guarded smart constructor, so
1807    //    every inhabitant provably lies in the interval.
1808    let mut intervals: HashMap<LocalId, Interval> = HashMap::new();
1809    let mut bare_params = vec![false; f.params.len()];
1810    let mut carrier_slots: HashMap<LocalId, Interval> = HashMap::new();
1811    for (i, p) in f.params.iter().enumerate() {
1812        let recurrence_bare = summary.param_bare(f.fn_id, i);
1813        bare_params[i] = recurrence_bare;
1814        if let Some(cv) = carrier_interval(&p.ty, carrier) {
1815            // Carrier param: its `.value` reads raw i64 with this proven bound.
1816            // The param value `c` itself stays a (boxed/struct) carrier slot —
1817            // not an Int-bare slot — so its `intervals` entry is unbounded
1818            // (it is never an arithmetic operand directly).
1819            carrier_slots.insert(p.local, cv);
1820            intervals.insert(p.local, Interval::unbounded());
1821            continue;
1822        }
1823        let iv = if recurrence_bare {
1824            summary
1825                .param_interval(f.fn_id, i)
1826                .filter(|iv| iv.fits_i64())
1827                .unwrap_or_else(|| Interval::between(i64::MIN as i128, i64::MAX as i128))
1828        } else {
1829            Interval::unbounded()
1830        };
1831        intervals.insert(p.local, iv);
1832    }
1833    // Record the carrier-projection facts on the result so the rewrite + emit
1834    // can recognize a bare carrier's `.value` read. (Set before the combine so
1835    // `tail_value_is_bare` / `expr_is_bare_i64` see them via `&facts`.)
1836    facts.carrier_slots = carrier_slots;
1837    // The eligible-carrier type table powers the NESTED carrier-field
1838    // recognition (`Project(Project(rec, "coord"), "value")` whose inner
1839    // `rec.coord` is stamped an eligible carrier). EMPTY on the Rust backend /
1840    // no-eligible-carrier baseline, so the nested form never fires there.
1841    facts.carrier_types = carrier.clone();
1842    // ETAP-2 multi-field carrier-`i64`: the eligible `(record, field)` bound
1843    // table powers the DIRECT bounded-field recognition (`Project(rec, "x")`
1844    // whose `rec` is a bounded record and `(record, "x")` is eligible). EMPTY
1845    // on the Rust backend / no-eligible-record baseline, so the direct-field
1846    // form never fires there. Set BEFORE the let-chain walk so a field read
1847    // bound to a slot is recognized as a bare leaf.
1848    facts.field_carrier_intervals = field_carrier.clone();
1849
1850    // 2. Walk the body's `Let` chain, deriving an interval (+ worst-join
1851    //    op class) for each bound slot. The carrier-projection facts let the
1852    //    walk read a bare carrier's `.value` (param-level OR nested field) as
1853    //    its proven i64 interval.
1854    let mut op_classes: HashMap<LocalId, OpClass> = HashMap::new();
1855    walk_let_chain(&f.body.node, &mut intervals, &mut op_classes, &facts);
1856
1857    // 3. Escape: a single use-flow scan. A slot escapes if any use-site is
1858    //    a general-Int context.
1859    let mut escaping: HashSet<LocalId> = HashSet::new();
1860    scan_escapes(&f.body.node, summary, &mut escaping);
1861
1862    // 4. Combine.
1863    for (slot, iv) in &intervals {
1864        let op_class = op_classes
1865            .get(slot)
1866            .copied()
1867            .unwrap_or(OpClass::OverflowFree);
1868        let escapes = escaping.contains(slot);
1869        let eligible = raw_i64_eligible(Some(*iv), std::iter::once(&op_class));
1870        let repr = if eligible && !escapes {
1871            Repr::Bare
1872        } else {
1873            Repr::Boxed
1874        };
1875        facts.values.insert(
1876            *slot,
1877            ValueFact {
1878                interval: Some(*iv),
1879                op_class,
1880                escapes,
1881                repr,
1882            },
1883        );
1884    }
1885    // Params absent from the Let walk still get a fact (their seed). Every
1886    // param is in `intervals`, so the combine above already inserted its
1887    // value fact — this `or_insert_with` is a backstop for any param the
1888    // combine somehow skipped. The recurrence path keeps its `summary` seed;
1889    // a carrier param is NOT Int-bare (its `intervals` entry is unbounded), so
1890    // the backstop boxes it — the value `c` is a carrier slot, only its
1891    // `.value` read goes raw (via `carrier_slots`).
1892    for (i, p) in f.params.iter().enumerate() {
1893        let seeded_bare = summary.param_bare(f.fn_id, i);
1894        facts.values.entry(p.local).or_insert_with(|| {
1895            if seeded_bare {
1896                ValueFact {
1897                    interval: intervals.get(&p.local).copied(),
1898                    op_class: OpClass::OverflowFree,
1899                    escapes: false,
1900                    repr: Repr::Bare,
1901                }
1902            } else {
1903                ValueFact::boxed()
1904            }
1905        });
1906    }
1907    facts.bare_params = bare_params;
1908
1909    // 5. Return: bare iff the summary says so AND the body's tail value is
1910    //    itself bare-eligible.
1911    facts.bare_return =
1912        summary.return_bare(f.fn_id) && tail_value_is_bare(&f.body.node, &facts, &escaping);
1913
1914    facts
1915}
1916
1917/// Walk the `Let` chain, recording each bound slot's interval and worst-
1918/// join op class. `env` holds param + already-bound intervals and is grown
1919/// in place; branches recurse so nested bindings are seen. `facts` carries
1920/// the carrier-projection facts (`carrier_slots` + `carrier_types`) so a bare
1921/// carrier's `.value` read (`Project`, param-level OR nested field) contributes
1922/// its proven interval to an arithmetic binding.
1923fn walk_let_chain(
1924    e: &MirExpr,
1925    env: &mut HashMap<LocalId, Interval>,
1926    op_classes: &mut HashMap<LocalId, OpClass>,
1927    facts: &FnBareFacts,
1928) {
1929    match e {
1930        MirExpr::Let(l) => {
1931            let mut worst = Interval::point(0);
1932            let iv = eval_interval(&l.node.value.node, env, &mut worst, facts);
1933            env.insert(l.node.binding, iv);
1934            // The binding's op class is the worst-join over its value
1935            // expression (so a transient out-of-i64 intermediate demotes).
1936            op_classes.insert(l.node.binding, OpClass::of_interval(worst.hull(iv)));
1937            walk_let_chain(&l.node.value.node, env, op_classes, facts);
1938            walk_let_chain(&l.node.body.node, env, op_classes, facts);
1939        }
1940        // A `match subject { y -> … }` Ident-binding arm ALIASES the subject:
1941        // the binder `y` carries the subject's interval. Without this, a
1942        // base-case `match n { y -> y }` (subj_ret) leaves `y` unknown, so the
1943        // body pass can't prove `y` bare and the return-repr / escape facts
1944        // disagree with codegen (which declares `y` at the subject's bare
1945        // type). Recognize the alias so `y` inherits `n`'s bound — the same
1946        // representation codegen emits. The subject's interval is evaluated in
1947        // the current `env` (the subject is already in scope).
1948        MirExpr::Match(m) => {
1949            let mut worst = Interval::point(0);
1950            let subj_iv = eval_interval(&m.node.subject.node, env, &mut worst, facts);
1951            walk_let_chain(&m.node.subject.node, env, op_classes, facts);
1952            for arm in &m.node.arms {
1953                if let MirPattern::Bind(slot, _) = &arm.pattern {
1954                    // Alias: the binder takes the subject's interval + op
1955                    // class (the subject is read verbatim, no new arithmetic).
1956                    env.entry(*slot).or_insert(subj_iv);
1957                    op_classes
1958                        .entry(*slot)
1959                        .or_insert_with(|| OpClass::of_interval(subj_iv));
1960                }
1961                walk_let_chain(&arm.body.node, env, op_classes, facts);
1962            }
1963        }
1964        _ => {
1965            visit_children(e, &mut |c| walk_let_chain(c, env, op_classes, facts));
1966        }
1967    }
1968}
1969
1970/// Abstractly evaluate a MIR expression to its interval, joining each
1971/// arithmetic node into `worst` (so a transient out-of-i64 intermediate
1972/// demotes the binding). Unknown shapes evaluate to `unbounded()`. `facts`
1973/// carries the carrier-projection facts so a `.value` read over a bare carrier
1974/// — a param/local (`carrier_slots`) OR a nested carrier-field read
1975/// (`Project(Project(..))` whose inner type is an eligible carrier) — evaluates
1976/// to the carrier's proven bound rather than `unbounded()`, the
1977/// carrier-projection leaf.
1978fn eval_interval(
1979    e: &MirExpr,
1980    env: &HashMap<LocalId, Interval>,
1981    worst: &mut Interval,
1982    facts: &FnBareFacts,
1983) -> Interval {
1984    match e {
1985        MirExpr::Literal(l) => match l.node {
1986            Literal::Int(k) => Interval::point(k as i128),
1987            _ => Interval::unbounded(),
1988        },
1989        MirExpr::Local(local) => env
1990            .get(&local.node.slot)
1991            .copied()
1992            .unwrap_or_else(Interval::unbounded),
1993        // ETAP-2 carrier-`i64`: `c.value` over a bare carrier (param/local or a
1994        // nested carrier field) reads the native i64 with the carrier's proven
1995        // bound; any other `Project` declines to `unbounded()`.
1996        MirExpr::Project(_) => facts
1997            .carrier_project_interval(e)
1998            .unwrap_or_else(Interval::unbounded),
1999        MirExpr::Neg(inner) => {
2000            let r = Interval::point(0).sub(eval_interval(&inner.node, env, worst, facts));
2001            *worst = worst.hull(r);
2002            r
2003        }
2004        MirExpr::BinOp(b) => {
2005            let l = eval_interval(&b.node.lhs.node, env, worst, facts);
2006            let r = eval_interval(&b.node.rhs.node, env, worst, facts);
2007            let result = match b.node.op {
2008                BinOp::Add => l.add(r),
2009                BinOp::Sub => l.sub(r),
2010                BinOp::Mul => l.mul(r),
2011                _ => Interval::unbounded(),
2012            };
2013            *worst = worst.hull(result);
2014            result
2015        }
2016        _ => Interval::unbounded(),
2017    }
2018}
2019
2020/// Single use-flow escape scan. A slot escapes if it (a) is passed to a
2021/// general (boxed) Int param of a callee, (b) is stored in an aggregate,
2022/// or (c) is stringified. We over-approximate: any unrecognized use of a
2023/// slot defaults to escaping.
2024fn scan_escapes(e: &MirExpr, summary: &Summary, out: &mut HashSet<LocalId>) {
2025    match e {
2026        // Aggregates: every Int element/field escapes — INCLUDING a bare
2027        // leaf reached through a `BinOp`/`Neg` (the aggregate emit does not
2028        // convert a bare compound), hence `*_deep` (BUG 3).
2029        MirExpr::List(items) | MirExpr::Tuple(items) => {
2030            for it in items {
2031                mark_operand_escapes_deep(&it.node, out);
2032                scan_escapes(&it.node, summary, out);
2033            }
2034        }
2035        MirExpr::MapLiteral(pairs) => {
2036            for (k, v) in pairs {
2037                mark_operand_escapes_deep(&k.node, out);
2038                mark_operand_escapes_deep(&v.node, out);
2039                scan_escapes(&k.node, summary, out);
2040                scan_escapes(&v.node, summary, out);
2041            }
2042        }
2043        MirExpr::Construct(c) => {
2044            for a in &c.node.args {
2045                mark_operand_escapes_deep(&a.node, out);
2046                scan_escapes(&a.node, summary, out);
2047            }
2048        }
2049        MirExpr::RecordCreate(r) => {
2050            for fld in &r.node.fields {
2051                mark_operand_escapes_deep(&fld.value.node, out);
2052                scan_escapes(&fld.value.node, summary, out);
2053            }
2054        }
2055        MirExpr::RecordUpdate(u) => {
2056            mark_operand_escapes_deep(&u.node.base.node, out);
2057            scan_escapes(&u.node.base.node, summary, out);
2058            for fld in &u.node.updates {
2059                mark_operand_escapes_deep(&fld.value.node, out);
2060                scan_escapes(&fld.value.node, summary, out);
2061            }
2062        }
2063        MirExpr::IndependentProduct(ip) => {
2064            for it in &ip.node.items {
2065                mark_operand_escapes_deep(&it.node, out);
2066                scan_escapes(&it.node, summary, out);
2067            }
2068        }
2069        // Stringification: every embedded value escapes (including a bare
2070        // leaf inside a `BinOp`/`Neg` — the interpolation emit stringifies
2071        // the whole compound, not the operands).
2072        MirExpr::InterpolatedStr(parts) => {
2073            for p in parts {
2074                if let super::super::expr::MirStrPart::Expr(ex) = p {
2075                    mark_operand_escapes_deep(&ex.node, out);
2076                    scan_escapes(&ex.node, summary, out);
2077                }
2078            }
2079        }
2080        // A call: an arg at a BOXED callee-param index escapes; an arg at a
2081        // BARE param index converts at the boundary and does NOT escape. A
2082        // builtin / intrinsic / fn-value callee is conservative — every Int
2083        // arg escapes (we don't model builtin param reps).
2084        MirExpr::Call(c) => match c.node.callee {
2085            MirCallee::Fn(target) => {
2086                for (i, a) in c.node.args.iter().enumerate() {
2087                    // A user-fn boxed-param position: the boxed-arithmetic
2088                    // emit converts a bare compound operand, so only a DIRECT
2089                    // bare `Local` escapes here (shallow), not a leaf buried
2090                    // in a `BinOp` (which `boxed_int_operand` converts).
2091                    if !summary.param_bare(target, i) {
2092                        mark_operand_escapes(&a.node, out);
2093                    }
2094                    scan_escapes(&a.node, summary, out);
2095                }
2096            }
2097            // A builtin / intrinsic / fn-value callee is a NON-converting
2098            // position: its Int args emit without `boxed_int_operand`, so a
2099            // bare leaf inside a `BinOp`/`Neg` arg (`String.fromInt(n + 1)`)
2100            // escapes too — `*_deep` (BUG 3).
2101            _ => {
2102                for a in &c.node.args {
2103                    mark_operand_escapes_deep(&a.node, out);
2104                    scan_escapes(&a.node, summary, out);
2105                }
2106            }
2107        },
2108        // A tail call: same, against the (self) callee's bare params.
2109        MirExpr::TailCall(tc) => {
2110            for (i, a) in tc.node.args.iter().enumerate() {
2111                if !summary.param_bare(tc.node.target, i) {
2112                    mark_operand_escapes(&a.node, out);
2113                }
2114                scan_escapes(&a.node, summary, out);
2115            }
2116        }
2117        // Control flow + arithmetic: recurse. A bare Local in tail/return
2118        // or arithmetic-operand position is consumed bare (no escape).
2119        _ => {
2120            visit_children(e, &mut |c| scan_escapes(c, summary, out));
2121        }
2122    }
2123}
2124
2125/// Mark the slot of a value flowing into a USER-FN-CALL boxed-param
2126/// position (an ordinary `Call(Fn)` / `TailCall` arg at a boxed index).
2127///
2128/// Only a bare `Local` read placed verbatim there escapes — its
2129/// representation must then be `AverInt` (the value itself crosses the
2130/// boundary). An ARITHMETIC expression (`acc * n`) at such a position does
2131/// NOT escape its operands: the boxed-arithmetic emit (`boxed_int_operand`)
2132/// converts each bare operand with `from_i64` and the FRESH result is what
2133/// crosses, so the operand keeps its own bare representation. Flagging a
2134/// counter read inside `acc * n` was the bug that demoted the factorial
2135/// counter — hence this variant does NOT recurse into `BinOp` / `Neg`.
2136fn mark_operand_escapes(e: &MirExpr, out: &mut HashSet<LocalId>) {
2137    if let MirExpr::Local(l) = e {
2138        out.insert(l.node.slot);
2139    }
2140}
2141
2142/// Mark every bare-`Local` leaf reaching a NON-CONVERTING escaping
2143/// position — an aggregate element/field (`[n + 1]`, a tuple/map/record
2144/// slot), a stringify embed, or a builtin/intrinsic Int arg
2145/// (`String.fromInt(n + 1)`).
2146///
2147/// BUG 3: unlike the user-fn boxed-param position, these emit sites do NOT
2148/// run `boxed_int_operand`, so a bare compound like `n + 1` is emitted as
2149/// raw `(n + 1)i64` straight into an `AverInt`-typed slot — a `rustc` type
2150/// mismatch, and (without `overflow-checks`) a wrong value if the compound
2151/// could wrap. So a bare leaf reaching these positions THROUGH a `BinOp` /
2152/// `Neg` tree must escape (→ boxed), exactly like a direct `Local` does.
2153/// Hence this variant RECURSES into `BinOp` / `Neg` (a literal leaf carries
2154/// no slot, so it is harmlessly ignored).
2155fn mark_operand_escapes_deep(e: &MirExpr, out: &mut HashSet<LocalId>) {
2156    match e {
2157        MirExpr::Local(l) => {
2158            out.insert(l.node.slot);
2159        }
2160        MirExpr::Neg(inner) => mark_operand_escapes_deep(&inner.node, out),
2161        MirExpr::BinOp(b) => {
2162            mark_operand_escapes_deep(&b.node.lhs.node, out);
2163            mark_operand_escapes_deep(&b.node.rhs.node, out);
2164        }
2165        _ => {}
2166    }
2167}
2168
2169/// Does the fn's tail value evaluate to a bare-eligible value? The tail is
2170/// the base-case value(s) reached after the `Let` chain / branches. We
2171/// require every reachable tail leaf to be a bare-eligible Local or a bare
2172/// literal; anything else demotes the return.
2173fn tail_value_is_bare(e: &MirExpr, facts: &FnBareFacts, escaping: &HashSet<LocalId>) -> bool {
2174    match e {
2175        MirExpr::Local(l) => facts.is_bare(l.node.slot) && !escaping.contains(&l.node.slot),
2176        MirExpr::Literal(l) => matches!(l.node, Literal::Int(_)),
2177        MirExpr::Let(let_node) => tail_value_is_bare(&let_node.node.body.node, facts, escaping),
2178        MirExpr::Match(m) => m
2179            .node
2180            .arms
2181            .iter()
2182            .all(|arm| tail_value_is_bare(&arm.body.node, facts, escaping)),
2183        MirExpr::IfThenElse(ite) => {
2184            tail_value_is_bare(&ite.node.then_branch.node, facts, escaping)
2185                && tail_value_is_bare(&ite.node.else_branch.node, facts, escaping)
2186        }
2187        // A self-tail-call's value is the recurrence, not a base value; it
2188        // does not constrain the return repr.
2189        MirExpr::TailCall(_) => true,
2190        MirExpr::Return(inner) => tail_value_is_bare(&inner.node, facts, escaping),
2191        // A compound `Add`/`Sub`/`Mul` tail value is bare ONLY when its
2192        // RESULT interval provably fits `i64` (BUG 2): a tree whose operands
2193        // are each bare can still overflow (`n + i64::MAX`), so route through
2194        // the interval-checked `expr_is_bare_i64` — the same gate codegen
2195        // uses, so analysis and emit never disagree. (A `Bare` leaf already
2196        // carries `escapes == false`, so an escaping operand is `Boxed` and
2197        // `expr_is_bare_i64` declines it.)
2198        MirExpr::BinOp(b) if matches!(b.node.op, BinOp::Add | BinOp::Sub | BinOp::Mul) => {
2199            facts.expr_is_bare_i64(e)
2200        }
2201        // ETAP-2 carrier-`i64`: a tail `c.value` over a bare carrier reads raw
2202        // i64, so the fn may return bare i64 (skip the project bridge).
2203        MirExpr::Project(_) => facts.is_carrier_project(e),
2204        _ => false,
2205    }
2206}
2207
2208// ── shared traversal + helpers ──────────────────────────────────────────
2209
2210fn collect_fn_values(e: &MirExpr, out: &mut HashSet<String>) {
2211    if let MirExpr::FnValue(name) = e {
2212        out.insert(name.clone());
2213    }
2214    visit_children(e, &mut |c| collect_fn_values(c, out));
2215}
2216
2217/// `true` when the source type-annotation string is exactly `Int`.
2218fn ty_str_is_int(ty: &str) -> bool {
2219    ty == "Int"
2220}
2221
2222/// `true` when the `Type` stamp is exactly `Int`. Exposed for the Rust
2223/// walker's consumption sites.
2224pub fn type_is_int(ty: Option<&Type>) -> bool {
2225    matches!(ty, Some(Type::Int))
2226}
2227
2228/// Test-only re-export of the immediate-children walk, so sibling-module
2229/// tests (`bare_i64_rewrite`) can recurse without duplicating the match.
2230#[cfg(test)]
2231pub(crate) fn tests_visit_children(e: &MirExpr, f: &mut dyn FnMut(&MirExpr)) {
2232    visit_children(e, f)
2233}
2234
2235/// Apply `f` to every immediate sub-expression of `e`. Kept in sync with
2236/// the exhaustive walk in `own_param.rs` / `instantiations.rs`. `pub(crate)`
2237/// so the sibling `bare_i64_rewrite` pass (the wasm-gc `mutual_recursion_box_set`
2238/// call-graph walk) can reuse this one exhaustive child enumeration instead
2239/// of forking a second copy.
2240pub(crate) fn visit_children(e: &MirExpr, f: &mut dyn FnMut(&MirExpr)) {
2241    match e {
2242        MirExpr::Literal(_) | MirExpr::Local(_) | MirExpr::FnValue(_) => {}
2243        MirExpr::Let(l) => {
2244            f(&l.node.value.node);
2245            f(&l.node.body.node);
2246        }
2247        MirExpr::Call(c) => {
2248            for a in &c.node.args {
2249                f(&a.node);
2250            }
2251        }
2252        MirExpr::TailCall(tc) => {
2253            for a in &tc.node.args {
2254                f(&a.node);
2255            }
2256        }
2257        MirExpr::BinOp(b) => {
2258            f(&b.node.lhs.node);
2259            f(&b.node.rhs.node);
2260        }
2261        MirExpr::Neg(inner)
2262        | MirExpr::Try(inner)
2263        | MirExpr::Return(inner)
2264        | MirExpr::Box(inner)
2265        | MirExpr::Unbox(inner) => f(&inner.node),
2266        MirExpr::Match(m) => {
2267            f(&m.node.subject.node);
2268            for arm in &m.node.arms {
2269                f(&arm.body.node);
2270            }
2271        }
2272        MirExpr::Construct(c) => {
2273            for a in &c.node.args {
2274                f(&a.node);
2275            }
2276        }
2277        MirExpr::RecordCreate(r) => {
2278            for field in &r.node.fields {
2279                f(&field.value.node);
2280            }
2281        }
2282        MirExpr::RecordUpdate(u) => {
2283            f(&u.node.base.node);
2284            for field in &u.node.updates {
2285                f(&field.value.node);
2286            }
2287        }
2288        MirExpr::Project(p) => f(&p.node.base.node),
2289        MirExpr::IfThenElse(ite) => {
2290            f(&ite.node.cond.node);
2291            f(&ite.node.then_branch.node);
2292            f(&ite.node.else_branch.node);
2293        }
2294        MirExpr::List(items) | MirExpr::Tuple(items) => {
2295            for i in items {
2296                f(&i.node);
2297            }
2298        }
2299        MirExpr::MapLiteral(pairs) => {
2300            for (k, v) in pairs {
2301                f(&k.node);
2302                f(&v.node);
2303            }
2304        }
2305        MirExpr::InterpolatedStr(parts) => {
2306            for p in parts {
2307                if let super::super::expr::MirStrPart::Expr(e) = p {
2308                    f(&e.node);
2309                }
2310            }
2311        }
2312        MirExpr::IndependentProduct(ip) => {
2313            for i in &ip.node.items {
2314                f(&i.node);
2315            }
2316        }
2317    }
2318}
2319
2320#[cfg(test)]
2321mod tests {
2322    use super::*;
2323    use crate::ir::mir::{lower_program, optimize};
2324    use crate::source::parse_source;
2325
2326    fn facts_for(src: &str) -> (MirProgram, BareI64Facts) {
2327        facts_for_with_carrier(src, &CarrierIntervals::new())
2328    }
2329
2330    /// Like [`facts_for`] but threads a caller-supplied carrier table —
2331    /// used by the carrier-slot tests. The empty-table caller
2332    /// ([`facts_for`]) reproduces the pre-slice behavior exactly.
2333    fn facts_for_with_carrier(src: &str, carrier: &CarrierIntervals) -> (MirProgram, BareI64Facts) {
2334        let mut items = parse_source(src).expect("parse");
2335        let cfg = crate::ir::pipeline::PipelineConfig {
2336            typecheck: Some(crate::ir::pipeline::TypecheckMode::Full { base_dir: None }),
2337            ..Default::default()
2338        };
2339        let result = crate::ir::pipeline::run(&mut items, cfg);
2340        assert!(
2341            result
2342                .typecheck
2343                .as_ref()
2344                .is_none_or(|t| t.errors.is_empty()),
2345            "typecheck errors: {:?}",
2346            result.typecheck.map(|t| t.errors)
2347        );
2348        let mir_items: Vec<crate::ir::hir::ResolvedTopLevel> = result.resolved_items.clone();
2349        let program = optimize(lower_program(&mir_items));
2350        let facts = analyze(&program, carrier);
2351        (program, facts)
2352    }
2353
2354    /// Build the carrier-interval table for `src` through the same
2355    /// refinement-via-opaque derivation the codegen entries use. Returns
2356    /// the parsed items + symbol table the table borrows from, so the
2357    /// caller can keep them alive while running `analyze`.
2358    fn carrier_table_for(
2359        items: &[crate::ast::TopLevel],
2360        symbols: &crate::ir::SymbolTable,
2361    ) -> CarrierIntervals {
2362        let empty_prefixes: HashSet<String> = HashSet::new();
2363        let empty_recursive: HashSet<crate::ir::FnId> = HashSet::new();
2364        let inputs = crate::codegen::proof_lower::ProofLowerInputs {
2365            entry_items: items,
2366            dep_modules: &[],
2367            module_prefixes: &empty_prefixes,
2368            recursive_fns: &empty_recursive,
2369            symbol_table: symbols,
2370            program_shape: None,
2371        };
2372        crate::codegen::proof_lower::carrier_interval_table(&inputs)
2373    }
2374
2375    fn fn_id_by_name<'a>(program: &'a MirProgram, name: &str) -> crate::ir::FnId {
2376        program
2377            .iter()
2378            .find(|(_, f)| f.name == name)
2379            .map(|(id, _)| *id)
2380            .unwrap_or_else(|| panic!("fn `{name}` not in program"))
2381    }
2382
2383    /// ETAP-2 SLICE 0+1 carrier-slot source. `carrier` seeds the bound;
2384    /// the empty-carrier variant is the revert-test baseline.
2385    const CARRIER_SRC: &str = r#"
2386module Toy
2387    intent = "t"
2388    depends []
2389
2390record IntRange
2391    value: Int
2392
2393fn fromInt(n: Int) -> Result<IntRange, String>
2394    match Bool.and(n >= 0, n <= 100)
2395        true  -> Result.Ok(IntRange(value = n))
2396        false -> Result.Err("err")
2397
2398fn toInt(c: IntRange) -> Int
2399    c.value
2400
2401fn doubled(c: IntRange) -> Int
2402    c.value + c.value
2403
2404fn main() -> Int
2405    match fromInt(50)
2406        Result.Ok(c)  -> toInt(c) + doubled(c)
2407        Result.Err(_) -> 0
2408"#;
2409
2410    /// Drive `CARRIER_SRC` through the pipeline + carrier table; return the
2411    /// MIR program + analysis facts.
2412    fn carrier_facts(carrier_on: bool) -> (MirProgram, BareI64Facts) {
2413        let mut items = parse_source(CARRIER_SRC).expect("parse");
2414        let cfg = crate::ir::pipeline::PipelineConfig {
2415            typecheck: Some(crate::ir::pipeline::TypecheckMode::Full { base_dir: None }),
2416            ..Default::default()
2417        };
2418        let result = crate::ir::pipeline::run(&mut items, cfg);
2419        let carrier = if carrier_on {
2420            carrier_table_for(&items, &result.symbol_table)
2421        } else {
2422            CarrierIntervals::new()
2423        };
2424        let mir_items = result.resolved_items.clone();
2425        let program = optimize(lower_program(&mir_items));
2426        let facts = analyze(&program, &carrier);
2427        (program, facts)
2428    }
2429
2430    #[test]
2431    fn bare_named_type_extracts_name_from_debug() {
2432        // The lowerer fills `MirParam.ty` with `format!("{:?}", Type)`, so a
2433        // named carrier renders as the Debug form. Pin the extractor.
2434        assert_eq!(
2435            bare_named_type("Named { id: Some(TypeId(0)), name: \"IntRange\" }"),
2436            Some("IntRange")
2437        );
2438        assert_eq!(
2439            bare_named_type("Named { id: None, name: \"Natural\" }"),
2440            Some("Natural")
2441        );
2442        // Non-Named (primitive / compound) debug strings decline.
2443        assert_eq!(bare_named_type("Int"), None);
2444        assert_eq!(
2445            bare_named_type("Result(Named { id: None, name: \"X\" }, Str)"),
2446            None
2447        );
2448    }
2449
2450    #[test]
2451    fn carrier_interval_table_derives_proven_bound() {
2452        // The table is keyed by the bare carrier type name and carries the
2453        // exact `[0, 100]` bound the proof side persists — byte-identical
2454        // derivation, fail-closed on an unrecognized invariant.
2455        let mut items = parse_source(CARRIER_SRC).expect("parse");
2456        let cfg = crate::ir::pipeline::PipelineConfig {
2457            typecheck: Some(crate::ir::pipeline::TypecheckMode::Full { base_dir: None }),
2458            ..Default::default()
2459        };
2460        let result = crate::ir::pipeline::run(&mut items, cfg);
2461        let table = carrier_table_for(&items, &result.symbol_table);
2462        let (iv, known) = table.get("IntRange").copied().expect("IntRange in table");
2463        assert!(known, "the [0,100] invariant is recognized");
2464        assert!(iv.fits_i64(), "the carrier bound fits i64");
2465        assert_eq!(iv, Interval::between(0, 100), "exact proven carrier bound");
2466    }
2467
2468    #[test]
2469    fn carrier_seam_enabled_records_carrier_projection_slots() {
2470        // The carrier-`i64` arithmetic seam is ON (`CARRIER_BARE_ELIGIBLE ==
2471        // true`). A carrier param is NOT marked an Int-bare param/slot — the
2472        // value `c` is the carrier (i64 STORAGE, never a raw Int operand), so
2473        // flagging it bare would make a call site spuriously `Unbox` it. What
2474        // the seam DOES is record the carrier param's slot in `carrier_slots`
2475        // (with its proven bound) so a `c.value` read renders raw i64. Pin
2476        // both: param NOT Int-bare, slot recorded as a carrier-projection
2477        // source with the `[0,100]` bound.
2478        assert!(
2479            CARRIER_BARE_ELIGIBLE,
2480            "this test pins the enabled carrier-arithmetic seam"
2481        );
2482        let (program, facts) = carrier_facts(true);
2483        for name in ["toInt", "doubled"] {
2484            let id = fn_id_by_name(&program, name);
2485            let f = facts.for_fn(id).expect("carrier facts");
2486            assert!(
2487                !f.param_is_bare(0),
2488                "`{name}`'s carrier param is NOT an Int-bare param (no spurious \
2489                 Unbox at the call site)"
2490            );
2491            let pf = program.fn_by_id(id).expect("mir fn");
2492            let carrier_slot = pf.params[0].local;
2493            assert_eq!(
2494                f.carrier_slots.get(&carrier_slot).copied(),
2495                Some(Interval::between(0, 100)),
2496                "`{name}`'s carrier param is a carrier-projection source with the \
2497                 proven [0,100] bound"
2498            );
2499        }
2500    }
2501
2502    #[test]
2503    fn carrier_off_table_records_no_carrier_slots() {
2504        // With the carrier table EMPTY (the Rust backend / no-eligible-carrier
2505        // baseline), no carrier-projection slot is recorded — the byte-
2506        // identical pre-slice behavior.
2507        let (program, facts) = carrier_facts(false);
2508        for name in ["toInt", "doubled"] {
2509            let id = fn_id_by_name(&program, name);
2510            let f = facts.for_fn(id).expect("carrier facts");
2511            assert!(
2512                f.carrier_slots.is_empty(),
2513                "with the carrier table empty, `{name}` records no carrier slot"
2514            );
2515        }
2516    }
2517
2518    /// NESTED carrier-FIELD source: `Holder { c: IntRange }` whose `nestedAdd`
2519    /// reads `h.c.value + h.c.value` (a `Project(Project(..))`). The base
2520    /// `h.c` is stamped the eligible carrier `IntRange`, so the analysis treats
2521    /// each nested `.value` as a raw i64 leaf at the `[0,100]` bound and the sum
2522    /// stays OverflowFree (bare); a wide `0..2^40` carrier's `c.value * c.value`
2523    /// overflows i64 and DECLINES (boxed) — the same fixpoint, fed the nested
2524    /// leaf. With the carrier table OFF, the nested form never fires.
2525    const NESTED_CARRIER_SRC: &str = r#"
2526module Toy
2527    intent = "t"
2528    depends []
2529
2530record IntRange
2531    value: Int
2532
2533record Holder
2534    c: IntRange
2535
2536record Wide
2537    value: Int
2538
2539record WideHolder
2540    c: Wide
2541
2542fn fromInt(n: Int) -> Result<IntRange, String>
2543    match Bool.and(n >= 0, n <= 100)
2544        true  -> Result.Ok(IntRange(value = n))
2545        false -> Result.Err("err")
2546
2547fn fromWide(n: Int) -> Result<Wide, String>
2548    match Bool.and(n >= 0, n <= 1099511627776)
2549        true  -> Result.Ok(Wide(value = n))
2550        false -> Result.Err("err")
2551
2552fn nestedAdd(h: Holder) -> Int
2553    h.c.value + h.c.value
2554
2555fn nestedWideMul(h: WideHolder) -> Int
2556    h.c.value * h.c.value
2557
2558fn main() -> Int
2559    0
2560"#;
2561
2562    fn nested_carrier_facts(carrier_on: bool) -> (MirProgram, BareI64Facts) {
2563        let mut items = parse_source(NESTED_CARRIER_SRC).expect("parse");
2564        let cfg = crate::ir::pipeline::PipelineConfig {
2565            typecheck: Some(crate::ir::pipeline::TypecheckMode::Full { base_dir: None }),
2566            ..Default::default()
2567        };
2568        let result = crate::ir::pipeline::run(&mut items, cfg);
2569        assert!(
2570            result
2571                .typecheck
2572                .as_ref()
2573                .is_none_or(|t| t.errors.is_empty()),
2574            "typecheck errors: {:?}",
2575            result.typecheck.map(|t| t.errors)
2576        );
2577        let carrier = if carrier_on {
2578            carrier_table_for(&items, &result.symbol_table)
2579        } else {
2580            CarrierIntervals::new()
2581        };
2582        let mir_items = result.resolved_items.clone();
2583        let program = optimize(lower_program(&mir_items));
2584        let facts = analyze(&program, &carrier);
2585        (program, facts)
2586    }
2587
2588    /// Find the `Project(Project(..), _)` nested-field read inside `e` and
2589    /// query the carrier-projection recognizer on it.
2590    fn find_nested_project_interval(e: &MirExpr, facts: &FnBareFacts) -> Option<Interval> {
2591        if let MirExpr::Project(p) = e
2592            && matches!(p.node.base.node, MirExpr::Project(_))
2593            && let Some(iv) = facts.carrier_project_interval(e)
2594        {
2595            return Some(iv);
2596        }
2597        let mut found = None;
2598        visit_children(e, &mut |c| {
2599            if found.is_none() {
2600                found = find_nested_project_interval(c, facts);
2601            }
2602        });
2603        found
2604    }
2605
2606    #[test]
2607    fn nested_carrier_field_in_range_is_bare_leaf() {
2608        // The carrier table is ON: the nested `h.c.value` read is recognized as
2609        // a raw i64 leaf carrying the carrier's proven `[0,100]` bound, so the
2610        // `+` over it is OverflowFree (the binding goes bare).
2611        let (program, facts) = nested_carrier_facts(true);
2612        let id = fn_id_by_name(&program, "nestedAdd");
2613        let f = facts.for_fn(id).expect("nestedAdd facts");
2614        let pf = program.fn_by_id(id).expect("mir fn");
2615        let iv = find_nested_project_interval(&pf.body.node, f)
2616            .expect("the nested h.c.value read is recognized as a carrier projection");
2617        assert_eq!(
2618            iv,
2619            Interval::between(0, 100),
2620            "the nested-field read carries the carrier's proven [0,100] bound"
2621        );
2622        // The whole `h.c.value + h.c.value` tree is a bare i64 expression.
2623        let sum =
2624            find_carrier_sum(&pf.body.node).expect("the body contains the `c.value + c.value` add");
2625        assert!(
2626            f.expr_is_bare_i64(sum),
2627            "in-range nested-field sum is bare-i64 eligible (OverflowFree)"
2628        );
2629    }
2630
2631    #[test]
2632    fn nested_carrier_field_off_table_declines() {
2633        // With the carrier table EMPTY, the nested form never fires — the read
2634        // is not recognized as a carrier projection (fail-closed → boxed).
2635        let (program, facts) = nested_carrier_facts(false);
2636        let id = fn_id_by_name(&program, "nestedAdd");
2637        let f = facts.for_fn(id).expect("nestedAdd facts");
2638        let pf = program.fn_by_id(id).expect("mir fn");
2639        assert!(
2640            find_nested_project_interval(&pf.body.node, f).is_none(),
2641            "with the carrier table empty, a nested-field read is NOT a carrier projection"
2642        );
2643    }
2644
2645    #[test]
2646    fn nested_carrier_field_wide_mul_declines() {
2647        // A wide `0..2^40` nested-field carrier: `h.c.value * h.c.value` reaches
2648        // up to 2^80, which overflows i64. Even though each nested read IS a
2649        // recognized carrier projection, the PRODUCT leaves i64, so the compound
2650        // declines to boxed — the same C0 gate as the param-level form.
2651        let (program, facts) = nested_carrier_facts(true);
2652        let id = fn_id_by_name(&program, "nestedWideMul");
2653        let f = facts.for_fn(id).expect("nestedWideMul facts");
2654        let pf = program.fn_by_id(id).expect("mir fn");
2655        // The nested leaf IS recognized (carries the wide bound) …
2656        let iv = find_nested_project_interval(&pf.body.node, f)
2657            .expect("the nested wide read is still a recognized carrier projection");
2658        assert!(iv.fits_i64(), "the [0,2^40] carrier bound itself fits i64");
2659        // … but the `*` product does NOT, so the compound is NOT bare-i64.
2660        let mul = find_carrier_sum(&pf.body.node)
2661            .expect("the body contains the `c.value * c.value` multiply");
2662        assert!(
2663            !f.expr_is_bare_i64(mul),
2664            "the wide-bound nested-field multiply overflows i64 and stays boxed"
2665        );
2666    }
2667
2668    /// Find the `Add`/`Mul` `BinOp` whose operands are nested-field carrier
2669    /// reads (the `c.value + c.value` / `c.value * c.value` body). Manual
2670    /// recursion over the tail-bearing node shapes (the body is a single arith
2671    /// node, possibly wrapped in `Return`/`Let`/`Match`), returning a borrowed
2672    /// reference (so it cannot route through the `visit_children` closure).
2673    fn find_carrier_sum(e: &MirExpr) -> Option<&MirExpr> {
2674        match e {
2675            MirExpr::BinOp(b)
2676                if matches!(b.node.op, BinOp::Add | BinOp::Mul)
2677                    && matches!(b.node.lhs.node, MirExpr::Project(_))
2678                    && matches!(b.node.rhs.node, MirExpr::Project(_)) =>
2679            {
2680                Some(e)
2681            }
2682            MirExpr::Return(inner) | MirExpr::Box(inner) | MirExpr::Unbox(inner) => {
2683                find_carrier_sum(&inner.node)
2684            }
2685            MirExpr::Let(l) => {
2686                find_carrier_sum(&l.node.value.node).or_else(|| find_carrier_sum(&l.node.body.node))
2687            }
2688            MirExpr::Match(m) => m
2689                .node
2690                .arms
2691                .iter()
2692                .find_map(|a| find_carrier_sum(&a.body.node)),
2693            _ => None,
2694        }
2695    }
2696
2697    #[test]
2698    fn countdown_counter_is_bare() {
2699        let src = r#"
2700module Countdown
2701    intent = "t"
2702    depends []
2703
2704fn countdown(n: Int) -> Int
2705    match n
2706        0 -> 0
2707        _ -> countdown(n - 1)
2708
2709fn main() -> Int
2710    countdown(20000)
2711"#;
2712        let (program, facts) = facts_for(src);
2713        let id = fn_id_by_name(&program, "countdown");
2714        let f = facts.for_fn(id).expect("countdown facts");
2715        assert!(
2716            f.param_is_bare(0),
2717            "the countdown counter must be proven bare i64"
2718        );
2719    }
2720
2721    #[test]
2722    fn factorial_counter_is_bare() {
2723        let src = r#"
2724module Factorial
2725    intent = "t"
2726    depends []
2727
2728fn factorial(n: Int, acc: Int) -> Int
2729    match n
2730        0 -> acc
2731        _ -> factorial(n - 1, acc * n)
2732
2733fn main() -> Int
2734    factorial(10, 1)
2735"#;
2736        let (program, facts) = facts_for(src);
2737        let id = fn_id_by_name(&program, "factorial");
2738        let f = facts.for_fn(id).expect("factorial facts");
2739        assert!(
2740            f.param_is_bare(0),
2741            "the factorial counter `n` must be proven bare i64"
2742        );
2743    }
2744
2745    #[test]
2746    fn unbounded_param_stays_boxed() {
2747        // A fn called with a non-literal arg cannot bound its counter, so
2748        // the param must stay boxed (fail-closed).
2749        let src = r#"
2750module M
2751    intent = "t"
2752    depends []
2753
2754fn down(n: Int) -> Int
2755    match n
2756        0 -> 0
2757        _ -> down(n - 1)
2758
2759fn caller(x: Int) -> Int
2760    down(x)
2761
2762fn main() -> Int
2763    caller(5)
2764"#;
2765        let (program, facts) = facts_for(src);
2766        let id = fn_id_by_name(&program, "down");
2767        let f = facts.for_fn(id).expect("down facts");
2768        // `down` is called with `x` (a non-literal param of `caller`), and
2769        // `caller`'s `x` comes from a literal — but the minimal summary
2770        // only bounds DIRECT literal callers, so `down`'s counter is not
2771        // provably bounded here and must stay boxed.
2772        assert!(
2773            !f.param_is_bare(0),
2774            "a counter reached via a non-literal arg must stay boxed (fail-closed)"
2775        );
2776    }
2777
2778    #[test]
2779    fn non_recursive_fn_param_stays_boxed() {
2780        // A non-recursive fn has no recurrence to bound its param; with no
2781        // base-case guard the counter is unbounded ⇒ boxed (fail-closed).
2782        let src = r#"
2783module M
2784    intent = "t"
2785    depends []
2786
2787fn twice(n: Int) -> Int
2788    n + n
2789
2790fn main() -> Int
2791    twice(10)
2792"#;
2793        let (program, facts) = facts_for(src);
2794        let id = fn_id_by_name(&program, "twice");
2795        let f = facts.for_fn(id).expect("twice facts");
2796        assert!(
2797            !f.param_is_bare(0),
2798            "a param with no bounding recurrence must stay boxed"
2799        );
2800    }
2801
2802    #[test]
2803    fn counter_stored_in_aggregate_demotes_via_escape() {
2804        // A bounded counter whose value is STORED in a list escapes (reaches
2805        // a general-Int aggregate), so the analysis must not mark the
2806        // value bare. We assert the body's escape predicate flags it: a
2807        // counter read into `[n]` is a general-Int aggregate store.
2808        let src = r#"
2809module M
2810    intent = "t"
2811    depends []
2812
2813fn collect(n: Int) -> List<Int>
2814    match n
2815        0 -> [0]
2816        _ -> [n]
2817
2818fn main() -> List<Int>
2819    collect(10)
2820"#;
2821        let (program, facts) = facts_for(src);
2822        let id = fn_id_by_name(&program, "collect");
2823        let f = facts.for_fn(id).expect("collect facts");
2824        // The return type is `List<Int>`, not `Int`, so the return is never
2825        // bare; and `n` flows into the `[n]` aggregate, an escape. The
2826        // param must stay boxed.
2827        assert!(
2828            !f.param_is_bare(0),
2829            "a counter stored in a List aggregate escapes → must stay boxed"
2830        );
2831        assert!(!f.bare_return, "a List<Int> return is never bare i64");
2832    }
2833
2834    #[test]
2835    fn worst_join_demotes_transient_out_of_i64_intermediate() {
2836        // The reused #511 worst-join discipline: `(n + i64::MAX) - i64::MAX`
2837        // cancels back into range, but the transient `n + i64::MAX`
2838        // overflows i64, so the binding's op class must NOT be
2839        // `OverflowFree`. Exercise the `eval_interval` + worst-join the body
2840        // pass uses, over a bare-param-seeded `n ∈ [0, 10]`.
2841        let mut env = HashMap::new();
2842        let n = LocalId(0);
2843        env.insert(n, Interval::between(0, 10));
2844        let big = i64::MAX as i128;
2845
2846        // Build the MIR for `(n + MAX) - MAX`.
2847        let lit = |k: i128| Spanned::bare(MirExpr::Literal(Spanned::bare(Literal::Int(k as i64))));
2848        let local_n = Spanned::bare(MirExpr::Local(Spanned::bare(
2849            super::super::super::expr::MirLocal::at(n),
2850        )));
2851        let add = Spanned::bare(MirExpr::BinOp(Spanned::bare(
2852            super::super::super::expr::MirBinOp {
2853                op: BinOp::Add,
2854                lhs: Box::new(local_n),
2855                rhs: Box::new(lit(big)),
2856            },
2857        )));
2858        let sub = MirExpr::BinOp(Spanned::bare(super::super::super::expr::MirBinOp {
2859            op: BinOp::Sub,
2860            lhs: Box::new(add),
2861            rhs: Box::new(lit(big)),
2862        }));
2863
2864        let mut worst = Interval::point(0);
2865        let no_carriers = FnBareFacts::default();
2866        let result = eval_interval(&sub, &env, &mut worst, &no_carriers);
2867        // The final value cancels back into [0, 10] (fits i64) …
2868        assert!(result.fits_i64(), "final value cancels back into range");
2869        // … but the worst-join saw the transient `n + i64::MAX` (out of i64),
2870        // so the op class over the whole expression is NOT OverflowFree.
2871        assert_ne!(
2872            OpClass::of_interval(worst.hull(result)),
2873            OpClass::OverflowFree,
2874            "the transient out-of-i64 intermediate must demote below OverflowFree"
2875        );
2876    }
2877
2878    // ── BUG 1: recurrence guard REACHABILITY ────────────────────────────
2879
2880    /// A decrement counter whose step does NOT divide `entry - K` steps OVER
2881    /// the equality guard `K` and diverges to -inf — the certified interval
2882    /// is fiction, so the param must NOT be bare (congruence skip).
2883    #[test]
2884    fn congruence_skip_declines() {
2885        let src = r#"
2886module M
2887    intent = "t"
2888    depends []
2889
2890fn loopit(n: Int, acc: Int) -> Int
2891    match n
2892        0 -> acc
2893        _ -> loopit(n - 4611686018427387905, acc)
2894
2895fn main() -> Int
2896    loopit(4, 0)
2897"#;
2898        let (program, facts) = facts_for(src);
2899        let id = fn_id_by_name(&program, "loopit");
2900        let f = facts.for_fn(id).expect("loopit facts");
2901        assert!(
2902            !f.param_is_bare(0),
2903            "a counter that steps OVER its equality guard (4 % (2^62+1) != 0) must box"
2904        );
2905    }
2906
2907    /// Entry below the guard: the decrement moves AWAY from `K`, diverging —
2908    /// must box.
2909    #[test]
2910    fn entry_below_guard_declines() {
2911        let src = r#"
2912module M
2913    intent = "t"
2914    depends []
2915
2916fn down(n: Int, acc: Int) -> Int
2917    match n
2918        100 -> acc
2919        _ -> down(n - 1, acc)
2920
2921fn main() -> Int
2922    down(5, 0)
2923"#;
2924        let (program, facts) = facts_for(src);
2925        let id = fn_id_by_name(&program, "down");
2926        let f = facts.for_fn(id).expect("down facts");
2927        assert!(
2928            !f.param_is_bare(0),
2929            "entry 5 < guard 100 decrements away from K → diverges → must box"
2930        );
2931    }
2932
2933    /// Odd entry, step 2 toward guard 0: parity-skip, never lands on 0 —
2934    /// must box.
2935    #[test]
2936    fn parity_skip_declines() {
2937        let src = r#"
2938module M
2939    intent = "t"
2940    depends []
2941
2942fn skip(n: Int, acc: Int) -> Int
2943    match n
2944        0 -> acc
2945        _ -> skip(n - 2, acc)
2946
2947fn main() -> Int
2948    skip(25, 0)
2949"#;
2950        let (program, facts) = facts_for(src);
2951        let id = fn_id_by_name(&program, "skip");
2952        let f = facts.for_fn(id).expect("skip facts");
2953        assert!(
2954            !f.param_is_bare(0),
2955            "odd entry 25 with step 2 toward guard 0 steps over 0 → must box"
2956        );
2957    }
2958
2959    /// Even entry, step 2 toward guard 0: the sequence LANDS on 0 — the fix
2960    /// declines only UNREACHABLE guards, not all step>1, so this stays BARE.
2961    #[test]
2962    fn divisible_reachable_guard_stays_bare() {
2963        let src = r#"
2964module M
2965    intent = "t"
2966    depends []
2967
2968fn skip(n: Int, acc: Int) -> Int
2969    match n
2970        0 -> acc
2971        _ -> skip(n - 2, acc)
2972
2973fn main() -> Int
2974    skip(24, 0)
2975"#;
2976        let (program, facts) = facts_for(src);
2977        let id = fn_id_by_name(&program, "skip");
2978        let f = facts.for_fn(id).expect("skip facts");
2979        assert!(
2980            f.param_is_bare(0),
2981            "even entry 24 with step 2 reaches guard 0 → must stay bare (win survives)"
2982        );
2983    }
2984
2985    // ── BUG 2: compound interval gate ───────────────────────────────────
2986
2987    /// A compound `n + i64::MAX` whose result interval leaves `i64` is NOT
2988    /// bare, even though both operands are bare — `expr_is_bare_i64` must
2989    /// decline it so codegen boxes the arithmetic.
2990    #[test]
2991    fn overflowing_compound_is_not_bare() {
2992        let mut facts = FnBareFacts::default();
2993        let n = LocalId(0);
2994        // `n` is a bare counter confined to `[1, 1]`.
2995        facts.values.insert(
2996            n,
2997            ValueFact {
2998                interval: Some(Interval::point(1)),
2999                op_class: OpClass::OverflowFree,
3000                escapes: false,
3001                repr: Repr::Bare,
3002            },
3003        );
3004        let big = i64::MAX as i128;
3005        let local_n = Spanned::bare(MirExpr::Local(Spanned::bare(
3006            super::super::super::expr::MirLocal::at(n),
3007        )));
3008        let lit = Spanned::bare(MirExpr::Literal(Spanned::bare(Literal::Int(big as i64))));
3009        let add = MirExpr::BinOp(Spanned::bare(super::super::super::expr::MirBinOp {
3010            op: BinOp::Add,
3011            lhs: Box::new(local_n),
3012            rhs: Box::new(lit),
3013        }));
3014        assert!(
3015            !facts.expr_is_bare_i64(&add),
3016            "`n + i64::MAX` (result [MAX+1, MAX+1]) leaves i64 → must NOT be bare"
3017        );
3018    }
3019
3020    /// A compound `n - 1` over a tight bare counter `[0, 20000]` STAYS in
3021    /// i64 → bare (the legitimate fast decrement must survive).
3022    #[test]
3023    fn in_range_compound_stays_bare() {
3024        let mut facts = FnBareFacts::default();
3025        let n = LocalId(0);
3026        facts.values.insert(
3027            n,
3028            ValueFact {
3029                interval: Some(Interval::between(-1, 20000)),
3030                op_class: OpClass::OverflowFree,
3031                escapes: false,
3032                repr: Repr::Bare,
3033            },
3034        );
3035        let local_n = Spanned::bare(MirExpr::Local(Spanned::bare(
3036            super::super::super::expr::MirLocal::at(n),
3037        )));
3038        let lit = Spanned::bare(MirExpr::Literal(Spanned::bare(Literal::Int(1))));
3039        let sub = MirExpr::BinOp(Spanned::bare(super::super::super::expr::MirBinOp {
3040            op: BinOp::Sub,
3041            lhs: Box::new(local_n),
3042            rhs: Box::new(lit),
3043        }));
3044        assert!(
3045            facts.expr_is_bare_i64(&sub),
3046            "`n - 1` over a tight `[-1, 20000]` counter stays in i64 → bare"
3047        );
3048    }
3049
3050    // ── BUG 3: escape scan recurses into BinOp ──────────────────────────
3051
3052    /// A bare counter reaching a `List<Int>` aggregate THROUGH a `BinOp`
3053    /// (`[n + 1]`) escapes — the escape scan must mark it so the param boxes
3054    /// (the aggregate emit does not convert a bare compound).
3055    #[test]
3056    fn binop_in_aggregate_escapes() {
3057        let src = r#"
3058module M
3059    intent = "t"
3060    depends []
3061
3062fn collect(n: Int) -> List<Int>
3063    match n
3064        1 -> [n + 1]
3065        _ -> collect(n - 1)
3066
3067fn main() -> List<Int>
3068    collect(2)
3069"#;
3070        let (program, facts) = facts_for(src);
3071        let id = fn_id_by_name(&program, "collect");
3072        let f = facts.for_fn(id).expect("collect facts");
3073        assert!(
3074            !f.param_is_bare(0),
3075            "a counter reaching `[n + 1]` through a BinOp escapes the aggregate → must box"
3076        );
3077    }
3078
3079    // ── BOUNDARY-COMPLETENESS regressions (PR #519 four defects) ─────────
3080    //
3081    // Each defect was a valid Aver program whose emitted Rust failed to
3082    // compile because the analysis/codegen disagreed on a value's
3083    // representation at a use position. The fix is fail-closed: the counter
3084    // stays bare (fast loop preserved) while the single crossing converts at
3085    // the boundary, OR the escaping value demotes. These assert the
3086    // analysis-observable half (the codegen boundary conversions are covered
3087    // by `tests/rust_codegen_regression.rs`).
3088
3089    /// Defect Q4: a bare compound `n + 1` flows as a Call arg to a BOXED
3090    /// param (`keep(x: Int)`). The counter stays bare — codegen converts the
3091    /// arg with `from_i64` at the boxed-param boundary (the value itself does
3092    /// not cross; a fresh `AverInt` does), so the fast loop is preserved.
3093    #[test]
3094    fn call_arg_to_boxed_param_keeps_counter_bare() {
3095        let src = r#"
3096module M
3097    intent = "t"
3098    depends []
3099
3100fn keep(x: Int) -> Int
3101    x
3102
3103fn down(n: Int) -> Int
3104    match n
3105        0 -> keep(n + 1)
3106        _ -> down(n - 1)
3107
3108fn main() -> Int
3109    down(2)
3110"#;
3111        let (program, facts) = facts_for(src);
3112        let down = facts
3113            .for_fn(fn_id_by_name(&program, "down"))
3114            .expect("down facts");
3115        // `down`'s counter stays bare (converted at the boxed-param boundary).
3116        assert!(
3117            down.param_is_bare(0),
3118            "the down counter stays bare; the boxed-param arg `n + 1` converts at the boundary"
3119        );
3120        // `keep`'s param is a general-Int (boxed) — it has a non-literal,
3121        // non-bare-supplyable caller arg shape, so it never goes bare.
3122        let keep = facts
3123            .for_fn(fn_id_by_name(&program, "keep"))
3124            .expect("keep facts");
3125        assert!(
3126            !keep.param_is_bare(0),
3127            "keep's general-Int param stays boxed (no bounding recurrence)"
3128        );
3129    }
3130
3131    /// Defect Q5: a fn `g` whose return is proven bare is consumed by `h`
3132    /// whose own return is the general Int. `g` keeps its bare return + bare
3133    /// counter (the fast loop); codegen boxes the call result with `from_i64`
3134    /// at `h`'s return crossing. The whole-program summary must still mark
3135    /// `g.bare_return` (the consumer demotion is a CODEGEN conversion, not an
3136    /// analysis demotion, so the win is preserved).
3137    #[test]
3138    fn bare_return_consumed_by_boxed_return_fn_stays_bare() {
3139        let src = r#"
3140module M
3141    intent = "t"
3142    depends []
3143
3144fn g(n: Int) -> Int
3145    match n
3146        0 -> 0
3147        _ -> g(n - 1)
3148
3149fn h() -> Int
3150    g(2)
3151
3152fn main() -> Int
3153    h()
3154"#;
3155        let (program, facts) = facts_for(src);
3156        let g = facts.for_fn(fn_id_by_name(&program, "g")).expect("g facts");
3157        assert!(g.param_is_bare(0), "g's bounded counter stays bare");
3158        assert!(
3159            g.bare_return,
3160            "g's return stays bare; the boxed consumer `h` converts at its return boundary"
3161        );
3162    }
3163
3164    /// Defect subj_ret (opus Area 3): a bare counter `n` aliased through an
3165    /// inner match binding `match n { y -> y }`. The alias must be TRACKED so
3166    /// `y` inherits `n`'s bare interval — otherwise the body facts and codegen
3167    /// (which declares `y` at `n`'s bare type) disagree. The counter stays
3168    /// bare; the return crossing boxes `y` with `from_i64`.
3169    #[test]
3170    fn match_binding_alias_is_tracked_bare() {
3171        let src = r#"
3172module M
3173    intent = "t"
3174    depends []
3175
3176fn loopit(n: Int) -> Int
3177    match n
3178        0 -> match n
3179            y -> y
3180        _ -> loopit(n - 1)
3181
3182fn main() -> Int
3183    loopit(3)
3184"#;
3185        let (program, facts) = facts_for(src);
3186        let f = facts
3187            .for_fn(fn_id_by_name(&program, "loopit"))
3188            .expect("loopit facts");
3189        assert!(
3190            f.param_is_bare(0),
3191            "the counter stays bare; the aliased binding `y` inherits its bare interval"
3192        );
3193        // The aliased binding `y` must carry a fact (not absent → unknown):
3194        // it aliases the bare param, so the analysis proves it bare and the
3195        // codegen agrees on the representation at the return crossing.
3196        let bare_y = f.values.values().any(|v| v.is_bare());
3197        assert!(
3198            bare_y,
3199            "at least the counter / its bare alias is proven bare"
3200        );
3201    }
3202
3203    /// Defect esc_match (opus Area 3, escaping alias): a bare compound
3204    /// `let x = n - 1` aliased into an `Int` aggregate `[x, x]`. `x` must
3205    /// DEMOTE (it reaches a general-Int aggregate); the counter `n` stays
3206    /// bare. Codegen boxes the binding value with `from_i64` at the let
3207    /// crossing.
3208    #[test]
3209    fn match_let_alias_into_aggregate_demotes() {
3210        let src = r#"
3211module M
3212    intent = "t"
3213    depends []
3214
3215fn loopit(n: Int) -> List<Int>
3216    match n
3217        0 -> match n - 1
3218            x -> [x, x]
3219        _ -> loopit(n - 1)
3220
3221fn main() -> List<Int>
3222    loopit(4)
3223"#;
3224        let (program, facts) = facts_for(src);
3225        let f = facts
3226            .for_fn(fn_id_by_name(&program, "loopit"))
3227            .expect("loopit facts");
3228        // The counter `n` stays bare (its only escaping use is via the boxed
3229        // `x` binding, not `n` itself).
3230        assert!(f.param_is_bare(0), "the counter `n` stays bare");
3231        // The `List<Int>` return is never bare.
3232        assert!(!f.bare_return, "a List<Int> return is never bare i64");
3233    }
3234
3235    /// Defect marms: a bare counter whose guard has ≥2 Int-literal base-case
3236    /// arms lowers to the dispatch-table match path. The counter legitimately
3237    /// stays bare and is compared against the literals — the codegen dispatch
3238    /// path must emit `subject == {K}i64` (not `i64 == AverInt`). This asserts
3239    /// the analysis keeps the multi-literal-arm counter bare (the codegen
3240    /// dispatch-bare path is covered by `tests/rust_codegen_regression.rs`).
3241    #[test]
3242    fn multi_literal_arm_counter_stays_bare() {
3243        let src = r#"
3244module M
3245    intent = "t"
3246    depends []
3247
3248fn loopit(n: Int, acc: Int) -> Int
3249    match n
3250        2 -> acc
3251        0 -> acc
3252        _ -> loopit(n - 1, acc + 1)
3253
3254fn main() -> Int
3255    loopit(5, 0)
3256"#;
3257        let (program, facts) = facts_for(src);
3258        let f = facts
3259            .for_fn(fn_id_by_name(&program, "loopit"))
3260            .expect("loopit facts");
3261        assert!(
3262            f.param_is_bare(0),
3263            "a ≥2-literal-arm bounded counter stays bare (dispatch path emits `== Ki64`)"
3264        );
3265    }
3266
3267    /// Multi-tail-call soundness hole: a counter with TWO self-tail-call
3268    /// paths at the same index — one decrements (`n - 1`), one GROWS (`n +
3269    /// 1_000_000_000_000_000_000`). The pre-fix recurrence recognizer
3270    /// stopped at the FIRST (decrement) path and seeded the param's interval
3271    /// from the decrement alone, marking it bare; at runtime the growth path
3272    /// drove `n` out of `i64` range and the emitted native `i64` op
3273    /// (`n + n`) silently wrapped in release (the C0 bug — caught only by the
3274    /// `overflow-checks` panic in dev). The fix requires EVERY self-tail-call
3275    /// arg at the index to be the SAME monotone decrement; a second growing
3276    /// path makes the recurrence unbounded ⇒ the param must BOX (fail-closed).
3277    #[test]
3278    fn multi_tailcall_growing_path_demotes_to_boxed() {
3279        let src = r#"
3280module M
3281    intent = "t"
3282    depends []
3283
3284fn loopit(n: Int, phase: Int) -> Int
3285    match n
3286        0 -> n + n
3287        _ -> match phase
3288            0 -> n + n
3289            5000 -> loopit(n - 1, phase)
3290            _ -> loopit(n + 1000000000000000000, phase - 1)
3291
3292fn main() -> Int
3293    loopit(8, 5)
3294"#;
3295        let (program, facts) = facts_for(src);
3296        let f = facts
3297            .for_fn(fn_id_by_name(&program, "loopit"))
3298            .expect("loopit facts");
3299        // The counter `n` has a SECOND, growing self-tail-call path, so it is
3300        // NOT a provably-bounded counter — it must box. Pre-fix this asserted
3301        // `param_is_bare(0) == true` (the soundness hole): the recognizer saw
3302        // only the `n - 1` path.
3303        assert!(
3304            !f.param_is_bare(0),
3305            "a counter with a growing second self-tail-call path is unbounded → must box"
3306        );
3307    }
3308
3309    /// Recursive-base-arm soundness hole: the equality-guard arm (`match n {
3310    /// 0 -> … }`) is treated as the counter's stopping point, but its body
3311    /// itself self-recurses (`0 -> loopit(n - 1)`). The counter therefore
3312    /// never stops at the guard `0` — it runs past it toward `-inf` (in ℤ) /
3313    /// wraps (as bare `i64`), so the `[K - step, entry]` bound is fiction.
3314    /// Pre-fix, `guard_literal_for` accepted the `0` arm as an equality guard
3315    /// without checking it terminates, and `walk_self_tailcall_steps` counted
3316    /// the base arm's `n - 1` as a valid decrement ⇒ `param_is_bare(0) == true`
3317    /// (the hole). The fix declines when the equality-guard arm self-recurses.
3318    #[test]
3319    fn recursive_base_arm_declines() {
3320        let src = r#"
3321module M
3322    intent = "t"
3323    depends []
3324
3325fn loopit(n: Int) -> Int
3326    match n
3327        0 -> loopit(n - 1)
3328        9223372036854775807 -> n + 1
3329        _ -> loopit(n - 1)
3330
3331fn main() -> Int
3332    loopit(5)
3333"#;
3334        let (program, facts) = facts_for(src);
3335        let f = facts
3336            .for_fn(fn_id_by_name(&program, "loopit"))
3337            .expect("loopit facts");
3338        // The equality-guard `0` arm self-recurses, so `0` is not a stopping
3339        // value and the counter is unbounded below — it must box. Pre-fix this
3340        // asserted `param_is_bare(0) == true` (the soundness hole).
3341        assert!(
3342            !f.param_is_bare(0),
3343            "a counter whose equality-guard base arm self-recurses is unbounded → must box"
3344        );
3345    }
3346
3347    /// Guard-dominance hole: the `0` literal arm that `guard_literal_for`
3348    /// latches onto lives in a DEAD `match n` binding, NOT in the match that
3349    /// actually gates the recursion (whose base case is `i64::MAX`). The
3350    /// counter decrements toward `-inf`, never stopping at `0`, so the
3351    /// `[K-step, entry]` floor is fiction. The fix requires the `K` arm and a
3352    /// self-tail-call to be sibling arms of the SAME `match counter`
3353    /// (`guard_dominates_recursion`); here they are not, so the param boxes.
3354    /// Found by the cross-vendor panel on the fixpoint PR — a latent hole
3355    /// pre-existing in the hand-rolled recognizers.
3356    #[test]
3357    fn non_dominating_guard_declines() {
3358        let src = r#"
3359module M
3360    intent = "t"
3361    depends []
3362
3363fn bad(n: Int) -> Int
3364    witness = match n
3365        0 -> 0
3366        _ -> 0
3367    match n
3368        9223372036854775807 -> n
3369        _ -> bad(n - 1)
3370
3371fn main() -> Int
3372    bad(5)
3373"#;
3374        let (program, facts) = facts_for(src);
3375        let f = facts
3376            .for_fn(fn_id_by_name(&program, "bad"))
3377            .expect("bad facts");
3378        // The `0` guard does not dominate the recursion (its match is a dead
3379        // binding); the real base case is `i64::MAX`, never reached descending
3380        // from 5 — the counter is unbounded below and must box.
3381        assert!(
3382            !f.param_is_bare(0),
3383            "a counter whose equality guard does not dominate the recursion is unbounded → must box"
3384        );
3385    }
3386
3387    /// The OTHER idiomatic countdown shape — `match n == 0 { true -> …;
3388    /// false -> down(n-1) }` — lowers to `IfThenElse { cond: n == 0, then,
3389    /// else }`. Its guard dominates the recursion (rec in the `n != 0` else
3390    /// branch, base in the `== 0` then branch), so the counter must STAY bare.
3391    /// Guards the over-box the dominance gate would otherwise cause on the
3392    /// `Eq`-cond form (caught by the empirical panel).
3393    #[test]
3394    fn comparison_equality_countdown_stays_bare() {
3395        let src = r#"
3396module M
3397    intent = "t"
3398    depends []
3399
3400fn down(n: Int) -> Int
3401    match n == 0
3402        true -> 0
3403        false -> down(n - 1)
3404
3405fn main() -> Int
3406    down(20000)
3407"#;
3408        let (program, facts) = facts_for(src);
3409        let f = facts
3410            .for_fn(fn_id_by_name(&program, "down"))
3411            .expect("down facts");
3412        // `n == 0` dominates (rec in the `!= 0` branch), so the counter is
3413        // bounded `[0, 20000]` and stays bare — same as `match n { 0 -> … }`.
3414        assert!(
3415            f.param_is_bare(0),
3416            "a `match n == 0` countdown's counter dominates and must stay bare (no over-box)"
3417        );
3418    }
3419
3420    // ── FIXPOINT producer: interval-VALUE goldens (byte-identity) ───────
3421
3422    /// The produced interval for a `(fn, param)`, read straight off the
3423    /// fixpoint producer (not just `param_is_bare`). Pins the VALUE.
3424    fn produced_interval(program: &MirProgram, fn_name: &str, i: usize) -> Option<Interval> {
3425        let id = fn_id_by_name(program, fn_name);
3426        let ivs = compute_param_intervals_for_test(program);
3427        ivs.get(&id).and_then(|v| v.get(i).copied()).flatten()
3428    }
3429
3430    /// Countdown's counter interval must equal the OLD closed-form value
3431    /// `[K-step, entry] = [-1, 20000]` (the old `param_recurrence_bound`
3432    /// combine `[min(E.lo, K-step), max(E.hi, K)]` for K=0, step=1, E=20000).
3433    /// This pins byte-identity at the VALUE level, not just `param_is_bare`.
3434    #[test]
3435    fn countdown_interval_is_K_minus_step_to_entry() {
3436        let src = r#"
3437module Countdown
3438    intent = "t"
3439    depends []
3440
3441fn countdown(n: Int) -> Int
3442    match n
3443        0 -> 0
3444        _ -> countdown(n - 1)
3445
3446fn main() -> Int
3447    countdown(20000)
3448"#;
3449        let (program, _facts) = facts_for(src);
3450        assert_eq!(
3451            produced_interval(&program, "countdown", 0),
3452            Some(Interval::between(-1, 20000)),
3453            "countdown's counter interval must be byte-identical to the old [K-step, entry]"
3454        );
3455    }
3456
3457    /// Factorial: `n` (the counter) is `[K-step, entry] = [-1, 10]`; `acc`
3458    /// (the growing accumulator) has no guard, so it widens out of i64 → None.
3459    #[test]
3460    fn factorial_n_interval_is_K_minus_step_to_entry_and_acc_is_none() {
3461        let src = r#"
3462module Factorial
3463    intent = "t"
3464    depends []
3465
3466fn factorial(n: Int, acc: Int) -> Int
3467    match n
3468        0 -> acc
3469        _ -> factorial(n - 1, acc * n)
3470
3471fn main() -> Int
3472    factorial(10, 1)
3473"#;
3474        let (program, _facts) = facts_for(src);
3475        assert_eq!(
3476            produced_interval(&program, "factorial", 0),
3477            Some(Interval::between(-1, 10)),
3478            "factorial `n` interval must be byte-identical to [K-step, entry] = [-1, 10]"
3479        );
3480        assert_eq!(
3481            produced_interval(&program, "factorial", 1),
3482            None,
3483            "factorial `acc` grows unbounded → boxed (None)"
3484        );
3485    }
3486
3487    /// The #519 modular non-landing decline, at the VALUE level: step 2,
3488    /// guard 0, entry 5 — `(5-0) % 2 != 0` withholds the floor, so the param
3489    /// is boxed (None), not `Some([0,5])` (the under-approximation the gate
3490    /// closes). Pins that the decline survives the fixpoint rewrite.
3491    #[test]
3492    fn step_two_modular_nonlanding_interval_is_none() {
3493        let src = r#"
3494module M
3495    intent = "t"
3496    depends []
3497
3498fn down(n: Int, acc: Int) -> Int
3499    match n
3500        0 -> acc
3501        _ -> down(n - 2, acc)
3502
3503fn main() -> Int
3504    down(5, 0)
3505"#;
3506        let (program, facts) = facts_for(src);
3507        let f = facts
3508            .for_fn(fn_id_by_name(&program, "down"))
3509            .expect("down facts");
3510        assert!(
3511            !f.param_is_bare(0),
3512            "odd entry 5, step 2 toward guard 0 steps over 0 → must box"
3513        );
3514        assert_eq!(
3515            produced_interval(&program, "down", 0),
3516            None,
3517            "the modular-hole counter must be None (the gate withholds the floor)"
3518        );
3519    }
3520
3521    /// Termination + boxing: an unguarded unit decrement with no reachable
3522    /// base case (the guard subject is a DIFFERENT param, so the counter `n`
3523    /// is never stopped) must (a) terminate the solve — no hang — and (b) map
3524    /// the counter to None (boxed), exercising widen on the descending `lo`.
3525    #[test]
3526    fn widen_terminates_unbounded_decrement() {
3527        let src = r#"
3528module M
3529    intent = "t"
3530    depends []
3531
3532fn spin(n: Int, k: Int) -> Int
3533    match k
3534        0 -> n
3535        _ -> spin(n - 1, k - 1)
3536
3537fn caller(k: Int) -> Int
3538    spin(7, k)
3539
3540fn main() -> Int
3541    caller(3)
3542"#;
3543        let (program, facts) = facts_for(src);
3544        // `spin`'s `n` has a unit decrement but its guard is on `k`, not `n`;
3545        // `n` has a literal entry (7) but no equality guard ON `n`, so no
3546        // floor is installed → the descent widens → None (boxed). The solve
3547        // must terminate (this test returning at all proves no hang).
3548        let f = facts
3549            .for_fn(fn_id_by_name(&program, "spin"))
3550            .expect("spin facts");
3551        assert!(
3552            !f.param_is_bare(0),
3553            "an unguarded decrement counter (guard is on another param) must box"
3554        );
3555        assert_eq!(
3556            produced_interval(&program, "spin", 0),
3557            None,
3558            "the unguarded decrement counter must widen to None, not hang"
3559        );
3560    }
3561
3562    // ---- multi-field carrier bound attribution -----------------------------
3563
3564    /// Build the per-`(record, field)` carrier-interval table for `src`
3565    /// through the same multi-field derivation the codegen entry uses.
3566    fn field_table_for(
3567        src: &str,
3568    ) -> HashMap<(String, String), (crate::ir::interval::Interval, bool)> {
3569        let mut items = parse_source(src).expect("parse");
3570        let cfg = crate::ir::pipeline::PipelineConfig {
3571            typecheck: Some(crate::ir::pipeline::TypecheckMode::Full { base_dir: None }),
3572            ..Default::default()
3573        };
3574        let result = crate::ir::pipeline::run(&mut items, cfg);
3575        let empty_prefixes: HashSet<String> = HashSet::new();
3576        let empty_recursive: HashSet<crate::ir::FnId> = HashSet::new();
3577        let inputs = crate::codegen::proof_lower::ProofLowerInputs {
3578            entry_items: &items,
3579            dep_modules: &[],
3580            module_prefixes: &empty_prefixes,
3581            recursive_fns: &empty_recursive,
3582            symbol_table: &result.symbol_table,
3583            program_shape: None,
3584        };
3585        crate::codegen::proof_lower::field_carrier_interval_table(&inputs)
3586    }
3587
3588    #[test]
3589    fn field_carrier_per_field_intervals() {
3590        // A 2-arg smart ctor bounding each field independently → each field
3591        // gets its own proven interval.
3592        let src = r#"
3593module Toy
3594    intent = "t"
3595    depends []
3596
3597record Coord
3598    x: Int
3599    y: Int
3600
3601fn coord(x: Int, y: Int) -> Result<Coord, String>
3602    match Bool.and(Bool.and(x >= 0, x <= 100), Bool.and(y >= 0, y <= 200))
3603        true  -> Result.Ok(Coord(x = x, y = y))
3604        false -> Result.Err("err")
3605
3606fn main() -> Int
3607    match coord(1, 2)
3608        Result.Ok(c)  -> c.x
3609        Result.Err(_) -> 0
3610"#;
3611        let table = field_table_for(src);
3612        let (ix, kx) = table
3613            .get(&("Coord".to_string(), "x".to_string()))
3614            .copied()
3615            .expect("x field bound");
3616        let (iy, ky) = table
3617            .get(&("Coord".to_string(), "y".to_string()))
3618            .copied()
3619            .expect("y field bound");
3620        assert!(kx && ky, "both fields recognized");
3621        use crate::ir::interval::Bound;
3622        assert_eq!(ix.lo, Bound::Finite(0));
3623        assert_eq!(ix.hi, Bound::Finite(100));
3624        assert_eq!(iy.lo, Bound::Finite(0));
3625        assert_eq!(iy.hi, Bound::Finite(200));
3626    }
3627
3628    #[test]
3629    fn field_carrier_cross_field_condition_dropped() {
3630        // A cross-field leaf (`x + y <= 50`) mentions two params; it is dropped
3631        // from each field's projection, so each field keeps only its own
3632        // single-variable bound. The bound is a sound over-approximation.
3633        let src = r#"
3634module Toy
3635    intent = "t"
3636    depends []
3637
3638record Coord
3639    x: Int
3640    y: Int
3641
3642fn coord(x: Int, y: Int) -> Result<Coord, String>
3643    match Bool.and(Bool.and(x >= 0, x <= 100), Bool.and(y >= 0, x + y <= 50))
3644        true  -> Result.Ok(Coord(x = x, y = y))
3645        false -> Result.Err("err")
3646
3647fn main() -> Int
3648    0
3649"#;
3650        let table = field_table_for(src);
3651        // x keeps its single-var [0, 100] (the cross-field `x + y <= 50` drops).
3652        let (ix, kx) = table
3653            .get(&("Coord".to_string(), "x".to_string()))
3654            .copied()
3655            .expect("x field bound");
3656        assert!(kx);
3657        use crate::ir::interval::Bound;
3658        assert_eq!(ix.lo, Bound::Finite(0));
3659        assert_eq!(ix.hi, Bound::Finite(100));
3660        // y has only `y >= 0` as a single-var leaf (the upper bound was the
3661        // dropped cross-field condition) → no fits_i64 upper bound, so the
3662        // interval is recognized-but-unbounded-above; it is NOT eligible.
3663        let y = table.get(&("Coord".to_string(), "y".to_string())).copied();
3664        if let Some((iy, ky)) = y {
3665            assert!(
3666                !(ky && iy.fits_i64()),
3667                "y with only a lower bound must not be a fits_i64 eligible field"
3668            );
3669        }
3670    }
3671
3672    #[test]
3673    fn field_carrier_only_one_field_bounded() {
3674        // A mixed record: one field gated, the other not mentioned in the
3675        // guard at all. Only the gated field gets a bound.
3676        let src = r#"
3677module Toy
3678    intent = "t"
3679    depends []
3680
3681record Coord
3682    x: Int
3683    y: Int
3684
3685fn coord(x: Int, y: Int) -> Result<Coord, String>
3686    match Bool.and(x >= 0, x <= 100)
3687        true  -> Result.Ok(Coord(x = x, y = y))
3688        false -> Result.Err("err")
3689
3690fn main() -> Int
3691    0
3692"#;
3693        let table = field_table_for(src);
3694        assert!(
3695            table.contains_key(&("Coord".to_string(), "x".to_string())),
3696            "the gated field x is bounded"
3697        );
3698        let y = table.get(&("Coord".to_string(), "y".to_string())).copied();
3699        assert!(
3700            y.is_none_or(|(iv, k)| !(k && iv.fits_i64())),
3701            "the ungated field y must not be an eligible bounded field"
3702        );
3703    }
3704
3705    #[test]
3706    fn field_carrier_mis_fire_no_smart_ctor() {
3707        // A plain 2-field record with NO smart constructor → no bound is
3708        // attributed to any field (the table is empty for it).
3709        let src = r#"
3710module Toy
3711    intent = "t"
3712    depends []
3713
3714record Coord
3715    x: Int
3716    y: Int
3717
3718fn main() -> Int
3719    Coord(x = 1, y = 2).x
3720"#;
3721        let table = field_table_for(src);
3722        assert!(
3723            !table.contains_key(&("Coord".to_string(), "x".to_string())),
3724            "a record with no smart ctor attributes no field bound"
3725        );
3726    }
3727}