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