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}