Skip to main content

lex_bytecode/
vm.rs

1//! M5: bytecode VM. Stack machine with effect dispatch through a host handler.
2
3use crate::op::*;
4use crate::program::*;
5use crate::value::{ActorCell, Value};
6use std::sync::{Arc, Mutex, OnceLock};
7use indexmap::IndexMap;
8use smol_str::SmolStr;
9use std::collections::{HashMap, VecDeque};
10
11// ── IC polymorphism instrumentation (throwaway, env-gated) ─────────
12// Enable with LEX_IC_STATS=1. With LEX_IC_STATS_OUT=<path> writes a
13// TSV to <path>.<pid> on each Vm drop; otherwise dumps to stderr.
14
15#[derive(Default)]
16struct IcStats {
17    sites: HashMap<(u32, u32), HashMap<u32, u64>>,
18}
19
20static IC_STATS: OnceLock<Mutex<IcStats>> = OnceLock::new();
21static IC_STATS_ENABLED: OnceLock<bool> = OnceLock::new();
22
23fn ic_stats_enabled() -> bool {
24    *IC_STATS_ENABLED.get_or_init(|| {
25        std::env::var("LEX_IC_STATS").map(|v| v == "1").unwrap_or(false)
26    })
27}
28
29fn record_ic_hit(fn_id: u32, site_idx: u32, shape_id: u32) {
30    let stats = IC_STATS.get_or_init(|| Mutex::new(IcStats::default()));
31    let mut s = stats.lock().unwrap();
32    *s.sites.entry((fn_id, site_idx)).or_default().entry(shape_id).or_insert(0) += 1;
33}
34
35pub fn dump_ic_stats() {
36    let Some(stats) = IC_STATS.get() else { return; };
37    let s = stats.lock().unwrap();
38    if s.sites.is_empty() { return; }
39    let mut out = String::from("fn_id\tsite_idx\tshape_id\thits\n");
40    let mut entries: Vec<_> = s.sites.iter().collect();
41    entries.sort_by_key(|((f, si), _)| (*f, *si));
42    for ((f, site), shapes) in entries {
43        let mut shape_entries: Vec<_> = shapes.iter().collect();
44        shape_entries.sort_by_key(|(sid, _)| **sid);
45        for (sid, hits) in shape_entries {
46            out.push_str(&format!("{f}\t{site}\t{sid}\t{hits}\n"));
47        }
48    }
49    match std::env::var("LEX_IC_STATS_OUT").ok() {
50        Some(path) => {
51            let pid = std::process::id();
52            let _ = std::fs::write(format!("{path}.{pid}"), out);
53        }
54        None => { eprint!("{out}"); }
55    }
56}
57
58#[derive(Debug, Clone, thiserror::Error)]
59pub enum VmError {
60    #[error("runtime panic: {0}")]
61    Panic(String),
62    #[error("type mismatch at runtime: {0}")]
63    TypeMismatch(String),
64    #[error("stack underflow")]
65    StackUnderflow,
66    #[error("unknown function: {0}")]
67    UnknownFunction(String),
68    #[error("effect handler error: {0}")]
69    Effect(String),
70    #[error("call stack overflow: recursion depth exceeded ({0})")]
71    CallStackOverflow(u32),
72    /// Refinement predicate failed at a call boundary (#209 slice 3).
73    /// Surfaced when a function declares `param :: Type{x | predicate}`,
74    /// the call-site arg couldn't be discharged statically (slice 2),
75    /// and the runtime evaluator finds the predicate is `false` for
76    /// the actual argument value. The `verdict` mirrors the shape of
77    /// `gate.verdict`-style records in `lex-trace`.
78    #[error("refinement violated: argument {param_index} of `{fn_name}` (binding `{binding}`): {reason}")]
79    RefinementFailed {
80        fn_name: String,
81        param_index: usize,
82        binding: String,
83        reason: String,
84    },
85}
86
87/// Maximum simultaneous call frames. Defends against unbounded
88/// recursion in agent-emitted code: a body that calls itself
89/// without a base case would otherwise blow the host's native
90/// stack and crash the process. Real Lex code rarely exceeds
91/// ~30 frames; 1024 is generous headroom while still well under
92/// the OS stack limit at any per-frame size we use.
93pub const MAX_CALL_DEPTH: u32 = 1024;
94
95/// Per-frame stack-record budget (#464 step 2). Counts the number of
96/// `Value` slots a frame may consume from `Vm::stack_record_arena`
97/// before further `Op::AllocStackRecord` requests fall back to the
98/// heap path. 64 slots at the current `size_of::<Value>() = 64B`
99/// gives ~4 KiB per frame, matching the design-doc proposal in
100/// `docs/design/escape-analysis.md`. A handler-shaped function
101/// (one outer record of ≤8 fields, plus a handful of small inner
102/// records) fits well inside this without growing.
103pub const STACK_RECORD_BUDGET_SLOTS: u32 = 64;
104
105/// Adaptive-memoization warmup window (#229 adaptive). A pure
106/// function is given this many cache-probing calls to demonstrate a
107/// hit; if it reaches the window with zero hits, memoization is
108/// disabled for it (its calls stop hashing args). A function that
109/// genuinely benefits — e.g. naive recursive `fib`, where each call
110/// immediately reuses sub-results — accumulates hits well before the
111/// window closes and stays enabled. 64 balances "give real reuse a
112/// chance" against "don't pay the hash forever on always-miss code".
113const MEMO_WARMUP_CALLS: u32 = 64;
114
115/// Per-function adaptive-memoization state (#229 adaptive). `enabled`
116/// starts true; once a function reaches `MEMO_WARMUP_CALLS` cache
117/// probes with `hits == 0`, it flips to false and that function's
118/// calls skip the args hash entirely for the rest of the Vm's life.
119#[derive(Clone, Copy)]
120struct MemoFnState {
121    calls: u32,
122    hits: u32,
123    enabled: bool,
124}
125
126impl Default for MemoFnState {
127    fn default() -> Self {
128        MemoFnState { calls: 0, hits: 0, enabled: true }
129    }
130}
131
132/// Host-side effect dispatch. Implementors decide what `kind`/`op` mean
133/// and how arguments map to side effects.
134pub trait EffectHandler {
135    fn dispatch(&mut self, kind: &str, op: &str, args: Vec<Value>) -> Result<Value, String>;
136
137    /// Hook called by the VM at every function call so handlers can
138    /// enforce per-call budget consumption (#225). The argument is
139    /// the sum of `[budget(N)]` declared on the callee's signature;
140    /// the handler returns `Err` to refuse the call (the VM converts
141    /// to `VmError::Effect`). Default impl is a no-op so legacy
142    /// handlers and pure-only runs are unaffected.
143    fn note_call_budget(&mut self, _budget_cost: u64) -> Result<(), String> {
144        Ok(())
145    }
146
147    /// Enter a per-request allocation scope (#463 scaffolding).
148    /// Called by the runtime layer (e.g. `net.serve_fn`'s request
149    /// loop) immediately before invoking the user handler closure
150    /// for one request. Implementations push a fresh arena onto
151    /// their internal stack and return its identifier; the matching
152    /// `exit_request_scope` call drops it.
153    ///
154    /// Default impl is a no-op — handlers without arena support
155    /// return a sentinel scope id which they ignore on exit.
156    /// `DefaultHandler` in `lex-runtime` provides the real
157    /// implementation.
158    ///
159    /// Today the VM does NOT route any `Value` allocations through
160    /// the returned arena — see the scaffolding notes in
161    /// `crates/lex-runtime/src/arena.rs`. The hook exists so the
162    /// follow-on slice that adds Value-rep arena routing has a
163    /// stable trait surface to extend.
164    fn enter_request_scope(&mut self) -> u64 { 0 }
165
166    /// Exit a per-request allocation scope opened by
167    /// `enter_request_scope`. Implementations drop the arena
168    /// associated with `scope_id`. Calling exit with a scope_id
169    /// that wasn't returned by a prior enter is implementation-
170    /// defined behavior — DefaultHandler treats it as a no-op so
171    /// mismatched pairs don't panic.
172    fn exit_request_scope(&mut self, _scope_id: u64) {}
173
174    /// `list.par_map` worker-handler factory (#305 slice 2).
175    ///
176    /// Each parallel worker thread runs its own `Vm` and therefore
177    /// needs its own effect handler. The parent handler may opt in
178    /// to per-worker dispatch by returning `Some(handler)` here;
179    /// returning `None` (the default) keeps slice-1 behavior: the
180    /// worker runs `DenyAllEffects` and any effect call inside the
181    /// closure fails with `VmError::Effect`.
182    ///
183    /// The returned handler must be `Send` so the worker can take
184    /// ownership across a thread boundary. Shared state (budget
185    /// pool, chat registry, etc.) is wired up by the implementer.
186    /// Per-worker independence (MCP client cache, output sink)
187    /// is intentional — the alternative is mutex-serialization of
188    /// the whole effect dispatch, which would defeat the parallelism.
189    fn spawn_for_worker(&self) -> Option<Box<dyn EffectHandler + Send>> {
190        None
191    }
192}
193
194/// `Vm` exposes itself as a `ClosureCaller` so the parser interpreter
195/// can invoke user-supplied closures during a `parser.run` walk
196/// (#221). The Vm is reentrant for closure invocation: pushing a new
197/// frame onto an active call stack is supported, and the handler
198/// stays in place so any effects the closure body fires dispatch
199/// normally.
200impl<'a> crate::parser_runtime::ClosureCaller for Vm<'a> {
201    fn call_closure(&mut self, closure: Value, args: Vec<Value>) -> Result<Value, String> {
202        self.invoke_closure_value(closure, args)
203            .map_err(|e| format!("{e:?}"))
204    }
205}
206
207/// A handler that fails any effect call. Useful as a default for pure-only runs.
208pub struct DenyAllEffects;
209impl EffectHandler for DenyAllEffects {
210    fn dispatch(&mut self, kind: &str, op: &str, _args: Vec<Value>) -> Result<Value, String> {
211        Err(format!("effects not permitted (attempted {kind}.{op})"))
212    }
213}
214
215/// Trace receiver. Implementors record the call/effect tree and may
216/// substitute effect responses (for replay).
217pub trait Tracer {
218    fn enter_call(&mut self, node_id: &str, name: &str, args: &[Value]);
219    fn enter_effect(&mut self, node_id: &str, kind: &str, op: &str, args: &[Value]);
220    fn exit_ok(&mut self, value: &Value);
221    fn exit_err(&mut self, message: &str);
222    /// Tail-call optimization: pop the current frame's open call without
223    /// re-entering the parent (the new call takes its place).
224    fn exit_call_tail(&mut self);
225    /// During replay, return Some(v) to substitute an effect's output.
226    fn override_effect(&mut self, _node_id: &str) -> Option<Value> { None }
227}
228
229/// No-op tracer for normal execution.
230pub struct NullTracer;
231impl Tracer for NullTracer {
232    fn enter_call(&mut self, _: &str, _: &str, _: &[Value]) {}
233    fn enter_effect(&mut self, _: &str, _: &str, _: &str, _: &[Value]) {}
234    fn exit_ok(&mut self, _: &Value) {}
235    fn exit_err(&mut self, _: &str) {}
236    fn exit_call_tail(&mut self) {}
237}
238
239#[derive(Debug, Clone)]
240pub(crate) enum FrameKind {
241    /// Top-level entry frame; doesn't correspond to a Call opcode.
242    Entry,
243    /// Frame opened by Call/TailCall. The `String` is the originating
244    /// `NodeId`; useful for diagnostics even if currently unread.
245    Call(#[allow(dead_code)] String),
246}
247
248pub struct Vm<'a> {
249    program: &'a Program,
250    handler: Box<dyn EffectHandler + 'a>,
251    pub(crate) tracer: Box<dyn Tracer + 'a>,
252    /// Per-call frames. Each frame has its own locals array and pc.
253    frames: Vec<Frame>,
254    stack: Vec<Value>,
255    /// Soft cap to avoid runaway computations in tests.
256    pub step_limit: u64,
257    pub steps: u64,
258    /// Per-Vm memoization cache for pure functions (#229). Keyed by
259    /// `(fn_id, hash_call_args(args))` — a 128-bit structural digest
260    /// of the arguments (see `hash_call_args`). Effectful functions
261    /// never enter this map. The cache lives for the lifetime of one
262    /// `Vm::call` chain — calling `Vm::with_handler` again starts a
263    /// fresh cache.
264    pure_memo: std::collections::HashMap<(u32, [u8; 16]), Value>,
265    /// Diagnostic counters for `--trace` observability (#229).
266    pub pure_memo_hits: u64,
267    pub pure_memo_misses: u64,
268    /// Number of effect-free calls that skipped the cache entirely
269    /// because adaptive memoization disabled their function (#229
270    /// adaptive). Observability only.
271    pub pure_memo_skips: u64,
272    /// Adaptive-memoization state, one entry per function (indexed by
273    /// `fn_id`), parallel to `field_ics` (#229 adaptive). Memoization
274    /// only pays when a function is called repeatedly with equal args;
275    /// the unconditional `hash_call_args` on every effect-free call is
276    /// pure overhead otherwise (the `response_build` profile: 0 hits /
277    /// 3600 misses, ~12% of instructions). After a warmup window with
278    /// zero hits we stop hashing that function's calls — always safe,
279    /// since the callee is pure and recomputing yields the same value.
280    /// Sticky for the Vm's lifetime: a function that hasn't hit in
281    /// `MEMO_WARMUP_CALLS` calls won't amortize later.
282    memo_fn_state: Vec<MemoFnState>,
283    /// Monomorphic inline caches for `Op::GetField` (#462 slice 1 +
284    /// shape-keyed verification slice). Indexed by
285    /// `[fn_id as usize][site_idx as usize]` — one entry per
286    /// field-access site within each function. `site_idx` is assigned
287    /// at compile time by `FnCompiler::field_get_sites` so every emit
288    /// produces a stable identifier independent of pc. The cache
289    /// survives the planned dispatch rewrite (#461) and a future
290    /// JIT (#465).
291    ///
292    /// Slot shape: `(shape_id, offset)`. The pre-shape-keyed slice
293    /// stored only the offset and re-verified each hit by walking
294    /// `IndexMap::get_index(off)` and string-comparing the field name
295    /// against the requested `name_idx`. After this slice, hits
296    /// against compile-time records (real `shape_id`) verify with a
297    /// single `u32` compare and skip the string compare entirely —
298    /// per the #462 slice-2b measurement that observed 0% polymorphism
299    /// and 86% of hits going to records with a real shape_id.
300    ///
301    /// `NO_SHAPE_ID` records (JSON / SQL / HTTP-built — 14% of measured
302    /// hits, 100% of inbox/gateway traffic) fall through to the
303    /// pre-slice name-compare verification. Distinct dynamic shapes
304    /// both carry `NO_SHAPE_ID` and would otherwise alias on a
305    /// pure-shape-keyed IC; keeping the name compare on that path
306    /// preserves correctness without a separate cache for them.
307    ///
308    /// Outer Vec is pre-sized to `program.functions.len()`; each inner
309    /// Vec is empty until the first GetField in that function runs,
310    /// at which point we one-shot allocate it to the compiler-recorded
311    /// `field_ic_sites` size and never resize again. Lazy on the inner
312    /// side so VMs created for short-lived scripts don't eagerly
313    /// allocate IC slots for functions they never enter.
314    field_ics: Vec<Vec<Option<(u32, usize)>>>,
315    /// Stack allocator for function locals (#389 slice 3).
316    ///
317    /// Every function frame claims `locals_count` contiguous slots from
318    /// this Vec on push and releases them on pop.  Because Lex uses
319    /// strictly LIFO frame semantics the most-recently-pushed frame's
320    /// slots always sit at the top of the Vec, so `truncate` is the
321    /// correct (and O(1)) release operation.
322    ///
323    /// The Vec is pre-allocated once at VM construction and then grows
324    /// only if the actual call depth × locals width exceeds the initial
325    /// capacity.  After a top-level `vm.call` returns the Vec is empty
326    /// again but its capacity is retained, so the next request incurs
327    /// zero allocations for locals up to the high-water mark.
328    locals_storage: Vec<Value>,
329    /// Stack-record arena (#464 step 2). Each `Op::AllocStackRecord`
330    /// at a non-escaping site appends its `field_count` field values
331    /// here; the produced `Value::StackRecord` carries `slab_start =
332    /// arena.len() - field_count` so reads are an O(1) slab index.
333    /// On `Op::Return` the arena is truncated back to
334    /// `frame.stack_record_arena_start`, releasing every record the
335    /// frame allocated in O(1) — same lifetime story as
336    /// `locals_storage` for frame locals.
337    ///
338    /// LIFO frame discipline guarantees a frame's records always sit
339    /// at the top of the arena while the frame is live, so neither
340    /// inter-frame interleaving nor index churn can occur.
341    stack_record_arena: Vec<Value>,
342    /// Per-Vm counters for #464 acceptance measurement. Incremented
343    /// on every `Op::MakeRecord` / `Op::AllocStackRecord` dispatch.
344    /// The bench reads these to compute the stack-allocation rate
345    /// (≥ 60% of records on the stack is the acceptance bar). Cheap
346    /// in the hot path — two unconditional u64 increments per record.
347    pub stack_record_allocs: u64,
348    pub stack_record_heap_fallbacks: u64,
349    pub heap_record_allocs: u64,
350}
351
352struct Frame {
353    fn_id: u32,
354    pc: usize,
355    /// Start index of this frame's locals in `Vm::locals_storage` (#389
356    /// slice 3). The frame owns `locals_storage[locals_start..locals_start
357    /// + locals_len]`; `Op::Return` truncates the Vec back to
358    /// `locals_start`, releasing the slots in O(1).
359    locals_start: usize,
360    locals_len: usize,
361    /// Stack base when this frame started (for cleanup on return).
362    stack_base: usize,
363    trace_kind: FrameKind,
364    /// Pure-fn memo key (#229). `Some(key)` if the call was eligible
365    /// for memoization and missed the cache; on Op::Return the key
366    /// is used to write the return value back into the cache.
367    /// `None` means "don't memoize" — either the function isn't pure,
368    /// the call wasn't through Op::Call, or memoization is disabled.
369    memo_key: Option<(u32, [u8; 16])>,
370    /// #464 step 2: start index of this frame's records in
371    /// `Vm::stack_record_arena`. On `Op::Return`, the arena is
372    /// truncated back here. Identical lifetime discipline to
373    /// `locals_start`.
374    stack_record_arena_start: usize,
375    /// Remaining stack-record budget for this frame, in Value-slot
376    /// units (#464 step 2). Initial value: `STACK_RECORD_BUDGET_SLOTS`.
377    /// When an `Op::AllocStackRecord` would consume more slots than
378    /// remain, the VM falls back to the heap path silently (same
379    /// observable effect as `Op::MakeRecord`), so the budget never
380    /// surfaces as a user-visible error.
381    stack_record_budget_remaining: u32,
382}
383
384/// Sum of `[budget(N)]` declarations on a function's signature
385/// (#225). Used by Op::Call / Op::TailCall / Op::CallClosure to
386/// notify the EffectHandler of per-call budget cost so the handler
387/// can deduct from a shared pool and refuse calls that would
388/// exceed the policy ceiling. Negative `Int` args are ignored —
389/// the static check (`policy::check_program`) treats budgets as
390/// non-negative.
391fn call_budget_cost(f: &crate::program::Function) -> u64 {
392    let mut total: u64 = 0;
393    for e in &f.effects {
394        if e.kind == "budget" {
395            if let Some(crate::program::EffectArg::Int(n)) = &e.arg {
396                if *n >= 0 {
397                    total = total.saturating_add(*n as u64);
398                }
399            }
400        }
401    }
402    total
403}
404
405/// Hash the argument list for a pure-fn memoization lookup (#229).
406///
407/// The memo cache (`pure_memo`) is keyed on this 128-bit digest with
408/// no secondary equality check, so the contract is: argument lists
409/// that are equal under `Value`'s `PartialEq` must produce the same
410/// digest, and the 128-bit width keeps the false-collision rate
411/// (which would return a wrong cached result) negligible.
412///
413/// History (#461 follow-up): this used to build a `serde_json::Value`
414/// of every arg, canonicalize it, and SHA-256 the bytes. Profiling
415/// the `response_build` workload showed that path at 27.6% of all
416/// instructions — it dominated the VM, since every effect-free call
417/// pays it whether or not the cache ever hits. The cache is per-`Vm`
418/// and ephemeral, so a cryptographic, cross-process-stable key was
419/// never needed. We now walk the `Value` tree directly into two
420/// domain-separated `SipHash` passes (deterministic fixed-key
421/// `DefaultHasher`), concatenating the two 64-bit outputs into a
422/// 128-bit key. No JSON allocation, no crypto.
423///
424/// The walk mirrors `Value::PartialEq` so the equal-args-equal-key
425/// contract holds: `Record` is hashed order-independently over its
426/// fields (matching `IndexMap`'s order-insensitive equality),
427/// `Closure` on `(body_hash, captures)` not `fn_id` (#222), and
428/// `Actor`/`Ticker` on pointer identity (matching `Arc::ptr_eq`).
429fn hash_call_args(args: &[Value]) -> [u8; 16] {
430    use std::collections::hash_map::DefaultHasher;
431    use std::hash::Hasher;
432    let mut h0 = DefaultHasher::new();
433    let mut h1 = DefaultHasher::new();
434    // Domain separator: makes the two passes diverge so the
435    // concatenated halves span the full 128-bit space rather than
436    // duplicating one 64-bit value.
437    h1.write_u8(0x9e);
438    h0.write_usize(args.len());
439    h1.write_usize(args.len());
440    for a in args {
441        hash_value_into(a, &mut h0);
442        hash_value_into(a, &mut h1);
443    }
444    let lo = h0.finish();
445    let hi = h1.finish();
446    let mut out = [0u8; 16];
447    out[..8].copy_from_slice(&lo.to_le_bytes());
448    out[8..].copy_from_slice(&hi.to_le_bytes());
449    out
450}
451
452/// Structural hash of a `Value` into `h`, consistent with
453/// `Value::PartialEq`. The leading discriminant byte keeps distinct
454/// variants from colliding (e.g. `Int(0)` vs `Bool(false)`).
455fn hash_value_into<H: std::hash::Hasher>(v: &Value, h: &mut H) {
456    use std::collections::hash_map::DefaultHasher;
457    use std::hash::Hasher as _;
458    match v {
459        Value::Int(n) => { h.write_u8(0x01); h.write_i64(*n); }
460        // Bit pattern, not value: total and deterministic. NaN==NaN
461        // by bits (a memo hit there is harmless — the callee is pure
462        // and returns the same result for bit-identical args), and
463        // +0.0/-0.0 differ (a harmless extra miss).
464        Value::Float(f) => { h.write_u8(0x02); h.write_u64(f.to_bits()); }
465        Value::Bool(b) => { h.write_u8(0x03); h.write_u8(*b as u8); }
466        Value::Str(s) => {
467            h.write_u8(0x04);
468            h.write_usize(s.len());
469            h.write(s.as_bytes());
470        }
471        Value::Bytes(b) => {
472            h.write_u8(0x05);
473            h.write_usize(b.len());
474            h.write(b);
475        }
476        Value::Unit => { h.write_u8(0x06); }
477        Value::List(items) => {
478            h.write_u8(0x07);
479            h.write_usize(items.len());
480            for it in items { hash_value_into(it, h); }
481        }
482        Value::Tuple(items) => {
483            h.write_u8(0x08);
484            h.write_usize(items.len());
485            for it in items { hash_value_into(it, h); }
486        }
487        Value::Deque(items) => {
488            h.write_u8(0x09);
489            h.write_usize(items.len());
490            for it in items { hash_value_into(it, h); }
491        }
492        // `IndexMap` equality is order-insensitive, so the hash must
493        // be too: combine per-entry sub-hashes with wrapping add (a
494        // commutative mix) rather than feeding them in iteration
495        // order.
496        Value::Record { fields, .. } => {
497            h.write_u8(0x0a);
498            let mut combined: u64 = 0;
499            for (k, val) in fields.iter() {
500                let mut e = DefaultHasher::new();
501                e.write(k.as_bytes());
502                e.write_u8(0xff);
503                hash_value_into(val, &mut e);
504                combined = combined.wrapping_add(e.finish());
505            }
506            h.write_u64(combined);
507            h.write_usize(fields.len());
508        }
509        Value::Variant { name, args } => {
510            h.write_u8(0x0b);
511            h.write_usize(name.len());
512            h.write(name.as_bytes());
513            h.write_usize(args.len());
514            for a in args { hash_value_into(a, h); }
515        }
516        // Identity is `(body_hash, captures)`, not `fn_id` (#222).
517        Value::Closure { body_hash, captures, .. } => {
518            h.write_u8(0x0c);
519            h.write(body_hash);
520            h.write_usize(captures.len());
521            for c in captures { hash_value_into(c, h); }
522        }
523        Value::F64Array { rows, cols, data } => {
524            h.write_u8(0x0d);
525            h.write_u32(*rows);
526            h.write_u32(*cols);
527            for f in data { h.write_u64(f.to_bits()); }
528        }
529        // BTreeMap / BTreeSet iterate in sorted key order — already
530        // canonical, so direct feed is order-independent.
531        Value::Map(m) => {
532            h.write_u8(0x0e);
533            h.write_usize(m.len());
534            for (k, val) in m {
535                hash_mapkey_into(k, h);
536                hash_value_into(val, h);
537            }
538        }
539        Value::Set(s) => {
540            h.write_u8(0x0f);
541            h.write_usize(s.len());
542            for k in s { hash_mapkey_into(k, h); }
543        }
544        // Pointer identity, matching `Arc::ptr_eq` in PartialEq.
545        Value::Actor(a) => {
546            h.write_u8(0x10);
547            h.write_usize(Arc::as_ptr(a) as *const () as usize);
548        }
549        Value::Ticker(t) => {
550            h.write_u8(0x11);
551            h.write_usize(Arc::as_ptr(t) as *const () as usize);
552        }
553        // Coarse summary (schema + dimensions), matching the prior
554        // `to_json` encoding which deliberately omitted the cell data
555        // (tables can be GB-scale). Equal tables share schema + dims
556        // so equal-args-equal-key holds; this is no coarser than the
557        // pre-#461-followup behavior.
558        Value::ArrowTable(t) => {
559            h.write_u8(0x12);
560            h.write_i64(t.num_rows() as i64);
561            h.write_i64(t.num_columns() as i64);
562            for f in t.schema().fields() {
563                h.write(f.name().as_bytes());
564                h.write_u8(0xfe);
565            }
566        }
567        // #464: a StackRecord crossing into the memo path means an
568        // escape the analysis was supposed to reject. Mirror the
569        // PartialEq / to_json panic rather than mint a bogus key.
570        Value::StackRecord { .. } =>
571            panic!("BUG(#464): Value::StackRecord reached memo hashing — \
572                    escape analysis should have prevented escape to a call boundary"),
573        Value::StackTuple { .. } =>
574            panic!("BUG(#464): Value::StackTuple reached memo hashing — \
575                    escape analysis should have prevented escape to a call boundary"),
576    }
577}
578
579/// Hash a `MapKey` into `h` with its own discriminant so a `Str`
580/// key and an `Int` key never collide.
581fn hash_mapkey_into<H: std::hash::Hasher>(k: &crate::value::MapKey, h: &mut H) {
582    use crate::value::MapKey;
583    match k {
584        MapKey::Str(s) => { h.write_u8(0x01); h.write_usize(s.len()); h.write(s.as_bytes()); }
585        MapKey::Int(n) => { h.write_u8(0x02); h.write_i64(*n); }
586    }
587}
588
589/// Evaluate a refinement predicate at runtime against the actual
590/// argument value (#209 slice 3). Mirrors `lex_types::discharge`'s
591/// static evaluator but operates on `Value` directly.
592///
593/// Returns `Ok(true)` / `Ok(false)` for a clean boolean verdict, or
594/// `Err(reason)` if the predicate references something the runtime
595/// can't resolve (free variable beyond the binding, unsupported AST
596/// node). Callers map `Ok(false)` and `Err` to `VmError::RefinementFailed`.
597fn eval_refinement(
598    predicate: &lex_ast::CExpr,
599    binding: &str,
600    arg: &Value,
601) -> Result<bool, String> {
602    match eval_refinement_inner(predicate, binding, arg) {
603        Ok(Value::Bool(b)) => Ok(b),
604        Ok(other) => Err(format!("predicate didn't reduce to a Bool, got {other:?}")),
605        Err(e) => Err(e),
606    }
607}
608
609fn eval_refinement_inner(
610    e: &lex_ast::CExpr,
611    binding: &str,
612    arg: &Value,
613) -> Result<Value, String> {
614    use lex_ast::{CExpr, CLit};
615    match e {
616        CExpr::Literal { value } => Ok(match value {
617            CLit::Int { value } => Value::Int(*value),
618            CLit::Float { value } => Value::Float(value.parse().unwrap_or(0.0)),
619            CLit::Bool { value } => Value::Bool(*value),
620            CLit::Str { value } => Value::Str(value.as_str().into()),
621            CLit::Bytes { value } => Value::Str(value.as_str().into()), // hex; unusual in predicates
622            CLit::Unit => Value::Unit,
623        }),
624        CExpr::Var { name } if name == binding => Ok(arg.clone()),
625        CExpr::Var { name } => Err(format!(
626            "predicate references free var `{name}`; runtime check \
627             only resolves the binding (slice 4 will plumb call-site \
628             context)")),
629        CExpr::UnaryOp { op, expr } => {
630            let v = eval_refinement_inner(expr, binding, arg)?;
631            match (op.as_str(), v) {
632                ("not", Value::Bool(b)) => Ok(Value::Bool(!b)),
633                ("-", Value::Int(n)) => Ok(Value::Int(-n)),
634                ("-", Value::Float(n)) => Ok(Value::Float(-n)),
635                (o, v) => Err(format!("unsupported unary `{o}` on {v:?}")),
636            }
637        }
638        CExpr::BinOp { op, lhs, rhs } => {
639            // Short-circuit `and` / `or` for the same reasons as the
640            // static evaluator.
641            if op == "and" || op == "or" {
642                let l = eval_refinement_inner(lhs, binding, arg)?;
643                let lb = match l {
644                    Value::Bool(b) => b,
645                    other => return Err(format!("`{op}` on non-bool: {other:?}")),
646                };
647                if op == "and" && !lb { return Ok(Value::Bool(false)); }
648                if op == "or"  &&  lb { return Ok(Value::Bool(true));  }
649                let r = eval_refinement_inner(rhs, binding, arg)?;
650                return match r {
651                    Value::Bool(b) => Ok(Value::Bool(b)),
652                    other => Err(format!("`{op}` on non-bool: {other:?}")),
653                };
654            }
655            let l = eval_refinement_inner(lhs, binding, arg)?;
656            let r = eval_refinement_inner(rhs, binding, arg)?;
657            apply_refinement_binop(op, &l, &r)
658        }
659        // Other AST forms (Call, Let, Match, FieldAccess, Lambda,
660        // Block, Constructors, Records, Tuples, Lists, Return) need
661        // a more general evaluator that can call back into the VM.
662        // Out of scope for slice 3; a future slice may unify this
663        // with the spec-checker's gate evaluator.
664        other => Err(format!("unsupported predicate node: {other:?}")),
665    }
666}
667
668fn apply_refinement_binop(op: &str, l: &Value, r: &Value) -> Result<Value, String> {
669    use Value::*;
670    match (op, l, r) {
671        ("+", Int(a), Int(b)) => Ok(Int(a + b)),
672        ("-", Int(a), Int(b)) => Ok(Int(a - b)),
673        ("*", Int(a), Int(b)) => Ok(Int(a * b)),
674        ("/", Int(a), Int(b)) if *b != 0 => Ok(Int(a / b)),
675        ("%", Int(a), Int(b)) if *b != 0 => Ok(Int(a % b)),
676        ("+", Float(a), Float(b)) => Ok(Float(a + b)),
677        ("-", Float(a), Float(b)) => Ok(Float(a - b)),
678        ("*", Float(a), Float(b)) => Ok(Float(a * b)),
679        ("/", Float(a), Float(b)) => Ok(Float(a / b)),
680
681        ("==", a, b) => Ok(Bool(a == b)),
682        ("!=", a, b) => Ok(Bool(a != b)),
683
684        ("<",  Int(a), Int(b)) => Ok(Bool(a < b)),
685        ("<=", Int(a), Int(b)) => Ok(Bool(a <= b)),
686        (">",  Int(a), Int(b)) => Ok(Bool(a > b)),
687        (">=", Int(a), Int(b)) => Ok(Bool(a >= b)),
688
689        ("<",  Float(a), Float(b)) => Ok(Bool(a < b)),
690        ("<=", Float(a), Float(b)) => Ok(Bool(a <= b)),
691        (">",  Float(a), Float(b)) => Ok(Bool(a > b)),
692        (">=", Float(a), Float(b)) => Ok(Bool(a >= b)),
693
694        (op, a, b) => Err(format!(
695            "unsupported binop `{op}` on {a:?} and {b:?}")),
696    }
697}
698
699fn const_str(constants: &[Const], idx: u32) -> String {
700    match constants.get(idx as usize) {
701        Some(Const::NodeId(s)) | Some(Const::Str(s)) => s.clone(),
702        _ => String::new(),
703    }
704}
705
706/// Read `LEX_PAR_MAX_CONCURRENCY` (default = available CPU cores,
707/// fallback 4). Capped at 64 so a malformed env var can't spawn an
708/// unreasonable number of OS threads.
709/// Order-defining comparator for `list.sort_by` keys (#338).
710/// Same-typed Int / Float / Str pairs compare via their native
711/// `Ord` / `PartialOrd`. Mixed-type or other key shapes compare
712/// as Equal; combined with `Vec::sort_by`'s stability that
713/// preserves the original element order — best-effort fallback
714/// that never panics.
715fn compare_sort_keys(a: &Value, b: &Value) -> std::cmp::Ordering {
716    use std::cmp::Ordering;
717    match (a, b) {
718        (Value::Int(x), Value::Int(y)) => x.cmp(y),
719        (Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
720        (Value::Str(x), Value::Str(y)) => x.cmp(y),
721        _ => Ordering::Equal,
722    }
723}
724
725fn par_max_concurrency() -> usize {
726    let from_env = std::env::var("LEX_PAR_MAX_CONCURRENCY")
727        .ok()
728        .and_then(|s| s.parse::<usize>().ok())
729        .filter(|n| *n > 0);
730    let default = std::thread::available_parallelism()
731        .map(|n| n.get())
732        .unwrap_or(4);
733    from_env.unwrap_or(default).min(64)
734}
735
736/// `list.par_map`'s runtime: spawn OS threads (capped by
737/// `LEX_PAR_MAX_CONCURRENCY`), apply `closure` to each item, return
738/// results in input order. Each worker runs a fresh `Vm` with
739/// [`DenyAllEffects`] for #305 slice 1 — effectful closures fail
740/// with `VmError::Effect`. Slice 2 will plumb a per-thread effect
741/// handler split.
742fn par_map_run<'a>(
743    program: &'a Program,
744    closure: Value,
745    items: Vec<Value>,
746    worker_handlers: Vec<Box<dyn EffectHandler + Send>>,
747) -> Result<Vec<Value>, VmError> {
748    if items.is_empty() {
749        return Ok(Vec::new());
750    }
751    let n_workers = worker_handlers.len().min(items.len()).max(1);
752    // Carve items into `n_workers` round-robin buckets so each
753    // worker processes (indices, items) pairs and we can reassemble
754    // in input order.
755    let mut buckets: Vec<Vec<(usize, Value)>> = (0..n_workers).map(|_| Vec::new()).collect();
756    for (i, v) in items.into_iter().enumerate() {
757        buckets[i % n_workers].push((i, v));
758    }
759    let n_total: usize = buckets.iter().map(|b| b.len()).sum();
760    let results: std::sync::Mutex<Vec<Option<Result<Value, String>>>> =
761        std::sync::Mutex::new((0..n_total).map(|_| None).collect());
762
763    // Pair each bucket with its pre-built handler so workers own
764    // their handler outright — no shared mutable state across
765    // worker threads.
766    let mut worker_handlers = worker_handlers;
767    worker_handlers.truncate(n_workers);
768    type Pair = (Vec<(usize, Value)>, Box<dyn EffectHandler + Send>);
769    let pairs: Vec<Pair> = buckets.into_iter().zip(worker_handlers).collect();
770
771    std::thread::scope(|s| {
772        let mut handles = Vec::with_capacity(pairs.len());
773        for (bucket, handler) in pairs {
774            let closure = closure.clone();
775            let results = &results;
776            handles.push(s.spawn(move || {
777                // `Box<dyn EffectHandler + Send>` has implicit
778                // `+ 'static`; that coerces to `+ 'a` because
779                // `'static` outlives any `'a`. The `Send` bound is
780                // auto-erased on the unsize coercion.
781                let handler_for_vm: Box<dyn EffectHandler + 'a> = handler;
782                let mut vm = Vm::with_handler(program, handler_for_vm);
783                for (idx, item) in bucket {
784                    let r = vm
785                        .invoke_closure_value(closure.clone(), vec![item])
786                        .map_err(|e| format!("{e:?}"));
787                    results.lock().unwrap()[idx] = Some(r);
788                }
789            }));
790        }
791        for h in handles {
792            h.join().map_err(|_| ()).ok();
793        }
794    });
795
796    let mut out = Vec::with_capacity(n_total);
797    let inner = results.into_inner().unwrap();
798    for r in inner {
799        match r {
800            Some(Ok(v)) => out.push(v),
801            Some(Err(e)) => return Err(VmError::Effect(format!("par_map worker: {e}"))),
802            None => return Err(VmError::Panic("par_map worker did not produce a result".into())),
803        }
804    }
805    Ok(out)
806}
807
808impl<'a> Vm<'a> {
809    pub fn new(program: &'a Program) -> Self {
810        Self::with_handler(program, Box::new(DenyAllEffects))
811    }
812
813    pub fn with_handler(program: &'a Program, handler: Box<dyn EffectHandler + 'a>) -> Self {
814        Self {
815            program,
816            handler,
817            tracer: Box::new(NullTracer),
818            // Pre-allocate enough capacity for a typical request so the first
819            // call incurs no reallocation (#389 slice 3).
820            frames: Vec::with_capacity(32),
821            stack: Vec::with_capacity(128),
822            step_limit: 10_000_000,
823            steps: 0,
824            pure_memo: std::collections::HashMap::new(),
825            pure_memo_hits: 0,
826            pure_memo_misses: 0,
827            pure_memo_skips: 0,
828            memo_fn_state: vec![MemoFnState::default(); program.functions.len()],
829            field_ics: vec![Vec::new(); program.functions.len()],
830            // 256 slots handles ~32 frames × 8 locals; grows on demand and
831            // retains capacity across consecutive vm.call() invocations.
832            locals_storage: Vec::with_capacity(256),
833            // #464 step 2: zero capacity at construction — handlers that
834            // never AllocStackRecord (most code today, until the lowering
835            // pass kicks in) pay nothing. First allocation triggers Vec
836            // growth; capacity is retained across `vm.call` invocations.
837            stack_record_arena: Vec::new(),
838            stack_record_allocs: 0,
839            stack_record_heap_fallbacks: 0,
840            heap_record_allocs: 0,
841        }
842    }
843
844    pub fn set_tracer(&mut self, tracer: Box<dyn Tracer + 'a>) {
845        self.tracer = tracer;
846    }
847
848    /// Cap the number of opcode dispatches before the VM aborts with
849    /// `step limit exceeded`. Useful as a runtime DoS guard against
850    /// untrusted code (e.g. the `agent-tool` sandbox, where an LLM
851    /// could emit `list.fold(list.range(0, 1_000_000_000), …)` to hang
852    /// the host). Default is 10_000_000.
853    pub fn set_step_limit(&mut self, limit: u64) {
854        self.step_limit = limit;
855    }
856
857    pub fn call(&mut self, name: &str, args: Vec<Value>) -> Result<Value, VmError> {
858        let fn_id = self.program.lookup(name).ok_or_else(|| VmError::Panic(format!("no function `{name}`")))?;
859        self.invoke(fn_id, args)
860    }
861
862    /// Vm-level handler for `parser.run` (#221). Routed here from
863    /// `Op::EffectCall` rather than through the `EffectHandler` so
864    /// the recursive parser interpreter has reentrant Vm access for
865    /// closure invocation. Returns the wrapped `Result[T, ParseErr]`
866    /// value the language sees.
867    fn run_parser_op(&mut self, args: Vec<Value>) -> Result<Value, String> {
868        let parser = args.first().cloned()
869            .ok_or_else(|| "parser.run: missing parser arg".to_string())?;
870        let input = match args.get(1) {
871            Some(Value::Str(s)) => s.clone(),
872            _ => return Err("parser.run: input must be Str".into()),
873        };
874        match crate::parser_runtime::run_parser(&parser, &input, 0, self) {
875            Ok((value, _pos)) => Ok(Value::Variant {
876                name: "Ok".into(),
877                args: vec![value],
878            }),
879            Err((pos, msg)) => {
880                let mut e: IndexMap<String, Value> = IndexMap::new();
881                e.insert("pos".into(), Value::Int(pos as i64));
882                e.insert("message".into(), Value::Str(msg.into()));
883                Ok(Value::Variant {
884                    name: "Err".into(),
885                    args: vec![Value::record_dynamic(e)],
886                })
887            }
888        }
889    }
890
891    // ---- Variant helpers used by conc.* registry ops (#444) ----
892    // Local helpers (avoid pulling in serde / public API). Lex's
893    // `Result`/`Option` are stdlib unions; their runtime shape is a
894    // `Value::Variant { name, args }` with the constructor name as
895    // declared (`Ok`/`Err`/`Some`/`None`).
896
897    /// VM-level handler for `conc.*` effect ops (#381).
898    ///
899    /// * `conc.spawn(init, handler)` — creates an `Actor` wrapping the
900    ///   initial state and the handler closure. No background thread is
901    ///   started; the actor runs synchronously on the calling thread
902    ///   under a `Mutex` so concurrent callers serialise.
903    ///
904    /// * `conc.ask(actor, msg)` — locks the actor, calls
905    ///   `handler(state, msg)` on *this* VM (reentrant), expects a
906    ///   2-tuple `(new_state, reply)`, updates the actor's state, and
907    ///   returns `reply`.
908    ///
909    /// * `conc.tell(actor, msg)` — same as `ask` but discards the
910    ///   reply and returns `Unit`.
911    fn run_conc_op(&mut self, op: &str, args: Vec<Value>) -> Result<Value, String> {
912        match op {
913            "spawn" => {
914                let mut it = args.into_iter();
915                let init = it.next().unwrap_or(Value::Unit);
916                let handler = it.next().unwrap_or(Value::Unit);
917                if !matches!(handler, Value::Closure { .. }) {
918                    return Err(format!(
919                        "conc.spawn: handler must be a Closure, got {handler:?}"));
920                }
921                Ok(Value::Actor(Arc::new(Mutex::new(ActorCell {
922                    state: init,
923                    handler: crate::value::ActorHandler::Lex(handler),
924                }))))
925            }
926            "ask" | "tell" => {
927                let mut it = args.into_iter();
928                let actor_val = it.next().unwrap_or(Value::Unit);
929                let msg = it.next().unwrap_or(Value::Unit);
930                let cell = match actor_val {
931                    Value::Actor(ref arc) => Arc::clone(arc),
932                    other => return Err(format!(
933                        "conc.{op}: first arg must be an Actor, got {other:?}")),
934                };
935                // Lock the actor: guarantees at-most-one-concurrent message.
936                let mut guard = cell.lock().map_err(|e| format!("conc.{op}: actor mutex poisoned: {e}"))?;
937                let handler = guard.handler.clone();
938                let state = guard.state.clone();
939                match handler {
940                    crate::value::ActorHandler::Lex(closure_val) => {
941                        // Call handler(state, msg) on this VM — full effect access.
942                        let result = self.invoke_closure_value(closure_val, vec![state, msg])
943                            .map_err(|e| format!("conc.{op}: handler error: {e:?}"))?;
944                        // Expect (new_state, reply) tuple.
945                        match result {
946                            Value::Tuple(mut parts) if parts.len() == 2 => {
947                                let reply = parts.pop().unwrap();
948                                let new_state = parts.pop().unwrap();
949                                guard.state = new_state;
950                                drop(guard);
951                                if op == "ask" { Ok(reply) } else { Ok(Value::Unit) }
952                            }
953                            other => Err(format!(
954                                "conc.{op}: handler must return a 2-tuple (new_state, reply), got {other:?}")),
955                        }
956                    }
957                    crate::value::ActorHandler::Native(native) => {
958                        // Native bridge: fire-and-forget; `state` is unused
959                        // (the bridge's "state" is the external resource, e.g.
960                        // a WebSocket connection). The closure receives `msg`
961                        // directly. `ask` returns whatever the bridge produces;
962                        // `tell` discards it. State stays untouched.
963                        drop(guard);
964                        let result = (native.send)(msg)
965                            .map_err(|e| format!("conc.{op}: native handler error: {e}"))?;
966                        if op == "ask" { Ok(result) } else { Ok(Value::Unit) }
967                    }
968                }
969            }
970            "register" => {
971                // conc.register(actor, name) -> Result[Unit, ConcError]
972                // Returns Ok(Unit) on first register, Err(AlreadyRegistered(name))
973                // if the name is taken. v1 stores the actor opaquely —
974                // see crate::conc_registry for the type-tag note.
975                let mut it = args.into_iter();
976                let actor = it.next().unwrap_or(Value::Unit);
977                if !matches!(actor, Value::Actor(_)) {
978                    return Err(format!(
979                        "conc.register: first arg must be an Actor, got {actor:?}"));
980                }
981                let name = match it.next() {
982                    Some(Value::Str(s)) => s.to_string(),
983                    other => return Err(format!(
984                        "conc.register: name must be Str, got {other:?}")),
985                };
986                Ok(match crate::conc_registry::register(&name, actor) {
987                    Ok(()) => variant_ok(Value::Unit),
988                    Err(crate::conc_registry::RegError::AlreadyRegistered(n)) => {
989                        variant_err(variant("AlreadyRegistered", vec![Value::Str(n.into())]))
990                    }
991                    Err(crate::conc_registry::RegError::NotRegistered(_)) => {
992                        unreachable!("register cannot produce NotRegistered")
993                    }
994                })
995            }
996            "lookup" => {
997                // conc.lookup(name) -> Option[Actor[S, M]]
998                // Returns Some(actor) if registered, None otherwise. The
999                // [S, M] static parametrisation at the call site is not
1000                // checked at runtime in v1 — caller's responsibility to
1001                // match the registration site's type.
1002                let mut it = args.into_iter();
1003                let name = match it.next() {
1004                    Some(Value::Str(s)) => s.to_string(),
1005                    other => return Err(format!(
1006                        "conc.lookup: name must be Str, got {other:?}")),
1007                };
1008                Ok(match crate::conc_registry::lookup(&name) {
1009                    Some(actor) => variant("Some", vec![actor]),
1010                    None => variant("None", vec![]),
1011                })
1012            }
1013            "unregister" => {
1014                // conc.unregister(name) -> Result[Unit, ConcError]
1015                let mut it = args.into_iter();
1016                let name = match it.next() {
1017                    Some(Value::Str(s)) => s.to_string(),
1018                    other => return Err(format!(
1019                        "conc.unregister: name must be Str, got {other:?}")),
1020                };
1021                Ok(match crate::conc_registry::unregister(&name) {
1022                    Ok(()) => variant_ok(Value::Unit),
1023                    Err(crate::conc_registry::RegError::NotRegistered(n)) => {
1024                        variant_err(variant("NotRegistered", vec![Value::Str(n.into())]))
1025                    }
1026                    Err(crate::conc_registry::RegError::AlreadyRegistered(_)) => {
1027                        unreachable!("unregister cannot produce AlreadyRegistered")
1028                    }
1029                })
1030            }
1031            "registered" => {
1032                // conc.registered() -> List[Str] — sorted snapshot.
1033                let names = crate::conc_registry::registered();
1034                Ok(Value::List(names.into_iter()
1035                    .map(|n| Value::Str(n.into()))
1036                    .collect()))
1037            }
1038            other => Err(format!("unknown conc.{other}")),
1039        }
1040    }
1041
1042    /// Invoke a `Value::Closure` by combining its captures with the
1043    /// supplied call args and dispatching to the underlying function.
1044    /// Used by the parser interpreter (#221) to call user-supplied
1045    /// `f` arguments inside `parser.map` / `parser.and_then` nodes.
1046    pub fn invoke_closure_value(
1047        &mut self,
1048        closure: Value,
1049        args: Vec<Value>,
1050    ) -> Result<Value, VmError> {
1051        let (fn_id, captures) = match closure {
1052            Value::Closure { fn_id, captures, .. } => (fn_id, captures),
1053            other => return Err(VmError::TypeMismatch(
1054                format!("invoke_closure_value: not a closure: {other:?}"))),
1055        };
1056        let mut combined = captures;
1057        combined.extend(args);
1058        self.invoke(fn_id, combined)
1059    }
1060
1061    /// Invoke a 1-arg closure without allocating a separate args
1062    /// `Vec` (#464 call-overhead). The closure's own `captures` Vec
1063    /// is reused as the combined `captures ++ [arg]` argument buffer,
1064    /// so the per-element call in `ListMap`/`ListFilter`/`SortByKey`
1065    /// allocates at most once (the `push`) instead of twice (a fresh
1066    /// `vec![arg]` plus the `extend`). Semantically identical to
1067    /// `invoke_closure_value(closure, vec![arg])`.
1068    pub fn invoke_closure_1(&mut self, closure: Value, arg: Value) -> Result<Value, VmError> {
1069        let (fn_id, mut combined) = match closure {
1070            Value::Closure { fn_id, captures, .. } => (fn_id, captures),
1071            other => return Err(VmError::TypeMismatch(
1072                format!("invoke_closure_1: not a closure: {other:?}"))),
1073        };
1074        combined.push(arg);
1075        self.invoke(fn_id, combined)
1076    }
1077
1078    /// Invoke a 2-arg closure without a separate args `Vec` — the
1079    /// `ListFold` combiner path. See `invoke_closure_1`.
1080    pub fn invoke_closure_2(&mut self, closure: Value, a: Value, b: Value) -> Result<Value, VmError> {
1081        let (fn_id, mut combined) = match closure {
1082            Value::Closure { fn_id, captures, .. } => (fn_id, captures),
1083            other => return Err(VmError::TypeMismatch(
1084                format!("invoke_closure_2: not a closure: {other:?}"))),
1085        };
1086        combined.push(a);
1087        combined.push(b);
1088        self.invoke(fn_id, combined)
1089    }
1090
1091    /// Open a request-scoped arena via the underlying
1092    /// `EffectHandler::enter_request_scope` (#463 scaffolding).
1093    /// Runtime layers — `net.serve_fn`, `net.serve_ws`,
1094    /// `net.serve_quic` — call this immediately before invoking the
1095    /// user handler closure for a single request. Pair with
1096    /// `exit_request_scope` once the response has been built and
1097    /// any lazy iterators in it have been drained (#477).
1098    ///
1099    /// Returns the scope id the runtime should pass back to
1100    /// `exit_request_scope`. The handler's default impl returns 0
1101    /// and the matching `exit` is a no-op; `DefaultHandler`'s
1102    /// implementation actually allocates an arena.
1103    pub fn enter_request_scope(&mut self) -> u64 {
1104        self.handler.enter_request_scope()
1105    }
1106
1107    /// Close the request scope opened by `enter_request_scope`.
1108    /// Drops the associated arena.
1109    pub fn exit_request_scope(&mut self, scope_id: u64) {
1110        self.handler.exit_request_scope(scope_id)
1111    }
1112
1113    pub fn invoke(&mut self, fn_id: u32, args: Vec<Value>) -> Result<Value, VmError> {
1114        let f = &self.program.functions[fn_id as usize];
1115        if args.len() != f.arity as usize {
1116            return Err(VmError::Panic(format!("arity mismatch calling {}", f.name)));
1117        }
1118        // Refinement runtime check at the public entry point too
1119        // (#209 slice 3). `Op::Call` checks for in-program calls;
1120        // this branch covers `vm.call("entry", ...)` from the host
1121        // and the reentrant `invoke_closure_value` path. Same
1122        // semantics, same error shape.
1123        //
1124        // Iterate `f.refinements` by reference — the loop body
1125        // only reads from `self.program` (via `r`) and from locals,
1126        // so we don't need to clone the Vec to detach it from
1127        // `&self`. The function name is cloned **lazily**, only on
1128        // the failure path: functions with no refinements (the common
1129        // case) never enter the loop, so the per-call `f.name.clone()`
1130        // was pure waste on the hot path (#464 call-overhead).
1131        for (i, refinement) in f.refinements.iter().enumerate() {
1132            if let Some(r) = refinement {
1133                let arg = args.get(i).cloned().unwrap_or(Value::Unit);
1134                match eval_refinement(&r.predicate, &r.binding, &arg) {
1135                    Ok(true) => {}
1136                    Ok(false) => return Err(VmError::RefinementFailed {
1137                        fn_name: f.name.clone(),
1138                        param_index: i,
1139                        binding: r.binding.clone(),
1140                        reason: format!("predicate failed for {} = {arg:?}", r.binding),
1141                    }),
1142                    Err(reason) => return Err(VmError::RefinementFailed {
1143                        fn_name: f.name.clone(),
1144                        param_index: i,
1145                        binding: r.binding.clone(),
1146                        reason,
1147                    }),
1148                }
1149            }
1150        }
1151        let f = &self.program.functions[fn_id as usize];
1152        // Claim slots from the locals stack allocator (#389 slice 3).
1153        let locals_start = self.locals_storage.len();
1154        let locals_len = f.locals_count.max(f.arity) as usize;
1155        self.locals_storage.resize(locals_start + locals_len, Value::Unit);
1156        for (i, v) in args.into_iter().enumerate() {
1157            self.locals_storage[locals_start + i] = v;
1158        }
1159        // Record the depth before pushing — this is what `run` will
1160        // exit at, supporting reentrant invocation from inside the
1161        // VM (e.g. the parser interpreter calling closures, #221).
1162        let base_depth = self.frames.len();
1163        self.push_frame(Frame {
1164            fn_id, pc: 0, locals_start, locals_len,
1165            stack_base: self.stack.len(),
1166            trace_kind: FrameKind::Entry,
1167            memo_key: None,
1168            stack_record_arena_start: self.stack_record_arena.len(),
1169            stack_record_budget_remaining: STACK_RECORD_BUDGET_SLOTS,
1170        })?;
1171        self.run_to(base_depth)
1172    }
1173
1174    /// All call-frame pushes funnel through here so the depth
1175    /// check can't be skipped by a missing branch. Returns
1176    /// `CallStackOverflow` instead of letting recursion blow the
1177    /// host's native stack.
1178    fn push_frame(&mut self, frame: Frame) -> Result<(), VmError> {
1179        if self.frames.len() as u32 >= MAX_CALL_DEPTH {
1180            return Err(VmError::CallStackOverflow(MAX_CALL_DEPTH));
1181        }
1182        self.frames.push(frame);
1183        Ok(())
1184    }
1185
1186    /// Run until the frame stack drops to `base_depth`. Required for
1187    /// reentrant invocation: a `Vm::invoke` call from inside an
1188    /// already-running `run()` must return when *its* frame returns,
1189    /// not when the entire frame stack empties (#221).
1190    fn run_to(&mut self, base_depth: usize) -> Result<Value, VmError> {
1191        // #461 slice A: cache the executing function's code slice across
1192        // ops instead of re-deriving `program.functions[fn_id].code` on
1193        // every iteration. The program is borrowed (`&'a Program`) and is
1194        // never mutated during a run, so the slice reference is valid for
1195        // the whole run and — crucially — is independent of the `&mut self`
1196        // borrow the op handlers take: it points into the caller-owned
1197        // `Program`, not into `*self`. Re-resolve only when `fn_id`
1198        // changes, which is exactly the frame-transition set (Call /
1199        // CallClosure / TailCall / Return); recursion into the same
1200        // `fn_id` correctly keeps the cached slice. `frame_idx` / `fn_id`
1201        // stay recomputed per op (cheap field reads), so the op handlers
1202        // are untouched and their `fn_id` bindings shadow as before.
1203        let program: &'a Program = self.program;
1204        let mut code: &'a [Op] = &[];
1205        let mut code_fn_id: u32 = u32::MAX;
1206        loop {
1207            if self.steps > self.step_limit {
1208                let frame_idx = self.frames.len() - 1;
1209                let fn_id = self.frames[frame_idx].fn_id;
1210                let fn_name = &program.functions[fn_id as usize].name;
1211                return Err(VmError::Panic(format!(
1212                    "step limit exceeded in `{fn_name}` ({} > {})",
1213                    self.steps, self.step_limit,
1214                )));
1215            }
1216            self.steps += 1;
1217            let frame_idx = self.frames.len() - 1;
1218            let pc = self.frames[frame_idx].pc;
1219            let fn_id = self.frames[frame_idx].fn_id;
1220            if fn_id != code_fn_id {
1221                code = &program.functions[fn_id as usize].code;
1222                code_fn_id = fn_id;
1223            }
1224            // #461 slice B: the bytecode verifier (#366) proves pc stays
1225            // in bounds for every reachable op — every path through a
1226            // function ends in Return / Jump / TailCall, so execution
1227            // never falls off the end of `code`. The per-op
1228            // `pc >= code.len()` guard is therefore redundant for verified
1229            // programs; demote it to a debug-only assertion. The `code[pc]`
1230            // index below stays bounds-checked, so a malformed program in
1231            // a release build still panics (loudly, just without the
1232            // bespoke message) rather than reading out of bounds — no
1233            // `unsafe`, no UB, only the cold error-return path leaves the
1234            // hot loop.
1235            debug_assert!(
1236                pc < code.len(),
1237                "ran past end of code in `{}`",
1238                program.functions[fn_id as usize].name,
1239            );
1240            let op = code[pc];
1241            self.frames[frame_idx].pc = pc + 1;
1242
1243            match op {
1244                Op::PushConst(i) => {
1245                    let c = &self.program.constants[i as usize];
1246                    self.stack.push(const_to_value(c));
1247                }
1248                Op::Pop => { self.pop()?; }
1249                Op::Dup => {
1250                    let v = self.peek()?.clone();
1251                    self.stack.push(v);
1252                }
1253                Op::LoadLocal(i) => {
1254                    let base = self.frames[frame_idx].locals_start;
1255                    let v = self.locals_storage[base + i as usize].clone();
1256                    self.stack.push(v);
1257                }
1258                Op::StoreLocal(i) => {
1259                    let v = self.pop()?;
1260                    let base = self.frames[frame_idx].locals_start;
1261                    self.locals_storage[base + i as usize] = v;
1262                }
1263                Op::MakeRecord { shape_idx, field_count } => {
1264                    self.heap_record_allocs += 1;
1265                    let shape = &self.program.record_shapes[shape_idx as usize];
1266                    let n = field_count as usize;
1267                    debug_assert_eq!(shape.len(), n,
1268                        "MakeRecord field_count must match record_shapes[shape_idx].len()");
1269                    let mut values: Vec<Value> = (0..n).map(|_| Value::Unit).collect();
1270                    for i in (0..n).rev() {
1271                        values[i] = self.pop()?;
1272                    }
1273                    let mut rec: IndexMap<SmolStr, Value> = IndexMap::with_capacity(n);
1274                    for (i, val) in values.into_iter().enumerate() {
1275                        let name: SmolStr = match &self.program.constants[shape[i] as usize] {
1276                            Const::FieldName(s) => s.as_str().into(),
1277                            _ => return Err(VmError::TypeMismatch("expected FieldName const".into())),
1278                        };
1279                        rec.insert(name, val);
1280                    }
1281                    self.stack.push(Value::Record { shape_id: shape_idx, fields: Box::new(rec) });
1282                }
1283                Op::AllocStackRecord { shape_idx, field_count } => {
1284                    // #464 step 2. Same value-stack contract as
1285                    // MakeRecord (pop `field_count`, push 1), but the
1286                    // fields live in the VM's stack-record arena
1287                    // instead of a heap-allocated IndexMap.
1288                    //
1289                    // Budget check: if this frame's remaining
1290                    // allocation budget can't cover `field_count`
1291                    // slots, fall back to MakeRecord behavior. The
1292                    // observable result is identical (a record
1293                    // value) so the compiler doesn't need to know
1294                    // ahead of time whether the budget will hold.
1295                    let n = field_count as usize;
1296                    let frame = &mut self.frames[frame_idx];
1297                    if frame.stack_record_budget_remaining < field_count as u32 {
1298                        self.stack_record_heap_fallbacks += 1;
1299                        // Heap fallback path — exact copy of
1300                        // MakeRecord's body. Compiler emitted
1301                        // AllocStackRecord because escape analysis
1302                        // proved the record can stay frame-local;
1303                        // the budget exhaustion is a runtime cost
1304                        // ceiling, not a correctness issue.
1305                        let shape = &self.program.record_shapes[shape_idx as usize];
1306                        debug_assert_eq!(shape.len(), n,
1307                            "AllocStackRecord field_count must match record_shapes[shape_idx].len()");
1308                        let mut values: Vec<Value> = (0..n).map(|_| Value::Unit).collect();
1309                        for i in (0..n).rev() {
1310                            values[i] = self.pop()?;
1311                        }
1312                        let mut rec: IndexMap<SmolStr, Value> = IndexMap::with_capacity(n);
1313                        for (i, val) in values.into_iter().enumerate() {
1314                            let name: SmolStr = match &self.program.constants[shape[i] as usize] {
1315                                Const::FieldName(s) => s.as_str().into(),
1316                                _ => return Err(VmError::TypeMismatch("expected FieldName const".into())),
1317                            };
1318                            rec.insert(name, val);
1319                        }
1320                        self.stack.push(Value::Record { shape_id: shape_idx, fields: Box::new(rec) });
1321                    } else {
1322                        self.stack_record_allocs += 1;
1323                        // Stack path: append the popped field values
1324                        // to the arena in shape order (matches the
1325                        // IndexMap insertion order used by MakeRecord,
1326                        // so the polymorphic GetField IC sees the same
1327                        // offset for either variant).
1328                        frame.stack_record_budget_remaining -= field_count as u32;
1329                        let slab_start = self.stack_record_arena.len();
1330                        // Reserve all slots upfront so we can write in
1331                        // shape order while popping in reverse —
1332                        // matches MakeRecord's idiom.
1333                        self.stack_record_arena.resize(slab_start + n, Value::Unit);
1334                        for i in (0..n).rev() {
1335                            let v = self.pop()?;
1336                            self.stack_record_arena[slab_start + i] = v;
1337                        }
1338                        self.stack.push(Value::StackRecord {
1339                            shape_id: shape_idx,
1340                            slab_start: slab_start as u32,
1341                            field_count,
1342                        });
1343                    }
1344                }
1345                Op::MakeTuple(n) => {
1346                    let mut items: Vec<Value> = (0..n).map(|_| Value::Unit).collect();
1347                    for i in (0..n as usize).rev() { items[i] = self.pop()?; }
1348                    self.stack.push(Value::Tuple(items));
1349                }
1350                Op::AllocStackTuple { arity } => {
1351                    // #464 tuple codegen. Same value-stack contract as
1352                    // MakeTuple (pop `arity`, push 1), but the elements
1353                    // live in the shared stack-record arena instead of
1354                    // a heap Vec. Budget exhaustion falls back to the
1355                    // MakeTuple heap path — identical observable result.
1356                    let n = arity as usize;
1357                    let frame = &mut self.frames[frame_idx];
1358                    if frame.stack_record_budget_remaining < arity as u32 {
1359                        self.stack_record_heap_fallbacks += 1;
1360                        let mut items: Vec<Value> = (0..n).map(|_| Value::Unit).collect();
1361                        for i in (0..n).rev() { items[i] = self.pop()?; }
1362                        self.stack.push(Value::Tuple(items));
1363                    } else {
1364                        self.stack_record_allocs += 1;
1365                        frame.stack_record_budget_remaining -= arity as u32;
1366                        let slab_start = self.stack_record_arena.len();
1367                        self.stack_record_arena.resize(slab_start + n, Value::Unit);
1368                        for i in (0..n).rev() {
1369                            let v = self.pop()?;
1370                            self.stack_record_arena[slab_start + i] = v;
1371                        }
1372                        self.stack.push(Value::StackTuple {
1373                            slab_start: slab_start as u32,
1374                            arity,
1375                        });
1376                    }
1377                }
1378                Op::MakeList(n) => {
1379                    let mut items: Vec<Value> = (0..n).map(|_| Value::Unit).collect();
1380                    for i in (0..n as usize).rev() { items[i] = self.pop()?; }
1381                    self.stack.push(Value::List(items.into()));
1382                }
1383                Op::MakeVariant { name_idx, arity } => {
1384                    let mut args: Vec<Value> = (0..arity).map(|_| Value::Unit).collect();
1385                    for i in (0..arity as usize).rev() { args[i] = self.pop()?; }
1386                    let name = match &self.program.constants[name_idx as usize] {
1387                        Const::VariantName(s) => s.clone(),
1388                        _ => return Err(VmError::TypeMismatch("expected VariantName const".into())),
1389                    };
1390                    self.stack.push(Value::Variant { name, args });
1391                }
1392                Op::GetField { name_idx, site_idx } => {
1393                    let v = self.pop()?;
1394                    match v {
1395                        Value::Record { fields: r, shape_id } => {
1396                            if ic_stats_enabled() {
1397                                record_ic_hit(fn_id, site_idx, shape_id);
1398                            }
1399                            // Inline cache keyed by (fn_id, site_idx) with
1400                            // shape_id-keyed verification (#462). Slot stores
1401                            // (shape_id_at_install, offset). Hit verification:
1402                            // - real shape_id (!= NO_SHAPE_ID) matches: offset
1403                            //   is guaranteed valid (records with the same
1404                            //   shape_id share the same field-name ordering
1405                            //   from the compile-time `record_shapes` table).
1406                            //   One u32 compare; no string work.
1407                            // - NO_SHAPE_ID matches NO_SHAPE_ID: distinct
1408                            //   dynamic shapes both carry this sentinel and
1409                            //   would otherwise alias, so we fall back to
1410                            //   verifying via name compare against the field
1411                            //   at the cached offset — the pre-slice
1412                            //   correctness path.
1413                            // On any mismatch we walk by name and reinstall
1414                            // (shape_id, offset).
1415                            let fid = fn_id as usize;
1416                            let sid = site_idx as usize;
1417                            if self.field_ics[fid].is_empty() {
1418                                let n = self.program.functions[fid].field_ic_sites as usize;
1419                                self.field_ics[fid] = vec![None; n];
1420                            }
1421                            let cached = self.field_ics[fid][sid];
1422                            let value = 'ic: {
1423                                if let Some((cached_shape, off)) = cached {
1424                                    if cached_shape == shape_id {
1425                                        if shape_id != crate::value::NO_SHAPE_ID {
1426                                            // Real shape match: offset is sound.
1427                                            if let Some((_, val)) = r.get_index(off) {
1428                                                break 'ic val.clone();
1429                                            }
1430                                        } else if let Some((k, val)) = r.get_index(off) {
1431                                            // Dynamic shape: verify by name.
1432                                            if let Const::FieldName(s) =
1433                                                &self.program.constants[name_idx as usize]
1434                                            {
1435                                                if s == k { break 'ic val.clone(); }
1436                                            }
1437                                        }
1438                                    }
1439                                }
1440                                // Cache miss: resolve by name, install
1441                                // (shape_id, offset).
1442                                let name = match &self.program.constants[name_idx as usize] {
1443                                    Const::FieldName(s) => s.as_str(),
1444                                    _ => return Err(VmError::TypeMismatch(
1445                                        "expected FieldName const".into())),
1446                                };
1447                                let (off, _, val) = r.get_full(name)
1448                                    .ok_or_else(|| VmError::TypeMismatch(
1449                                        format!("missing field `{name}`")))?;
1450                                self.field_ics[fid][sid] = Some((shape_id, off));
1451                                val.clone()
1452                            };
1453                            self.stack.push(value);
1454                        }
1455                        Value::StackRecord { shape_id, slab_start, field_count } => {
1456                            // #464 step 2: dispatch over a stack-allocated
1457                            // record. The IC slot stored
1458                            // (shape_id, offset_in_shape) is interoperable
1459                            // with the heap path because MakeRecord builds
1460                            // the IndexMap in shape order — offset N means
1461                            // the same field in either representation. So
1462                            // we share `field_ics` with the heap path; no
1463                            // per-variant cache pollution.
1464                            if ic_stats_enabled() {
1465                                record_ic_hit(fn_id, site_idx, shape_id);
1466                            }
1467                            let fid = fn_id as usize;
1468                            let sid = site_idx as usize;
1469                            if self.field_ics[fid].is_empty() {
1470                                let n = self.program.functions[fid].field_ic_sites as usize;
1471                                self.field_ics[fid] = vec![None; n];
1472                            }
1473                            let cached = self.field_ics[fid][sid];
1474                            let value = 'ic: {
1475                                if let Some((cached_shape, off)) = cached {
1476                                    if cached_shape == shape_id && (off as u16) < field_count {
1477                                        // Shape-keyed verification is sound
1478                                        // here for the same reason as the
1479                                        // heap path — compile-time shape
1480                                        // IDs are issued by
1481                                        // `Program::record_shapes` and
1482                                        // their field order is fixed.
1483                                        // Stack records always carry a
1484                                        // compile-time shape_id (NO_SHAPE_ID
1485                                        // is impossible — AllocStackRecord
1486                                        // is only emitted at compile time
1487                                        // with a known shape_idx).
1488                                        let idx = slab_start as usize + off;
1489                                        break 'ic self.stack_record_arena[idx].clone();
1490                                    }
1491                                }
1492                                // Cache miss: walk the shape's field-name
1493                                // vec to find the slot for `name_idx`. The
1494                                // miss path is O(field_count) like the
1495                                // heap path, but the hot retrieval after
1496                                // install is one array index — cheaper
1497                                // than IndexMap::get_index.
1498                                let shape =
1499                                    &self.program.record_shapes[shape_id as usize];
1500                                let target_name = match &self.program.constants[name_idx as usize] {
1501                                    Const::FieldName(s) => s.as_str(),
1502                                    _ => return Err(VmError::TypeMismatch(
1503                                        "expected FieldName const".into())),
1504                                };
1505                                let mut found: Option<usize> = None;
1506                                for (i, fn_const_idx) in shape.iter().enumerate() {
1507                                    if let Const::FieldName(s) =
1508                                        &self.program.constants[*fn_const_idx as usize]
1509                                    {
1510                                        if s == target_name { found = Some(i); break; }
1511                                    }
1512                                }
1513                                let off = found.ok_or_else(|| VmError::TypeMismatch(
1514                                    format!("missing field `{target_name}` on stack record")))?;
1515                                self.field_ics[fid][sid] = Some((shape_id, off));
1516                                self.stack_record_arena[slab_start as usize + off].clone()
1517                            };
1518                            self.stack.push(value);
1519                        }
1520                        other => return Err(VmError::TypeMismatch(
1521                            format!("GetField on non-record: {other:?}"))),
1522                    }
1523                }
1524                Op::GetElem(i) => {
1525                    let v = self.pop()?;
1526                    match v {
1527                        Value::Tuple(items) => {
1528                            let v = items.into_iter().nth(i as usize)
1529                                .ok_or_else(|| VmError::TypeMismatch(format!("tuple index {i} out of range")))?;
1530                            self.stack.push(v);
1531                        }
1532                        // #464 tuple codegen: positional read out of a
1533                        // frame-local tuple. The arena slot is an O(1)
1534                        // index, mirroring the heap path's nth().
1535                        Value::StackTuple { slab_start, arity } => {
1536                            if i >= arity {
1537                                return Err(VmError::TypeMismatch(
1538                                    format!("tuple index {i} out of range")));
1539                            }
1540                            let v = self.stack_record_arena[slab_start as usize + i as usize].clone();
1541                            self.stack.push(v);
1542                        }
1543                        other => return Err(VmError::TypeMismatch(format!("GetElem on non-tuple: {other:?}"))),
1544                    }
1545                }
1546                Op::TestVariant(i) => {
1547                    let name = match &self.program.constants[i as usize] {
1548                        Const::VariantName(s) => s.clone(),
1549                        _ => return Err(VmError::TypeMismatch("expected VariantName const".into())),
1550                    };
1551                    let v = self.pop()?;
1552                    match &v {
1553                        Value::Variant { name: vname, .. } => {
1554                            self.stack.push(Value::Bool(vname == &name));
1555                        }
1556                        // For tag-only enums of primitive type (e.g. ParseError = Empty | NotNumber)
1557                        // the value is currently a Variant too, since constructors emit MakeVariant.
1558                        other => return Err(VmError::TypeMismatch(format!("TestVariant on non-variant: {other:?}"))),
1559                    }
1560                }
1561                Op::GetVariant(_i) => {
1562                    let v = self.pop()?;
1563                    match v {
1564                        Value::Variant { args, .. } => {
1565                            self.stack.push(Value::Tuple(args));
1566                        }
1567                        other => return Err(VmError::TypeMismatch(format!("GetVariant on non-variant: {other:?}"))),
1568                    }
1569                }
1570                Op::GetVariantArg(i) => {
1571                    let v = self.pop()?;
1572                    match v {
1573                        Value::Variant { mut args, .. } => {
1574                            if (i as usize) >= args.len() {
1575                                return Err(VmError::TypeMismatch("variant arg index oob".into()));
1576                            }
1577                            self.stack.push(args.swap_remove(i as usize));
1578                        }
1579                        other => return Err(VmError::TypeMismatch(format!("GetVariantArg on non-variant: {other:?}"))),
1580                    }
1581                }
1582                Op::GetListLen => {
1583                    let v = self.pop()?;
1584                    match v {
1585                        Value::List(items) => self.stack.push(Value::Int(items.len() as i64)),
1586                        other => return Err(VmError::TypeMismatch(format!("GetListLen on non-list: {other:?}"))),
1587                    }
1588                }
1589                Op::GetListElem(i) => {
1590                    let v = self.pop()?;
1591                    match v {
1592                        Value::List(items) => {
1593                            let v = items.into_iter().nth(i as usize)
1594                                .ok_or_else(|| VmError::TypeMismatch("list index oob".into()))?;
1595                            self.stack.push(v);
1596                        }
1597                        other => return Err(VmError::TypeMismatch(format!("GetListElem on non-list: {other:?}"))),
1598                    }
1599                }
1600                Op::GetListElemDyn => {
1601                    // Stack: [list, idx]
1602                    let idx = match self.pop()? {
1603                        Value::Int(n) => n as usize,
1604                        other => return Err(VmError::TypeMismatch(format!("GetListElemDyn idx: {other:?}"))),
1605                    };
1606                    let v = self.pop()?;
1607                    match v {
1608                        Value::List(items) => {
1609                            let v = items.into_iter().nth(idx)
1610                                .ok_or_else(|| VmError::TypeMismatch("list index oob".into()))?;
1611                            self.stack.push(v);
1612                        }
1613                        other => return Err(VmError::TypeMismatch(format!("GetListElemDyn on non-list: {other:?}"))),
1614                    }
1615                }
1616                Op::ListAppend => {
1617                    let value = self.pop()?;
1618                    let list = self.pop()?;
1619                    match list {
1620                        Value::List(mut items) => {
1621                            items.push_back(value);
1622                            self.stack.push(Value::List(items));
1623                        }
1624                        other => return Err(VmError::TypeMismatch(format!("ListAppend on non-list: {other:?}"))),
1625                    }
1626                }
1627                Op::Jump(off) => {
1628                    let new_pc = (self.frames[frame_idx].pc as i32 + off) as usize;
1629                    self.frames[frame_idx].pc = new_pc;
1630                }
1631                Op::JumpIf(off) => {
1632                    let v = self.pop()?;
1633                    if v.as_bool() {
1634                        let new_pc = (self.frames[frame_idx].pc as i32 + off) as usize;
1635                        self.frames[frame_idx].pc = new_pc;
1636                    }
1637                }
1638                Op::JumpIfNot(off) => {
1639                    let v = self.pop()?;
1640                    if !v.as_bool() {
1641                        let new_pc = (self.frames[frame_idx].pc as i32 + off) as usize;
1642                        self.frames[frame_idx].pc = new_pc;
1643                    }
1644                }
1645                Op::MakeClosure { fn_id, capture_count } => {
1646                    let n = capture_count as usize;
1647                    let mut captures: Vec<Value> = (0..n).map(|_| Value::Unit).collect();
1648                    for i in (0..n).rev() { captures[i] = self.pop()?; }
1649                    // Look up the canonical body hash so the resulting
1650                    // `Value::Closure` carries it for equality (#222).
1651                    let body_hash = self.program.functions[fn_id as usize].body_hash;
1652                    self.stack.push(Value::Closure { fn_id, body_hash, captures });
1653                }
1654                Op::CallClosure { arity, node_id_idx } => {
1655                    let arity = arity as usize;
1656                    // Args sit on the value stack at [args_base..]; the
1657                    // closure sits just below them at args_base - 1. Take
1658                    // the closure out (leaving a Unit placeholder), then
1659                    // write its captures and pop the args directly into
1660                    // the callee's locals — no per-call args Vec and no
1661                    // `captures.extend(args)` realloc (#464). The combined
1662                    // [captures, args] view the tracer wants is exactly
1663                    // the contiguous locals slice we just filled.
1664                    let args_base = self.stack.len() - arity;
1665                    let closure = std::mem::replace(&mut self.stack[args_base - 1], Value::Unit);
1666                    let (fn_id, captures) = match closure {
1667                        Value::Closure { fn_id, captures, .. } => (fn_id, captures),
1668                        other => return Err(VmError::TypeMismatch(format!("CallClosure on non-closure: {other:?}"))),
1669                    };
1670                    let fid = fn_id as usize;
1671                    let node_id = const_str(&self.program.constants, node_id_idx);
1672                    let budget_cost = call_budget_cost(&self.program.functions[fid]);
1673                    if budget_cost > 0 {
1674                        self.handler.note_call_budget(budget_cost)
1675                            .map_err(VmError::Effect)?;
1676                    }
1677                    let cap_n = captures.len();
1678                    let locals_start = self.locals_storage.len();
1679                    let locals_len = self.program.functions[fid].locals_count
1680                        .max(self.program.functions[fid].arity) as usize;
1681                    self.locals_storage.resize(locals_start + locals_len, Value::Unit);
1682                    for (i, v) in captures.into_iter().enumerate() {
1683                        self.locals_storage[locals_start + i] = v;
1684                    }
1685                    // Move the args off the value stack into the locals
1686                    // following the captures (popping leaves the args off
1687                    // the stack; the closure's Unit placeholder is then
1688                    // the top, so truncate it away).
1689                    for i in (0..arity).rev() {
1690                        self.locals_storage[locals_start + cap_n + i] = self.pop()?;
1691                    }
1692                    self.stack.truncate(args_base - 1);
1693                    self.tracer.enter_call(&node_id, &self.program.functions[fid].name, &self.locals_storage[locals_start..locals_start + cap_n + arity]);
1694                    self.push_frame(Frame {
1695                        fn_id, pc: 0, locals_start, locals_len,
1696                        stack_base: self.stack.len(),
1697                        trace_kind: FrameKind::Call(node_id),
1698                        // Op::CallClosure intentionally doesn't memoize
1699                        // for v1 (#229) — closures over captures need a
1700                        // hashing strategy that includes the captures.
1701                        // Direct Op::Call is the v1 surface.
1702                        memo_key: None,
1703                        stack_record_arena_start: self.stack_record_arena.len(),
1704                        stack_record_budget_remaining: STACK_RECORD_BUDGET_SLOTS,
1705                    })?;
1706                }
1707                Op::SortByKey { node_id_idx: _ } => {
1708                    // #338: pop (xs, f). For each x in xs, invoke
1709                    // f(x) to derive a sortable key. Stable-sort the
1710                    // (key, value) pairs by key. Return the values
1711                    // in sorted order. Keys must be Int / Float /
1712                    // Str; mixed-type pairs and other types compare
1713                    // as equal (preserving original order — stable
1714                    // sort).
1715                    let f = self.pop()?;
1716                    let xs = self.pop()?;
1717                    let items = match xs {
1718                        Value::List(v) => v,
1719                        other => return Err(VmError::TypeMismatch(
1720                            format!("SortByKey requires a List, got: {other:?}"))),
1721                    };
1722                    if !matches!(f, Value::Closure { .. }) {
1723                        return Err(VmError::TypeMismatch(
1724                            format!("SortByKey requires a closure, got: {f:?}")));
1725                    }
1726                    let mut keyed: Vec<(Value, Value)> = Vec::with_capacity(items.len());
1727                    for item in items {
1728                        let key = self.invoke_closure_1(f.clone(), item.clone())?;
1729                        keyed.push((key, item));
1730                    }
1731                    keyed.sort_by(|(ka, _), (kb, _)| compare_sort_keys(ka, kb));
1732                    let sorted: VecDeque<Value> = keyed.into_iter().map(|(_, v)| v).collect();
1733                    self.stack.push(Value::List(sorted));
1734                }
1735                Op::ParallelMap { node_id_idx: _ } => {
1736                    // #305 slice 1: pop (xs, f) and apply f to each
1737                    // element across OS threads.
1738                    //
1739                    // #305 slice 2: each worker now asks the parent
1740                    // handler for a thread-safe per-worker handler via
1741                    // `EffectHandler::spawn_for_worker`. Handlers that
1742                    // opt in (e.g. `DefaultHandler`) yield a fresh
1743                    // instance sharing the budget pool; handlers that
1744                    // don't fall back to the slice-1 behavior of
1745                    // `DenyAllEffects` in the worker.
1746                    let f = self.pop()?;
1747                    let xs = self.pop()?;
1748                    let items = match xs {
1749                        Value::List(v) => v,
1750                        other => return Err(VmError::TypeMismatch(
1751                            format!("ParallelMap requires a List, got: {other:?}"))),
1752                    };
1753                    if !matches!(f, Value::Closure { .. }) {
1754                        return Err(VmError::TypeMismatch(
1755                            format!("ParallelMap requires a closure, got: {f:?}")));
1756                    }
1757                    // Pre-build one handler per worker on the main
1758                    // thread so the worker just owns its handler with
1759                    // no shared borrowing. The actual worker count is
1760                    // capped by `LEX_PAR_MAX_CONCURRENCY` (resolved
1761                    // inside par_map_run); cap ≤ items.len() so we
1762                    // never over-allocate handlers.
1763                    let n_workers = par_max_concurrency().max(1).min(items.len().max(1));
1764                    let mut worker_handlers: Vec<Box<dyn EffectHandler + Send>> =
1765                        Vec::with_capacity(n_workers);
1766                    for _ in 0..n_workers {
1767                        worker_handlers.push(
1768                            self.handler
1769                                .spawn_for_worker()
1770                                .unwrap_or_else(|| Box::new(DenyAllEffects)),
1771                        );
1772                    }
1773                    let results = par_map_run(self.program, f, items.into_iter().collect(), worker_handlers)?;
1774                    self.stack.push(Value::List(results.into()));
1775                }
1776                Op::ListMap { node_id_idx: _ } => {
1777                    // #464: native map. Owns `xs` (no per-iteration
1778                    // clone of the input or accumulator that the old
1779                    // inlined `LoadLocal`-based loop incurred) and
1780                    // builds the output with one pre-sized allocation.
1781                    let f = self.pop()?;
1782                    let xs = self.pop()?;
1783                    let items = match xs {
1784                        Value::List(v) => v,
1785                        other => return Err(VmError::TypeMismatch(
1786                            format!("ListMap requires a List, got: {other:?}"))),
1787                    };
1788                    if !matches!(f, Value::Closure { .. }) {
1789                        return Err(VmError::TypeMismatch(
1790                            format!("ListMap requires a closure, got: {f:?}")));
1791                    }
1792                    let mut out: VecDeque<Value> = VecDeque::with_capacity(items.len());
1793                    for item in items {
1794                        out.push_back(self.invoke_closure_1(f.clone(), item)?);
1795                    }
1796                    self.stack.push(Value::List(out));
1797                }
1798                Op::ListFilter { node_id_idx: _ } => {
1799                    // #464: native filter. Pred is applied to a clone
1800                    // of each element; the original element is kept on
1801                    // a true result.
1802                    let f = self.pop()?;
1803                    let xs = self.pop()?;
1804                    let items = match xs {
1805                        Value::List(v) => v,
1806                        other => return Err(VmError::TypeMismatch(
1807                            format!("ListFilter requires a List, got: {other:?}"))),
1808                    };
1809                    if !matches!(f, Value::Closure { .. }) {
1810                        return Err(VmError::TypeMismatch(
1811                            format!("ListFilter requires a closure, got: {f:?}")));
1812                    }
1813                    let mut out: VecDeque<Value> = VecDeque::new();
1814                    for item in items {
1815                        let keep = self.invoke_closure_1(f.clone(), item.clone())?;
1816                        if keep.as_bool() {
1817                            out.push_back(item);
1818                        }
1819                    }
1820                    self.stack.push(Value::List(out));
1821                }
1822                Op::ListFold { node_id_idx: _ } => {
1823                    // #464: native left-fold. `acc` is threaded by
1824                    // value; each element is moved into the combiner.
1825                    let f = self.pop()?;
1826                    let init = self.pop()?;
1827                    let xs = self.pop()?;
1828                    let items = match xs {
1829                        Value::List(v) => v,
1830                        other => return Err(VmError::TypeMismatch(
1831                            format!("ListFold requires a List, got: {other:?}"))),
1832                    };
1833                    if !matches!(f, Value::Closure { .. }) {
1834                        return Err(VmError::TypeMismatch(
1835                            format!("ListFold requires a closure, got: {f:?}")));
1836                    }
1837                    let mut acc = init;
1838                    for item in items {
1839                        acc = self.invoke_closure_2(f.clone(), acc, item)?;
1840                    }
1841                    self.stack.push(acc);
1842                }
1843                Op::Call { fn_id, arity, node_id_idx } => {
1844                    let arity = arity as usize;
1845                    let fid = fn_id as usize;
1846                    // Args sit on the value stack at [args_base..]. We
1847                    // read them in place for the refinement / memo /
1848                    // trace checks and only move them into the locals
1849                    // slot-allocator at the very end — avoiding a
1850                    // per-call args Vec (#464 call-overhead). The stack
1851                    // naturally holds the args until consumed, so the
1852                    // only early-exit cleanup is truncating them off on
1853                    // a memo hit; a refinement error aborts the VM.
1854                    let args_base = self.stack.len() - arity;
1855                    let node_id = const_str(&self.program.constants, node_id_idx);
1856                    let budget_cost = call_budget_cost(&self.program.functions[fid]);
1857                    if budget_cost > 0 {
1858                        self.handler.note_call_budget(budget_cost)
1859                            .map_err(VmError::Effect)?;
1860                    }
1861                    // Refinement runtime check (#209 slice 3). Each
1862                    // param's `Option<Refinement>` is evaluated against
1863                    // the actual arg before the frame is pushed. The
1864                    // tracer sees the call enter; failure surfaces as
1865                    // `VmError::RefinementFailed` *before* the body
1866                    // starts, which means an erroring trace shows the
1867                    // call as enter+exit_err with the verdict reason
1868                    // (same shape as `gate.verdict`).
1869                    //
1870                    // Iterate by reference — the loop body reads only
1871                    // through `r` (borrowed from `self.program`) and the
1872                    // arg slots on the stack; we don't mutate `self`, so
1873                    // the borrows are disjoint.
1874                    let refinements = &self.program.functions[fid].refinements;
1875                    for (i, refinement) in refinements.iter().enumerate() {
1876                        if let Some(r) = refinement {
1877                            let arg = self.stack[args_base + i].clone();
1878                            match eval_refinement(&r.predicate, &r.binding, &arg) {
1879                                Ok(true) => { /* satisfied, continue */ }
1880                                Ok(false) => {
1881                                    return Err(VmError::RefinementFailed {
1882                                        fn_name: self.program.functions[fid].name.clone(),
1883                                        param_index: i,
1884                                        binding: r.binding.clone(),
1885                                        reason: format!(
1886                                            "predicate failed for {} = {arg:?}",
1887                                            r.binding),
1888                                    });
1889                                }
1890                                Err(reason) => {
1891                                    return Err(VmError::RefinementFailed {
1892                                        fn_name: self.program.functions[fid].name.clone(),
1893                                        param_index: i,
1894                                        binding: r.binding.clone(),
1895                                        reason,
1896                                    });
1897                                }
1898                            }
1899                        }
1900                    }
1901                    // Pure-fn memoization (#229): if the callee declares
1902                    // no effects, hash the args and consult the cache.
1903                    // On hit, push the cached value, emit synthetic
1904                    // enter+exit trace events (so the trace still shows
1905                    // the call), and skip the frame push entirely.
1906                    //
1907                    // Adaptive gate (#229 adaptive): only hash if this
1908                    // function still has memoization enabled. A pure
1909                    // function whose args never repeat pays the hash for
1910                    // nothing; after a warmup window with zero hits we
1911                    // disable it and its calls take the plain path below.
1912                    let memo_key: Option<(u32, [u8; 16])> =
1913                        if self.program.functions[fid].effects.is_empty()
1914                            && self.memo_fn_state[fid].enabled
1915                        {
1916                            Some((fn_id, hash_call_args(&self.stack[args_base..])))
1917                        } else {
1918                            if self.program.functions[fid].effects.is_empty() {
1919                                self.pure_memo_skips += 1;
1920                            }
1921                            None
1922                        };
1923                    if let Some(key) = memo_key {
1924                        self.memo_fn_state[fid].calls += 1;
1925                        if let Some(cached) = self.pure_memo.get(&key).cloned() {
1926                            self.memo_fn_state[fid].hits += 1;
1927                            self.pure_memo_hits += 1;
1928                            self.tracer.enter_call(&node_id, &self.program.functions[fid].name, &self.stack[args_base..]);
1929                            self.tracer.exit_ok(&cached);
1930                            self.stack.truncate(args_base);
1931                            self.stack.push(cached);
1932                            continue;
1933                        }
1934                        self.pure_memo_misses += 1;
1935                        // Disable on a cold function: warmup elapsed with
1936                        // no hit. Always safe — the callee is pure, so the
1937                        // plain path recomputes the identical result.
1938                        let st = &mut self.memo_fn_state[fid];
1939                        if st.calls >= MEMO_WARMUP_CALLS && st.hits == 0 {
1940                            st.enabled = false;
1941                        }
1942                    }
1943                    self.tracer.enter_call(&node_id, &self.program.functions[fid].name, &self.stack[args_base..]);
1944                    let locals_len = self.program.functions[fid].locals_count
1945                        .max(self.program.functions[fid].arity) as usize;
1946                    let locals_start = self.locals_storage.len();
1947                    self.locals_storage.resize(locals_start + locals_len, Value::Unit);
1948                    // Move the args off the stack into the callee's
1949                    // locals (popping leaves the stack at `args_base`).
1950                    for i in (0..arity).rev() {
1951                        self.locals_storage[locals_start + i] = self.pop()?;
1952                    }
1953                    self.push_frame(Frame {
1954                        fn_id, pc: 0, locals_start, locals_len,
1955                        stack_base: self.stack.len(),
1956                        trace_kind: FrameKind::Call(node_id),
1957                        memo_key,
1958                        stack_record_arena_start: self.stack_record_arena.len(),
1959                        stack_record_budget_remaining: STACK_RECORD_BUDGET_SLOTS,
1960                    })?;
1961                }
1962                Op::TailCall { fn_id, arity, node_id_idx } => {
1963                    let arity = arity as usize;
1964                    let fid = fn_id as usize;
1965                    // Args sit on the value stack at [args_base..]. Read
1966                    // them in place for the refinement / trace checks and
1967                    // move them into the reused frame's locals at the end
1968                    // — no per-call args Vec (#464). Tail calls have no
1969                    // memoization, so the consumers are refinement, trace,
1970                    // then the locals move. The args live on `self.stack`
1971                    // while locals live on `self.locals_storage`, so the
1972                    // `truncate(old_locals_start)` below (which releases
1973                    // the *old* frame's locals) doesn't touch them.
1974                    let args_base = self.stack.len() - arity;
1975                    let node_id = const_str(&self.program.constants, node_id_idx);
1976                    let budget_cost = call_budget_cost(&self.program.functions[fid]);
1977                    if budget_cost > 0 {
1978                        self.handler.note_call_budget(budget_cost)
1979                            .map_err(VmError::Effect)?;
1980                    }
1981                    // Refinement runtime check on tail calls too
1982                    // (#209 slice 3). Same shape as Op::Call.
1983                    let refinements = &self.program.functions[fid].refinements;
1984                    for (i, refinement) in refinements.iter().enumerate() {
1985                        if let Some(r) = refinement {
1986                            let arg = self.stack[args_base + i].clone();
1987                            match eval_refinement(&r.predicate, &r.binding, &arg) {
1988                                Ok(true) => {}
1989                                Ok(false) => return Err(VmError::RefinementFailed {
1990                                    fn_name: self.program.functions[fid].name.clone(),
1991                                    param_index: i,
1992                                    binding: r.binding.clone(),
1993                                    reason: format!(
1994                                        "predicate failed for {} = {arg:?}",
1995                                        r.binding),
1996                                }),
1997                                Err(reason) => return Err(VmError::RefinementFailed {
1998                                    fn_name: self.program.functions[fid].name.clone(),
1999                                    param_index: i,
2000                                    binding: r.binding.clone(),
2001                                    reason,
2002                                }),
2003                            }
2004                        }
2005                    }
2006                    // A tail call closes the current call's trace frame and
2007                    // opens a new one in its place — preserves the caller's
2008                    // tree depth in the trace.
2009                    self.tracer.exit_call_tail();
2010                    self.tracer.enter_call(&node_id, &self.program.functions[fid].name, &self.stack[args_base..]);
2011                    // Reuse the current frame's locals_start position:
2012                    // truncate to release old locals then extend for the
2013                    // new function (#389 slice 3, same as Op::Return but
2014                    // without popping the frame).
2015                    let old_locals_start = self.frames.last().unwrap().locals_start;
2016                    self.locals_storage.truncate(old_locals_start);
2017                    let new_locals_len = self.program.functions[fid].locals_count
2018                        .max(self.program.functions[fid].arity) as usize;
2019                    self.locals_storage.resize(old_locals_start + new_locals_len, Value::Unit);
2020                    // Move the args off the value stack into the callee's
2021                    // locals (popping leaves the stack at `args_base`).
2022                    for i in (0..arity).rev() {
2023                        self.locals_storage[old_locals_start + i] = self.pop()?;
2024                    }
2025                    // #464 step 2: a tail-called function gets a fresh
2026                    // stack-record arena view. Release any records the
2027                    // pre-tail-call code allocated (they can't be live
2028                    // — the args have already been popped off the
2029                    // value stack) and refill the budget for the
2030                    // callee.
2031                    let arena_start = self.frames.last().unwrap().stack_record_arena_start;
2032                    self.stack_record_arena.truncate(arena_start);
2033                    let frame = self.frames.last_mut().unwrap();
2034                    frame.fn_id = fn_id;
2035                    frame.pc = 0;
2036                    frame.locals_len = new_locals_len;
2037                    frame.trace_kind = FrameKind::Call(node_id);
2038                    frame.stack_record_budget_remaining = STACK_RECORD_BUDGET_SLOTS;
2039                }
2040                Op::EffectCall { kind_idx, op_idx, arity, node_id_idx } => {
2041                    let mut args: Vec<Value> = (0..arity).map(|_| Value::Unit).collect();
2042                    for i in (0..arity as usize).rev() { args[i] = self.pop()?; }
2043                    let kind = match &self.program.constants[kind_idx as usize] {
2044                        Const::Str(s) => s.clone(),
2045                        _ => return Err(VmError::TypeMismatch("expected Str const for effect kind".into())),
2046                    };
2047                    let op_name = match &self.program.constants[op_idx as usize] {
2048                        Const::Str(s) => s.clone(),
2049                        _ => return Err(VmError::TypeMismatch("expected Str const for effect op".into())),
2050                    };
2051                    let node_id = const_str(&self.program.constants, node_id_idx);
2052                    self.tracer.enter_effect(&node_id, &kind, &op_name, &args);
2053                    let result = match self.tracer.override_effect(&node_id) {
2054                        Some(v) => Ok(v),
2055                        // VM-level intercept for `parser.run` (#221).
2056                        // Routed inline rather than through the handler
2057                        // because the parser interpreter needs reentrant
2058                        // VM access to invoke `Value::Closure` values
2059                        // from `Map` / `AndThen` nodes.
2060                        None if (kind.as_str(), op_name.as_str()) == ("parser", "run")
2061                            => self.run_parser_op(args),
2062                        // VM-level intercept for `conc.*` (#381). The actor
2063                        // handler closure must run on the calling VM so it can
2064                        // dispatch arbitrary effects through the same handler
2065                        // chain (e.g. sql queries inside an actor).
2066                        None if kind.as_str() == "conc"
2067                            => self.run_conc_op(op_name.as_str(), args),
2068                        None => self.handler.dispatch(&kind, &op_name, args),
2069                    };
2070                    match result {
2071                        Ok(v) => {
2072                            self.tracer.exit_ok(&v);
2073                            self.stack.push(v);
2074                        }
2075                        Err(e) => {
2076                            self.tracer.exit_err(&e);
2077                            return Err(VmError::Effect(e));
2078                        }
2079                    }
2080                }
2081                Op::Return => {
2082                    let v = self.pop()?;
2083                    let frame = self.frames.pop().unwrap();
2084                    // Trim any extra stuff that the function pushed but didn't pop.
2085                    self.stack.truncate(frame.stack_base);
2086                    // Release this frame's locals back to the arena (#389 slice 3).
2087                    // LIFO frame ordering guarantees this frame's slots are at the top.
2088                    self.locals_storage.truncate(frame.locals_start);
2089                    // #464 step 2: release this frame's stack-record
2090                    // slab. LIFO frame discipline guarantees its
2091                    // records sit at the top of the arena. The
2092                    // returned value `v` is escape-proven not to be
2093                    // one of them — the compiler only emits
2094                    // AllocStackRecord at sites that don't reach
2095                    // `Return`.
2096                    self.stack_record_arena.truncate(frame.stack_record_arena_start);
2097                    if matches!(frame.trace_kind, FrameKind::Call(_)) {
2098                        self.tracer.exit_ok(&v);
2099                    }
2100                    // Pure-fn memoization (#229): if this frame was a
2101                    // memoizable call that missed the cache, write the
2102                    // computed return value back so the next call with
2103                    // the same args returns it without re-executing.
2104                    if let Some(key) = frame.memo_key {
2105                        self.pure_memo.insert(key, v.clone());
2106                    }
2107                    // Exit when we've returned past the depth this
2108                    // `run_to` was entered at — supports reentrancy
2109                    // (a nested `invoke` returns into its caller, not
2110                    // out of the outermost VM run, #221).
2111                    if self.frames.len() <= base_depth {
2112                        return Ok(v);
2113                    }
2114                    self.stack.push(v);
2115                }
2116                Op::Panic(i) => {
2117                    let msg = match &self.program.constants[i as usize] {
2118                        Const::Str(s) => s.clone(),
2119                        _ => "panic".into(),
2120                    };
2121                    return Err(VmError::Panic(msg));
2122                }
2123                // Arithmetic
2124                Op::IntAdd => self.bin_int(|a, b| Value::Int(a + b))?,
2125                Op::IntSub => self.bin_int(|a, b| Value::Int(a - b))?,
2126                Op::IntMul => self.bin_int(|a, b| Value::Int(a * b))?,
2127                Op::IntDiv => self.bin_int(|a, b| Value::Int(a / b))?,
2128                Op::IntMod => self.bin_int(|a, b| Value::Int(a % b))?,
2129                Op::IntNeg => {
2130                    let a = self.pop()?.as_int();
2131                    self.stack.push(Value::Int(-a));
2132                }
2133                Op::IntEq => self.bin_int(|a, b| Value::Bool(a == b))?,
2134                Op::IntLt => self.bin_int(|a, b| Value::Bool(a < b))?,
2135                Op::IntLe => self.bin_int(|a, b| Value::Bool(a <= b))?,
2136                Op::FloatAdd => self.bin_float(|a, b| Value::Float(a + b))?,
2137                Op::FloatSub => self.bin_float(|a, b| Value::Float(a - b))?,
2138                Op::FloatMul => self.bin_float(|a, b| Value::Float(a * b))?,
2139                Op::FloatDiv => self.bin_float(|a, b| Value::Float(a / b))?,
2140                Op::FloatNeg => {
2141                    let a = self.pop()?.as_float();
2142                    self.stack.push(Value::Float(-a));
2143                }
2144                Op::FloatEq => self.bin_float(|a, b| Value::Bool(a == b))?,
2145                Op::FloatLt => self.bin_float(|a, b| Value::Bool(a < b))?,
2146                Op::FloatLe => self.bin_float(|a, b| Value::Bool(a <= b))?,
2147                Op::NumAdd => {
2148                    // #308: `+` is overloaded — Str+Str concatenates,
2149                    // numerics add. Other arithmetic ops (-, *, /, %)
2150                    // still reject Str at the type-checker layer.
2151                    let b = self.pop()?;
2152                    let a = self.pop()?;
2153                    match (a, b) {
2154                        (Value::Int(x), Value::Int(y)) => self.stack.push(Value::Int(x + y)),
2155                        (Value::Float(x), Value::Float(y)) => self.stack.push(Value::Float(x + y)),
2156                        (Value::Str(x), Value::Str(y)) => {
2157                            // SmolStr is immutable; concatenate via a temporary String.
2158                            let mut s = String::with_capacity(x.len() + y.len());
2159                            s.push_str(&x);
2160                            s.push_str(&y);
2161                            self.stack.push(Value::Str(s.into()));
2162                        }
2163                        (a, b) => return Err(VmError::TypeMismatch(format!("Num op: {a:?} {b:?}"))),
2164                    }
2165                }
2166                Op::NumSub => self.bin_num(|a, b| Value::Int(a - b), |a, b| Value::Float(a - b))?,
2167                Op::NumMul => self.bin_num(|a, b| Value::Int(a * b), |a, b| Value::Float(a * b))?,
2168                Op::NumDiv => self.bin_num(|a, b| Value::Int(a / b), |a, b| Value::Float(a / b))?,
2169                Op::NumMod => self.bin_int(|a, b| Value::Int(a % b))?,
2170                Op::NumNeg => {
2171                    let v = self.pop()?;
2172                    match v {
2173                        Value::Int(n) => self.stack.push(Value::Int(-n)),
2174                        Value::Float(f) => self.stack.push(Value::Float(-f)),
2175                        other => return Err(VmError::TypeMismatch(format!("NumNeg on {other:?}"))),
2176                    }
2177                }
2178                Op::NumEq => self.bin_eq()?,
2179                Op::NumLt => self.bin_ord(|a, b| Value::Bool(a < b), |a, b| Value::Bool(a < b), |a, b| Value::Bool(a < b))?,
2180                Op::NumLe => self.bin_ord(|a, b| Value::Bool(a <= b), |a, b| Value::Bool(a <= b), |a, b| Value::Bool(a <= b))?,
2181                Op::BoolAnd => {
2182                    let b = self.pop()?.as_bool();
2183                    let a = self.pop()?.as_bool();
2184                    self.stack.push(Value::Bool(a && b));
2185                }
2186                Op::BoolOr => {
2187                    let b = self.pop()?.as_bool();
2188                    let a = self.pop()?.as_bool();
2189                    self.stack.push(Value::Bool(a || b));
2190                }
2191                Op::BoolNot => {
2192                    let a = self.pop()?.as_bool();
2193                    self.stack.push(Value::Bool(!a));
2194                }
2195                Op::StrConcat => {
2196                    let b = self.pop()?;
2197                    let a = self.pop()?;
2198                    let s = format!("{}{}", a.as_str(), b.as_str());
2199                    self.stack.push(Value::Str(s.into()));
2200                }
2201                Op::StrLen => {
2202                    let v = self.pop()?;
2203                    self.stack.push(Value::Int(v.as_str().len() as i64));
2204                }
2205                Op::StrEq => {
2206                    let b = self.pop()?;
2207                    let a = self.pop()?;
2208                    self.stack.push(Value::Bool(a.as_str() == b.as_str()));
2209                }
2210                Op::BytesLen => {
2211                    let v = self.pop()?;
2212                    match v {
2213                        Value::Bytes(b) => self.stack.push(Value::Int(b.len() as i64)),
2214                        other => return Err(VmError::TypeMismatch(format!("BytesLen on {other:?}"))),
2215                    }
2216                }
2217                Op::BytesEq => {
2218                    let b = self.pop()?;
2219                    let a = self.pop()?;
2220                    let eq = match (a, b) {
2221                        (Value::Bytes(x), Value::Bytes(y)) => x == y,
2222                        _ => return Err(VmError::TypeMismatch("BytesEq operands".into())),
2223                    };
2224                    self.stack.push(Value::Bool(eq));
2225                }
2226
2227                // Superinstructions (#461).
2228                Op::LoadLocalAddIntConst { local_idx, imm_const_idx } => {
2229                    let base = self.frames[frame_idx].locals_start;
2230                    let a = self.locals_storage[base + local_idx as usize].as_int();
2231                    let b = match &self.program.constants[imm_const_idx as usize] {
2232                        Const::Int(n) => *n,
2233                        c => return Err(VmError::TypeMismatch(
2234                            format!("LoadLocalAddIntConst expected Int const, got {c:?}"))),
2235                    };
2236                    self.stack.push(Value::Int(a + b));
2237                    // Override the default `pc + 1`: skip past the
2238                    // two inert primitive ops (the original
2239                    // PushConst + IntAdd) that the peephole pass
2240                    // left in place for body-hash stability.
2241                    self.frames[frame_idx].pc = pc + 3;
2242                }
2243                Op::LoadLocalAddLocal { lhs_idx, rhs_idx } => {
2244                    let base = self.frames[frame_idx].locals_start;
2245                    let a = self.locals_storage[base + lhs_idx as usize].as_int();
2246                    let b = self.locals_storage[base + rhs_idx as usize].as_int();
2247                    self.stack.push(Value::Int(a + b));
2248                    // Override the default `pc + 1`: skip past the
2249                    // two inert primitive ops (the original
2250                    // LoadLocal(rhs_idx) + IntAdd) that the peephole
2251                    // pass left in place for body-hash stability.
2252                    self.frames[frame_idx].pc = pc + 3;
2253                }
2254                Op::LoadLocalSubLocal { lhs_idx, rhs_idx } => {
2255                    let base = self.frames[frame_idx].locals_start;
2256                    let a = self.locals_storage[base + lhs_idx as usize].as_int();
2257                    let b = self.locals_storage[base + rhs_idx as usize].as_int();
2258                    self.stack.push(Value::Int(a - b));
2259                    self.frames[frame_idx].pc = pc + 3;
2260                }
2261                Op::LoadLocalMulLocal { lhs_idx, rhs_idx } => {
2262                    let base = self.frames[frame_idx].locals_start;
2263                    let a = self.locals_storage[base + lhs_idx as usize].as_int();
2264                    let b = self.locals_storage[base + rhs_idx as usize].as_int();
2265                    self.stack.push(Value::Int(a * b));
2266                    self.frames[frame_idx].pc = pc + 3;
2267                }
2268                Op::LoadLocalGetField { local_idx, name_idx, site_idx } => {
2269                    // #461 slice 9: fused `LoadLocal + GetField`. Reads
2270                    // the field directly out of the local record by
2271                    // reference and pushes it, advancing pc by 2 (one
2272                    // tombstone — the original GetField). Avoids the
2273                    // unfused pair's whole-record clone onto the value
2274                    // stack: the dominant heap-record churn on the
2275                    // `response_build` profile (`r.total` field reads).
2276                    let base = self.frames[frame_idx].locals_start;
2277                    let v = self.read_local_record_field(
2278                        base, local_idx, fn_id, name_idx, site_idx, "LoadLocalGetField")?;
2279                    self.stack.push(v);
2280                    self.frames[frame_idx].pc = pc + 2;
2281                }
2282                Op::LoadLocalGetFieldAdd { local_idx, name_idx, site_idx } => {
2283                    // #461 slice 7: fused `LoadLocal + GetField + IntAdd`.
2284                    // Pop the prior stack top (the accumulator), read the
2285                    // field by reference (shared IC via
2286                    // `read_local_record_field`), push the sum, advance
2287                    // pc by 3 (skip the GetField and IntAdd tombstones).
2288                    let acc = self.pop()?.as_int();
2289                    let base = self.frames[frame_idx].locals_start;
2290                    let b = self.read_local_record_field(
2291                        base, local_idx, fn_id, name_idx, site_idx, "LoadLocalGetFieldAdd")?.as_int();
2292                    self.stack.push(Value::Int(acc + b));
2293                    self.frames[frame_idx].pc = pc + 3;
2294                }
2295                Op::LoadLocalGetFieldSub { local_idx, name_idx, site_idx } => {
2296                    // #461 slice 8: `LoadLocal + GetField + IntSub`. The
2297                    // `acc - r.field` idiom. IntSub computes
2298                    // deeper-minus-top; the field was on top in the
2299                    // unfused form, so the result is `acc - field`.
2300                    let acc = self.pop()?.as_int();
2301                    let base = self.frames[frame_idx].locals_start;
2302                    let b = self.read_local_record_field(
2303                        base, local_idx, fn_id, name_idx, site_idx, "LoadLocalGetFieldSub")?.as_int();
2304                    self.stack.push(Value::Int(acc - b));
2305                    self.frames[frame_idx].pc = pc + 3;
2306                }
2307                Op::LoadLocalGetFieldMul { local_idx, name_idx, site_idx } => {
2308                    // #461 slice 8: `LoadLocal + GetField + IntMul`. The
2309                    // `acc * r.field` idiom (mul is commutative, so
2310                    // operand order doesn't matter).
2311                    let acc = self.pop()?.as_int();
2312                    let base = self.frames[frame_idx].locals_start;
2313                    let b = self.read_local_record_field(
2314                        base, local_idx, fn_id, name_idx, site_idx, "LoadLocalGetFieldMul")?.as_int();
2315                    self.stack.push(Value::Int(acc * b));
2316                    self.frames[frame_idx].pc = pc + 3;
2317                }
2318                Op::LoadLocalEqIntConstJumpIfNot { local_idx, imm_const_idx, jump_offset } => {
2319                    // First jump-aware fusion (#461 slice 5). The
2320                    // JumpIfNot's offset is relative to its own
2321                    // pc + 1 = (pc + 3) + 1 = pc + 4, so the branch
2322                    // target is `pc + 4 + jump_offset`. Fall-through
2323                    // (equal → JumpIfNot doesn't jump) is `pc + 4`
2324                    // (skip past the 3 tombstones — PushConst +
2325                    // IntEq + JumpIfNot).
2326                    let base = self.frames[frame_idx].locals_start;
2327                    let a = self.locals_storage[base + local_idx as usize].as_int();
2328                    let b = match &self.program.constants[imm_const_idx as usize] {
2329                        Const::Int(n) => *n,
2330                        _ => return Err(VmError::TypeMismatch(
2331                            "LoadLocalEqIntConstJumpIfNot expects Const::Int".into())),
2332                    };
2333                    let next_pc = if a == b {
2334                        pc + 4
2335                    } else {
2336                        ((pc as i32 + 4) + jump_offset) as usize
2337                    };
2338                    self.frames[frame_idx].pc = next_pc;
2339                }
2340                Op::LoadLocalStoreEqIntConstJumpIfNot { src, dst, imm_const_idx, jump_offset } => {
2341                    // Slice 6: absorbs LoadLocal + StoreLocal + slice-5 op.
2342                    // 6-slot window total (this op + 5 tombstones); fall-
2343                    // through is `pc + 6`, branch target is `pc + 6 +
2344                    // jump_offset` (the original JumpIfNot was at slot
2345                    // pc+5, with offset relative to its own pc+1 = pc+6).
2346                    let base = self.frames[frame_idx].locals_start;
2347                    let a = self.locals_storage[base + src as usize].as_int();
2348                    // Mirror the original `StoreLocal(dst)` — later
2349                    // arm tests in the same `match` expect to find
2350                    // the scrutinee at `locals[dst]`.
2351                    self.locals_storage[base + dst as usize] = Value::Int(a);
2352                    let b = match &self.program.constants[imm_const_idx as usize] {
2353                        Const::Int(n) => *n,
2354                        _ => return Err(VmError::TypeMismatch(
2355                            "LoadLocalStoreEqIntConstJumpIfNot expects Const::Int".into())),
2356                    };
2357                    let next_pc = if a == b {
2358                        pc + 6
2359                    } else {
2360                        ((pc as i32 + 6) + jump_offset) as usize
2361                    };
2362                    self.frames[frame_idx].pc = next_pc;
2363                }
2364                Op::LoadLocalAddIntConstStoreLocal { src, imm_const_idx, dest } => {
2365                    let base = self.frames[frame_idx].locals_start;
2366                    let a = self.locals_storage[base + src as usize].as_int();
2367                    let b = match &self.program.constants[imm_const_idx as usize] {
2368                        Const::Int(n) => *n,
2369                        c => return Err(VmError::TypeMismatch(
2370                            format!("LoadLocalAddIntConstStoreLocal expected Int const, got {c:?}"))),
2371                    };
2372                    self.locals_storage[base + dest as usize] = Value::Int(a + b);
2373                    // Skip past the 3 inert primitive ops we
2374                    // absorbed (original PushConst + IntAdd +
2375                    // StoreLocal).
2376                    self.frames[frame_idx].pc = pc + 4;
2377                }
2378            }
2379        }
2380    }
2381
2382    fn pop(&mut self) -> Result<Value, VmError> {
2383        self.stack.pop().ok_or(VmError::StackUnderflow)
2384    }
2385    fn peek(&self) -> Result<&Value, VmError> {
2386        self.stack.last().ok_or(VmError::StackUnderflow)
2387    }
2388
2389    /// IC-cached field read of `locals[local_idx]`, shared by the
2390    /// field-read fusions: slice 9's `LoadLocalGetField` and slice
2391    /// 7/8's `LoadLocalGetField{Add,Sub,Mul}`. Uses the same
2392    /// `(fn_id, site_idx)` inline-cache slot as the unfused
2393    /// `Op::GetField`, so the paths stay cache-consistent.
2394    /// `op_name` only appears in the non-record error message.
2395    ///
2396    /// Reads the record **by reference** and clones out only the
2397    /// selected field — it does *not* clone the whole record. The
2398    /// unfused `[LoadLocal, GetField]` pair clones the entire record
2399    /// (`Box<IndexMap>` for a heap record) onto the value stack just
2400    /// to read one field and drop the rest; on the `response_build`
2401    /// profile that whole-record clone+drop of the returned `Response`
2402    /// dominated the malloc traffic. Borrowing in place removes it.
2403    ///
2404    /// Borrow discipline: the inline-cache slot can't be written while
2405    /// the record (a borrow of `self.locals_storage`) is live, so the
2406    /// match yields `(value, install)` and the `field_ics` write
2407    /// happens after the borrow ends.
2408    ///
2409    /// `#[inline(always)]`: hot dispatch path, called from four tight
2410    /// `run_to` arms; leaving it out-of-line showed up as a standalone
2411    /// call frame on the profile.
2412    #[inline(always)]
2413    fn read_local_record_field(
2414        &mut self,
2415        base: usize,
2416        local_idx: u16,
2417        fn_id: u32,
2418        name_idx: u32,
2419        site_idx: u32,
2420        op_name: &str,
2421    ) -> Result<Value, VmError> {
2422        let fid = fn_id as usize;
2423        let sid = site_idx as usize;
2424        if self.field_ics[fid].is_empty() {
2425            let n = self.program.functions[fid].field_ic_sites as usize;
2426            self.field_ics[fid] = vec![None; n];
2427        }
2428        let cached = self.field_ics[fid][sid];
2429        let li = base + local_idx as usize;
2430
2431        let (value, install): (Value, Option<(u32, usize)>) =
2432            match &self.locals_storage[li] {
2433                Value::Record { fields: r, shape_id } => {
2434                    let shape_id = *shape_id;
2435                    if ic_stats_enabled() {
2436                        record_ic_hit(fn_id, site_idx, shape_id);
2437                    }
2438                    let hit = if let Some((cached_shape, off)) = cached {
2439                        if cached_shape == shape_id {
2440                            if shape_id != crate::value::NO_SHAPE_ID {
2441                                r.get_index(off).map(|(_, val)| val.clone())
2442                            } else if let Some((k, val)) = r.get_index(off) {
2443                                match &self.program.constants[name_idx as usize] {
2444                                    Const::FieldName(s) if s == k => Some(val.clone()),
2445                                    _ => None,
2446                                }
2447                            } else { None }
2448                        } else { None }
2449                    } else { None };
2450                    match hit {
2451                        Some(v) => (v, None),
2452                        None => {
2453                            let name = match &self.program.constants[name_idx as usize] {
2454                                Const::FieldName(s) => s.as_str(),
2455                                _ => return Err(VmError::TypeMismatch(
2456                                    "expected FieldName const".into())),
2457                            };
2458                            let (off, _, val) = r.get_full(name)
2459                                .ok_or_else(|| VmError::TypeMismatch(
2460                                    format!("missing field `{name}`")))?;
2461                            (val.clone(), Some((shape_id, off)))
2462                        }
2463                    }
2464                }
2465                &Value::StackRecord { shape_id, slab_start, field_count } => {
2466                    if ic_stats_enabled() {
2467                        record_ic_hit(fn_id, site_idx, shape_id);
2468                    }
2469                    if let Some((cached_shape, off)) = cached {
2470                        if cached_shape == shape_id && (off as u16) < field_count {
2471                            let idx = slab_start as usize + off;
2472                            (self.stack_record_arena[idx].clone(), None)
2473                        } else {
2474                            let off = self.resolve_stack_field(shape_id, name_idx)?;
2475                            (self.stack_record_arena[slab_start as usize + off].clone(),
2476                             Some((shape_id, off)))
2477                        }
2478                    } else {
2479                        let off = self.resolve_stack_field(shape_id, name_idx)?;
2480                        (self.stack_record_arena[slab_start as usize + off].clone(),
2481                         Some((shape_id, off)))
2482                    }
2483                }
2484                other => return Err(VmError::TypeMismatch(
2485                    format!("{op_name} on non-record: {other:?}"))),
2486            };
2487        if let Some(entry) = install {
2488            self.field_ics[fid][sid] = Some(entry);
2489        }
2490        Ok(value)
2491    }
2492
2493    /// Resolve a field offset within a stack-record shape by name
2494    /// (the slow path when the inline cache misses). Factored out so
2495    /// `read_local_record_field` doesn't hold the `locals_storage`
2496    /// borrow across the `record_shapes` / `constants` walk.
2497    #[inline]
2498    fn resolve_stack_field(&self, shape_id: u32, name_idx: u32) -> Result<usize, VmError> {
2499        let shape = &self.program.record_shapes[shape_id as usize];
2500        let target_name = match &self.program.constants[name_idx as usize] {
2501            Const::FieldName(s) => s.as_str(),
2502            _ => return Err(VmError::TypeMismatch("expected FieldName const".into())),
2503        };
2504        for (i, fn_const_idx) in shape.iter().enumerate() {
2505            if let Const::FieldName(s) = &self.program.constants[*fn_const_idx as usize] {
2506                if s == target_name { return Ok(i); }
2507            }
2508        }
2509        Err(VmError::TypeMismatch(
2510            format!("missing field `{target_name}` on stack record")))
2511    }
2512
2513    fn bin_int(&mut self, f: impl Fn(i64, i64) -> Value) -> Result<(), VmError> {
2514        let b = self.pop()?.as_int();
2515        let a = self.pop()?.as_int();
2516        self.stack.push(f(a, b));
2517        Ok(())
2518    }
2519    fn bin_float(&mut self, f: impl Fn(f64, f64) -> Value) -> Result<(), VmError> {
2520        let b = self.pop()?.as_float();
2521        let a = self.pop()?.as_float();
2522        self.stack.push(f(a, b));
2523        Ok(())
2524    }
2525    fn bin_num(
2526        &mut self,
2527        i: impl Fn(i64, i64) -> Value,
2528        f: impl Fn(f64, f64) -> Value,
2529    ) -> Result<(), VmError> {
2530        let b = self.pop()?;
2531        let a = self.pop()?;
2532        match (a, b) {
2533            (Value::Int(x), Value::Int(y)) => { self.stack.push(i(x, y)); Ok(()) }
2534            (Value::Float(x), Value::Float(y)) => { self.stack.push(f(x, y)); Ok(()) }
2535            (a, b) => Err(VmError::TypeMismatch(format!("Num op: {a:?} {b:?}"))),
2536        }
2537    }
2538
2539    /// Like `bin_num` but also handles `Str` operands via lexicographic order.
2540    /// Used by `NumLt` / `NumLe` because the type checker admits `Str < Str`
2541    /// and `>` / `>=` compile as swap+NumLt / swap+NumLe (#332).
2542    fn bin_ord(
2543        &mut self,
2544        i: impl Fn(i64, i64) -> Value,
2545        f: impl Fn(f64, f64) -> Value,
2546        s: impl Fn(&str, &str) -> Value,
2547    ) -> Result<(), VmError> {
2548        let b = self.pop()?;
2549        let a = self.pop()?;
2550        match (a, b) {
2551            (Value::Int(x), Value::Int(y)) => { self.stack.push(i(x, y)); Ok(()) }
2552            (Value::Float(x), Value::Float(y)) => { self.stack.push(f(x, y)); Ok(()) }
2553            (Value::Str(x), Value::Str(y)) => { self.stack.push(s(&x, &y)); Ok(()) }
2554            (a, b) => Err(VmError::TypeMismatch(format!("Num op: {a:?} {b:?}"))),
2555        }
2556    }
2557    fn bin_eq(&mut self) -> Result<(), VmError> {
2558        let b = self.pop()?;
2559        let a = self.pop()?;
2560        self.stack.push(Value::Bool(a == b));
2561        Ok(())
2562    }
2563}
2564
2565impl Drop for Vm<'_> {
2566    fn drop(&mut self) {
2567        if ic_stats_enabled() {
2568            dump_ic_stats();
2569        }
2570    }
2571}
2572
2573/// Construct a `Value::Variant` with the given name and args.
2574/// Used by `conc.*` registry ops to return `Result`/`Option`/`ConcError`
2575/// values without hand-writing the struct literal at every site.
2576fn variant(name: &str, args: Vec<Value>) -> Value {
2577    Value::Variant { name: name.to_string(), args }
2578}
2579fn variant_ok(payload: Value) -> Value { variant("Ok", vec![payload]) }
2580fn variant_err(payload: Value) -> Value { variant("Err", vec![payload]) }
2581
2582fn const_to_value(c: &Const) -> Value {
2583    match c {
2584        Const::Int(n) => Value::Int(*n),
2585        Const::Float(f) => Value::Float(*f),
2586        Const::Bool(b) => Value::Bool(*b),
2587        Const::Str(s) => Value::Str(s.as_str().into()),
2588        Const::Bytes(b) => Value::Bytes(b.clone()),
2589        Const::Unit => Value::Unit,
2590        Const::FieldName(s) | Const::VariantName(s) | Const::NodeId(s) => Value::Str(s.as_str().into()),
2591    }
2592}
2593
2594#[cfg(test)]
2595mod memo_hash_tests {
2596    //! #461 follow-up: invariants for the structural memo-key hash
2597    //! that replaced the SHA-256-over-canonical-JSON path. The memo
2598    //! cache keys on this digest with no equality fallback, so the
2599    //! load-bearing property is "equal-under-PartialEq args produce
2600    //! an equal key" — plus enough discrimination that distinct args
2601    //! don't collide in practice.
2602    use super::*;
2603    use indexmap::IndexMap;
2604
2605    fn rec(pairs: &[(&str, Value)]) -> Value {
2606        let mut m: IndexMap<SmolStr, Value> = IndexMap::new();
2607        for (k, v) in pairs { m.insert((*k).into(), v.clone()); }
2608        Value::Record { shape_id: crate::value::NO_SHAPE_ID, fields: Box::new(m) }
2609    }
2610
2611    #[test]
2612    fn identical_args_hash_equal() {
2613        let a = vec![Value::Int(7), Value::Str("hi".into())];
2614        let b = vec![Value::Int(7), Value::Str("hi".into())];
2615        assert_eq!(hash_call_args(&a), hash_call_args(&b));
2616    }
2617
2618    #[test]
2619    fn distinct_scalars_differ() {
2620        assert_ne!(hash_call_args(&[Value::Int(7)]), hash_call_args(&[Value::Int(8)]));
2621        assert_ne!(hash_call_args(&[Value::Int(0)]), hash_call_args(&[Value::Bool(false)]));
2622        assert_ne!(hash_call_args(&[Value::Int(0)]), hash_call_args(&[Value::Unit]));
2623        assert_ne!(hash_call_args(&[Value::Bool(true)]), hash_call_args(&[Value::Bool(false)]));
2624    }
2625
2626    #[test]
2627    fn arity_is_part_of_the_key() {
2628        assert_ne!(
2629            hash_call_args(&[Value::Int(1), Value::Int(2)]),
2630            hash_call_args(&[Value::Int(1)]),
2631        );
2632        // A 2-arg call vs a single Tuple arg of the same elements
2633        // must not collide.
2634        assert_ne!(
2635            hash_call_args(&[Value::Int(1), Value::Int(2)]),
2636            hash_call_args(&[Value::Tuple(vec![Value::Int(1), Value::Int(2)])]),
2637        );
2638    }
2639
2640    #[test]
2641    fn record_hash_is_field_order_independent() {
2642        // IndexMap equality ignores insertion order, so the key must
2643        // too — otherwise equal records would miss the cache.
2644        let r1 = rec(&[("a", Value::Int(1)), ("b", Value::Int(2))]);
2645        let r2 = rec(&[("b", Value::Int(2)), ("a", Value::Int(1))]);
2646        assert_eq!(r1, r2, "precondition: records compare equal");
2647        assert_eq!(hash_call_args(&[r1]), hash_call_args(&[r2]));
2648    }
2649
2650    #[test]
2651    fn record_distinguishes_values_and_keys() {
2652        let base = rec(&[("a", Value::Int(1)), ("b", Value::Int(2))]);
2653        let diff_val = rec(&[("a", Value::Int(1)), ("b", Value::Int(3))]);
2654        let diff_key = rec(&[("a", Value::Int(1)), ("c", Value::Int(2))]);
2655        assert_ne!(hash_call_args(std::slice::from_ref(&base)), hash_call_args(&[diff_val]));
2656        assert_ne!(hash_call_args(&[base]), hash_call_args(&[diff_key]));
2657    }
2658
2659    #[test]
2660    fn shape_id_does_not_affect_record_key() {
2661        // PartialEq ignores shape_id; the key must too.
2662        let mut m: IndexMap<SmolStr, Value> = IndexMap::new();
2663        m.insert("a".into(), Value::Int(1));
2664        let r_no_shape = Value::Record { shape_id: crate::value::NO_SHAPE_ID, fields: Box::new(m.clone()) };
2665        let r_shaped = Value::Record { shape_id: 3, fields: Box::new(m) };
2666        assert_eq!(r_no_shape, r_shaped);
2667        assert_eq!(hash_call_args(&[r_no_shape]), hash_call_args(&[r_shaped]));
2668    }
2669
2670    #[test]
2671    fn variant_name_and_args_matter() {
2672        let some1 = Value::Variant { name: "Some".into(), args: vec![Value::Int(1)] };
2673        let some1b = Value::Variant { name: "Some".into(), args: vec![Value::Int(1)] };
2674        let some2 = Value::Variant { name: "Some".into(), args: vec![Value::Int(2)] };
2675        let none = Value::Variant { name: "None".into(), args: vec![] };
2676        assert_eq!(hash_call_args(std::slice::from_ref(&some1)), hash_call_args(&[some1b]));
2677        assert_ne!(hash_call_args(std::slice::from_ref(&some1)), hash_call_args(&[some2]));
2678        assert_ne!(hash_call_args(&[some1]), hash_call_args(&[none]));
2679    }
2680
2681    #[test]
2682    fn float_bit_pattern_keys() {
2683        assert_eq!(hash_call_args(&[Value::Float(1.5)]), hash_call_args(&[Value::Float(1.5)]));
2684        assert_ne!(hash_call_args(&[Value::Float(1.5)]), hash_call_args(&[Value::Float(2.5)]));
2685        // Same NaN bit pattern → same key (harmless: pure callee is
2686        // deterministic on bit-identical args).
2687        let nan = f64::NAN;
2688        assert_eq!(hash_call_args(&[Value::Float(nan)]), hash_call_args(&[Value::Float(nan)]));
2689    }
2690}