Skip to main content

lex_bytecode/
vm.rs

1//! M5: bytecode VM. Stack machine with effect dispatch through a host handler.
2
3use crate::op::*;
4use crate::program::*;
5use crate::value::Value;
6use indexmap::IndexMap;
7
8#[derive(Debug, Clone, thiserror::Error)]
9pub enum VmError {
10    #[error("runtime panic: {0}")]
11    Panic(String),
12    #[error("type mismatch at runtime: {0}")]
13    TypeMismatch(String),
14    #[error("stack underflow")]
15    StackUnderflow,
16    #[error("unknown function: {0}")]
17    UnknownFunction(String),
18    #[error("effect handler error: {0}")]
19    Effect(String),
20    #[error("call stack overflow: recursion depth exceeded ({0})")]
21    CallStackOverflow(u32),
22    /// Refinement predicate failed at a call boundary (#209 slice 3).
23    /// Surfaced when a function declares `param :: Type{x | predicate}`,
24    /// the call-site arg couldn't be discharged statically (slice 2),
25    /// and the runtime evaluator finds the predicate is `false` for
26    /// the actual argument value. The `verdict` mirrors the shape of
27    /// `gate.verdict`-style records in `lex-trace`.
28    #[error("refinement violated: argument {param_index} of `{fn_name}` (binding `{binding}`): {reason}")]
29    RefinementFailed {
30        fn_name: String,
31        param_index: usize,
32        binding: String,
33        reason: String,
34    },
35}
36
37/// Maximum simultaneous call frames. Defends against unbounded
38/// recursion in agent-emitted code: a body that calls itself
39/// without a base case would otherwise blow the host's native
40/// stack and crash the process. Real Lex code rarely exceeds
41/// ~30 frames; 1024 is generous headroom while still well under
42/// the OS stack limit at any per-frame size we use.
43pub const MAX_CALL_DEPTH: u32 = 1024;
44
45/// Host-side effect dispatch. Implementors decide what `kind`/`op` mean
46/// and how arguments map to side effects.
47pub trait EffectHandler {
48    fn dispatch(&mut self, kind: &str, op: &str, args: Vec<Value>) -> Result<Value, String>;
49
50    /// Hook called by the VM at every function call so handlers can
51    /// enforce per-call budget consumption (#225). The argument is
52    /// the sum of `[budget(N)]` declared on the callee's signature;
53    /// the handler returns `Err` to refuse the call (the VM converts
54    /// to `VmError::Effect`). Default impl is a no-op so legacy
55    /// handlers and pure-only runs are unaffected.
56    fn note_call_budget(&mut self, _budget_cost: u64) -> Result<(), String> {
57        Ok(())
58    }
59
60    /// `list.par_map` worker-handler factory (#305 slice 2).
61    ///
62    /// Each parallel worker thread runs its own `Vm` and therefore
63    /// needs its own effect handler. The parent handler may opt in
64    /// to per-worker dispatch by returning `Some(handler)` here;
65    /// returning `None` (the default) keeps slice-1 behavior: the
66    /// worker runs `DenyAllEffects` and any effect call inside the
67    /// closure fails with `VmError::Effect`.
68    ///
69    /// The returned handler must be `Send` so the worker can take
70    /// ownership across a thread boundary. Shared state (budget
71    /// pool, chat registry, etc.) is wired up by the implementer.
72    /// Per-worker independence (MCP client cache, output sink)
73    /// is intentional — the alternative is mutex-serialization of
74    /// the whole effect dispatch, which would defeat the parallelism.
75    fn spawn_for_worker(&self) -> Option<Box<dyn EffectHandler + Send>> {
76        None
77    }
78}
79
80/// `Vm` exposes itself as a `ClosureCaller` so the parser interpreter
81/// can invoke user-supplied closures during a `parser.run` walk
82/// (#221). The Vm is reentrant for closure invocation: pushing a new
83/// frame onto an active call stack is supported, and the handler
84/// stays in place so any effects the closure body fires dispatch
85/// normally.
86impl<'a> crate::parser_runtime::ClosureCaller for Vm<'a> {
87    fn call_closure(&mut self, closure: Value, args: Vec<Value>) -> Result<Value, String> {
88        self.invoke_closure_value(closure, args)
89            .map_err(|e| format!("{e:?}"))
90    }
91}
92
93/// A handler that fails any effect call. Useful as a default for pure-only runs.
94pub struct DenyAllEffects;
95impl EffectHandler for DenyAllEffects {
96    fn dispatch(&mut self, kind: &str, op: &str, _args: Vec<Value>) -> Result<Value, String> {
97        Err(format!("effects not permitted (attempted {kind}.{op})"))
98    }
99}
100
101/// Trace receiver. Implementors record the call/effect tree and may
102/// substitute effect responses (for replay).
103pub trait Tracer {
104    fn enter_call(&mut self, node_id: &str, name: &str, args: &[Value]);
105    fn enter_effect(&mut self, node_id: &str, kind: &str, op: &str, args: &[Value]);
106    fn exit_ok(&mut self, value: &Value);
107    fn exit_err(&mut self, message: &str);
108    /// Tail-call optimization: pop the current frame's open call without
109    /// re-entering the parent (the new call takes its place).
110    fn exit_call_tail(&mut self);
111    /// During replay, return Some(v) to substitute an effect's output.
112    fn override_effect(&mut self, _node_id: &str) -> Option<Value> { None }
113}
114
115/// No-op tracer for normal execution.
116pub struct NullTracer;
117impl Tracer for NullTracer {
118    fn enter_call(&mut self, _: &str, _: &str, _: &[Value]) {}
119    fn enter_effect(&mut self, _: &str, _: &str, _: &str, _: &[Value]) {}
120    fn exit_ok(&mut self, _: &Value) {}
121    fn exit_err(&mut self, _: &str) {}
122    fn exit_call_tail(&mut self) {}
123}
124
125#[derive(Debug, Clone)]
126pub(crate) enum FrameKind {
127    /// Top-level entry frame; doesn't correspond to a Call opcode.
128    Entry,
129    /// Frame opened by Call/TailCall. The `String` is the originating
130    /// `NodeId`; useful for diagnostics even if currently unread.
131    Call(#[allow(dead_code)] String),
132}
133
134pub struct Vm<'a> {
135    program: &'a Program,
136    handler: Box<dyn EffectHandler + 'a>,
137    pub(crate) tracer: Box<dyn Tracer + 'a>,
138    /// Per-call frames. Each frame has its own locals array and pc.
139    frames: Vec<Frame>,
140    stack: Vec<Value>,
141    /// Soft cap to avoid runaway computations in tests.
142    pub step_limit: u64,
143    pub steps: u64,
144    /// Per-Vm memoization cache for pure functions (#229). Keyed by
145    /// `(fn_id, sha256(canonical_json(args))[..16])`. Effectful
146    /// functions never enter this map. The cache lives for the
147    /// lifetime of one `Vm::call` chain — calling `Vm::with_handler`
148    /// again starts a fresh cache.
149    pure_memo: std::collections::HashMap<(u32, [u8; 16]), Value>,
150    /// Diagnostic counters for `--trace` observability (#229).
151    pub pure_memo_hits: u64,
152    pub pure_memo_misses: u64,
153}
154
155struct Frame {
156    fn_id: u32,
157    pc: usize,
158    locals: Vec<Value>,
159    /// Stack base when this frame started (for cleanup on return).
160    stack_base: usize,
161    trace_kind: FrameKind,
162    /// Pure-fn memo key (#229). `Some(key)` if the call was eligible
163    /// for memoization and missed the cache; on Op::Return the key
164    /// is used to write the return value back into the cache.
165    /// `None` means "don't memoize" — either the function isn't pure,
166    /// the call wasn't through Op::Call, or memoization is disabled.
167    memo_key: Option<(u32, [u8; 16])>,
168}
169
170/// Sum of `[budget(N)]` declarations on a function's signature
171/// (#225). Used by Op::Call / Op::TailCall / Op::CallClosure to
172/// notify the EffectHandler of per-call budget cost so the handler
173/// can deduct from a shared pool and refuse calls that would
174/// exceed the policy ceiling. Negative `Int` args are ignored —
175/// the static check (`policy::check_program`) treats budgets as
176/// non-negative.
177fn call_budget_cost(f: &crate::program::Function) -> u64 {
178    let mut total: u64 = 0;
179    for e in &f.effects {
180        if e.kind == "budget" {
181            if let Some(crate::program::EffectArg::Int(n)) = &e.arg {
182                if *n >= 0 {
183                    total = total.saturating_add(*n as u64);
184                }
185            }
186        }
187    }
188    total
189}
190
191/// Hash the argument list for a pure-fn memoization lookup (#229).
192/// Routes through the canonical-JSON path so two semantically-equal
193/// argument lists produce the same hash regardless of how the
194/// containing `Value`s were assembled (Records use IndexMap so field
195/// order is already stable, but canon_json gives the same property
196/// for the inner serde_json shape).
197fn hash_call_args(args: &[Value]) -> [u8; 16] {
198    use sha2::{Digest, Sha256};
199    let json = serde_json::Value::Array(args.iter().map(Value::to_json).collect());
200    let canonical = lex_ast::canon_json::hash_canonical(&json);
201    let mut hasher = Sha256::new();
202    hasher.update(canonical);
203    let full = hasher.finalize();
204    let mut out = [0u8; 16];
205    out.copy_from_slice(&full[..16]);
206    out
207}
208
209/// Evaluate a refinement predicate at runtime against the actual
210/// argument value (#209 slice 3). Mirrors `lex_types::discharge`'s
211/// static evaluator but operates on `Value` directly.
212///
213/// Returns `Ok(true)` / `Ok(false)` for a clean boolean verdict, or
214/// `Err(reason)` if the predicate references something the runtime
215/// can't resolve (free variable beyond the binding, unsupported AST
216/// node). Callers map `Ok(false)` and `Err` to `VmError::RefinementFailed`.
217fn eval_refinement(
218    predicate: &lex_ast::CExpr,
219    binding: &str,
220    arg: &Value,
221) -> Result<bool, String> {
222    match eval_refinement_inner(predicate, binding, arg) {
223        Ok(Value::Bool(b)) => Ok(b),
224        Ok(other) => Err(format!("predicate didn't reduce to a Bool, got {other:?}")),
225        Err(e) => Err(e),
226    }
227}
228
229fn eval_refinement_inner(
230    e: &lex_ast::CExpr,
231    binding: &str,
232    arg: &Value,
233) -> Result<Value, String> {
234    use lex_ast::{CExpr, CLit};
235    match e {
236        CExpr::Literal { value } => Ok(match value {
237            CLit::Int { value } => Value::Int(*value),
238            CLit::Float { value } => Value::Float(value.parse().unwrap_or(0.0)),
239            CLit::Bool { value } => Value::Bool(*value),
240            CLit::Str { value } => Value::Str(value.clone()),
241            CLit::Bytes { value } => Value::Str(value.clone()), // hex; unusual in predicates
242            CLit::Unit => Value::Unit,
243        }),
244        CExpr::Var { name } if name == binding => Ok(arg.clone()),
245        CExpr::Var { name } => Err(format!(
246            "predicate references free var `{name}`; runtime check \
247             only resolves the binding (slice 4 will plumb call-site \
248             context)")),
249        CExpr::UnaryOp { op, expr } => {
250            let v = eval_refinement_inner(expr, binding, arg)?;
251            match (op.as_str(), v) {
252                ("not", Value::Bool(b)) => Ok(Value::Bool(!b)),
253                ("-", Value::Int(n)) => Ok(Value::Int(-n)),
254                ("-", Value::Float(n)) => Ok(Value::Float(-n)),
255                (o, v) => Err(format!("unsupported unary `{o}` on {v:?}")),
256            }
257        }
258        CExpr::BinOp { op, lhs, rhs } => {
259            // Short-circuit `and` / `or` for the same reasons as the
260            // static evaluator.
261            if op == "and" || op == "or" {
262                let l = eval_refinement_inner(lhs, binding, arg)?;
263                let lb = match l {
264                    Value::Bool(b) => b,
265                    other => return Err(format!("`{op}` on non-bool: {other:?}")),
266                };
267                if op == "and" && !lb { return Ok(Value::Bool(false)); }
268                if op == "or"  &&  lb { return Ok(Value::Bool(true));  }
269                let r = eval_refinement_inner(rhs, binding, arg)?;
270                return match r {
271                    Value::Bool(b) => Ok(Value::Bool(b)),
272                    other => Err(format!("`{op}` on non-bool: {other:?}")),
273                };
274            }
275            let l = eval_refinement_inner(lhs, binding, arg)?;
276            let r = eval_refinement_inner(rhs, binding, arg)?;
277            apply_refinement_binop(op, &l, &r)
278        }
279        // Other AST forms (Call, Let, Match, FieldAccess, Lambda,
280        // Block, Constructors, Records, Tuples, Lists, Return) need
281        // a more general evaluator that can call back into the VM.
282        // Out of scope for slice 3; a future slice may unify this
283        // with the spec-checker's gate evaluator.
284        other => Err(format!("unsupported predicate node: {other:?}")),
285    }
286}
287
288fn apply_refinement_binop(op: &str, l: &Value, r: &Value) -> Result<Value, String> {
289    use Value::*;
290    match (op, l, r) {
291        ("+", Int(a), Int(b)) => Ok(Int(a + b)),
292        ("-", Int(a), Int(b)) => Ok(Int(a - b)),
293        ("*", Int(a), Int(b)) => Ok(Int(a * b)),
294        ("/", Int(a), Int(b)) if *b != 0 => Ok(Int(a / b)),
295        ("%", Int(a), Int(b)) if *b != 0 => Ok(Int(a % b)),
296        ("+", Float(a), Float(b)) => Ok(Float(a + b)),
297        ("-", Float(a), Float(b)) => Ok(Float(a - b)),
298        ("*", Float(a), Float(b)) => Ok(Float(a * b)),
299        ("/", Float(a), Float(b)) => Ok(Float(a / b)),
300
301        ("==", a, b) => Ok(Bool(a == b)),
302        ("!=", a, b) => Ok(Bool(a != b)),
303
304        ("<",  Int(a), Int(b)) => Ok(Bool(a < b)),
305        ("<=", Int(a), Int(b)) => Ok(Bool(a <= b)),
306        (">",  Int(a), Int(b)) => Ok(Bool(a > b)),
307        (">=", Int(a), Int(b)) => Ok(Bool(a >= b)),
308
309        ("<",  Float(a), Float(b)) => Ok(Bool(a < b)),
310        ("<=", Float(a), Float(b)) => Ok(Bool(a <= b)),
311        (">",  Float(a), Float(b)) => Ok(Bool(a > b)),
312        (">=", Float(a), Float(b)) => Ok(Bool(a >= b)),
313
314        (op, a, b) => Err(format!(
315            "unsupported binop `{op}` on {a:?} and {b:?}")),
316    }
317}
318
319fn const_str(constants: &[Const], idx: u32) -> String {
320    match constants.get(idx as usize) {
321        Some(Const::NodeId(s)) | Some(Const::Str(s)) => s.clone(),
322        _ => String::new(),
323    }
324}
325
326/// Read `LEX_PAR_MAX_CONCURRENCY` (default = available CPU cores,
327/// fallback 4). Capped at 64 so a malformed env var can't spawn an
328/// unreasonable number of OS threads.
329/// Order-defining comparator for `list.sort_by` keys (#338).
330/// Same-typed Int / Float / Str pairs compare via their native
331/// `Ord` / `PartialOrd`. Mixed-type or other key shapes compare
332/// as Equal; combined with `Vec::sort_by`'s stability that
333/// preserves the original element order — best-effort fallback
334/// that never panics.
335fn compare_sort_keys(a: &Value, b: &Value) -> std::cmp::Ordering {
336    use std::cmp::Ordering;
337    match (a, b) {
338        (Value::Int(x), Value::Int(y)) => x.cmp(y),
339        (Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
340        (Value::Str(x), Value::Str(y)) => x.cmp(y),
341        _ => Ordering::Equal,
342    }
343}
344
345fn par_max_concurrency() -> usize {
346    let from_env = std::env::var("LEX_PAR_MAX_CONCURRENCY")
347        .ok()
348        .and_then(|s| s.parse::<usize>().ok())
349        .filter(|n| *n > 0);
350    let default = std::thread::available_parallelism()
351        .map(|n| n.get())
352        .unwrap_or(4);
353    from_env.unwrap_or(default).min(64)
354}
355
356/// `list.par_map`'s runtime: spawn OS threads (capped by
357/// `LEX_PAR_MAX_CONCURRENCY`), apply `closure` to each item, return
358/// results in input order. Each worker runs a fresh `Vm` with
359/// [`DenyAllEffects`] for #305 slice 1 — effectful closures fail
360/// with `VmError::Effect`. Slice 2 will plumb a per-thread effect
361/// handler split.
362fn par_map_run<'a>(
363    program: &'a Program,
364    closure: Value,
365    items: Vec<Value>,
366    worker_handlers: Vec<Box<dyn EffectHandler + Send>>,
367) -> Result<Vec<Value>, VmError> {
368    if items.is_empty() {
369        return Ok(Vec::new());
370    }
371    let n_workers = worker_handlers.len().min(items.len()).max(1);
372    // Carve items into `n_workers` round-robin buckets so each
373    // worker processes (indices, items) pairs and we can reassemble
374    // in input order.
375    let mut buckets: Vec<Vec<(usize, Value)>> = (0..n_workers).map(|_| Vec::new()).collect();
376    for (i, v) in items.into_iter().enumerate() {
377        buckets[i % n_workers].push((i, v));
378    }
379    let n_total: usize = buckets.iter().map(|b| b.len()).sum();
380    let results: std::sync::Mutex<Vec<Option<Result<Value, String>>>> =
381        std::sync::Mutex::new((0..n_total).map(|_| None).collect());
382
383    // Pair each bucket with its pre-built handler so workers own
384    // their handler outright — no shared mutable state across
385    // worker threads.
386    let mut worker_handlers = worker_handlers;
387    worker_handlers.truncate(n_workers);
388    type Pair = (Vec<(usize, Value)>, Box<dyn EffectHandler + Send>);
389    let pairs: Vec<Pair> = buckets.into_iter().zip(worker_handlers).collect();
390
391    std::thread::scope(|s| {
392        let mut handles = Vec::with_capacity(pairs.len());
393        for (bucket, handler) in pairs {
394            let closure = closure.clone();
395            let results = &results;
396            handles.push(s.spawn(move || {
397                // `Box<dyn EffectHandler + Send>` has implicit
398                // `+ 'static`; that coerces to `+ 'a` because
399                // `'static` outlives any `'a`. The `Send` bound is
400                // auto-erased on the unsize coercion.
401                let handler_for_vm: Box<dyn EffectHandler + 'a> = handler;
402                let mut vm = Vm::with_handler(program, handler_for_vm);
403                for (idx, item) in bucket {
404                    let r = vm
405                        .invoke_closure_value(closure.clone(), vec![item])
406                        .map_err(|e| format!("{e:?}"));
407                    results.lock().unwrap()[idx] = Some(r);
408                }
409            }));
410        }
411        for h in handles {
412            h.join().map_err(|_| ()).ok();
413        }
414    });
415
416    let mut out = Vec::with_capacity(n_total);
417    let inner = results.into_inner().unwrap();
418    for r in inner {
419        match r {
420            Some(Ok(v)) => out.push(v),
421            Some(Err(e)) => return Err(VmError::Effect(format!("par_map worker: {e}"))),
422            None => return Err(VmError::Panic("par_map worker did not produce a result".into())),
423        }
424    }
425    Ok(out)
426}
427
428impl<'a> Vm<'a> {
429    pub fn new(program: &'a Program) -> Self {
430        Self::with_handler(program, Box::new(DenyAllEffects))
431    }
432
433    pub fn with_handler(program: &'a Program, handler: Box<dyn EffectHandler + 'a>) -> Self {
434        Self {
435            program,
436            handler,
437            tracer: Box::new(NullTracer),
438            frames: Vec::new(),
439            stack: Vec::new(),
440            step_limit: 10_000_000,
441            steps: 0,
442            pure_memo: std::collections::HashMap::new(),
443            pure_memo_hits: 0,
444            pure_memo_misses: 0,
445        }
446    }
447
448    pub fn set_tracer(&mut self, tracer: Box<dyn Tracer + 'a>) {
449        self.tracer = tracer;
450    }
451
452    /// Cap the number of opcode dispatches before the VM aborts with
453    /// `step limit exceeded`. Useful as a runtime DoS guard against
454    /// untrusted code (e.g. the `agent-tool` sandbox, where an LLM
455    /// could emit `list.fold(list.range(0, 1_000_000_000), …)` to hang
456    /// the host). Default is 10_000_000.
457    pub fn set_step_limit(&mut self, limit: u64) {
458        self.step_limit = limit;
459    }
460
461    pub fn call(&mut self, name: &str, args: Vec<Value>) -> Result<Value, VmError> {
462        let fn_id = self.program.lookup(name).ok_or_else(|| VmError::Panic(format!("no function `{name}`")))?;
463        self.invoke(fn_id, args)
464    }
465
466    /// Vm-level handler for `parser.run` (#221). Routed here from
467    /// `Op::EffectCall` rather than through the `EffectHandler` so
468    /// the recursive parser interpreter has reentrant Vm access for
469    /// closure invocation. Returns the wrapped `Result[T, ParseErr]`
470    /// value the language sees.
471    fn run_parser_op(&mut self, args: Vec<Value>) -> Result<Value, String> {
472        let parser = args.first().cloned()
473            .ok_or_else(|| "parser.run: missing parser arg".to_string())?;
474        let input = match args.get(1) {
475            Some(Value::Str(s)) => s.clone(),
476            _ => return Err("parser.run: input must be Str".into()),
477        };
478        match crate::parser_runtime::run_parser(&parser, &input, 0, self) {
479            Ok((value, _pos)) => Ok(Value::Variant {
480                name: "Ok".into(),
481                args: vec![value],
482            }),
483            Err((pos, msg)) => {
484                let mut e = indexmap::IndexMap::new();
485                e.insert("pos".into(), Value::Int(pos as i64));
486                e.insert("message".into(), Value::Str(msg));
487                Ok(Value::Variant {
488                    name: "Err".into(),
489                    args: vec![Value::Record(e)],
490                })
491            }
492        }
493    }
494
495    /// Invoke a `Value::Closure` by combining its captures with the
496    /// supplied call args and dispatching to the underlying function.
497    /// Used by the parser interpreter (#221) to call user-supplied
498    /// `f` arguments inside `parser.map` / `parser.and_then` nodes.
499    pub fn invoke_closure_value(
500        &mut self,
501        closure: Value,
502        args: Vec<Value>,
503    ) -> Result<Value, VmError> {
504        let (fn_id, captures) = match closure {
505            Value::Closure { fn_id, captures, .. } => (fn_id, captures),
506            other => return Err(VmError::TypeMismatch(
507                format!("invoke_closure_value: not a closure: {other:?}"))),
508        };
509        let mut combined = captures;
510        combined.extend(args);
511        self.invoke(fn_id, combined)
512    }
513
514    pub fn invoke(&mut self, fn_id: u32, args: Vec<Value>) -> Result<Value, VmError> {
515        let f = &self.program.functions[fn_id as usize];
516        if args.len() != f.arity as usize {
517            return Err(VmError::Panic(format!("arity mismatch calling {}", f.name)));
518        }
519        // Refinement runtime check at the public entry point too
520        // (#209 slice 3). `Op::Call` checks for in-program calls;
521        // this branch covers `vm.call("entry", ...)` from the host
522        // and the reentrant `invoke_closure_value` path. Same
523        // semantics, same error shape.
524        let f_name = f.name.clone();
525        let refinements = f.refinements.clone();
526        for (i, refinement) in refinements.iter().enumerate() {
527            if let Some(r) = refinement {
528                let arg = args.get(i).cloned().unwrap_or(Value::Unit);
529                match eval_refinement(&r.predicate, &r.binding, &arg) {
530                    Ok(true) => {}
531                    Ok(false) => return Err(VmError::RefinementFailed {
532                        fn_name: f_name,
533                        param_index: i,
534                        binding: r.binding.clone(),
535                        reason: format!("predicate failed for {} = {arg:?}", r.binding),
536                    }),
537                    Err(reason) => return Err(VmError::RefinementFailed {
538                        fn_name: f_name,
539                        param_index: i,
540                        binding: r.binding.clone(),
541                        reason,
542                    }),
543                }
544            }
545        }
546        let f = &self.program.functions[fn_id as usize];
547        let mut locals = vec![Value::Unit; f.locals_count.max(f.arity) as usize];
548        for (i, v) in args.into_iter().enumerate() { locals[i] = v; }
549        // Record the depth before pushing — this is what `run` will
550        // exit at, supporting reentrant invocation from inside the
551        // VM (e.g. the parser interpreter calling closures, #221).
552        let base_depth = self.frames.len();
553        self.push_frame(Frame {
554            fn_id, pc: 0, locals, stack_base: self.stack.len(),
555            trace_kind: FrameKind::Entry,
556            memo_key: None,
557        })?;
558        self.run_to(base_depth)
559    }
560
561    /// All call-frame pushes funnel through here so the depth
562    /// check can't be skipped by a missing branch. Returns
563    /// `CallStackOverflow` instead of letting recursion blow the
564    /// host's native stack.
565    fn push_frame(&mut self, frame: Frame) -> Result<(), VmError> {
566        if self.frames.len() as u32 >= MAX_CALL_DEPTH {
567            return Err(VmError::CallStackOverflow(MAX_CALL_DEPTH));
568        }
569        self.frames.push(frame);
570        Ok(())
571    }
572
573    /// Run until the frame stack drops to `base_depth`. Required for
574    /// reentrant invocation: a `Vm::invoke` call from inside an
575    /// already-running `run()` must return when *its* frame returns,
576    /// not when the entire frame stack empties (#221).
577    fn run_to(&mut self, base_depth: usize) -> Result<Value, VmError> {
578        loop {
579            if self.steps > self.step_limit {
580                let frame_idx = self.frames.len() - 1;
581                let fn_id = self.frames[frame_idx].fn_id;
582                let fn_name = &self.program.functions[fn_id as usize].name;
583                return Err(VmError::Panic(format!(
584                    "step limit exceeded in `{fn_name}` ({} > {})",
585                    self.steps, self.step_limit,
586                )));
587            }
588            self.steps += 1;
589            let frame_idx = self.frames.len() - 1;
590            let pc = self.frames[frame_idx].pc;
591            let fn_id = self.frames[frame_idx].fn_id;
592            let code = &self.program.functions[fn_id as usize].code;
593            if pc >= code.len() {
594                let fn_name = &self.program.functions[fn_id as usize].name;
595                return Err(VmError::Panic(format!("ran past end of code in `{fn_name}`")));
596            }
597            let op = code[pc].clone();
598            self.frames[frame_idx].pc = pc + 1;
599
600            match op {
601                Op::PushConst(i) => {
602                    let c = &self.program.constants[i as usize];
603                    self.stack.push(const_to_value(c));
604                }
605                Op::Pop => { self.pop()?; }
606                Op::Dup => {
607                    let v = self.peek()?.clone();
608                    self.stack.push(v);
609                }
610                Op::LoadLocal(i) => {
611                    let v = self.frames[frame_idx].locals[i as usize].clone();
612                    self.stack.push(v);
613                }
614                Op::StoreLocal(i) => {
615                    let v = self.pop()?;
616                    self.frames[frame_idx].locals[i as usize] = v;
617                }
618                Op::MakeRecord { field_name_indices } => {
619                    let n = field_name_indices.len();
620                    let mut values: Vec<Value> = (0..n).map(|_| Value::Unit).collect();
621                    for i in (0..n).rev() {
622                        values[i] = self.pop()?;
623                    }
624                    let mut rec = IndexMap::new();
625                    for (i, val) in values.into_iter().enumerate() {
626                        let name = match &self.program.constants[field_name_indices[i] as usize] {
627                            Const::FieldName(s) => s.clone(),
628                            _ => return Err(VmError::TypeMismatch("expected FieldName const".into())),
629                        };
630                        rec.insert(name, val);
631                    }
632                    self.stack.push(Value::Record(rec));
633                }
634                Op::MakeTuple(n) => {
635                    let mut items: Vec<Value> = (0..n).map(|_| Value::Unit).collect();
636                    for i in (0..n as usize).rev() { items[i] = self.pop()?; }
637                    self.stack.push(Value::Tuple(items));
638                }
639                Op::MakeList(n) => {
640                    let mut items: Vec<Value> = (0..n).map(|_| Value::Unit).collect();
641                    for i in (0..n as usize).rev() { items[i] = self.pop()?; }
642                    self.stack.push(Value::List(items));
643                }
644                Op::MakeVariant { name_idx, arity } => {
645                    let mut args: Vec<Value> = (0..arity).map(|_| Value::Unit).collect();
646                    for i in (0..arity as usize).rev() { args[i] = self.pop()?; }
647                    let name = match &self.program.constants[name_idx as usize] {
648                        Const::VariantName(s) => s.clone(),
649                        _ => return Err(VmError::TypeMismatch("expected VariantName const".into())),
650                    };
651                    self.stack.push(Value::Variant { name, args });
652                }
653                Op::GetField(i) => {
654                    let name = match &self.program.constants[i as usize] {
655                        Const::FieldName(s) => s.clone(),
656                        _ => return Err(VmError::TypeMismatch("expected FieldName const".into())),
657                    };
658                    let v = self.pop()?;
659                    match v {
660                        Value::Record(r) => {
661                            let v = r.get(&name).cloned()
662                                .ok_or_else(|| VmError::TypeMismatch(format!("missing field `{name}`")))?;
663                            self.stack.push(v);
664                        }
665                        other => return Err(VmError::TypeMismatch(format!("GetField on non-record: {other:?}"))),
666                    }
667                }
668                Op::GetElem(i) => {
669                    let v = self.pop()?;
670                    match v {
671                        Value::Tuple(items) => {
672                            let v = items.into_iter().nth(i as usize)
673                                .ok_or_else(|| VmError::TypeMismatch(format!("tuple index {i} out of range")))?;
674                            self.stack.push(v);
675                        }
676                        other => return Err(VmError::TypeMismatch(format!("GetElem on non-tuple: {other:?}"))),
677                    }
678                }
679                Op::TestVariant(i) => {
680                    let name = match &self.program.constants[i as usize] {
681                        Const::VariantName(s) => s.clone(),
682                        _ => return Err(VmError::TypeMismatch("expected VariantName const".into())),
683                    };
684                    let v = self.pop()?;
685                    match &v {
686                        Value::Variant { name: vname, .. } => {
687                            self.stack.push(Value::Bool(vname == &name));
688                        }
689                        // For tag-only enums of primitive type (e.g. ParseError = Empty | NotNumber)
690                        // the value is currently a Variant too, since constructors emit MakeVariant.
691                        other => return Err(VmError::TypeMismatch(format!("TestVariant on non-variant: {other:?}"))),
692                    }
693                }
694                Op::GetVariant(_i) => {
695                    let v = self.pop()?;
696                    match v {
697                        Value::Variant { args, .. } => {
698                            self.stack.push(Value::Tuple(args));
699                        }
700                        other => return Err(VmError::TypeMismatch(format!("GetVariant on non-variant: {other:?}"))),
701                    }
702                }
703                Op::GetVariantArg(i) => {
704                    let v = self.pop()?;
705                    match v {
706                        Value::Variant { mut args, .. } => {
707                            if (i as usize) >= args.len() {
708                                return Err(VmError::TypeMismatch("variant arg index oob".into()));
709                            }
710                            self.stack.push(args.swap_remove(i as usize));
711                        }
712                        other => return Err(VmError::TypeMismatch(format!("GetVariantArg on non-variant: {other:?}"))),
713                    }
714                }
715                Op::GetListLen => {
716                    let v = self.pop()?;
717                    match v {
718                        Value::List(items) => self.stack.push(Value::Int(items.len() as i64)),
719                        other => return Err(VmError::TypeMismatch(format!("GetListLen on non-list: {other:?}"))),
720                    }
721                }
722                Op::GetListElem(i) => {
723                    let v = self.pop()?;
724                    match v {
725                        Value::List(items) => {
726                            let v = items.into_iter().nth(i as usize)
727                                .ok_or_else(|| VmError::TypeMismatch("list index oob".into()))?;
728                            self.stack.push(v);
729                        }
730                        other => return Err(VmError::TypeMismatch(format!("GetListElem on non-list: {other:?}"))),
731                    }
732                }
733                Op::GetListElemDyn => {
734                    // Stack: [list, idx]
735                    let idx = match self.pop()? {
736                        Value::Int(n) => n as usize,
737                        other => return Err(VmError::TypeMismatch(format!("GetListElemDyn idx: {other:?}"))),
738                    };
739                    let v = self.pop()?;
740                    match v {
741                        Value::List(items) => {
742                            let v = items.into_iter().nth(idx)
743                                .ok_or_else(|| VmError::TypeMismatch("list index oob".into()))?;
744                            self.stack.push(v);
745                        }
746                        other => return Err(VmError::TypeMismatch(format!("GetListElemDyn on non-list: {other:?}"))),
747                    }
748                }
749                Op::ListAppend => {
750                    let value = self.pop()?;
751                    let list = self.pop()?;
752                    match list {
753                        Value::List(mut items) => {
754                            items.push(value);
755                            self.stack.push(Value::List(items));
756                        }
757                        other => return Err(VmError::TypeMismatch(format!("ListAppend on non-list: {other:?}"))),
758                    }
759                }
760                Op::Jump(off) => {
761                    let new_pc = (self.frames[frame_idx].pc as i32 + off) as usize;
762                    self.frames[frame_idx].pc = new_pc;
763                }
764                Op::JumpIf(off) => {
765                    let v = self.pop()?;
766                    if v.as_bool() {
767                        let new_pc = (self.frames[frame_idx].pc as i32 + off) as usize;
768                        self.frames[frame_idx].pc = new_pc;
769                    }
770                }
771                Op::JumpIfNot(off) => {
772                    let v = self.pop()?;
773                    if !v.as_bool() {
774                        let new_pc = (self.frames[frame_idx].pc as i32 + off) as usize;
775                        self.frames[frame_idx].pc = new_pc;
776                    }
777                }
778                Op::MakeClosure { fn_id, capture_count } => {
779                    let n = capture_count as usize;
780                    let mut captures: Vec<Value> = (0..n).map(|_| Value::Unit).collect();
781                    for i in (0..n).rev() { captures[i] = self.pop()?; }
782                    // Look up the canonical body hash so the resulting
783                    // `Value::Closure` carries it for equality (#222).
784                    let body_hash = self.program.functions[fn_id as usize].body_hash;
785                    self.stack.push(Value::Closure { fn_id, body_hash, captures });
786                }
787                Op::CallClosure { arity, node_id_idx } => {
788                    let mut args: Vec<Value> = (0..arity).map(|_| Value::Unit).collect();
789                    for i in (0..arity as usize).rev() { args[i] = self.pop()?; }
790                    let closure = self.pop()?;
791                    let (fn_id, captures) = match closure {
792                        Value::Closure { fn_id, captures, .. } => (fn_id, captures),
793                        other => return Err(VmError::TypeMismatch(format!("CallClosure on non-closure: {other:?}"))),
794                    };
795                    let node_id = const_str(&self.program.constants, node_id_idx);
796                    let callee_name = self.program.functions[fn_id as usize].name.clone();
797                    let budget_cost = call_budget_cost(&self.program.functions[fn_id as usize]);
798                    if budget_cost > 0 {
799                        self.handler.note_call_budget(budget_cost)
800                            .map_err(VmError::Effect)?;
801                    }
802                    let mut combined = captures;
803                    combined.extend(args);
804                    self.tracer.enter_call(&node_id, &callee_name, &combined);
805                    let f = &self.program.functions[fn_id as usize];
806                    let mut locals = vec![Value::Unit; f.locals_count.max(f.arity) as usize];
807                    for (i, v) in combined.into_iter().enumerate() { locals[i] = v; }
808                    self.push_frame(Frame {
809                        fn_id, pc: 0, locals, stack_base: self.stack.len(),
810                        trace_kind: FrameKind::Call(node_id),
811                        // Op::CallClosure intentionally doesn't memoize
812                        // for v1 (#229) — closures over captures need a
813                        // hashing strategy that includes the captures.
814                        // Direct Op::Call is the v1 surface.
815                        memo_key: None,
816                    })?;
817                }
818                Op::SortByKey { node_id_idx: _ } => {
819                    // #338: pop (xs, f). For each x in xs, invoke
820                    // f(x) to derive a sortable key. Stable-sort the
821                    // (key, value) pairs by key. Return the values
822                    // in sorted order. Keys must be Int / Float /
823                    // Str; mixed-type pairs and other types compare
824                    // as equal (preserving original order — stable
825                    // sort).
826                    let f = self.pop()?;
827                    let xs = self.pop()?;
828                    let items = match xs {
829                        Value::List(v) => v,
830                        other => return Err(VmError::TypeMismatch(
831                            format!("SortByKey requires a List, got: {other:?}"))),
832                    };
833                    if !matches!(f, Value::Closure { .. }) {
834                        return Err(VmError::TypeMismatch(
835                            format!("SortByKey requires a closure, got: {f:?}")));
836                    }
837                    let mut keyed: Vec<(Value, Value)> = Vec::with_capacity(items.len());
838                    for item in items {
839                        let key = self.invoke_closure_value(f.clone(), vec![item.clone()])?;
840                        keyed.push((key, item));
841                    }
842                    keyed.sort_by(|(ka, _), (kb, _)| compare_sort_keys(ka, kb));
843                    let sorted: Vec<Value> = keyed.into_iter().map(|(_, v)| v).collect();
844                    self.stack.push(Value::List(sorted));
845                }
846                Op::ParallelMap { node_id_idx: _ } => {
847                    // #305 slice 1: pop (xs, f) and apply f to each
848                    // element across OS threads.
849                    //
850                    // #305 slice 2: each worker now asks the parent
851                    // handler for a thread-safe per-worker handler via
852                    // `EffectHandler::spawn_for_worker`. Handlers that
853                    // opt in (e.g. `DefaultHandler`) yield a fresh
854                    // instance sharing the budget pool; handlers that
855                    // don't fall back to the slice-1 behavior of
856                    // `DenyAllEffects` in the worker.
857                    let f = self.pop()?;
858                    let xs = self.pop()?;
859                    let items = match xs {
860                        Value::List(v) => v,
861                        other => return Err(VmError::TypeMismatch(
862                            format!("ParallelMap requires a List, got: {other:?}"))),
863                    };
864                    if !matches!(f, Value::Closure { .. }) {
865                        return Err(VmError::TypeMismatch(
866                            format!("ParallelMap requires a closure, got: {f:?}")));
867                    }
868                    // Pre-build one handler per worker on the main
869                    // thread so the worker just owns its handler with
870                    // no shared borrowing. The actual worker count is
871                    // capped by `LEX_PAR_MAX_CONCURRENCY` (resolved
872                    // inside par_map_run); cap ≤ items.len() so we
873                    // never over-allocate handlers.
874                    let n_workers = par_max_concurrency().max(1).min(items.len().max(1));
875                    let mut worker_handlers: Vec<Box<dyn EffectHandler + Send>> =
876                        Vec::with_capacity(n_workers);
877                    for _ in 0..n_workers {
878                        worker_handlers.push(
879                            self.handler
880                                .spawn_for_worker()
881                                .unwrap_or_else(|| Box::new(DenyAllEffects)),
882                        );
883                    }
884                    let results = par_map_run(self.program, f, items, worker_handlers)?;
885                    self.stack.push(Value::List(results));
886                }
887                Op::Call { fn_id, arity, node_id_idx } => {
888                    let mut args: Vec<Value> = (0..arity).map(|_| Value::Unit).collect();
889                    for i in (0..arity as usize).rev() { args[i] = self.pop()?; }
890                    let node_id = const_str(&self.program.constants, node_id_idx);
891                    let callee_name = self.program.functions[fn_id as usize].name.clone();
892                    let budget_cost = call_budget_cost(&self.program.functions[fn_id as usize]);
893                    if budget_cost > 0 {
894                        self.handler.note_call_budget(budget_cost)
895                            .map_err(VmError::Effect)?;
896                    }
897                    // Refinement runtime check (#209 slice 3). Each
898                    // param's `Option<Refinement>` is evaluated against
899                    // the actual arg before the frame is pushed. The
900                    // tracer sees the call enter; failure surfaces as
901                    // `VmError::RefinementFailed` *before* the body
902                    // starts, which means an erroring trace shows the
903                    // call as enter+exit_err with the verdict reason
904                    // (same shape as `gate.verdict`).
905                    let refinements = self.program.functions[fn_id as usize]
906                        .refinements.clone();
907                    for (i, refinement) in refinements.iter().enumerate() {
908                        if let Some(r) = refinement {
909                            let arg = args.get(i).cloned().unwrap_or(Value::Unit);
910                            match eval_refinement(&r.predicate, &r.binding, &arg) {
911                                Ok(true) => { /* satisfied, continue */ }
912                                Ok(false) => {
913                                    return Err(VmError::RefinementFailed {
914                                        fn_name: callee_name.clone(),
915                                        param_index: i,
916                                        binding: r.binding.clone(),
917                                        reason: format!(
918                                            "predicate failed for {} = {arg:?}",
919                                            r.binding),
920                                    });
921                                }
922                                Err(reason) => {
923                                    return Err(VmError::RefinementFailed {
924                                        fn_name: callee_name.clone(),
925                                        param_index: i,
926                                        binding: r.binding.clone(),
927                                        reason,
928                                    });
929                                }
930                            }
931                        }
932                    }
933                    // Pure-fn memoization (#229): if the callee declares
934                    // no effects, hash the args and consult the cache.
935                    // On hit, push the cached value, emit synthetic
936                    // enter+exit trace events (so the trace still shows
937                    // the call), and skip the frame push entirely.
938                    let f = &self.program.functions[fn_id as usize];
939                    let memo_key: Option<(u32, [u8; 16])> = if f.effects.is_empty() {
940                        Some((fn_id, hash_call_args(&args)))
941                    } else {
942                        None
943                    };
944                    if let Some(key) = memo_key {
945                        if let Some(cached) = self.pure_memo.get(&key).cloned() {
946                            self.pure_memo_hits += 1;
947                            self.tracer.enter_call(&node_id, &callee_name, &args);
948                            self.tracer.exit_ok(&cached);
949                            self.stack.push(cached);
950                            continue;
951                        }
952                        self.pure_memo_misses += 1;
953                    }
954                    self.tracer.enter_call(&node_id, &callee_name, &args);
955                    let mut locals = vec![Value::Unit; f.locals_count.max(f.arity) as usize];
956                    for (i, v) in args.into_iter().enumerate() { locals[i] = v; }
957                    self.push_frame(Frame {
958                        fn_id, pc: 0, locals, stack_base: self.stack.len(),
959                        trace_kind: FrameKind::Call(node_id),
960                        memo_key,
961                    })?;
962                }
963                Op::TailCall { fn_id, arity, node_id_idx } => {
964                    let mut args: Vec<Value> = (0..arity).map(|_| Value::Unit).collect();
965                    for i in (0..arity as usize).rev() { args[i] = self.pop()?; }
966                    let node_id = const_str(&self.program.constants, node_id_idx);
967                    let callee_name = self.program.functions[fn_id as usize].name.clone();
968                    let budget_cost = call_budget_cost(&self.program.functions[fn_id as usize]);
969                    if budget_cost > 0 {
970                        self.handler.note_call_budget(budget_cost)
971                            .map_err(VmError::Effect)?;
972                    }
973                    // Refinement runtime check on tail calls too
974                    // (#209 slice 3). Same shape as Op::Call.
975                    let refinements = self.program.functions[fn_id as usize]
976                        .refinements.clone();
977                    for (i, refinement) in refinements.iter().enumerate() {
978                        if let Some(r) = refinement {
979                            let arg = args.get(i).cloned().unwrap_or(Value::Unit);
980                            match eval_refinement(&r.predicate, &r.binding, &arg) {
981                                Ok(true) => {}
982                                Ok(false) => return Err(VmError::RefinementFailed {
983                                    fn_name: callee_name.clone(),
984                                    param_index: i,
985                                    binding: r.binding.clone(),
986                                    reason: format!(
987                                        "predicate failed for {} = {arg:?}",
988                                        r.binding),
989                                }),
990                                Err(reason) => return Err(VmError::RefinementFailed {
991                                    fn_name: callee_name.clone(),
992                                    param_index: i,
993                                    binding: r.binding.clone(),
994                                    reason,
995                                }),
996                            }
997                        }
998                    }
999                    // A tail call closes the current call's trace frame and
1000                    // opens a new one in its place — preserves the caller's
1001                    // tree depth in the trace.
1002                    self.tracer.exit_call_tail();
1003                    self.tracer.enter_call(&node_id, &callee_name, &args);
1004                    let f = &self.program.functions[fn_id as usize];
1005                    let frame = self.frames.last_mut().unwrap();
1006                    frame.fn_id = fn_id;
1007                    frame.pc = 0;
1008                    frame.locals = vec![Value::Unit; f.locals_count.max(f.arity) as usize];
1009                    for (i, v) in args.into_iter().enumerate() { frame.locals[i] = v; }
1010                    frame.trace_kind = FrameKind::Call(node_id);
1011                }
1012                Op::EffectCall { kind_idx, op_idx, arity, node_id_idx } => {
1013                    let mut args: Vec<Value> = (0..arity).map(|_| Value::Unit).collect();
1014                    for i in (0..arity as usize).rev() { args[i] = self.pop()?; }
1015                    let kind = match &self.program.constants[kind_idx as usize] {
1016                        Const::Str(s) => s.clone(),
1017                        _ => return Err(VmError::TypeMismatch("expected Str const for effect kind".into())),
1018                    };
1019                    let op_name = match &self.program.constants[op_idx as usize] {
1020                        Const::Str(s) => s.clone(),
1021                        _ => return Err(VmError::TypeMismatch("expected Str const for effect op".into())),
1022                    };
1023                    let node_id = const_str(&self.program.constants, node_id_idx);
1024                    self.tracer.enter_effect(&node_id, &kind, &op_name, &args);
1025                    let result = match self.tracer.override_effect(&node_id) {
1026                        Some(v) => Ok(v),
1027                        // VM-level intercept for `parser.run` (#221).
1028                        // Routed inline rather than through the handler
1029                        // because the parser interpreter needs reentrant
1030                        // VM access to invoke `Value::Closure` values
1031                        // from `Map` / `AndThen` nodes.
1032                        None if (kind.as_str(), op_name.as_str()) == ("parser", "run")
1033                            => self.run_parser_op(args.clone()),
1034                        None => self.handler.dispatch(&kind, &op_name, args.clone()),
1035                    };
1036                    match result {
1037                        Ok(v) => {
1038                            self.tracer.exit_ok(&v);
1039                            self.stack.push(v);
1040                        }
1041                        Err(e) => {
1042                            self.tracer.exit_err(&e);
1043                            return Err(VmError::Effect(e));
1044                        }
1045                    }
1046                }
1047                Op::Return => {
1048                    let v = self.pop()?;
1049                    let frame = self.frames.pop().unwrap();
1050                    // Trim any extra stuff that the function pushed but didn't pop.
1051                    self.stack.truncate(frame.stack_base);
1052                    if matches!(frame.trace_kind, FrameKind::Call(_)) {
1053                        self.tracer.exit_ok(&v);
1054                    }
1055                    // Pure-fn memoization (#229): if this frame was a
1056                    // memoizable call that missed the cache, write the
1057                    // computed return value back so the next call with
1058                    // the same args returns it without re-executing.
1059                    if let Some(key) = frame.memo_key {
1060                        self.pure_memo.insert(key, v.clone());
1061                    }
1062                    // Exit when we've returned past the depth this
1063                    // `run_to` was entered at — supports reentrancy
1064                    // (a nested `invoke` returns into its caller, not
1065                    // out of the outermost VM run, #221).
1066                    if self.frames.len() <= base_depth {
1067                        return Ok(v);
1068                    }
1069                    self.stack.push(v);
1070                }
1071                Op::Panic(i) => {
1072                    let msg = match &self.program.constants[i as usize] {
1073                        Const::Str(s) => s.clone(),
1074                        _ => "panic".into(),
1075                    };
1076                    return Err(VmError::Panic(msg));
1077                }
1078                // Arithmetic
1079                Op::IntAdd => self.bin_int(|a, b| Value::Int(a + b))?,
1080                Op::IntSub => self.bin_int(|a, b| Value::Int(a - b))?,
1081                Op::IntMul => self.bin_int(|a, b| Value::Int(a * b))?,
1082                Op::IntDiv => self.bin_int(|a, b| Value::Int(a / b))?,
1083                Op::IntMod => self.bin_int(|a, b| Value::Int(a % b))?,
1084                Op::IntNeg => {
1085                    let a = self.pop()?.as_int();
1086                    self.stack.push(Value::Int(-a));
1087                }
1088                Op::IntEq => self.bin_int(|a, b| Value::Bool(a == b))?,
1089                Op::IntLt => self.bin_int(|a, b| Value::Bool(a < b))?,
1090                Op::IntLe => self.bin_int(|a, b| Value::Bool(a <= b))?,
1091                Op::FloatAdd => self.bin_float(|a, b| Value::Float(a + b))?,
1092                Op::FloatSub => self.bin_float(|a, b| Value::Float(a - b))?,
1093                Op::FloatMul => self.bin_float(|a, b| Value::Float(a * b))?,
1094                Op::FloatDiv => self.bin_float(|a, b| Value::Float(a / b))?,
1095                Op::FloatNeg => {
1096                    let a = self.pop()?.as_float();
1097                    self.stack.push(Value::Float(-a));
1098                }
1099                Op::FloatEq => self.bin_float(|a, b| Value::Bool(a == b))?,
1100                Op::FloatLt => self.bin_float(|a, b| Value::Bool(a < b))?,
1101                Op::FloatLe => self.bin_float(|a, b| Value::Bool(a <= b))?,
1102                Op::NumAdd => {
1103                    // #308: `+` is overloaded — Str+Str concatenates,
1104                    // numerics add. Other arithmetic ops (-, *, /, %)
1105                    // still reject Str at the type-checker layer.
1106                    let b = self.pop()?;
1107                    let a = self.pop()?;
1108                    match (a, b) {
1109                        (Value::Int(x), Value::Int(y)) => self.stack.push(Value::Int(x + y)),
1110                        (Value::Float(x), Value::Float(y)) => self.stack.push(Value::Float(x + y)),
1111                        (Value::Str(x), Value::Str(y)) => {
1112                            let mut s = x;
1113                            s.push_str(&y);
1114                            self.stack.push(Value::Str(s));
1115                        }
1116                        (a, b) => return Err(VmError::TypeMismatch(format!("Num op: {a:?} {b:?}"))),
1117                    }
1118                }
1119                Op::NumSub => self.bin_num(|a, b| Value::Int(a - b), |a, b| Value::Float(a - b))?,
1120                Op::NumMul => self.bin_num(|a, b| Value::Int(a * b), |a, b| Value::Float(a * b))?,
1121                Op::NumDiv => self.bin_num(|a, b| Value::Int(a / b), |a, b| Value::Float(a / b))?,
1122                Op::NumMod => self.bin_int(|a, b| Value::Int(a % b))?,
1123                Op::NumNeg => {
1124                    let v = self.pop()?;
1125                    match v {
1126                        Value::Int(n) => self.stack.push(Value::Int(-n)),
1127                        Value::Float(f) => self.stack.push(Value::Float(-f)),
1128                        other => return Err(VmError::TypeMismatch(format!("NumNeg on {other:?}"))),
1129                    }
1130                }
1131                Op::NumEq => self.bin_eq()?,
1132                Op::NumLt => self.bin_ord(|a, b| Value::Bool(a < b), |a, b| Value::Bool(a < b), |a, b| Value::Bool(a < b))?,
1133                Op::NumLe => self.bin_ord(|a, b| Value::Bool(a <= b), |a, b| Value::Bool(a <= b), |a, b| Value::Bool(a <= b))?,
1134                Op::BoolAnd => {
1135                    let b = self.pop()?.as_bool();
1136                    let a = self.pop()?.as_bool();
1137                    self.stack.push(Value::Bool(a && b));
1138                }
1139                Op::BoolOr => {
1140                    let b = self.pop()?.as_bool();
1141                    let a = self.pop()?.as_bool();
1142                    self.stack.push(Value::Bool(a || b));
1143                }
1144                Op::BoolNot => {
1145                    let a = self.pop()?.as_bool();
1146                    self.stack.push(Value::Bool(!a));
1147                }
1148                Op::StrConcat => {
1149                    let b = self.pop()?;
1150                    let a = self.pop()?;
1151                    let s = format!("{}{}", a.as_str(), b.as_str());
1152                    self.stack.push(Value::Str(s));
1153                }
1154                Op::StrLen => {
1155                    let v = self.pop()?;
1156                    self.stack.push(Value::Int(v.as_str().len() as i64));
1157                }
1158                Op::StrEq => {
1159                    let b = self.pop()?;
1160                    let a = self.pop()?;
1161                    self.stack.push(Value::Bool(a.as_str() == b.as_str()));
1162                }
1163                Op::BytesLen => {
1164                    let v = self.pop()?;
1165                    match v {
1166                        Value::Bytes(b) => self.stack.push(Value::Int(b.len() as i64)),
1167                        other => return Err(VmError::TypeMismatch(format!("BytesLen on {other:?}"))),
1168                    }
1169                }
1170                Op::BytesEq => {
1171                    let b = self.pop()?;
1172                    let a = self.pop()?;
1173                    let eq = match (a, b) {
1174                        (Value::Bytes(x), Value::Bytes(y)) => x == y,
1175                        _ => return Err(VmError::TypeMismatch("BytesEq operands".into())),
1176                    };
1177                    self.stack.push(Value::Bool(eq));
1178                }
1179            }
1180        }
1181    }
1182
1183    fn pop(&mut self) -> Result<Value, VmError> {
1184        self.stack.pop().ok_or(VmError::StackUnderflow)
1185    }
1186    fn peek(&self) -> Result<&Value, VmError> {
1187        self.stack.last().ok_or(VmError::StackUnderflow)
1188    }
1189
1190    fn bin_int(&mut self, f: impl Fn(i64, i64) -> Value) -> Result<(), VmError> {
1191        let b = self.pop()?.as_int();
1192        let a = self.pop()?.as_int();
1193        self.stack.push(f(a, b));
1194        Ok(())
1195    }
1196    fn bin_float(&mut self, f: impl Fn(f64, f64) -> Value) -> Result<(), VmError> {
1197        let b = self.pop()?.as_float();
1198        let a = self.pop()?.as_float();
1199        self.stack.push(f(a, b));
1200        Ok(())
1201    }
1202    fn bin_num(
1203        &mut self,
1204        i: impl Fn(i64, i64) -> Value,
1205        f: impl Fn(f64, f64) -> Value,
1206    ) -> Result<(), VmError> {
1207        let b = self.pop()?;
1208        let a = self.pop()?;
1209        match (a, b) {
1210            (Value::Int(x), Value::Int(y)) => { self.stack.push(i(x, y)); Ok(()) }
1211            (Value::Float(x), Value::Float(y)) => { self.stack.push(f(x, y)); Ok(()) }
1212            (a, b) => Err(VmError::TypeMismatch(format!("Num op: {a:?} {b:?}"))),
1213        }
1214    }
1215
1216    /// Like `bin_num` but also handles `Str` operands via lexicographic order.
1217    /// Used by `NumLt` / `NumLe` because the type checker admits `Str < Str`
1218    /// and `>` / `>=` compile as swap+NumLt / swap+NumLe (#332).
1219    fn bin_ord(
1220        &mut self,
1221        i: impl Fn(i64, i64) -> Value,
1222        f: impl Fn(f64, f64) -> Value,
1223        s: impl Fn(&str, &str) -> Value,
1224    ) -> Result<(), VmError> {
1225        let b = self.pop()?;
1226        let a = self.pop()?;
1227        match (a, b) {
1228            (Value::Int(x), Value::Int(y)) => { self.stack.push(i(x, y)); Ok(()) }
1229            (Value::Float(x), Value::Float(y)) => { self.stack.push(f(x, y)); Ok(()) }
1230            (Value::Str(x), Value::Str(y)) => { self.stack.push(s(&x, &y)); Ok(()) }
1231            (a, b) => Err(VmError::TypeMismatch(format!("Num op: {a:?} {b:?}"))),
1232        }
1233    }
1234    fn bin_eq(&mut self) -> Result<(), VmError> {
1235        let b = self.pop()?;
1236        let a = self.pop()?;
1237        self.stack.push(Value::Bool(a == b));
1238        Ok(())
1239    }
1240}
1241
1242fn const_to_value(c: &Const) -> Value {
1243    match c {
1244        Const::Int(n) => Value::Int(*n),
1245        Const::Float(f) => Value::Float(*f),
1246        Const::Bool(b) => Value::Bool(*b),
1247        Const::Str(s) => Value::Str(s.clone()),
1248        Const::Bytes(b) => Value::Bytes(b.clone()),
1249        Const::Unit => Value::Unit,
1250        Const::FieldName(s) | Const::VariantName(s) | Const::NodeId(s) => Value::Str(s.clone()),
1251    }
1252}