Skip to main content

lex_bytecode/vm/
mod.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
11mod closures;
12mod dispatch;
13mod memo;
14mod native_list;
15
16use memo::*;
17use native_list::*;
18
19// ── IC polymorphism instrumentation (throwaway, env-gated) ─────────
20// Enable with LEX_IC_STATS=1. With LEX_IC_STATS_OUT=<path> writes a
21// TSV to <path>.<pid> on each Vm drop; otherwise dumps to stderr.
22
23#[derive(Default)]
24struct IcStats {
25    sites: HashMap<(u32, u32), HashMap<u32, u64>>,
26}
27
28static IC_STATS: OnceLock<Mutex<IcStats>> = OnceLock::new();
29static IC_STATS_ENABLED: OnceLock<bool> = OnceLock::new();
30
31fn ic_stats_enabled() -> bool {
32    *IC_STATS_ENABLED.get_or_init(|| {
33        std::env::var("LEX_IC_STATS").map(|v| v == "1").unwrap_or(false)
34    })
35}
36
37fn record_ic_hit(fn_id: u32, site_idx: u32, shape_id: u32) {
38    let stats = IC_STATS.get_or_init(|| Mutex::new(IcStats::default()));
39    let mut s = stats.lock().unwrap();
40    *s.sites.entry((fn_id, site_idx)).or_default().entry(shape_id).or_insert(0) += 1;
41}
42
43pub fn dump_ic_stats() {
44    let Some(stats) = IC_STATS.get() else { return; };
45    let s = stats.lock().unwrap();
46    if s.sites.is_empty() { return; }
47    let mut out = String::from("fn_id\tsite_idx\tshape_id\thits\n");
48    let mut entries: Vec<_> = s.sites.iter().collect();
49    entries.sort_by_key(|((f, si), _)| (*f, *si));
50    for ((f, site), shapes) in entries {
51        let mut shape_entries: Vec<_> = shapes.iter().collect();
52        shape_entries.sort_by_key(|(sid, _)| **sid);
53        for (sid, hits) in shape_entries {
54            out.push_str(&format!("{f}\t{site}\t{sid}\t{hits}\n"));
55        }
56    }
57    match std::env::var("LEX_IC_STATS_OUT").ok() {
58        Some(path) => {
59            let pid = std::process::id();
60            let _ = std::fs::write(format!("{path}.{pid}"), out);
61        }
62        None => { eprint!("{out}"); }
63    }
64}
65
66#[derive(Debug, Clone, thiserror::Error)]
67pub enum VmError {
68    #[error("runtime panic: {0}")]
69    Panic(String),
70    #[error("type mismatch at runtime: {0}")]
71    TypeMismatch(String),
72    #[error("stack underflow")]
73    StackUnderflow,
74    #[error("unknown function: {0}")]
75    UnknownFunction(String),
76    #[error("effect handler error: {0}")]
77    Effect(String),
78    /// The program called `std.process.exit(code)` (#754).
79    ///
80    /// Not a failure — a deliberate signal — but it travels the error
81    /// channel because that is the VM's only unwind path, and unwinding
82    /// is the point: the alternative is calling `std::process::exit`
83    /// inside the handler, which would terminate the process where it
84    /// stands and silently skip everything `lex run` does *after* the
85    /// call returns (finalising the trace, writing the `Trace`
86    /// attestation, recording committed ops). A run that exits is still
87    /// a run that happened, and it should leave the same evidence.
88    #[error("process exit requested: {0}")]
89    ProcessExit(i32),
90    #[error("call stack overflow: recursion depth exceeded ({0})")]
91    CallStackOverflow(u32),
92    /// Refinement predicate failed at a call boundary (#209 slice 3).
93    /// Surfaced when a function declares `param :: Type{x | predicate}`,
94    /// the call-site arg couldn't be discharged statically (slice 2),
95    /// and the runtime evaluator finds the predicate is `false` for
96    /// the actual argument value. The `verdict` mirrors the shape of
97    /// `gate.verdict`-style records in `lex-trace`.
98    #[error("refinement violated: argument {param_index} of `{fn_name}` (binding `{binding}`): {reason}")]
99    RefinementFailed {
100        fn_name: String,
101        param_index: usize,
102        binding: String,
103        reason: String,
104    },
105    /// Integer division or modulo with a zero divisor (#696). Without
106    /// this guard the host `/`/`%` panics and takes the whole process
107    /// down — the crash report had a conformance harness compute a
108    /// rate over an empty set in teardown, far from any user-visible
109    /// division. Surfacing a catchable `VmError` instead keeps the
110    /// failure inside the language's error model. Float div/mod is
111    /// exempt: IEEE-754 yields inf/NaN rather than trapping.
112    #[error("integer {op} by zero")]
113    DivByZero {
114        /// `"division"` or `"modulo"` — names the offending operator.
115        op: &'static str,
116    },
117}
118
119/// Maximum simultaneous call frames. Defends against unbounded
120/// recursion in agent-emitted code: a body that calls itself
121/// without a base case would otherwise blow the host's native
122/// stack and crash the process. Real Lex code rarely exceeds
123/// ~30 frames; 1024 is generous headroom while still well under
124/// the OS stack limit at any per-frame size we use.
125pub const MAX_CALL_DEPTH: u32 = 1024;
126
127/// Per-frame stack-record budget (#464 step 2). Counts the number of
128/// `Value` slots a frame may consume from `Vm::stack_record_arena`
129/// before further `Op::AllocStackRecord` requests fall back to the
130/// heap path. 64 slots at the current `size_of::<Value>() = 64B`
131/// gives ~4 KiB per frame, matching the design-doc proposal in
132/// `docs/design/escape-analysis.md`. A handler-shaped function
133/// (one outer record of ≤8 fields, plus a handful of small inner
134/// records) fits well inside this without growing.
135pub const STACK_RECORD_BUDGET_SLOTS: u32 = 64;
136
137/// Host-side effect dispatch. Implementors decide what `kind`/`op` mean
138/// and how arguments map to side effects.
139pub trait EffectHandler {
140    fn dispatch(&mut self, kind: &str, op: &str, args: Vec<Value>) -> Result<Value, String>;
141
142    /// Hook called by the VM at every function call so handlers can
143    /// enforce per-call budget consumption (#225). The argument is
144    /// the sum of `[budget(N)]` declared on the callee's signature;
145    /// the handler returns `Err` to refuse the call (the VM converts
146    /// to `VmError::Effect`). Default impl is a no-op so legacy
147    /// handlers and pure-only runs are unaffected.
148    fn note_call_budget(&mut self, _budget_cost: u64) -> Result<(), String> {
149        Ok(())
150    }
151
152    /// Has the program asked to terminate, and with what status (#754)?
153    ///
154    /// Checked by the VM after every effect dispatch. A handler that
155    /// supports `std.process.exit` records the requested code and
156    /// returns it here **once**; the VM then unwinds with
157    /// [`VmError::ProcessExit`].
158    ///
159    /// A hook rather than a richer error type on `dispatch` for the
160    /// same reason `note_call_budget` is one: the default impl keeps
161    /// every existing handler compiling and behaving exactly as before.
162    /// Signalling through the `String` error channel was the
163    /// alternative, and a sentinel string is not a contract.
164    fn take_exit(&mut self) -> Option<i32> {
165        None
166    }
167
168    /// Enter a per-request allocation scope (#463 scaffolding).
169    /// Called by the runtime layer (e.g. `net.serve_fn`'s request
170    /// loop) immediately before invoking the user handler closure
171    /// for one request. Implementations push a fresh arena onto
172    /// their internal stack and return its identifier; the matching
173    /// `exit_request_scope` call drops it.
174    ///
175    /// Default impl is a no-op — handlers without arena support
176    /// return a sentinel scope id which they ignore on exit.
177    /// `DefaultHandler` in `lex-runtime` provides the real
178    /// implementation.
179    ///
180    /// Today the VM does NOT route any `Value` allocations through
181    /// the returned arena — see the scaffolding notes in
182    /// `crates/lex-runtime/src/arena.rs`. The hook exists so the
183    /// follow-on slice that adds Value-rep arena routing has a
184    /// stable trait surface to extend.
185    fn enter_request_scope(&mut self) -> u64 { 0 }
186
187    /// Exit a per-request allocation scope opened by
188    /// `enter_request_scope`. Implementations drop the arena
189    /// associated with `scope_id`. Calling exit with a scope_id
190    /// that wasn't returned by a prior enter is implementation-
191    /// defined behavior — DefaultHandler treats it as a no-op so
192    /// mismatched pairs don't panic.
193    fn exit_request_scope(&mut self, _scope_id: u64) {}
194
195    /// `list.par_map` worker-handler factory (#305 slice 2).
196    ///
197    /// Each parallel worker thread runs its own `Vm` and therefore
198    /// needs its own effect handler. The parent handler may opt in
199    /// to per-worker dispatch by returning `Some(handler)` here;
200    /// returning `None` (the default) keeps slice-1 behavior: the
201    /// worker runs `DenyAllEffects` and any effect call inside the
202    /// closure fails with `VmError::Effect`.
203    ///
204    /// The returned handler must be `Send` so the worker can take
205    /// ownership across a thread boundary. Shared state (budget
206    /// pool, chat registry, etc.) is wired up by the implementer.
207    /// Per-worker independence (MCP client cache, output sink)
208    /// is intentional — the alternative is mutex-serialization of
209    /// the whole effect dispatch, which would defeat the parallelism.
210    fn spawn_for_worker(&self) -> Option<Box<dyn EffectHandler + Send>> {
211        None
212    }
213}
214
215/// A handler that fails any effect call. Useful as a default for pure-only runs.
216pub struct DenyAllEffects;
217impl EffectHandler for DenyAllEffects {
218    fn dispatch(&mut self, kind: &str, op: &str, _args: Vec<Value>) -> Result<Value, String> {
219        Err(format!("effects not permitted (attempted {kind}.{op})"))
220    }
221}
222
223/// Trace receiver. Implementors record the call/effect tree and may
224/// substitute effect responses (for replay).
225pub trait Tracer {
226    fn enter_call(&mut self, node_id: &str, name: &str, args: &[Value]);
227    fn enter_effect(&mut self, node_id: &str, kind: &str, op: &str, args: &[Value]);
228    fn exit_ok(&mut self, value: &Value);
229    fn exit_err(&mut self, message: &str);
230    /// Tail-call optimization: pop the current frame's open call without
231    /// re-entering the parent (the new call takes its place).
232    fn exit_call_tail(&mut self);
233    /// During replay, return Some(v) to substitute an effect's output.
234    fn override_effect(&mut self, _node_id: &str) -> Option<Value> { None }
235}
236
237/// No-op tracer for normal execution.
238pub struct NullTracer;
239impl Tracer for NullTracer {
240    fn enter_call(&mut self, _: &str, _: &str, _: &[Value]) {}
241    fn enter_effect(&mut self, _: &str, _: &str, _: &str, _: &[Value]) {}
242    fn exit_ok(&mut self, _: &Value) {}
243    fn exit_err(&mut self, _: &str) {}
244    fn exit_call_tail(&mut self) {}
245}
246
247#[derive(Debug, Clone)]
248pub(crate) enum FrameKind {
249    /// Top-level entry frame; doesn't correspond to a Call opcode.
250    Entry,
251    /// Frame opened by Call/TailCall. The `String` is the originating
252    /// `NodeId`; useful for diagnostics even if currently unread.
253    Call(#[allow(dead_code)] String),
254}
255
256pub struct Vm<'a> {
257    program: &'a Program,
258    handler: Box<dyn EffectHandler + 'a>,
259    pub(crate) tracer: Box<dyn Tracer + 'a>,
260    /// Per-call frames. Each frame has its own locals array and pc.
261    frames: Vec<Frame>,
262    stack: Vec<Value>,
263    /// Soft cap to avoid runaway computations in tests.
264    pub step_limit: u64,
265    pub steps: u64,
266    /// Per-Vm memoization cache for pure functions (#229). Keyed by
267    /// `(fn_id, hash_call_args(args))` — a 128-bit structural digest
268    /// of the arguments (see `hash_call_args`). Effectful functions
269    /// never enter this map. The cache lives for the lifetime of one
270    /// `Vm::call` chain — calling `Vm::with_handler` again starts a
271    /// fresh cache.
272    pure_memo: std::collections::HashMap<(u32, [u8; 16]), Value>,
273    /// Diagnostic counters for `--trace` observability (#229).
274    pub pure_memo_hits: u64,
275    pub pure_memo_misses: u64,
276    /// Number of effect-free calls that skipped the cache entirely
277    /// because adaptive memoization disabled their function (#229
278    /// adaptive). Observability only.
279    pub pure_memo_skips: u64,
280    /// Adaptive-memoization state, one entry per function (indexed by
281    /// `fn_id`), parallel to `field_ics` (#229 adaptive). Memoization
282    /// only pays when a function is called repeatedly with equal args;
283    /// the unconditional `hash_call_args` on every effect-free call is
284    /// pure overhead otherwise (the `response_build` profile: 0 hits /
285    /// 3600 misses, ~12% of instructions). After a warmup window with
286    /// zero hits we stop hashing that function's calls — always safe,
287    /// since the callee is pure and recomputing yields the same value.
288    /// Sticky for the Vm's lifetime: a function that hasn't hit in
289    /// `MEMO_WARMUP_CALLS` calls won't amortize later.
290    memo_fn_state: Vec<MemoFnState>,
291    /// Monomorphic inline caches for `Op::GetField` (#462 slice 1 +
292    /// shape-keyed verification slice). Indexed by
293    /// `[fn_id as usize][site_idx as usize]` — one entry per
294    /// field-access site within each function. `site_idx` is assigned
295    /// at compile time by `FnCompiler::field_get_sites` so every emit
296    /// produces a stable identifier independent of pc. The cache
297    /// survives the planned dispatch rewrite (#461) and a future
298    /// JIT (#465).
299    ///
300    /// Slot shape: `(shape_id, offset)`. The pre-shape-keyed slice
301    /// stored only the offset and re-verified each hit by walking
302    /// `IndexMap::get_index(off)` and string-comparing the field name
303    /// against the requested `name_idx`. After this slice, hits
304    /// against compile-time records (real `shape_id`) verify with a
305    /// single `u32` compare and skip the string compare entirely —
306    /// per the #462 slice-2b measurement that observed 0% polymorphism
307    /// and 86% of hits going to records with a real shape_id.
308    ///
309    /// `NO_SHAPE_ID` records (JSON / SQL / HTTP-built — 14% of measured
310    /// hits, 100% of inbox/gateway traffic) fall through to the
311    /// pre-slice name-compare verification. Distinct dynamic shapes
312    /// both carry `NO_SHAPE_ID` and would otherwise alias on a
313    /// pure-shape-keyed IC; keeping the name compare on that path
314    /// preserves correctness without a separate cache for them.
315    ///
316    /// Outer Vec is pre-sized to `program.functions.len()`; each inner
317    /// Vec is empty until the first GetField in that function runs,
318    /// at which point we one-shot allocate it to the compiler-recorded
319    /// `field_ic_sites` size and never resize again. Lazy on the inner
320    /// side so VMs created for short-lived scripts don't eagerly
321    /// allocate IC slots for functions they never enter.
322    field_ics: Vec<Vec<Option<(u32, usize)>>>,
323    /// Stack allocator for function locals (#389 slice 3).
324    ///
325    /// Every function frame claims `locals_count` contiguous slots from
326    /// this Vec on push and releases them on pop.  Because Lex uses
327    /// strictly LIFO frame semantics the most-recently-pushed frame's
328    /// slots always sit at the top of the Vec, so `truncate` is the
329    /// correct (and O(1)) release operation.
330    ///
331    /// The Vec is pre-allocated once at VM construction and then grows
332    /// only if the actual call depth × locals width exceeds the initial
333    /// capacity.  After a top-level `vm.call` returns the Vec is empty
334    /// again but its capacity is retained, so the next request incurs
335    /// zero allocations for locals up to the high-water mark.
336    locals_storage: Vec<Value>,
337    /// Stack-record arena (#464 step 2). Each `Op::AllocStackRecord`
338    /// at a non-escaping site appends its `field_count` field values
339    /// here; the produced `Value::StackRecord` carries `slab_start =
340    /// arena.len() - field_count` so reads are an O(1) slab index.
341    /// On `Op::Return` the arena is truncated back to
342    /// `frame.stack_record_arena_start`, releasing every record the
343    /// frame allocated in O(1) — same lifetime story as
344    /// `locals_storage` for frame locals.
345    ///
346    /// LIFO frame discipline guarantees a frame's records always sit
347    /// at the top of the arena while the frame is live, so neither
348    /// inter-frame interleaving nor index churn can occur.
349    stack_record_arena: Vec<Value>,
350    /// Per-Vm counters for #464 acceptance measurement. Incremented
351    /// on every `Op::MakeRecord` / `Op::AllocStackRecord` dispatch.
352    /// The bench reads these to compute the stack-allocation rate
353    /// (≥ 60% of records on the stack is the acceptance bar). Cheap
354    /// in the hot path — two unconditional u64 increments per record.
355    pub stack_record_allocs: u64,
356    pub stack_record_heap_fallbacks: u64,
357    pub heap_record_allocs: u64,
358    /// Request-scoped arena slab (#463 slice 2a). Mirrors the shape of
359    /// `stack_record_arena` but lives across frames inside the
360    /// request scope opened by `EffectHandler::enter_request_scope`.
361    /// Each `Op::AllocArenaRecord` / `Op::AllocArenaTuple` appends its
362    /// field values here and pushes a handle (`Value::ArenaRecord` /
363    /// `Value::ArenaTuple`) whose `slab_start` indexes back in.
364    /// Truncated to the saved start on `exit_request_scope`, releasing
365    /// every value the scope built in O(1) — same lifetime story as
366    /// `stack_record_arena` truncating on `Op::Return`.
367    ///
368    /// Slabs nest LIFO: `arena_scope_starts` holds the
369    /// `arena_slab.len()` snapshot taken at each `enter_request_scope`,
370    /// and `exit_request_scope` truncates back to the matching entry.
371    /// An empty `arena_scope_starts` means **no active scope** — the
372    /// alloc ops fall back to their `MakeRecord` / `MakeTuple` heap
373    /// path, so the VM stays sound when arena-lowered bytecode runs in
374    /// a non-handler context.
375    arena_slab: Vec<Value>,
376    /// LIFO stack of `arena_slab.len()` snapshots, one per active
377    /// request scope. See `arena_slab`.
378    arena_scope_starts: Vec<u32>,
379    /// Counters for #463 slice-2b acceptance (will be the
380    /// arena-allocation-rate gate, paralleling the #464 stack-rate
381    /// counters above). Incremented in the op handlers; harmless in
382    /// slice 2a since codegen doesn't emit the ops yet.
383    pub arena_record_allocs: u64,
384    pub arena_record_heap_fallbacks: u64,
385    /// Optional JIT tier hook (#465 phase-1 integration). Consulted
386    /// by the `Op::Call` dispatch arm after refinements + memo. See
387    /// `crate::jit_hook` for the trait contract. `None` means
388    /// "interpreter-only" — that branch in the dispatch arm folds
389    /// to a single null-pointer check the optimizer can hoist.
390    jit_hook: Option<Box<dyn crate::jit_hook::JitHook + 'a>>,
391}
392
393struct Frame {
394    fn_id: u32,
395    pc: usize,
396    /// Start index of this frame's locals in `Vm::locals_storage` (#389
397    /// slice 3). The frame owns `locals_storage[locals_start..locals_start
398    /// + locals_len]`; `Op::Return` truncates the Vec back to
399    /// `locals_start`, releasing the slots in O(1).
400    locals_start: usize,
401    locals_len: usize,
402    /// Stack base when this frame started (for cleanup on return).
403    stack_base: usize,
404    trace_kind: FrameKind,
405    /// Pure-fn memo key (#229). `Some(key)` if the call was eligible
406    /// for memoization and missed the cache; on Op::Return the key
407    /// is used to write the return value back into the cache.
408    /// `None` means "don't memoize" — either the function isn't pure,
409    /// the call wasn't through Op::Call, or memoization is disabled.
410    memo_key: Option<(u32, [u8; 16])>,
411    /// #464 step 2: start index of this frame's records in
412    /// `Vm::stack_record_arena`. On `Op::Return`, the arena is
413    /// truncated back here. Identical lifetime discipline to
414    /// `locals_start`.
415    stack_record_arena_start: usize,
416    /// Remaining stack-record budget for this frame, in Value-slot
417    /// units (#464 step 2). Initial value: `STACK_RECORD_BUDGET_SLOTS`.
418    /// When an `Op::AllocStackRecord` would consume more slots than
419    /// remain, the VM falls back to the heap path silently (same
420    /// observable effect as `Op::MakeRecord`), so the budget never
421    /// surfaces as a user-visible error.
422    stack_record_budget_remaining: u32,
423}
424
425/// Sum of `[budget(N)]` declarations on a function's signature
426/// (#225). Used by Op::Call / Op::TailCall / Op::CallClosure to
427/// notify the EffectHandler of per-call budget cost so the handler
428/// can deduct from a shared pool and refuse calls that would
429/// exceed the policy ceiling. Negative `Int` args are ignored —
430/// the static check (`policy::check_program`) treats budgets as
431/// non-negative.
432fn call_budget_cost(f: &crate::program::Function) -> u64 {
433    let mut total: u64 = 0;
434    for e in &f.effects {
435        if e.kind == "budget" {
436            if let Some(crate::program::EffectArg::Int(n)) = &e.arg {
437                if *n >= 0 {
438                    total = total.saturating_add(*n as u64);
439                }
440            }
441        }
442    }
443    total
444}
445
446/// Evaluate a refinement predicate at runtime against the actual
447/// argument value (#209 slice 3). Mirrors `lex_types::discharge`'s
448/// static evaluator but operates on `Value` directly.
449///
450/// Returns `Ok(true)` / `Ok(false)` for a clean boolean verdict, or
451/// `Err(reason)` if the predicate references something the runtime
452/// can't resolve (free variable beyond the binding, unsupported AST
453/// node). Callers map `Ok(false)` and `Err` to `VmError::RefinementFailed`.
454fn eval_refinement(
455    predicate: &lex_ast::CExpr,
456    binding: &str,
457    arg: &Value,
458) -> Result<bool, String> {
459    match eval_refinement_inner(predicate, binding, arg) {
460        Ok(Value::Bool(b)) => Ok(b),
461        Ok(other) => Err(format!("predicate didn't reduce to a Bool, got {other:?}")),
462        Err(e) => Err(e),
463    }
464}
465
466fn eval_refinement_inner(
467    e: &lex_ast::CExpr,
468    binding: &str,
469    arg: &Value,
470) -> Result<Value, String> {
471    use lex_ast::{CExpr, CLit};
472    match e {
473        CExpr::Literal { value } => Ok(match value {
474            CLit::Int { value } => Value::Int(*value),
475            CLit::Float { value } => Value::Float(value.parse().unwrap_or(0.0)),
476            CLit::Bool { value } => Value::Bool(*value),
477            CLit::Str { value } => Value::Str(value.as_str().into()),
478            CLit::Bytes { value } => Value::Str(value.as_str().into()), // hex; unusual in predicates
479            CLit::Unit => Value::Unit,
480        }),
481        CExpr::Var { name } if name == binding => Ok(arg.clone()),
482        CExpr::Var { name } => Err(format!(
483            "predicate references free var `{name}`; runtime check \
484             only resolves the binding (slice 4 will plumb call-site \
485             context)")),
486        CExpr::UnaryOp { op, expr } => {
487            let v = eval_refinement_inner(expr, binding, arg)?;
488            match (op.as_str(), v) {
489                ("not", Value::Bool(b)) => Ok(Value::Bool(!b)),
490                ("-", Value::Int(n)) => Ok(Value::Int(-n)),
491                ("-", Value::Float(n)) => Ok(Value::Float(-n)),
492                (o, v) => Err(format!("unsupported unary `{o}` on {v:?}")),
493            }
494        }
495        CExpr::BinOp { op, lhs, rhs } => {
496            // Short-circuit `and` / `or` for the same reasons as the
497            // static evaluator.
498            if op == "and" || op == "or" {
499                let l = eval_refinement_inner(lhs, binding, arg)?;
500                let lb = match l {
501                    Value::Bool(b) => b,
502                    other => return Err(format!("`{op}` on non-bool: {other:?}")),
503                };
504                if op == "and" && !lb { return Ok(Value::Bool(false)); }
505                if op == "or"  &&  lb { return Ok(Value::Bool(true));  }
506                let r = eval_refinement_inner(rhs, binding, arg)?;
507                return match r {
508                    Value::Bool(b) => Ok(Value::Bool(b)),
509                    other => Err(format!("`{op}` on non-bool: {other:?}")),
510                };
511            }
512            let l = eval_refinement_inner(lhs, binding, arg)?;
513            let r = eval_refinement_inner(rhs, binding, arg)?;
514            apply_refinement_binop(op, &l, &r)
515        }
516        // Other AST forms (Call, Let, Match, FieldAccess, Lambda,
517        // Block, Constructors, Records, Tuples, Lists, Return) need
518        // a more general evaluator that can call back into the VM.
519        // Out of scope for slice 3; a future slice may unify this
520        // with the spec-checker's gate evaluator.
521        other => Err(format!("unsupported predicate node: {other:?}")),
522    }
523}
524
525fn apply_refinement_binop(op: &str, l: &Value, r: &Value) -> Result<Value, String> {
526    use Value::*;
527    match (op, l, r) {
528        ("+", Int(a), Int(b)) => Ok(Int(a + b)),
529        ("-", Int(a), Int(b)) => Ok(Int(a - b)),
530        ("*", Int(a), Int(b)) => Ok(Int(a * b)),
531        ("/", Int(a), Int(b)) if *b != 0 => Ok(Int(a / b)),
532        ("%", Int(a), Int(b)) if *b != 0 => Ok(Int(a % b)),
533        ("+", Float(a), Float(b)) => Ok(Float(a + b)),
534        ("-", Float(a), Float(b)) => Ok(Float(a - b)),
535        ("*", Float(a), Float(b)) => Ok(Float(a * b)),
536        ("/", Float(a), Float(b)) => Ok(Float(a / b)),
537
538        ("==", a, b) => Ok(Bool(a == b)),
539        ("!=", a, b) => Ok(Bool(a != b)),
540
541        ("<",  Int(a), Int(b)) => Ok(Bool(a < b)),
542        ("<=", Int(a), Int(b)) => Ok(Bool(a <= b)),
543        (">",  Int(a), Int(b)) => Ok(Bool(a > b)),
544        (">=", Int(a), Int(b)) => Ok(Bool(a >= b)),
545
546        ("<",  Float(a), Float(b)) => Ok(Bool(a < b)),
547        ("<=", Float(a), Float(b)) => Ok(Bool(a <= b)),
548        (">",  Float(a), Float(b)) => Ok(Bool(a > b)),
549        (">=", Float(a), Float(b)) => Ok(Bool(a >= b)),
550
551        (op, a, b) => Err(format!(
552            "unsupported binop `{op}` on {a:?} and {b:?}")),
553    }
554}
555
556fn const_str(constants: &[Const], idx: u32) -> String {
557    match constants.get(idx as usize) {
558        Some(Const::NodeId(s)) | Some(Const::Str(s)) => s.clone(),
559        _ => String::new(),
560    }
561}
562
563impl<'a> Vm<'a> {
564    pub fn new(program: &'a Program) -> Self {
565        Self::with_handler(program, Box::new(DenyAllEffects))
566    }
567
568    pub fn with_handler(program: &'a Program, handler: Box<dyn EffectHandler + 'a>) -> Self {
569        Self {
570            program,
571            handler,
572            tracer: Box::new(NullTracer),
573            // Pre-allocate enough capacity for a typical request so the first
574            // call incurs no reallocation (#389 slice 3).
575            frames: Vec::with_capacity(32),
576            stack: Vec::with_capacity(128),
577            step_limit: 10_000_000,
578            steps: 0,
579            pure_memo: std::collections::HashMap::new(),
580            pure_memo_hits: 0,
581            pure_memo_misses: 0,
582            pure_memo_skips: 0,
583            memo_fn_state: vec![MemoFnState::default(); program.functions.len()],
584            field_ics: vec![Vec::new(); program.functions.len()],
585            // 256 slots handles ~32 frames × 8 locals; grows on demand and
586            // retains capacity across consecutive vm.call() invocations.
587            locals_storage: Vec::with_capacity(256),
588            // #464 step 2: zero capacity at construction — handlers that
589            // never AllocStackRecord (most code today, until the lowering
590            // pass kicks in) pay nothing. First allocation triggers Vec
591            // growth; capacity is retained across `vm.call` invocations.
592            stack_record_arena: Vec::new(),
593            stack_record_allocs: 0,
594            stack_record_heap_fallbacks: 0,
595            heap_record_allocs: 0,
596            // #463 slice 2a: empty until the first enter_request_scope.
597            // Programs that never enter a scope incur zero arena cost
598            // (the alloc ops, if reached, fall back to the heap path).
599            arena_slab: Vec::new(),
600            arena_scope_starts: Vec::new(),
601            arena_record_allocs: 0,
602            arena_record_heap_fallbacks: 0,
603            jit_hook: None,
604        }
605    }
606
607    pub fn set_tracer(&mut self, tracer: Box<dyn Tracer + 'a>) {
608        self.tracer = tracer;
609    }
610
611    /// Install (or replace) the JIT hook consulted by `Op::Call`'s
612    /// dispatch arm. With `None`, dispatch behaves exactly as before
613    /// — the hook check is a single null-option branch the optimizer
614    /// can hoist. See the [`crate::jit_hook`] module for the
615    /// contract callers must uphold.
616    pub fn set_jit_hook(&mut self, hook: Option<Box<dyn crate::jit_hook::JitHook + 'a>>) {
617        self.jit_hook = hook;
618    }
619
620    /// Cap the number of opcode dispatches before the VM aborts with
621    /// `step limit exceeded`. Useful as a runtime DoS guard against
622    /// untrusted code (e.g. the `agent-tool` sandbox, where an LLM
623    /// could emit `list.fold(list.range(0, 1_000_000_000), …)` to hang
624    /// the host). Default is 10_000_000.
625    pub fn set_step_limit(&mut self, limit: u64) {
626        self.step_limit = limit;
627    }
628
629    pub fn call(&mut self, name: &str, args: Vec<Value>) -> Result<Value, VmError> {
630        let fn_id = self.program.lookup(name).ok_or_else(|| VmError::Panic(format!("no function `{name}`")))?;
631        self.invoke(fn_id, args)
632    }
633
634    /// Vm-level handler for `parser.run` (#221). Routed here from
635    /// `Op::EffectCall` rather than through the `EffectHandler` so
636    /// the recursive parser interpreter has reentrant Vm access for
637    /// closure invocation. Returns the wrapped `Result[T, ParseErr]`
638    /// value the language sees.
639    fn run_parser_op(&mut self, args: Vec<Value>) -> Result<Value, String> {
640        let parser = args.first().cloned()
641            .ok_or_else(|| "parser.run: missing parser arg".to_string())?;
642        let input = match args.get(1) {
643            Some(Value::Str(s)) => s.clone(),
644            _ => return Err("parser.run: input must be Str".into()),
645        };
646        match crate::parser_runtime::run_parser(&parser, &input, 0, self) {
647            Ok((value, _pos)) => Ok(Value::Variant {
648                name: "Ok".into(),
649                args: vec![value],
650            }),
651            Err((pos, msg)) => {
652                let mut e: IndexMap<String, Value> = IndexMap::new();
653                e.insert("pos".into(), Value::Int(pos as i64));
654                e.insert("message".into(), Value::Str(msg.into()));
655                Ok(Value::Variant {
656                    name: "Err".into(),
657                    args: vec![Value::record_dynamic(e)],
658                })
659            }
660        }
661    }
662
663    // ---- Variant helpers used by conc.* registry ops (#444) ----
664    // Local helpers (avoid pulling in serde / public API). Lex's
665    // `Result`/`Option` are stdlib unions; their runtime shape is a
666    // `Value::Variant { name, args }` with the constructor name as
667    // declared (`Ok`/`Err`/`Some`/`None`).
668
669    /// VM-level handler for `conc.*` effect ops (#381).
670    ///
671    /// * `conc.spawn(init, handler)` — creates an `Actor` wrapping the
672    ///   initial state and the handler closure. No background thread is
673    ///   started; the actor runs synchronously on the calling thread
674    ///   under a `Mutex` so concurrent callers serialise.
675    ///
676    /// * `conc.ask(actor, msg)` — locks the actor, calls
677    ///   `handler(state, msg)` on *this* VM (reentrant), expects a
678    ///   2-tuple `(new_state, reply)`, updates the actor's state, and
679    ///   returns `reply`.
680    ///
681    /// * `conc.tell(actor, msg)` — same as `ask` but discards the
682    ///   reply and returns `Unit`.
683    fn run_conc_op(&mut self, op: &str, args: Vec<Value>) -> Result<Value, String> {
684        match op {
685            "spawn" => {
686                let mut it = args.into_iter();
687                let init = it.next().unwrap_or(Value::Unit);
688                let handler = it.next().unwrap_or(Value::Unit);
689                if !matches!(handler, Value::Closure { .. }) {
690                    return Err(format!(
691                        "conc.spawn: handler must be a Closure, got {handler:?}"));
692                }
693                Ok(Value::Actor(Arc::new(Mutex::new(ActorCell {
694                    state: init,
695                    handler: crate::value::ActorHandler::Lex(handler),
696                }))))
697            }
698            "ask" | "tell" => {
699                let mut it = args.into_iter();
700                let actor_val = it.next().unwrap_or(Value::Unit);
701                let msg = it.next().unwrap_or(Value::Unit);
702                let cell = match actor_val {
703                    Value::Actor(ref arc) => Arc::clone(arc),
704                    other => return Err(format!(
705                        "conc.{op}: first arg must be an Actor, got {other:?}")),
706                };
707                // Lock the actor: guarantees at-most-one-concurrent message.
708                let mut guard = cell.lock().map_err(|e| format!("conc.{op}: actor mutex poisoned: {e}"))?;
709                let handler = guard.handler.clone();
710                let state = guard.state.clone();
711                match handler {
712                    crate::value::ActorHandler::Lex(closure_val) => {
713                        // Call handler(state, msg) on this VM — full effect access.
714                        let result = self.invoke_closure_value(closure_val, vec![state, msg])
715                            .map_err(|e| format!("conc.{op}: handler error: {e:?}"))?;
716                        // #698: when `ask`/`tell` runs inside a `net.serve` worker, an
717                        // arena request-scope is active, so the handler's `(new_state,
718                        // reply)` tuple is allocated as a `Value::ArenaTuple` rather than
719                        // a heap `Value::Tuple` — and the bare match below would reject it.
720                        // Materialize arena handles into heap-owned form NOW, while the
721                        // producing scope is still active: the reply crosses back to the
722                        // caller and `new_state` persists in the actor cell beyond this
723                        // request's arena scope, so both must be heap-owned. Idempotent
724                        // (a no-op walk) when there are no arena handles, e.g. from `main`.
725                        let result = self.materialize_arena_handles(result);
726                        // Expect (new_state, reply) tuple.
727                        match result {
728                            Value::Tuple(mut parts) if parts.len() == 2 => {
729                                let reply = parts.pop().unwrap();
730                                let new_state = parts.pop().unwrap();
731                                guard.state = new_state;
732                                drop(guard);
733                                if op == "ask" { Ok(reply) } else { Ok(Value::Unit) }
734                            }
735                            other => Err(format!(
736                                "conc.{op}: handler must return a 2-tuple (new_state, reply), got {other:?}")),
737                        }
738                    }
739                    crate::value::ActorHandler::Native(native) => {
740                        // Native bridge: fire-and-forget; `state` is unused
741                        // (the bridge's "state" is the external resource, e.g.
742                        // a WebSocket connection). The closure receives `msg`
743                        // directly. `ask` returns whatever the bridge produces;
744                        // `tell` discards it. State stays untouched.
745                        drop(guard);
746                        let result = (native.send)(msg)
747                            .map_err(|e| format!("conc.{op}: native handler error: {e}"))?;
748                        if op == "ask" { Ok(result) } else { Ok(Value::Unit) }
749                    }
750                }
751            }
752            "register" => {
753                // conc.register(actor, name) -> Result[Unit, ConcError]
754                // Returns Ok(Unit) on first register, Err(AlreadyRegistered(name))
755                // if the name is taken. v1 stores the actor opaquely —
756                // see crate::conc_registry for the type-tag note.
757                let mut it = args.into_iter();
758                let actor = it.next().unwrap_or(Value::Unit);
759                if !matches!(actor, Value::Actor(_)) {
760                    return Err(format!(
761                        "conc.register: first arg must be an Actor, got {actor:?}"));
762                }
763                let name = match it.next() {
764                    Some(Value::Str(s)) => s.to_string(),
765                    other => return Err(format!(
766                        "conc.register: name must be Str, got {other:?}")),
767                };
768                Ok(match crate::conc_registry::register(&name, actor) {
769                    Ok(()) => variant_ok(Value::Unit),
770                    Err(crate::conc_registry::RegError::AlreadyRegistered(n)) => {
771                        variant_err(variant("AlreadyRegistered", vec![Value::Str(n.into())]))
772                    }
773                    Err(crate::conc_registry::RegError::NotRegistered(_)) => {
774                        unreachable!("register cannot produce NotRegistered")
775                    }
776                })
777            }
778            "lookup" => {
779                // conc.lookup(name) -> Option[Actor[S, M]]
780                // Returns Some(actor) if registered, None otherwise. The
781                // [S, M] static parametrisation at the call site is not
782                // checked at runtime in v1 — caller's responsibility to
783                // match the registration site's type.
784                let mut it = args.into_iter();
785                let name = match it.next() {
786                    Some(Value::Str(s)) => s.to_string(),
787                    other => return Err(format!(
788                        "conc.lookup: name must be Str, got {other:?}")),
789                };
790                Ok(match crate::conc_registry::lookup(&name) {
791                    Some(actor) => variant("Some", vec![actor]),
792                    None => variant("None", vec![]),
793                })
794            }
795            "unregister" => {
796                // conc.unregister(name) -> Result[Unit, ConcError]
797                let mut it = args.into_iter();
798                let name = match it.next() {
799                    Some(Value::Str(s)) => s.to_string(),
800                    other => return Err(format!(
801                        "conc.unregister: name must be Str, got {other:?}")),
802                };
803                Ok(match crate::conc_registry::unregister(&name) {
804                    Ok(()) => variant_ok(Value::Unit),
805                    Err(crate::conc_registry::RegError::NotRegistered(n)) => {
806                        variant_err(variant("NotRegistered", vec![Value::Str(n.into())]))
807                    }
808                    Err(crate::conc_registry::RegError::AlreadyRegistered(_)) => {
809                        unreachable!("unregister cannot produce AlreadyRegistered")
810                    }
811                })
812            }
813            "registered" => {
814                // conc.registered() -> List[Str] — sorted snapshot.
815                let names = crate::conc_registry::registered();
816                Ok(Value::List(names.into_iter()
817                    .map(|n| Value::Str(n.into()))
818                    .collect()))
819            }
820            other => Err(format!("unknown conc.{other}")),
821        }
822    }
823
824    /// Open a request-scoped arena via the underlying
825    /// `EffectHandler::enter_request_scope` (#463 scaffolding).
826    /// Runtime layers — `net.serve_fn`, `net.serve_ws`,
827    /// `net.serve_quic` — call this immediately before invoking the
828    /// user handler closure for a single request. Pair with
829    /// `exit_request_scope` once the response has been built and
830    /// any lazy iterators in it have been drained (#477).
831    ///
832    /// Returns the scope id the runtime should pass back to
833    /// `exit_request_scope`. The handler's default impl returns 0
834    /// and the matching `exit` is a no-op; `DefaultHandler`'s
835    /// implementation actually allocates an arena.
836    pub fn enter_request_scope(&mut self) -> u64 {
837        // #463 slice 2a: snapshot the slab high-water mark so
838        // `exit_request_scope` can truncate back to here, releasing
839        // every arena-allocated value the scope built in O(1).
840        self.arena_scope_starts.push(self.arena_slab.len() as u32);
841        self.handler.enter_request_scope()
842    }
843
844    /// True iff there is at least one active request scope — i.e. an
845    /// `enter_request_scope` not yet matched by `exit_request_scope`.
846    /// Runtime layers use this to skip `materialize_arena_handles` on
847    /// paths where no scope was entered (e.g. tiny-http worker
848    /// dispatch), keeping the no-arena path zero-cost. Slice 2b-i.
849    pub fn arena_scope_active(&self) -> bool {
850        !self.arena_scope_starts.is_empty()
851    }
852
853    /// Close the request scope opened by `enter_request_scope`.
854    /// Drops the associated arena.
855    pub fn exit_request_scope(&mut self, scope_id: u64) {
856        // #463 slice 2a: truncate the slab back to the matching
857        // `enter` snapshot, then notify the handler. Out-of-order /
858        // unpaired exits (e.g. a stray `exit` with no prior `enter`)
859        // are tolerated as no-ops — the handler does the same, and a
860        // stray exit shouldn't crash a live server.
861        if let Some(start) = self.arena_scope_starts.pop() {
862            self.arena_slab.truncate(start as usize);
863        }
864        self.handler.exit_request_scope(scope_id)
865    }
866
867    /// Deep-walk `value` and resolve every `Value::ArenaRecord` /
868    /// `Value::ArenaTuple` handle into its heap-owned equivalent
869    /// (`Value::Record` / `Value::Tuple`), reading field contents
870    /// out of `Vm::arena_slab` along the way. Primitives, closures,
871    /// maps/sets, and the host-managed handles (`Actor` / `Ticker` /
872    /// `ArrowTable`) are returned unchanged.
873    ///
874    /// **The boundary helper** flagged in
875    /// `docs/design/arena-plumbing.md` § "Arena handles MUST be
876    /// readable at serialization". Callers — the response
877    /// serialization path in `lex-runtime`, the trace recorder when
878    /// it records a Call/EffectCall arg, anywhere a value crosses
879    /// out of the VM into host-managed storage — call this
880    /// **while the producing scope is still active**, before
881    /// `exit_request_scope`. After exit the slab is truncated, so a
882    /// handle materialized after-the-fact would read garbage (or
883    /// panic on the bounds check).
884    ///
885    /// `Value::StackRecord` / `Value::StackTuple` would similarly
886    /// need slab resolution, but the #464 escape analysis prevents
887    /// them from reaching boundary-crossing ops in the first place
888    /// (they're frame-local by construction). Reaching here means a
889    /// hand-built or analysis-buggy program; we panic with the same
890    /// loud-not-silent contract the other inspection paths use.
891    ///
892    /// Idempotent on already-materialized values (no arena handles
893    /// in the tree → only the recursive walk's clones, no slab
894    /// lookups). Cost per call is one walk + clone of the tree —
895    /// amortized over the per-node mallocs avoided during request
896    /// handling, the net stays strongly positive.
897    pub fn materialize_arena_handles(&self, value: Value) -> Value {
898        use crate::value::Value as V;
899        match value {
900            // Primitives + opaque handles cross unchanged. Cheap
901            // — clones are essentially free for the Copy-ish ones
902            // and Arc-bumps for the handle types.
903            V::Int(_) | V::Float(_) | V::Bool(_) | V::Str(_) | V::Bytes(_)
904            | V::Unit | V::Closure { .. } | V::F64Array { .. }
905            | V::Map(_) | V::Set(_) | V::Actor(_) | V::Ticker(_)
906            | V::ArrowTable(_) => value,
907
908            // Containers: recurse on each element. Map/Set keys are
909            // MapKey (Str | Int), never Value, so no handles can
910            // hide there.
911            V::List(items) => V::List(
912                items.into_iter().map(|v| self.materialize_arena_handles(v)).collect()),
913            V::Tuple(items) => V::Tuple(
914                items.into_iter().map(|v| self.materialize_arena_handles(v)).collect()),
915            V::Deque(items) => V::Deque(
916                items.into_iter().map(|v| self.materialize_arena_handles(v)).collect()),
917            V::Variant { name, args } => V::Variant {
918                name,
919                args: args.into_iter().map(|v| self.materialize_arena_handles(v)).collect(),
920            },
921            V::Record { shape_id, fields } => {
922                let mut out: IndexMap<SmolStr, Value> = IndexMap::with_capacity(fields.len());
923                for (k, v) in fields.into_iter() {
924                    out.insert(k, self.materialize_arena_handles(v));
925                }
926                V::Record { shape_id, fields: Box::new(out) }
927            }
928
929            // The actual resolution work — read the slab and build a
930            // heap form. Field-name ordering for ArenaRecord matches
931            // the shape's, same as `MakeRecord`'s IndexMap insertion
932            // pattern; that's the contract that makes the polymorphic
933            // GetField IC work, and we reuse it here.
934            V::ArenaRecord { shape_id, slab_start, field_count } => {
935                let start = slab_start as usize;
936                let n = field_count as usize;
937                debug_assert!(start + n <= self.arena_slab.len(),
938                    "ArenaRecord handle out of bounds — likely materialized after exit_request_scope");
939                let shape = &self.program.record_shapes[shape_id as usize];
940                let mut fields: IndexMap<SmolStr, Value> = IndexMap::with_capacity(n);
941                for (i, name_const_idx) in shape.iter().take(n).enumerate() {
942                    let name: SmolStr = match &self.program.constants[*name_const_idx as usize] {
943                        Const::FieldName(s) => s.as_str().into(),
944                        _ => panic!("BUG(#463): ArenaRecord shape entry not a FieldName const"),
945                    };
946                    let v = self.materialize_arena_handles(self.arena_slab[start + i].clone());
947                    fields.insert(name, v);
948                }
949                V::Record { shape_id, fields: Box::new(fields) }
950            }
951            V::ArenaTuple { slab_start, arity } => {
952                let start = slab_start as usize;
953                let n = arity as usize;
954                debug_assert!(start + n <= self.arena_slab.len(),
955                    "ArenaTuple handle out of bounds — likely materialized after exit_request_scope");
956                let items: Vec<Value> = (0..n)
957                    .map(|i| self.materialize_arena_handles(self.arena_slab[start + i].clone()))
958                    .collect();
959                V::Tuple(items)
960            }
961
962            // #464 stack handles are frame-local; the analysis
963            // prevents them from reaching any boundary the
964            // materializer is called at. Reach = bug; panic loud.
965            V::StackRecord { .. } =>
966                panic!("BUG(#464/#463): Value::StackRecord reached materialize_arena_handles \
967                        — escape analysis should keep stack handles inside their frame"),
968            V::StackTuple { .. } =>
969                panic!("BUG(#464/#463): Value::StackTuple reached materialize_arena_handles \
970                        — escape analysis should keep stack handles inside their frame"),
971        }
972    }
973
974    /// Read a named field out of a record without materializing its
975    /// parent. Works uniformly on `Value::Record` (heap) and
976    /// `Value::ArenaRecord` (slab handle), so a runtime layer can
977    /// consume the response record structurally — straight out of
978    /// the arena slab — instead of paying for a tree-wide
979    /// `materialize_arena_handles` walk just to read three top-level
980    /// fields.
981    ///
982    /// Returns `None` if the value isn't a record or the field
983    /// doesn't exist. The returned `Value` is a clone of the slot
984    /// contents (records' field values can themselves be records,
985    /// variants, etc.; cloning at the boundary is unavoidable
986    /// without lifetime trickery on the public API).
987    ///
988    /// Performance: on the heap path it's a `IndexMap::get` + clone.
989    /// On the arena path it's a linear walk of the shape's
990    /// field-name vec (`field_count` long, typically ≤ 10) +
991    /// an O(1) slab index + clone. The polymorphic-IC equivalent
992    /// inside the VM is faster, but this API is for **host**
993    /// consumers, not hot-loop dispatch.
994    ///
995    /// `Value::StackRecord` is deliberately not handled — those
996    /// handles are frame-local by construction (#464 escape pass)
997    /// and shouldn't reach host boundaries; reaching them here is
998    /// a soundness bug surfaced as a panic, matching the existing
999    /// inspection-path contract.
1000    pub fn get_record_field(&self, value: &Value, name: &str) -> Option<Value> {
1001        match value {
1002            Value::Record { fields, .. } => fields.get(name).cloned(),
1003            Value::ArenaRecord { shape_id, slab_start, field_count } => {
1004                let shape = self.program.record_shapes.get(*shape_id as usize)?;
1005                let n = (*field_count as usize).min(shape.len());
1006                for (i, &name_const_idx) in shape.iter().take(n).enumerate() {
1007                    if let Const::FieldName(s) = &self.program.constants[name_const_idx as usize] {
1008                        if s == name {
1009                            return Some(self.arena_slab[*slab_start as usize + i].clone());
1010                        }
1011                    }
1012                }
1013                None
1014            }
1015            Value::StackRecord { .. } =>
1016                panic!("BUG(#464): Value::StackRecord reached Vm::get_record_field \
1017                        — frame-local handles should never reach the host boundary"),
1018            _ => None,
1019        }
1020    }
1021
1022    /// Positional read out of a tuple without materializing its
1023    /// parent. Works uniformly on `Value::Tuple` and
1024    /// `Value::ArenaTuple`. See `get_record_field` for the lifetime
1025    /// rationale.
1026    pub fn get_tuple_elem(&self, value: &Value, idx: u16) -> Option<Value> {
1027        match value {
1028            Value::Tuple(items) => items.get(idx as usize).cloned(),
1029            Value::ArenaTuple { slab_start, arity } => {
1030                if idx >= *arity { return None; }
1031                Some(self.arena_slab[*slab_start as usize + idx as usize].clone())
1032            }
1033            Value::StackTuple { .. } =>
1034                panic!("BUG(#464): Value::StackTuple reached Vm::get_tuple_elem \
1035                        — frame-local handles should never reach the host boundary"),
1036            _ => None,
1037        }
1038    }
1039
1040    /// Arena-aware `to_json` — produces a `serde_json::Value` from
1041    /// a `Value` whose tree may contain `ArenaRecord` / `ArenaTuple`
1042    /// handles, reading them straight out of `Vm::arena_slab`
1043    /// instead of materializing into a heap `Value::Record` mirror
1044    /// first.
1045    ///
1046    /// Equivalent output to `value.to_json()` on a fully-materialized
1047    /// tree (idempotent in that sense). Use this when serializing a
1048    /// handler return value to JSON for the response — saves the
1049    /// per-node IndexMap allocations the materialize-then-to_json
1050    /// pattern pays.
1051    pub fn value_to_json(&self, value: &Value) -> serde_json::Value {
1052        use serde_json::Value as J;
1053        match value {
1054            // Primitives + opaque host handles: delegate to the
1055            // existing `Value::to_json` — its output is identical
1056            // and it handles the host-handle types we don't model
1057            // (Actor / Ticker / ArrowTable / F64Array / Map / Set /
1058            // Closure / Bytes encoding) in one place.
1059            Value::Int(_) | Value::Float(_) | Value::Bool(_) | Value::Str(_)
1060            | Value::Bytes(_) | Value::Unit | Value::Closure { .. }
1061            | Value::F64Array { .. } | Value::Map(_) | Value::Set(_)
1062            | Value::Actor(_) | Value::Ticker(_) | Value::ArrowTable(_)
1063                => value.to_json(),
1064
1065            Value::List(items) => J::Array(items.iter().map(|v| self.value_to_json(v)).collect()),
1066            Value::Tuple(items) => J::Array(items.iter().map(|v| self.value_to_json(v)).collect()),
1067            Value::Deque(items) => J::Array(items.iter().map(|v| self.value_to_json(v)).collect()),
1068            Value::Variant { name, args } => {
1069                let mut m = serde_json::Map::new();
1070                m.insert("$variant".into(), J::String(name.clone()));
1071                m.insert("args".into(),
1072                    J::Array(args.iter().map(|v| self.value_to_json(v)).collect()));
1073                J::Object(m)
1074            }
1075            Value::Record { fields, .. } => {
1076                let mut m = serde_json::Map::new();
1077                for (k, v) in fields.iter() {
1078                    m.insert(k.to_string(), self.value_to_json(v));
1079                }
1080                J::Object(m)
1081            }
1082
1083            // Slab-direct: read the cells in shape order, emit a
1084            // JSON object using the shape's field names. The cost
1085            // delta vs the `Value::to_json` materialize-then-walk
1086            // path is the saved `Box<IndexMap>` allocation +
1087            // insertion + drop.
1088            Value::ArenaRecord { shape_id, slab_start, field_count } => {
1089                let shape = match self.program.record_shapes.get(*shape_id as usize) {
1090                    Some(s) => s,
1091                    None => return J::Null,
1092                };
1093                let n = (*field_count as usize).min(shape.len());
1094                let mut m = serde_json::Map::with_capacity(n);
1095                for (i, &name_const_idx) in shape.iter().take(n).enumerate() {
1096                    let name = match &self.program.constants[name_const_idx as usize] {
1097                        Const::FieldName(s) => s.to_string(),
1098                        _ => continue,
1099                    };
1100                    let cell = &self.arena_slab[*slab_start as usize + i];
1101                    m.insert(name, self.value_to_json(cell));
1102                }
1103                J::Object(m)
1104            }
1105            Value::ArenaTuple { slab_start, arity } => {
1106                let start = *slab_start as usize;
1107                let n = *arity as usize;
1108                let items: Vec<serde_json::Value> = (0..n)
1109                    .map(|i| self.value_to_json(&self.arena_slab[start + i]))
1110                    .collect();
1111                J::Array(items)
1112            }
1113
1114            // Stack handles must not reach the host — same defensive
1115            // panic as the other inspection paths.
1116            Value::StackRecord { .. } =>
1117                panic!("BUG(#464): Value::StackRecord reached Vm::value_to_json \
1118                        — frame-local handles should never reach the host boundary"),
1119            Value::StackTuple { .. } =>
1120                panic!("BUG(#464): Value::StackTuple reached Vm::value_to_json \
1121                        — frame-local handles should never reach the host boundary"),
1122        }
1123    }
1124
1125    pub fn invoke(&mut self, fn_id: u32, args: Vec<Value>) -> Result<Value, VmError> {
1126        let f = &self.program.functions[fn_id as usize];
1127        if args.len() != f.arity as usize {
1128            return Err(VmError::Panic(format!("arity mismatch calling {}", f.name)));
1129        }
1130        // Refinement runtime check at the public entry point too
1131        // (#209 slice 3). `Op::Call` checks for in-program calls;
1132        // this branch covers `vm.call("entry", ...)` from the host
1133        // and the reentrant `invoke_closure_value` path. Same
1134        // semantics, same error shape.
1135        //
1136        // Iterate `f.refinements` by reference — the loop body
1137        // only reads from `self.program` (via `r`) and from locals,
1138        // so we don't need to clone the Vec to detach it from
1139        // `&self`. The function name is cloned **lazily**, only on
1140        // the failure path: functions with no refinements (the common
1141        // case) never enter the loop, so the per-call `f.name.clone()`
1142        // was pure waste on the hot path (#464 call-overhead).
1143        for (i, refinement) in f.refinements.iter().enumerate() {
1144            if let Some(r) = refinement {
1145                let arg = args.get(i).cloned().unwrap_or(Value::Unit);
1146                match eval_refinement(&r.predicate, &r.binding, &arg) {
1147                    Ok(true) => {}
1148                    Ok(false) => return Err(VmError::RefinementFailed {
1149                        fn_name: f.name.clone(),
1150                        param_index: i,
1151                        binding: r.binding.clone(),
1152                        reason: format!("predicate failed for {} = {arg:?}", r.binding),
1153                    }),
1154                    Err(reason) => return Err(VmError::RefinementFailed {
1155                        fn_name: f.name.clone(),
1156                        param_index: i,
1157                        binding: r.binding.clone(),
1158                        reason,
1159                    }),
1160                }
1161            }
1162        }
1163        // #465 JIT tier hook at the public entry — same contract as
1164        // the `Op::Call` dispatch arm. Pure-fn memo is not consulted
1165        // at this layer (memo is per-Op::Call); the hook fires
1166        // unconditionally for refinement-clean calls. Pass the step
1167        // counter + limit so JITed loops can account against the
1168        // VM's DoS guard (architectural fix; see jit_hook.rs).
1169        if let Some(mut hook) = self.jit_hook.take() {
1170            let step_ptr = &mut self.steps as *mut u64;
1171            let limit = self.step_limit;
1172            let hook_result = hook.try_call(fn_id, &args, step_ptr, limit);
1173            self.jit_hook = Some(hook);
1174            if let Some(result) = hook_result? {
1175                return Ok(result);
1176            }
1177        }
1178        let f = &self.program.functions[fn_id as usize];
1179        // Claim slots from the locals stack allocator (#389 slice 3).
1180        let locals_start = self.locals_storage.len();
1181        let locals_len = f.locals_count.max(f.arity) as usize;
1182        self.locals_storage.resize(locals_start + locals_len, Value::Unit);
1183        for (i, v) in args.into_iter().enumerate() {
1184            self.locals_storage[locals_start + i] = v;
1185        }
1186        // Record the depth before pushing — this is what `run` will
1187        // exit at, supporting reentrant invocation from inside the
1188        // VM (e.g. the parser interpreter calling closures, #221).
1189        let base_depth = self.frames.len();
1190        self.push_frame(Frame {
1191            fn_id, pc: 0, locals_start, locals_len,
1192            stack_base: self.stack.len(),
1193            trace_kind: FrameKind::Entry,
1194            memo_key: None,
1195            stack_record_arena_start: self.stack_record_arena.len(),
1196            stack_record_budget_remaining: STACK_RECORD_BUDGET_SLOTS,
1197        })?;
1198        self.run_to(base_depth)
1199    }
1200
1201    /// All call-frame pushes funnel through here so the depth
1202    /// check can't be skipped by a missing branch. Returns
1203    /// `CallStackOverflow` instead of letting recursion blow the
1204    /// host's native stack.
1205    fn push_frame(&mut self, frame: Frame) -> Result<(), VmError> {
1206        if self.frames.len() as u32 >= MAX_CALL_DEPTH {
1207            return Err(VmError::CallStackOverflow(MAX_CALL_DEPTH));
1208        }
1209        self.frames.push(frame);
1210        Ok(())
1211    }
1212
1213}
1214
1215impl Drop for Vm<'_> {
1216    fn drop(&mut self) {
1217        if ic_stats_enabled() {
1218            dump_ic_stats();
1219        }
1220    }
1221}
1222
1223/// Construct a `Value::Variant` with the given name and args.
1224/// Used by `conc.*` registry ops to return `Result`/`Option`/`ConcError`
1225/// values without hand-writing the struct literal at every site.
1226fn variant(name: &str, args: Vec<Value>) -> Value {
1227    Value::Variant { name: name.to_string(), args }
1228}
1229fn variant_ok(payload: Value) -> Value { variant("Ok", vec![payload]) }
1230fn variant_err(payload: Value) -> Value { variant("Err", vec![payload]) }
1231
1232fn const_to_value(c: &Const) -> Value {
1233    match c {
1234        Const::Int(n) => Value::Int(*n),
1235        Const::Float(f) => Value::Float(*f),
1236        Const::Bool(b) => Value::Bool(*b),
1237        Const::Str(s) => Value::Str(s.as_str().into()),
1238        Const::Bytes(b) => Value::Bytes(b.clone()),
1239        Const::Unit => Value::Unit,
1240        Const::FieldName(s) | Const::VariantName(s) | Const::NodeId(s) => Value::Str(s.as_str().into()),
1241    }
1242}