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