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