Skip to main content

lex_bytecode/
escape.rs

1//! Escape analysis for stack-allocatable aggregate sites —
2//! `Op::MakeRecord` and `Op::MakeTuple` (#464 + tuple widening).
3//!
4//! Walks every function's bytecode to classify each aggregate
5//! allocation site as either **stack-allocatable** (the value never
6//! leaves the function frame) or **escapes** (used as a closure
7//! capture, returned, stored in another aggregate, passed to a call,
8//! sent on a channel, etc.). The escape rules are identical for
9//! records and tuples — only the eventual codegen opcode differs —
10//! so a single `Slot::Agg(pc)` lattice value tracks both.
11//!
12//! ## Status
13//!
14//! - **Records** (`MakeRecord`): analysis + codegen complete (#464).
15//!   `compiler::apply_escape_lowering` rewrites non-escaping
16//!   `MakeRecord` to `AllocStackRecord`.
17//! - **Tuples** (`MakeTuple`): analysis only, this slice. Sites are
18//!   classified and reported with `SiteKind::Tuple`, but no codegen
19//!   consumes them yet — `apply_escape_lowering` only rewrites
20//!   `MakeRecord`, so reporting tuple sites is inert until a tuple
21//!   stack-alloc opcode lands. Mirrors how #464 sequenced records
22//!   (analysis → codegen → bench).
23//!
24//! ## Approach
25//!
26//! Abstract interpretation over the bytecode CFG. Each abstract
27//! state tracks:
28//! - per-stack-slot: `Slot::Agg(pc)` (the aggregate allocated at
29//!   `pc`, still local) or `Slot::Other` (anything else — int,
30//!   string, captured value, aggregate we've stopped tracking)
31//! - per-local: same `Slot` lattice, indexed by `locals[i]`
32//!
33//! At each op we update the abstract state and union any newly-
34//! observed escapes into a `HashSet<u32>` keyed by allocation pc.
35//! Worklist fixpoint iterates until no state changes — joins use a
36//! pointwise merge that downgrades `Agg(a) ⊔ Agg(b)` (a ≠ b) and
37//! `Agg(a) ⊔ Other` to `Other`, marking the involved sites as
38//! escaped (we can no longer prove they stay local from this
39//! merge point forward).
40//!
41//! ## Intra-procedural limit
42//!
43//! Calls (`Call`, `TailCall`, `CallClosure`) escape all argument
44//! aggregates — we can't see the callee's body from here. Inlining
45//! could recover the cross-fn case but is deliberately out of
46//! scope; the issue's wording ("function frame") matches
47//! intra-procedural.
48//!
49//! ## Conservative defaults
50//!
51//! Whenever the analysis can't prove an aggregate stays local, it
52//! defaults to *escaped*. False positives (sites flagged as
53//! escaping when they actually don't) cost a heap allocation per
54//! request — the existing baseline. False negatives (a flagged-
55//! local site that actually escapes) would corrupt memory under
56//! stack-alloc codegen, so the codegen step treats the analysis
57//! output as a *necessary* but not sufficient precondition and
58//! pairs it with an unconditional runtime fallback.
59
60use std::collections::{HashMap, HashSet};
61
62use crate::op::Op;
63use crate::program::Function;
64
65/// Scope policy for the escape analysis. Only `Op::Return` consults
66/// it — every other escape rule is identical across policies.
67///
68/// - `FrameScope` (default for #464 stack-record lowering): `Return`
69///   leaks its operand, because the returned value crosses the
70///   current function's frame into the caller.
71/// - `RequestScope` (for #463 arena routing): `Return` does NOT leak
72///   its operand — the value goes to the caller's stack and the
73///   caller is in the same request scope opened by
74///   `EffectHandler::enter_request_scope`. What the caller does with
75///   the returned value is an inter-procedural question, deliberately
76///   left to the caller's own conservative `Call` arm (which marks
77///   its args as escaping in the intra-procedural first cut). See
78///   `docs/design/arena-plumbing.md`.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum Policy {
81    FrameScope,
82    RequestScope,
83}
84
85/// Abstract value at a stack or local slot during the analysis.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub enum Slot {
88    /// Holds the stack-allocatable aggregate (a record from
89    /// `Op::MakeRecord` or a tuple from `Op::MakeTuple`) allocated at
90    /// this pc. As long as every consumer reads this slot via a field
91    /// / element read (`GetField` / `GetElem`), `Pop`, or a
92    /// `StoreLocal`/`LoadLocal` round-trip, the site stays a
93    /// stack-alloc candidate. The escape rules are identical for both
94    /// aggregate kinds — only the codegen opcode differs — so the
95    /// analysis tracks them with one variant keyed on the alloc pc.
96    Agg(u32),
97    /// Anything else — primitives, already-escaped aggregates,
98    /// values produced by ops we don't model precisely. Treated
99    /// as not-a-tracked-aggregate for escape purposes.
100    Other,
101}
102
103impl Slot {
104    /// Pointwise merge for join points. Same site survives;
105    /// anything else collapses to `Other`. Callers responsible for
106    /// recording any `Rec(_)` that was merged-away into the
107    /// escape set — we lose track of those sites past this merge.
108    fn merge(self, other: Slot) -> Slot {
109        match (self, other) {
110            (Slot::Agg(a), Slot::Agg(b)) if a == b => Slot::Agg(a),
111            _ => Slot::Other,
112        }
113    }
114}
115
116/// Abstract state at one program point: the value stack from
117/// bottom up, plus a flat local-variable map.
118#[derive(Debug, Clone, PartialEq, Eq)]
119struct State {
120    stack: Vec<Slot>,
121    locals: Vec<Slot>,
122}
123
124impl State {
125    fn entry(locals_count: usize, arity: usize) -> Self {
126        // Function parameters land in the first `arity` locals;
127        // they're potentially-escaped values handed in by the
128        // caller, but the *sites* that produced them live in the
129        // caller's frame and aren't our concern. Treat as Other.
130        Self {
131            stack: Vec::new(),
132            locals: vec![Slot::Other; locals_count.max(arity)],
133        }
134    }
135
136    /// Merge `other` into `self`. Returns `(merged_state, escaped_sites)`
137    /// — the sites that we lost track of during the merge. Callers
138    /// union the escapes into the function-level set.
139    fn merge_with(&self, other: &State) -> (State, HashSet<u32>) {
140        let mut escaped = HashSet::new();
141        // Mismatched stack depths are a verifier-level bug (#347
142        // already checks this); for the escape analysis we just
143        // truncate to the shorter and proceed — any sites on the
144        // extra slots count as escapes since they're no longer
145        // reachable from the join state.
146        let stack_len = self.stack.len().min(other.stack.len());
147        let mut stack = Vec::with_capacity(stack_len);
148        for i in 0..stack_len {
149            let merged = self.stack[i].merge(other.stack[i]);
150            // If a Rec was merged-away (either path had Rec, the
151            // result is Other), the corresponding site escapes.
152            if merged == Slot::Other {
153                if let Slot::Agg(p) = self.stack[i]  { escaped.insert(p); }
154                if let Slot::Agg(p) = other.stack[i] { escaped.insert(p); }
155            }
156            stack.push(merged);
157        }
158        // The dropped tail of the longer stack also leaks any Rec.
159        for tail in self.stack.iter().skip(stack_len).chain(other.stack.iter().skip(stack_len)) {
160            if let Slot::Agg(p) = tail { escaped.insert(*p); }
161        }
162        let local_len = self.locals.len().max(other.locals.len());
163        let mut locals = Vec::with_capacity(local_len);
164        for i in 0..local_len {
165            let a = self.locals.get(i).copied().unwrap_or(Slot::Other);
166            let b = other.locals.get(i).copied().unwrap_or(Slot::Other);
167            let merged = a.merge(b);
168            if merged == Slot::Other {
169                if let Slot::Agg(p) = a { escaped.insert(p); }
170                if let Slot::Agg(p) = b { escaped.insert(p); }
171            }
172            locals.push(merged);
173        }
174        (State { stack, locals }, escaped)
175    }
176}
177
178/// Per-function escape report — the artifact codegen consumes to
179/// decide where to emit a stack-alloc opcode vs the heap constructor.
180///
181/// Each entry is keyed by the allocation pc (the `Op::MakeRecord` or
182/// `Op::MakeTuple` site's index in the function's `code` vec) and
183/// tagged with its `SiteKind`. `escapes = false` means: across every
184/// reachable path through the function, the aggregate allocated here
185/// is only ever read locally (`GetField` / `GetElem`, dropped via
186/// `Pop`, round-tripped through locals) — never returned, captured,
187/// stored in another aggregate, or passed to a call.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct EscapeReport {
190    pub fn_name: String,
191    /// One entry per stack-allocatable aggregate (`MakeRecord` /
192    /// `MakeTuple`) site in the function, in pc order.
193    pub sites: Vec<EscapeSite>,
194}
195
196/// Which aggregate constructor an escape site is — determines which
197/// stack-alloc opcode a future codegen slice would emit for it
198/// (`AllocStackRecord` for records; tuple stack-alloc is not yet
199/// implemented, so `Tuple` sites are reported but not lowered).
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
201pub enum SiteKind {
202    /// `Op::MakeRecord`: `shape_idx` is meaningful, `field_count` is
203    /// the record's field count.
204    Record,
205    /// `Op::MakeTuple`: `shape_idx` is unused (`0`), `field_count` is
206    /// the tuple arity.
207    Tuple,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Hash)]
211pub struct EscapeSite {
212    pub pc: u32,
213    pub kind: SiteKind,
214    pub shape_idx: u32,
215    pub field_count: u16,
216    pub escapes: bool,
217}
218
219/// Analyze every function in `functions`. Returns one
220/// `EscapeReport` per function that contains at least one
221/// `MakeRecord` site (functions with no record allocations are
222/// omitted — the consumer doesn't care about them).
223pub fn analyze_program(functions: &[Function]) -> Vec<EscapeReport> {
224    functions
225        .iter()
226        .filter_map(|f| {
227            let r = analyze_function(f);
228            (!r.sites.is_empty()).then_some(r)
229        })
230        .collect()
231}
232
233/// Analyze one function under the default `Policy::FrameScope`.
234/// See `analyze_function_with_policy` for the policy-parameterized
235/// form used by #463 arena routing.
236pub fn analyze_function(func: &Function) -> EscapeReport {
237    analyze_function_with_policy(func, Policy::FrameScope)
238}
239
240/// Analyze one function under the given scope policy. Cheap to call
241/// even when there are no aggregate sites (early-exits after the
242/// first pass over `code`).
243pub fn analyze_function_with_policy(func: &Function, policy: Policy) -> EscapeReport {
244    // Inventory the MakeRecord sites first. If there are none,
245    // skip the whole fixpoint — the function can't benefit from
246    // stack allocation anyway.
247    let sites: Vec<(u32, SiteKind, u32, u16)> = func
248        .code
249        .iter()
250        .enumerate()
251        .filter_map(|(pc, op)| match op {
252            Op::MakeRecord { shape_idx, field_count } => {
253                Some((pc as u32, SiteKind::Record, *shape_idx, *field_count))
254            }
255            Op::MakeTuple(arity) => {
256                Some((pc as u32, SiteKind::Tuple, 0, *arity))
257            }
258            _ => None,
259        })
260        .collect();
261    if sites.is_empty() {
262        return EscapeReport { fn_name: func.name.clone(), sites: vec![] };
263    }
264
265    let n = func.code.len();
266    let locals_count = func.locals_count as usize;
267    let arity = func.arity as usize;
268
269    // Per-pc in-states, computed by the fixpoint. None = unvisited.
270    let mut in_state: Vec<Option<State>> = vec![None; n];
271    let mut escaped: HashSet<u32> = HashSet::new();
272
273    let mut worklist: Vec<(usize, State)> = vec![(0, State::entry(locals_count, arity))];
274
275    while let Some((pc, incoming)) = worklist.pop() {
276        if pc >= n { continue; }
277
278        // Merge into existing in-state; only enqueue successors
279        // when something actually changed (fixpoint termination).
280        let (merged, new_escapes) = match &in_state[pc] {
281            Some(prev) => {
282                let (m, e) = prev.merge_with(&incoming);
283                if &m == prev && e.is_empty() {
284                    continue;
285                }
286                (m, e)
287            }
288            None => (incoming, HashSet::new()),
289        };
290        escaped.extend(new_escapes);
291        in_state[pc] = Some(merged.clone());
292
293        // Step the op, get the out-state + any successors.
294        let (out, succs, leaked) = step(pc, &func.code[pc], merged, policy);
295        escaped.extend(leaked);
296        for s in succs {
297            if s < n {
298                worklist.push((s, out.clone()));
299            }
300        }
301    }
302
303    let report_sites = sites
304        .into_iter()
305        .map(|(pc, kind, shape_idx, field_count)| EscapeSite {
306            pc,
307            kind,
308            shape_idx,
309            field_count,
310            escapes: escaped.contains(&pc),
311        })
312        .collect();
313    EscapeReport { fn_name: func.name.clone(), sites: report_sites }
314}
315
316/// Apply one op to the abstract state. Returns the new state, the
317/// list of successor pcs (with their starting state being the
318/// returned state, except for control-flow ops where successors
319/// share the *same* state), and any sites that escaped during the
320/// step.
321fn step(pc: usize, op: &Op, mut s: State, policy: Policy) -> (State, Vec<usize>, HashSet<u32>) {
322    let mut escapes: HashSet<u32> = HashSet::new();
323
324    // Helper closures for the common pop-n / push patterns.
325    let leak = |slot: Slot, into: &mut HashSet<u32>| {
326        if let Slot::Agg(p) = slot { into.insert(p); }
327    };
328    let pop_n_leak = |state: &mut State, n: usize, esc: &mut HashSet<u32>| {
329        for _ in 0..n {
330            if let Some(top) = state.stack.pop() { leak(top, esc); }
331        }
332    };
333    let pop_n_drop = |state: &mut State, n: usize| {
334        for _ in 0..n { state.stack.pop(); }
335    };
336
337    match op {
338        Op::PushConst(_) => { s.stack.push(Slot::Other); }
339        Op::Pop => { s.stack.pop(); /* drop — no escape on plain pop */ }
340        Op::Dup => {
341            // Aliasing breaks our linear-flow tracking. Anything
342            // duplicated escapes — both copies become Other.
343            if let Some(top) = s.stack.pop() {
344                leak(top, &mut escapes);
345                s.stack.push(Slot::Other);
346                s.stack.push(Slot::Other);
347            }
348        }
349
350        Op::LoadLocal(i) => {
351            let slot = s.locals.get(*i as usize).copied().unwrap_or(Slot::Other);
352            s.stack.push(slot);
353        }
354        Op::StoreLocal(i) => {
355            if let Some(top) = s.stack.pop() {
356                let i = *i as usize;
357                if i >= s.locals.len() { s.locals.resize(i + 1, Slot::Other); }
358                // Round-tripping an aggregate through a local keeps it
359                // tracked. Overwriting a local that held a *different*
360                // tracked aggregate does NOT make the old one escape:
361                // Lex `let` bindings are immutable, so the compiler
362                // only reuses a slot once its previous occupant is out
363                // of scope (dead). Any real escape of that occupant —
364                // returned, passed to a call, stored into another
365                // aggregate — flows through a `Load → {Return, Call,
366                // MakeRecord/MakeTuple/...}` chain those ops already
367                // leak. Flagging it here was a pure false-positive
368                // that defeated stack-alloc for the common
369                // destructure-then-bind pattern (`compile_match`
370                // rewinds its `__scrut`/`__tuple` temp slots, so the
371                // enclosing `let` reuses them — see #464 tuple codegen).
372                s.locals[i] = top;
373            }
374        }
375
376        // Allocation site.
377        Op::MakeRecord { field_count, .. } => {
378            // Field values get stored in the new heap record; if
379            // any of them is itself a tracked Rec, it escapes (now
380            // referenced from inside the parent).
381            pop_n_leak(&mut s, *field_count as usize, &mut escapes);
382            s.stack.push(Slot::Agg(pc as u32));
383        }
384        // #464 step 2: post-lowering form of MakeRecord (escape
385        // proved). Re-running the analysis on already-lowered code
386        // must produce the same shape, so treat it identically.
387        Op::AllocStackRecord { field_count, .. } => {
388            pop_n_leak(&mut s, *field_count as usize, &mut escapes);
389            s.stack.push(Slot::Agg(pc as u32));
390        }
391        // A tuple is a stack-allocatable aggregate, tracked the same
392        // way as a record: its field operands leak (any tracked
393        // aggregate stored inside it escapes), and the tuple itself
394        // becomes a tracked candidate keyed on this pc. `GetElem`
395        // reads an element without escaping the tuple, exactly as
396        // `GetField` does for records.
397        Op::MakeTuple(n) => {
398            pop_n_leak(&mut s, *n as usize, &mut escapes);
399            s.stack.push(Slot::Agg(pc as u32));
400        }
401        // Post-lowering form of MakeTuple (escape proved). Re-running
402        // the analysis on already-lowered code must classify it the
403        // same way, so treat it identically — mirrors AllocStackRecord.
404        Op::AllocStackTuple { arity } => {
405            pop_n_leak(&mut s, *arity as usize, &mut escapes);
406            s.stack.push(Slot::Agg(pc as u32));
407        }
408        // Lists and variants remain escape sinks for any tracked
409        // aggregate operand and don't create new tracked candidates —
410        // list stack-allocation needs variable-length arena handling
411        // (a later slice), and variants aren't a stack-alloc target.
412        Op::MakeList(n) => {
413            pop_n_leak(&mut s, *n as usize, &mut escapes);
414            s.stack.push(Slot::Other);
415        }
416        Op::MakeVariant { arity, .. } => {
417            pop_n_leak(&mut s, *arity as usize, &mut escapes);
418            s.stack.push(Slot::Other);
419        }
420        Op::MakeClosure { capture_count, .. } => {
421            pop_n_leak(&mut s, *capture_count as usize, &mut escapes);
422            s.stack.push(Slot::Other);
423        }
424
425        // Field / element reads — receiver is consumed but only to
426        // read a field. Doesn't escape; the receiver becomes
427        // unreferenced after the op.
428        Op::GetField { .. } => { s.stack.pop(); s.stack.push(Slot::Other); }
429        Op::GetElem(_) => { s.stack.pop(); s.stack.push(Slot::Other); }
430        Op::TestVariant(_) => { /* peek-only */ s.stack.pop(); s.stack.push(Slot::Other); }
431        Op::GetVariant(_) => { s.stack.pop(); s.stack.push(Slot::Other); }
432        Op::GetVariantArg(_) => { s.stack.pop(); s.stack.push(Slot::Other); }
433        Op::GetListLen => { s.stack.pop(); s.stack.push(Slot::Other); }
434        Op::GetListElem(_) => { s.stack.pop(); s.stack.push(Slot::Other); }
435        Op::GetListElemDyn => {
436            // pop [list, idx] → push elem
437            s.stack.pop(); s.stack.pop(); s.stack.push(Slot::Other);
438        }
439        Op::ListAppend => {
440            // pop [list, value]; value is now inside the list → escape
441            if let Some(value) = s.stack.pop() { leak(value, &mut escapes); }
442            s.stack.pop(); // list itself
443            s.stack.push(Slot::Other);
444        }
445
446        // Control flow.
447        Op::Jump(off) => {
448            let target = (pc as i32 + 1 + off) as usize;
449            return (s, vec![target], escapes);
450        }
451        Op::JumpIf(off) | Op::JumpIfNot(off) => {
452            s.stack.pop(); // consumed Bool
453            let target = (pc as i32 + 1 + off) as usize;
454            return (s, vec![pc + 1, target], escapes);
455        }
456        Op::Return => {
457            let top = s.stack.pop();
458            // The only policy-sensitive arm: under FrameScope the
459            // returned value crosses our frame and leaks; under
460            // RequestScope it stays inside the request and does not.
461            if matches!(policy, Policy::FrameScope) {
462                if let Some(top) = top { leak(top, &mut escapes); }
463            }
464            return (s, vec![], escapes);
465        }
466        Op::Panic(_) => {
467            return (s, vec![], escapes);
468        }
469        Op::TailCall { arity, .. } => {
470            pop_n_leak(&mut s, *arity as usize, &mut escapes);
471            return (s, vec![], escapes);
472        }
473        Op::Call { arity, .. } => {
474            pop_n_leak(&mut s, *arity as usize, &mut escapes);
475            s.stack.push(Slot::Other);
476        }
477        Op::CallClosure { arity, .. } => {
478            // pop arity args + 1 closure
479            pop_n_leak(&mut s, *arity as usize + 1, &mut escapes);
480            s.stack.push(Slot::Other);
481        }
482        Op::SortByKey { .. } | Op::ParallelMap { .. }
483        | Op::ListMap { .. } | Op::ListFilter { .. } => {
484            // pop [xs, f]; both escape
485            pop_n_leak(&mut s, 2, &mut escapes);
486            s.stack.push(Slot::Other);
487        }
488        Op::ListFold { .. } => {
489            // pop [xs, init, f]; all escape into the native op
490            pop_n_leak(&mut s, 3, &mut escapes);
491            s.stack.push(Slot::Other);
492        }
493        Op::EffectCall { arity, .. } => {
494            pop_n_leak(&mut s, *arity as usize, &mut escapes);
495            s.stack.push(Slot::Other);
496        }
497
498        // Pure arithmetic / comparison / logic / string ops. Their
499        // operands are primitives in well-typed code (the existing
500        // type checker rejects record-typed args to IntAdd etc.),
501        // so we don't expect Rec slots to flow in. If one does, the
502        // pop_n_drop loses the Rec without recording escape — but
503        // that would be a type-system bug surfaced elsewhere.
504        Op::IntAdd | Op::IntSub | Op::IntMul | Op::IntDiv | Op::IntMod
505        | Op::IntEq | Op::IntLt | Op::IntLe
506        | Op::FloatAdd | Op::FloatSub | Op::FloatMul | Op::FloatDiv
507        | Op::FloatEq | Op::FloatLt | Op::FloatLe
508        | Op::NumAdd | Op::NumSub | Op::NumMul | Op::NumDiv | Op::NumMod
509        | Op::NumEq | Op::NumLt | Op::NumLe
510        | Op::BoolAnd | Op::BoolOr
511        | Op::StrConcat | Op::StrEq | Op::BytesEq => {
512            pop_n_drop(&mut s, 2);
513            s.stack.push(Slot::Other);
514        }
515        Op::IntNeg | Op::FloatNeg | Op::NumNeg | Op::BoolNot
516        | Op::StrLen | Op::BytesLen => {
517            s.stack.pop();
518            s.stack.push(Slot::Other);
519        }
520
521        // Superinstructions (#461). All operate on Int locals and
522        // primitive stack values — they neither produce nor consume
523        // Rec slots. The trailing tombstones are inert; the verifier
524        // pattern (skip ahead by N) is mirrored here.
525        Op::LoadLocalAddIntConst { .. } => {
526            // +1 net (LoadLocal + PushConst + IntAdd)
527            s.stack.push(Slot::Other);
528        }
529        Op::LoadLocalAddIntConstStoreLocal { dest, .. } => {
530            // delta 0; updates a local with an Int. Overwriting a
531            // local doesn't escape its prior aggregate — same rule as
532            // plain StoreLocal above (the dest is Int-typed by this
533            // superinstruction's contract, so prev is never an
534            // aggregate in practice; relaxed for consistency).
535            let i = *dest as usize;
536            if i >= s.locals.len() { s.locals.resize(i + 1, Slot::Other); }
537            s.locals[i] = Slot::Other;
538            return (s, vec![pc + 4], escapes);
539        }
540        Op::LoadLocalAddLocal { .. }
541        | Op::LoadLocalSubLocal { .. }
542        | Op::LoadLocalMulLocal { .. } => {
543            // +1 net (two LoadLocal + one binop)
544            s.stack.push(Slot::Other);
545            return (s, vec![pc + 3], escapes);
546        }
547        Op::LoadLocalGetField { .. } => {
548            // #461 slice 9: equivalent to LoadLocal + GetField —
549            // reads a field out of a local record (which does NOT
550            // escape the receiver, matching plain GetField) and
551            // pushes the field value (Other). Net delta +1; skip the
552            // single tombstone (pc+2). (Escape analysis runs before
553            // peephole, so this arm is exercised only if the analysis
554            // is ever re-run on fused code; it's here for exhaustive
555            // matching and forward-correctness.)
556            s.stack.push(Slot::Other);
557            return (s, vec![pc + 2], escapes);
558        }
559        Op::LoadLocalGetFieldAdd { .. }
560        | Op::LoadLocalGetFieldSub { .. }
561        | Op::LoadLocalGetFieldMul { .. } => {
562            // #461 slice 7/8: net delta on the value stack is 0 (pops
563            // prior Int top, pushes Int result). The receiver is read
564            // from a local — the analysis tracks locals separately,
565            // and reading a local doesn't escape its Rec slot (the
566            // round-trip-through-local rule from StoreLocal applies
567            // here too: the value stays referenced). We pop the
568            // existing top (the accumulator) and push a fresh Other
569            // (the result). Skip past the two tombstones.
570            s.stack.pop();
571            s.stack.push(Slot::Other);
572            return (s, vec![pc + 3], escapes);
573        }
574        Op::LoadLocalEqIntConstJumpIfNot { jump_offset, .. } => {
575            // delta 0; two successors (fall-through past tombstones,
576            // and the branch target relative to the original
577            // JumpIfNot's pc).
578            let target = (pc as i32 + 4 + jump_offset) as usize;
579            return (s, vec![pc + 4, target], escapes);
580        }
581        Op::LoadLocalStoreEqIntConstJumpIfNot { dst, jump_offset, .. } => {
582            // delta 0; also writes locals[dst] := locals[src].
583            // Treat the local update the same as StoreLocal of an
584            // Other (the scrutinee is an Int per slice-6's contract).
585            let i = *dst as usize;
586            if i >= s.locals.len() { s.locals.resize(i + 1, Slot::Other); }
587            // Overwriting a local doesn't escape its prior aggregate
588            // (same rule as plain StoreLocal); dst is Int-typed here.
589            s.locals[i] = Slot::Other;
590            let target = (pc as i32 + 6 + jump_offset) as usize;
591            return (s, vec![pc + 6, target], escapes);
592        }
593    }
594
595    (s, vec![pc + 1], escapes)
596}
597
598/// Convenience wrapper over `analyze_program` returning a map
599/// keyed by `(fn_name, pc)` for direct lookup during codegen.
600pub fn build_escape_index(functions: &[Function]) -> HashMap<(String, u32), bool> {
601    let mut idx = HashMap::new();
602    for report in analyze_program(functions) {
603        for site in report.sites {
604            idx.insert((report.fn_name.clone(), site.pc), site.escapes);
605        }
606    }
607    idx
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use crate::op::Op;
614    use crate::program::{Function, ZERO_BODY_HASH};
615
616    /// Helper: build a minimal Function with the given code and
617    /// just enough machinery for the analyzer.
618    fn func(name: &str, locals_count: u16, arity: u16, code: Vec<Op>) -> Function {
619        Function {
620            name: name.into(),
621            arity,
622            locals_count,
623            code,
624            effects: vec![],
625            body_hash: ZERO_BODY_HASH,
626            refinements: vec![],
627            field_ic_sites: 0,
628        }
629    }
630
631    /// Expectation helper: a list of `(pc, expected_escapes)` pairs.
632    fn assert_escapes(report: &EscapeReport, expected: &[(u32, bool)]) {
633        let got: Vec<(u32, bool)> = report.sites.iter().map(|s| (s.pc, s.escapes)).collect();
634        assert_eq!(got, expected,
635            "escape report for `{}` differs from expected", report.fn_name);
636    }
637
638    #[test]
639    fn record_built_and_dropped_does_not_escape() {
640        // PushConst PushConst MakeRecord Pop Return
641        let f = func("dropper", 0, 0, vec![
642            Op::PushConst(0),
643            Op::PushConst(1),
644            Op::MakeRecord { shape_idx: 0, field_count: 2 },
645            Op::Pop,
646            Op::PushConst(0),
647            Op::Return,
648        ]);
649        let r = analyze_function(&f);
650        assert_escapes(&r, &[(2, false)]);
651    }
652
653    #[test]
654    fn record_returned_escapes() {
655        let f = func("returner", 0, 0, vec![
656            Op::PushConst(0),
657            Op::PushConst(1),
658            Op::MakeRecord { shape_idx: 0, field_count: 2 },
659            Op::Return,
660        ]);
661        let r = analyze_function(&f);
662        assert_escapes(&r, &[(2, true)]);
663    }
664
665    #[test]
666    fn record_field_read_only_does_not_escape() {
667        // PushConst PushConst MakeRecord GetField Return (returns the field, not the record)
668        let f = func("reader", 0, 0, vec![
669            Op::PushConst(0),
670            Op::PushConst(1),
671            Op::MakeRecord { shape_idx: 0, field_count: 2 },
672            Op::GetField { name_idx: 0, site_idx: 0 },
673            Op::Return,
674        ]);
675        let r = analyze_function(&f);
676        assert_escapes(&r, &[(2, false)]);
677    }
678
679    #[test]
680    fn record_round_tripped_through_local_does_not_escape() {
681        let f = func("roundtrip", 1, 0, vec![
682            Op::PushConst(0),
683            Op::PushConst(1),
684            Op::MakeRecord { shape_idx: 0, field_count: 2 },
685            Op::StoreLocal(0),
686            Op::LoadLocal(0),
687            Op::GetField { name_idx: 0, site_idx: 0 },
688            Op::Return,
689        ]);
690        let r = analyze_function(&f);
691        assert_escapes(&r, &[(2, false)]);
692    }
693
694    #[test]
695    fn record_stored_into_outer_record_escapes() {
696        // Build inner, then build outer with inner as one of its fields.
697        let f = func("nest", 0, 0, vec![
698            Op::PushConst(0),
699            Op::PushConst(1),
700            Op::MakeRecord { shape_idx: 0, field_count: 2 }, // inner @ pc=2
701            Op::PushConst(2),
702            Op::MakeRecord { shape_idx: 1, field_count: 2 }, // outer @ pc=4
703            Op::Return,                                       // outer escapes
704        ]);
705        let r = analyze_function(&f);
706        // inner escapes (captured in outer); outer escapes (returned).
707        assert_escapes(&r, &[(2, true), (4, true)]);
708    }
709
710    #[test]
711    fn record_passed_to_call_escapes() {
712        let f = func("passer", 0, 0, vec![
713            Op::PushConst(0),
714            Op::PushConst(1),
715            Op::MakeRecord { shape_idx: 0, field_count: 2 },
716            Op::Call { fn_id: 1, arity: 1, node_id_idx: 0 },
717            Op::Return,
718        ]);
719        let r = analyze_function(&f);
720        assert_escapes(&r, &[(2, true)]);
721    }
722
723    #[test]
724    fn record_captured_in_closure_escapes() {
725        let f = func("capturer", 0, 0, vec![
726            Op::PushConst(0),
727            Op::PushConst(1),
728            Op::MakeRecord { shape_idx: 0, field_count: 2 },
729            Op::MakeClosure { fn_id: 1, capture_count: 1 },
730            Op::Return,
731        ]);
732        let r = analyze_function(&f);
733        assert_escapes(&r, &[(2, true)]);
734    }
735
736    #[test]
737    fn record_in_one_branch_returned_escapes_after_merge() {
738        // if cond { rec1 } else { rec2 } — Return after merge.
739        // Conservative analysis: at the merge both sites escape.
740        let f = func("brancher", 0, 1, vec![
741            Op::LoadLocal(0),                          // pc=0
742            Op::JumpIfNot(4),                          // pc=1; offset 4 → pc=6
743            Op::PushConst(0),                          // pc=2
744            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=3 (then-branch)
745            Op::Jump(2),                               // pc=4; offset 2 → pc=7
746            Op::PushConst(1),                          // pc=5 (unreached fall-through dead code)
747            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=6 (else-branch)
748            Op::Return,                                // pc=7 (merge + return)
749        ]);
750        let r = analyze_function(&f);
751        // Both record sites escape — Return sees a merged stack.
752        assert_escapes(&r, &[(3, true), (6, true)]);
753    }
754
755    #[test]
756    fn two_sites_classified_independently() {
757        // One record returned, one popped — they should classify
758        // separately. Sequencing: build keeper, store it; build
759        // discard, pop it; load keeper, return.
760        let f = func("mixed", 1, 0, vec![
761            Op::PushConst(0),
762            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // keeper @ pc=1
763            Op::StoreLocal(0),
764            Op::PushConst(0),
765            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // discard @ pc=4
766            Op::Pop,
767            Op::LoadLocal(0),
768            Op::Return,
769        ]);
770        let r = analyze_function(&f);
771        assert_escapes(&r, &[(1, true), (4, false)]);
772    }
773
774    #[test]
775    fn function_with_no_record_sites_produces_empty_report() {
776        let f = func("pure_arith", 0, 2, vec![
777            Op::LoadLocal(0),
778            Op::LoadLocal(1),
779            Op::IntAdd,
780            Op::Return,
781        ]);
782        let r = analyze_function(&f);
783        assert!(r.sites.is_empty());
784    }
785
786    #[test]
787    fn analyze_program_skips_no_record_functions() {
788        let f1 = func("noreds", 0, 0, vec![Op::PushConst(0), Op::Return]);
789        let f2 = func("hasrec", 0, 0, vec![
790            Op::PushConst(0),
791            Op::MakeRecord { shape_idx: 0, field_count: 1 },
792            Op::Return,
793        ]);
794        let reports = analyze_program(&[f1, f2]);
795        assert_eq!(reports.len(), 1);
796        assert_eq!(reports[0].fn_name, "hasrec");
797    }
798
799    #[test]
800    fn record_passed_to_effect_call_escapes() {
801        let f = func("effecting", 0, 0, vec![
802            Op::PushConst(0),
803            Op::MakeRecord { shape_idx: 0, field_count: 1 },
804            Op::EffectCall { kind_idx: 0, op_idx: 0, arity: 1, node_id_idx: 0 },
805            Op::Return,
806        ]);
807        let r = analyze_function(&f);
808        assert_escapes(&r, &[(1, true)]);
809    }
810
811    #[test]
812    fn record_duplicated_escapes() {
813        // Dup is conservatively an escape — aliasing breaks the
814        // linear-flow assumption.
815        let f = func("duper", 0, 0, vec![
816            Op::PushConst(0),
817            Op::MakeRecord { shape_idx: 0, field_count: 1 },
818            Op::Dup,
819            Op::Pop,
820            Op::Pop,
821            Op::PushConst(0),
822            Op::Return,
823        ]);
824        let r = analyze_function(&f);
825        assert_escapes(&r, &[(1, true)]);
826    }
827
828    #[test]
829    fn record_in_list_escapes() {
830        let f = func("listed", 0, 0, vec![
831            Op::PushConst(0),
832            Op::MakeRecord { shape_idx: 0, field_count: 1 },
833            Op::MakeList(1),
834            Op::Return,
835        ]);
836        let r = analyze_function(&f);
837        assert_escapes(&r, &[(1, true)]);
838    }
839
840    #[test]
841    fn build_escape_index_keys_by_fn_and_pc() {
842        let f = func("idx_test", 0, 0, vec![
843            Op::PushConst(0),
844            Op::MakeRecord { shape_idx: 0, field_count: 1 },
845            Op::Pop,
846            Op::PushConst(0),
847            Op::Return,
848        ]);
849        let idx = build_escape_index(&[f]);
850        assert_eq!(idx.get(&("idx_test".into(), 1)), Some(&false));
851    }
852
853    // ---- tuple widening (#464 tuple analysis slice) ----
854
855    #[test]
856    fn tuple_built_and_dropped_does_not_escape() {
857        let f = func("tup_drop", 0, 0, vec![
858            Op::PushConst(0),
859            Op::PushConst(1),
860            Op::MakeTuple(2), // pc=2
861            Op::Pop,
862            Op::PushConst(0),
863            Op::Return,
864        ]);
865        let r = analyze_function(&f);
866        assert_escapes(&r, &[(2, false)]);
867        assert_eq!(r.sites[0].kind, SiteKind::Tuple);
868        assert_eq!(r.sites[0].field_count, 2);
869    }
870
871    #[test]
872    fn tuple_returned_escapes() {
873        let f = func("tup_ret", 0, 0, vec![
874            Op::PushConst(0),
875            Op::PushConst(1),
876            Op::MakeTuple(2), // pc=2
877            Op::Return,
878        ]);
879        let r = analyze_function(&f);
880        assert_escapes(&r, &[(2, true)]);
881    }
882
883    #[test]
884    fn tuple_elem_read_only_does_not_escape() {
885        // Reading an element (GetElem) consumes the tuple locally,
886        // mirroring GetField on a record — the tuple stays a candidate.
887        let f = func("tup_read", 0, 0, vec![
888            Op::PushConst(0),
889            Op::PushConst(1),
890            Op::MakeTuple(2), // pc=2
891            Op::GetElem(0),
892            Op::Return,
893        ]);
894        let r = analyze_function(&f);
895        assert_escapes(&r, &[(2, false)]);
896    }
897
898    #[test]
899    fn tuple_round_tripped_through_local_does_not_escape() {
900        let f = func("tup_rt", 1, 0, vec![
901            Op::PushConst(0),
902            Op::PushConst(1),
903            Op::MakeTuple(2), // pc=2
904            Op::StoreLocal(0),
905            Op::LoadLocal(0),
906            Op::GetElem(1),
907            Op::Return,
908        ]);
909        let r = analyze_function(&f);
910        assert_escapes(&r, &[(2, false)]);
911    }
912
913    #[test]
914    fn tuple_passed_to_call_escapes() {
915        let f = func("tup_call", 0, 0, vec![
916            Op::PushConst(0),
917            Op::PushConst(1),
918            Op::MakeTuple(2), // pc=2
919            Op::Call { fn_id: 1, arity: 1, node_id_idx: 0 },
920            Op::Return,
921        ]);
922        let r = analyze_function(&f);
923        assert_escapes(&r, &[(2, true)]);
924    }
925
926    #[test]
927    fn record_stored_into_tuple_escapes_tuple_stays_local() {
928        // Build a record, wrap it in a tuple, drop the tuple. The
929        // record escapes into the tuple; the tuple itself is local.
930        let f = func("rec_in_tup", 0, 0, vec![
931            Op::PushConst(0),
932            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // rec @ pc=1
933            Op::MakeTuple(1),                                // tup @ pc=2 holds rec
934            Op::Pop,
935            Op::PushConst(0),
936            Op::Return,
937        ]);
938        let r = analyze_function(&f);
939        assert_escapes(&r, &[(1, true), (2, false)]);
940    }
941
942    #[test]
943    fn tuple_stored_into_record_escapes() {
944        let f = func("tup_in_rec", 0, 0, vec![
945            Op::PushConst(0),
946            Op::PushConst(1),
947            Op::MakeTuple(2),                                // tup @ pc=2
948            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // rec @ pc=3 holds tup
949            Op::Return,
950        ]);
951        let r = analyze_function(&f);
952        assert_escapes(&r, &[(2, true), (3, true)]);
953    }
954
955    #[test]
956    fn tuple_and_record_sites_carry_distinct_kinds() {
957        let f = func("mixed_kinds", 0, 0, vec![
958            Op::PushConst(0),
959            Op::MakeRecord { shape_idx: 7, field_count: 1 }, // pc=1 record
960            Op::Pop,
961            Op::PushConst(0),
962            Op::PushConst(1),
963            Op::MakeTuple(2), // pc=5 tuple
964            Op::Pop,
965            Op::PushConst(0),
966            Op::Return,
967        ]);
968        let r = analyze_function(&f);
969        assert_eq!(r.sites.len(), 2);
970        assert_eq!((r.sites[0].pc, r.sites[0].kind, r.sites[0].shape_idx), (1, SiteKind::Record, 7));
971        assert_eq!((r.sites[1].pc, r.sites[1].kind, r.sites[1].field_count), (5, SiteKind::Tuple, 2));
972        assert!(!r.sites[0].escapes && !r.sites[1].escapes);
973    }
974
975    #[test]
976    fn aggregate_overwritten_in_dead_slot_does_not_escape() {
977        // rec1 is stored into local 0, then local 0 is overwritten
978        // with an Int and rec1 is never otherwise used. Lex's
979        // immutable `let` bindings mean a slot is only reused once its
980        // prior occupant is dead, so the overwrite must NOT flag rec1
981        // as escaping (the relaxed StoreLocal rule — #464 tuple
982        // codegen). Pre-relaxation this returned `true`.
983        let f = func("overwrite", 1, 0, vec![
984            Op::PushConst(0),
985            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // rec1 @ pc=1
986            Op::StoreLocal(0),
987            Op::PushConst(0),
988            Op::StoreLocal(0), // overwrite local 0 with an Int
989            Op::PushConst(0),
990            Op::Return,
991        ]);
992        let r = analyze_function(&f);
993        assert_escapes(&r, &[(1, false)]);
994    }
995
996    #[test]
997    fn aggregate_loaded_then_returned_still_escapes() {
998        // Soundness guard for the relaxed overwrite rule: an aggregate
999        // stored in a local, then LOADED and returned, still escapes —
1000        // the `Return` leak catches it independent of any overwrite.
1001        let f = func("load_then_return", 1, 0, vec![
1002            Op::PushConst(0),
1003            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // rec1 @ pc=1
1004            Op::StoreLocal(0),
1005            Op::LoadLocal(0),
1006            Op::Return, // returns rec1 → escapes
1007        ]);
1008        let r = analyze_function(&f);
1009        assert_escapes(&r, &[(1, true)]);
1010    }
1011
1012    #[test]
1013    fn tuple_only_function_now_produces_report() {
1014        // Pre-widening this function had no tracked sites and was
1015        // omitted from analyze_program; now its tuple site is reported.
1016        let f = func("tuponly", 0, 0, vec![
1017            Op::PushConst(0),
1018            Op::PushConst(1),
1019            Op::MakeTuple(2),
1020            Op::Pop,
1021            Op::PushConst(0),
1022            Op::Return,
1023        ]);
1024        let reports = analyze_program(&[f]);
1025        assert_eq!(reports.len(), 1);
1026        assert_eq!(reports[0].sites.len(), 1);
1027        assert_eq!(reports[0].sites[0].kind, SiteKind::Tuple);
1028    }
1029}