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 id: {0}")]
17    UnknownFunction(u32),
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                return Err(VmError::Panic(format!(
581                    "step limit exceeded ({} > {})",
582                    self.steps, self.step_limit,
583                )));
584            }
585            self.steps += 1;
586            let frame_idx = self.frames.len() - 1;
587            let pc = self.frames[frame_idx].pc;
588            let fn_id = self.frames[frame_idx].fn_id;
589            let code = &self.program.functions[fn_id as usize].code;
590            if pc >= code.len() {
591                return Err(VmError::Panic("ran past end of code".into()));
592            }
593            let op = code[pc].clone();
594            self.frames[frame_idx].pc = pc + 1;
595
596            match op {
597                Op::PushConst(i) => {
598                    let c = &self.program.constants[i as usize];
599                    self.stack.push(const_to_value(c));
600                }
601                Op::Pop => { self.pop()?; }
602                Op::Dup => {
603                    let v = self.peek()?.clone();
604                    self.stack.push(v);
605                }
606                Op::LoadLocal(i) => {
607                    let v = self.frames[frame_idx].locals[i as usize].clone();
608                    self.stack.push(v);
609                }
610                Op::StoreLocal(i) => {
611                    let v = self.pop()?;
612                    self.frames[frame_idx].locals[i as usize] = v;
613                }
614                Op::MakeRecord { field_name_indices } => {
615                    let n = field_name_indices.len();
616                    let mut values: Vec<Value> = (0..n).map(|_| Value::Unit).collect();
617                    for i in (0..n).rev() {
618                        values[i] = self.pop()?;
619                    }
620                    let mut rec = IndexMap::new();
621                    for (i, val) in values.into_iter().enumerate() {
622                        let name = match &self.program.constants[field_name_indices[i] as usize] {
623                            Const::FieldName(s) => s.clone(),
624                            _ => return Err(VmError::TypeMismatch("expected FieldName const".into())),
625                        };
626                        rec.insert(name, val);
627                    }
628                    self.stack.push(Value::Record(rec));
629                }
630                Op::MakeTuple(n) => {
631                    let mut items: Vec<Value> = (0..n).map(|_| Value::Unit).collect();
632                    for i in (0..n as usize).rev() { items[i] = self.pop()?; }
633                    self.stack.push(Value::Tuple(items));
634                }
635                Op::MakeList(n) => {
636                    let mut items: Vec<Value> = (0..n).map(|_| Value::Unit).collect();
637                    for i in (0..n as usize).rev() { items[i] = self.pop()?; }
638                    self.stack.push(Value::List(items));
639                }
640                Op::MakeVariant { name_idx, arity } => {
641                    let mut args: Vec<Value> = (0..arity).map(|_| Value::Unit).collect();
642                    for i in (0..arity as usize).rev() { args[i] = self.pop()?; }
643                    let name = match &self.program.constants[name_idx as usize] {
644                        Const::VariantName(s) => s.clone(),
645                        _ => return Err(VmError::TypeMismatch("expected VariantName const".into())),
646                    };
647                    self.stack.push(Value::Variant { name, args });
648                }
649                Op::GetField(i) => {
650                    let name = match &self.program.constants[i as usize] {
651                        Const::FieldName(s) => s.clone(),
652                        _ => return Err(VmError::TypeMismatch("expected FieldName const".into())),
653                    };
654                    let v = self.pop()?;
655                    match v {
656                        Value::Record(r) => {
657                            let v = r.get(&name).cloned()
658                                .ok_or_else(|| VmError::TypeMismatch(format!("missing field `{name}`")))?;
659                            self.stack.push(v);
660                        }
661                        other => return Err(VmError::TypeMismatch(format!("GetField on non-record: {other:?}"))),
662                    }
663                }
664                Op::GetElem(i) => {
665                    let v = self.pop()?;
666                    match v {
667                        Value::Tuple(items) => {
668                            let v = items.into_iter().nth(i as usize)
669                                .ok_or_else(|| VmError::TypeMismatch(format!("tuple index {i} out of range")))?;
670                            self.stack.push(v);
671                        }
672                        other => return Err(VmError::TypeMismatch(format!("GetElem on non-tuple: {other:?}"))),
673                    }
674                }
675                Op::TestVariant(i) => {
676                    let name = match &self.program.constants[i as usize] {
677                        Const::VariantName(s) => s.clone(),
678                        _ => return Err(VmError::TypeMismatch("expected VariantName const".into())),
679                    };
680                    let v = self.pop()?;
681                    match &v {
682                        Value::Variant { name: vname, .. } => {
683                            self.stack.push(Value::Bool(vname == &name));
684                        }
685                        // For tag-only enums of primitive type (e.g. ParseError = Empty | NotNumber)
686                        // the value is currently a Variant too, since constructors emit MakeVariant.
687                        other => return Err(VmError::TypeMismatch(format!("TestVariant on non-variant: {other:?}"))),
688                    }
689                }
690                Op::GetVariant(_i) => {
691                    let v = self.pop()?;
692                    match v {
693                        Value::Variant { args, .. } => {
694                            self.stack.push(Value::Tuple(args));
695                        }
696                        other => return Err(VmError::TypeMismatch(format!("GetVariant on non-variant: {other:?}"))),
697                    }
698                }
699                Op::GetVariantArg(i) => {
700                    let v = self.pop()?;
701                    match v {
702                        Value::Variant { mut args, .. } => {
703                            if (i as usize) >= args.len() {
704                                return Err(VmError::TypeMismatch("variant arg index oob".into()));
705                            }
706                            self.stack.push(args.swap_remove(i as usize));
707                        }
708                        other => return Err(VmError::TypeMismatch(format!("GetVariantArg on non-variant: {other:?}"))),
709                    }
710                }
711                Op::GetListLen => {
712                    let v = self.pop()?;
713                    match v {
714                        Value::List(items) => self.stack.push(Value::Int(items.len() as i64)),
715                        other => return Err(VmError::TypeMismatch(format!("GetListLen on non-list: {other:?}"))),
716                    }
717                }
718                Op::GetListElem(i) => {
719                    let v = self.pop()?;
720                    match v {
721                        Value::List(items) => {
722                            let v = items.into_iter().nth(i as usize)
723                                .ok_or_else(|| VmError::TypeMismatch("list index oob".into()))?;
724                            self.stack.push(v);
725                        }
726                        other => return Err(VmError::TypeMismatch(format!("GetListElem on non-list: {other:?}"))),
727                    }
728                }
729                Op::GetListElemDyn => {
730                    // Stack: [list, idx]
731                    let idx = match self.pop()? {
732                        Value::Int(n) => n as usize,
733                        other => return Err(VmError::TypeMismatch(format!("GetListElemDyn idx: {other:?}"))),
734                    };
735                    let v = self.pop()?;
736                    match v {
737                        Value::List(items) => {
738                            let v = items.into_iter().nth(idx)
739                                .ok_or_else(|| VmError::TypeMismatch("list index oob".into()))?;
740                            self.stack.push(v);
741                        }
742                        other => return Err(VmError::TypeMismatch(format!("GetListElemDyn on non-list: {other:?}"))),
743                    }
744                }
745                Op::ListAppend => {
746                    let value = self.pop()?;
747                    let list = self.pop()?;
748                    match list {
749                        Value::List(mut items) => {
750                            items.push(value);
751                            self.stack.push(Value::List(items));
752                        }
753                        other => return Err(VmError::TypeMismatch(format!("ListAppend on non-list: {other:?}"))),
754                    }
755                }
756                Op::Jump(off) => {
757                    let new_pc = (self.frames[frame_idx].pc as i32 + off) as usize;
758                    self.frames[frame_idx].pc = new_pc;
759                }
760                Op::JumpIf(off) => {
761                    let v = self.pop()?;
762                    if v.as_bool() {
763                        let new_pc = (self.frames[frame_idx].pc as i32 + off) as usize;
764                        self.frames[frame_idx].pc = new_pc;
765                    }
766                }
767                Op::JumpIfNot(off) => {
768                    let v = self.pop()?;
769                    if !v.as_bool() {
770                        let new_pc = (self.frames[frame_idx].pc as i32 + off) as usize;
771                        self.frames[frame_idx].pc = new_pc;
772                    }
773                }
774                Op::MakeClosure { fn_id, capture_count } => {
775                    let n = capture_count as usize;
776                    let mut captures: Vec<Value> = (0..n).map(|_| Value::Unit).collect();
777                    for i in (0..n).rev() { captures[i] = self.pop()?; }
778                    // Look up the canonical body hash so the resulting
779                    // `Value::Closure` carries it for equality (#222).
780                    let body_hash = self.program.functions[fn_id as usize].body_hash;
781                    self.stack.push(Value::Closure { fn_id, body_hash, captures });
782                }
783                Op::CallClosure { arity, node_id_idx } => {
784                    let mut args: Vec<Value> = (0..arity).map(|_| Value::Unit).collect();
785                    for i in (0..arity as usize).rev() { args[i] = self.pop()?; }
786                    let closure = self.pop()?;
787                    let (fn_id, captures) = match closure {
788                        Value::Closure { fn_id, captures, .. } => (fn_id, captures),
789                        other => return Err(VmError::TypeMismatch(format!("CallClosure on non-closure: {other:?}"))),
790                    };
791                    let node_id = const_str(&self.program.constants, node_id_idx);
792                    let callee_name = self.program.functions[fn_id as usize].name.clone();
793                    let budget_cost = call_budget_cost(&self.program.functions[fn_id as usize]);
794                    if budget_cost > 0 {
795                        self.handler.note_call_budget(budget_cost)
796                            .map_err(VmError::Effect)?;
797                    }
798                    let mut combined = captures;
799                    combined.extend(args);
800                    self.tracer.enter_call(&node_id, &callee_name, &combined);
801                    let f = &self.program.functions[fn_id as usize];
802                    let mut locals = vec![Value::Unit; f.locals_count.max(f.arity) as usize];
803                    for (i, v) in combined.into_iter().enumerate() { locals[i] = v; }
804                    self.push_frame(Frame {
805                        fn_id, pc: 0, locals, stack_base: self.stack.len(),
806                        trace_kind: FrameKind::Call(node_id),
807                        // Op::CallClosure intentionally doesn't memoize
808                        // for v1 (#229) — closures over captures need a
809                        // hashing strategy that includes the captures.
810                        // Direct Op::Call is the v1 surface.
811                        memo_key: None,
812                    })?;
813                }
814                Op::SortByKey { node_id_idx: _ } => {
815                    // #338: pop (xs, f). For each x in xs, invoke
816                    // f(x) to derive a sortable key. Stable-sort the
817                    // (key, value) pairs by key. Return the values
818                    // in sorted order. Keys must be Int / Float /
819                    // Str; mixed-type pairs and other types compare
820                    // as equal (preserving original order — stable
821                    // sort).
822                    let f = self.pop()?;
823                    let xs = self.pop()?;
824                    let items = match xs {
825                        Value::List(v) => v,
826                        other => return Err(VmError::TypeMismatch(
827                            format!("SortByKey requires a List, got: {other:?}"))),
828                    };
829                    if !matches!(f, Value::Closure { .. }) {
830                        return Err(VmError::TypeMismatch(
831                            format!("SortByKey requires a closure, got: {f:?}")));
832                    }
833                    let mut keyed: Vec<(Value, Value)> = Vec::with_capacity(items.len());
834                    for item in items {
835                        let key = self.invoke_closure_value(f.clone(), vec![item.clone()])?;
836                        keyed.push((key, item));
837                    }
838                    keyed.sort_by(|(ka, _), (kb, _)| compare_sort_keys(ka, kb));
839                    let sorted: Vec<Value> = keyed.into_iter().map(|(_, v)| v).collect();
840                    self.stack.push(Value::List(sorted));
841                }
842                Op::ParallelMap { node_id_idx: _ } => {
843                    // #305 slice 1: pop (xs, f) and apply f to each
844                    // element across OS threads.
845                    //
846                    // #305 slice 2: each worker now asks the parent
847                    // handler for a thread-safe per-worker handler via
848                    // `EffectHandler::spawn_for_worker`. Handlers that
849                    // opt in (e.g. `DefaultHandler`) yield a fresh
850                    // instance sharing the budget pool; handlers that
851                    // don't fall back to the slice-1 behavior of
852                    // `DenyAllEffects` in the worker.
853                    let f = self.pop()?;
854                    let xs = self.pop()?;
855                    let items = match xs {
856                        Value::List(v) => v,
857                        other => return Err(VmError::TypeMismatch(
858                            format!("ParallelMap requires a List, got: {other:?}"))),
859                    };
860                    if !matches!(f, Value::Closure { .. }) {
861                        return Err(VmError::TypeMismatch(
862                            format!("ParallelMap requires a closure, got: {f:?}")));
863                    }
864                    // Pre-build one handler per worker on the main
865                    // thread so the worker just owns its handler with
866                    // no shared borrowing. The actual worker count is
867                    // capped by `LEX_PAR_MAX_CONCURRENCY` (resolved
868                    // inside par_map_run); cap ≤ items.len() so we
869                    // never over-allocate handlers.
870                    let n_workers = par_max_concurrency().max(1).min(items.len().max(1));
871                    let mut worker_handlers: Vec<Box<dyn EffectHandler + Send>> =
872                        Vec::with_capacity(n_workers);
873                    for _ in 0..n_workers {
874                        worker_handlers.push(
875                            self.handler
876                                .spawn_for_worker()
877                                .unwrap_or_else(|| Box::new(DenyAllEffects)),
878                        );
879                    }
880                    let results = par_map_run(self.program, f, items, worker_handlers)?;
881                    self.stack.push(Value::List(results));
882                }
883                Op::Call { fn_id, arity, node_id_idx } => {
884                    let mut args: Vec<Value> = (0..arity).map(|_| Value::Unit).collect();
885                    for i in (0..arity as usize).rev() { args[i] = self.pop()?; }
886                    let node_id = const_str(&self.program.constants, node_id_idx);
887                    let callee_name = self.program.functions[fn_id as usize].name.clone();
888                    let budget_cost = call_budget_cost(&self.program.functions[fn_id as usize]);
889                    if budget_cost > 0 {
890                        self.handler.note_call_budget(budget_cost)
891                            .map_err(VmError::Effect)?;
892                    }
893                    // Refinement runtime check (#209 slice 3). Each
894                    // param's `Option<Refinement>` is evaluated against
895                    // the actual arg before the frame is pushed. The
896                    // tracer sees the call enter; failure surfaces as
897                    // `VmError::RefinementFailed` *before* the body
898                    // starts, which means an erroring trace shows the
899                    // call as enter+exit_err with the verdict reason
900                    // (same shape as `gate.verdict`).
901                    let refinements = self.program.functions[fn_id as usize]
902                        .refinements.clone();
903                    for (i, refinement) in refinements.iter().enumerate() {
904                        if let Some(r) = refinement {
905                            let arg = args.get(i).cloned().unwrap_or(Value::Unit);
906                            match eval_refinement(&r.predicate, &r.binding, &arg) {
907                                Ok(true) => { /* satisfied, continue */ }
908                                Ok(false) => {
909                                    return Err(VmError::RefinementFailed {
910                                        fn_name: callee_name.clone(),
911                                        param_index: i,
912                                        binding: r.binding.clone(),
913                                        reason: format!(
914                                            "predicate failed for {} = {arg:?}",
915                                            r.binding),
916                                    });
917                                }
918                                Err(reason) => {
919                                    return Err(VmError::RefinementFailed {
920                                        fn_name: callee_name.clone(),
921                                        param_index: i,
922                                        binding: r.binding.clone(),
923                                        reason,
924                                    });
925                                }
926                            }
927                        }
928                    }
929                    // Pure-fn memoization (#229): if the callee declares
930                    // no effects, hash the args and consult the cache.
931                    // On hit, push the cached value, emit synthetic
932                    // enter+exit trace events (so the trace still shows
933                    // the call), and skip the frame push entirely.
934                    let f = &self.program.functions[fn_id as usize];
935                    let memo_key: Option<(u32, [u8; 16])> = if f.effects.is_empty() {
936                        Some((fn_id, hash_call_args(&args)))
937                    } else {
938                        None
939                    };
940                    if let Some(key) = memo_key {
941                        if let Some(cached) = self.pure_memo.get(&key).cloned() {
942                            self.pure_memo_hits += 1;
943                            self.tracer.enter_call(&node_id, &callee_name, &args);
944                            self.tracer.exit_ok(&cached);
945                            self.stack.push(cached);
946                            continue;
947                        }
948                        self.pure_memo_misses += 1;
949                    }
950                    self.tracer.enter_call(&node_id, &callee_name, &args);
951                    let mut locals = vec![Value::Unit; f.locals_count.max(f.arity) as usize];
952                    for (i, v) in args.into_iter().enumerate() { locals[i] = v; }
953                    self.push_frame(Frame {
954                        fn_id, pc: 0, locals, stack_base: self.stack.len(),
955                        trace_kind: FrameKind::Call(node_id),
956                        memo_key,
957                    })?;
958                }
959                Op::TailCall { fn_id, arity, node_id_idx } => {
960                    let mut args: Vec<Value> = (0..arity).map(|_| Value::Unit).collect();
961                    for i in (0..arity as usize).rev() { args[i] = self.pop()?; }
962                    let node_id = const_str(&self.program.constants, node_id_idx);
963                    let callee_name = self.program.functions[fn_id as usize].name.clone();
964                    let budget_cost = call_budget_cost(&self.program.functions[fn_id as usize]);
965                    if budget_cost > 0 {
966                        self.handler.note_call_budget(budget_cost)
967                            .map_err(VmError::Effect)?;
968                    }
969                    // Refinement runtime check on tail calls too
970                    // (#209 slice 3). Same shape as Op::Call.
971                    let refinements = self.program.functions[fn_id as usize]
972                        .refinements.clone();
973                    for (i, refinement) in refinements.iter().enumerate() {
974                        if let Some(r) = refinement {
975                            let arg = args.get(i).cloned().unwrap_or(Value::Unit);
976                            match eval_refinement(&r.predicate, &r.binding, &arg) {
977                                Ok(true) => {}
978                                Ok(false) => return Err(VmError::RefinementFailed {
979                                    fn_name: callee_name.clone(),
980                                    param_index: i,
981                                    binding: r.binding.clone(),
982                                    reason: format!(
983                                        "predicate failed for {} = {arg:?}",
984                                        r.binding),
985                                }),
986                                Err(reason) => return Err(VmError::RefinementFailed {
987                                    fn_name: callee_name.clone(),
988                                    param_index: i,
989                                    binding: r.binding.clone(),
990                                    reason,
991                                }),
992                            }
993                        }
994                    }
995                    // A tail call closes the current call's trace frame and
996                    // opens a new one in its place — preserves the caller's
997                    // tree depth in the trace.
998                    self.tracer.exit_call_tail();
999                    self.tracer.enter_call(&node_id, &callee_name, &args);
1000                    let f = &self.program.functions[fn_id as usize];
1001                    let frame = self.frames.last_mut().unwrap();
1002                    frame.fn_id = fn_id;
1003                    frame.pc = 0;
1004                    frame.locals = vec![Value::Unit; f.locals_count.max(f.arity) as usize];
1005                    for (i, v) in args.into_iter().enumerate() { frame.locals[i] = v; }
1006                    frame.trace_kind = FrameKind::Call(node_id);
1007                }
1008                Op::EffectCall { kind_idx, op_idx, arity, node_id_idx } => {
1009                    let mut args: Vec<Value> = (0..arity).map(|_| Value::Unit).collect();
1010                    for i in (0..arity as usize).rev() { args[i] = self.pop()?; }
1011                    let kind = match &self.program.constants[kind_idx as usize] {
1012                        Const::Str(s) => s.clone(),
1013                        _ => return Err(VmError::TypeMismatch("expected Str const for effect kind".into())),
1014                    };
1015                    let op_name = match &self.program.constants[op_idx as usize] {
1016                        Const::Str(s) => s.clone(),
1017                        _ => return Err(VmError::TypeMismatch("expected Str const for effect op".into())),
1018                    };
1019                    let node_id = const_str(&self.program.constants, node_id_idx);
1020                    self.tracer.enter_effect(&node_id, &kind, &op_name, &args);
1021                    let result = match self.tracer.override_effect(&node_id) {
1022                        Some(v) => Ok(v),
1023                        // VM-level intercept for `parser.run` (#221).
1024                        // Routed inline rather than through the handler
1025                        // because the parser interpreter needs reentrant
1026                        // VM access to invoke `Value::Closure` values
1027                        // from `Map` / `AndThen` nodes.
1028                        None if (kind.as_str(), op_name.as_str()) == ("parser", "run")
1029                            => self.run_parser_op(args.clone()),
1030                        None => self.handler.dispatch(&kind, &op_name, args.clone()),
1031                    };
1032                    match result {
1033                        Ok(v) => {
1034                            self.tracer.exit_ok(&v);
1035                            self.stack.push(v);
1036                        }
1037                        Err(e) => {
1038                            self.tracer.exit_err(&e);
1039                            return Err(VmError::Effect(e));
1040                        }
1041                    }
1042                }
1043                Op::Return => {
1044                    let v = self.pop()?;
1045                    let frame = self.frames.pop().unwrap();
1046                    // Trim any extra stuff that the function pushed but didn't pop.
1047                    self.stack.truncate(frame.stack_base);
1048                    if matches!(frame.trace_kind, FrameKind::Call(_)) {
1049                        self.tracer.exit_ok(&v);
1050                    }
1051                    // Pure-fn memoization (#229): if this frame was a
1052                    // memoizable call that missed the cache, write the
1053                    // computed return value back so the next call with
1054                    // the same args returns it without re-executing.
1055                    if let Some(key) = frame.memo_key {
1056                        self.pure_memo.insert(key, v.clone());
1057                    }
1058                    // Exit when we've returned past the depth this
1059                    // `run_to` was entered at — supports reentrancy
1060                    // (a nested `invoke` returns into its caller, not
1061                    // out of the outermost VM run, #221).
1062                    if self.frames.len() <= base_depth {
1063                        return Ok(v);
1064                    }
1065                    self.stack.push(v);
1066                }
1067                Op::Panic(i) => {
1068                    let msg = match &self.program.constants[i as usize] {
1069                        Const::Str(s) => s.clone(),
1070                        _ => "panic".into(),
1071                    };
1072                    return Err(VmError::Panic(msg));
1073                }
1074                // Arithmetic
1075                Op::IntAdd => self.bin_int(|a, b| Value::Int(a + b))?,
1076                Op::IntSub => self.bin_int(|a, b| Value::Int(a - b))?,
1077                Op::IntMul => self.bin_int(|a, b| Value::Int(a * b))?,
1078                Op::IntDiv => self.bin_int(|a, b| Value::Int(a / b))?,
1079                Op::IntMod => self.bin_int(|a, b| Value::Int(a % b))?,
1080                Op::IntNeg => {
1081                    let a = self.pop()?.as_int();
1082                    self.stack.push(Value::Int(-a));
1083                }
1084                Op::IntEq => self.bin_int(|a, b| Value::Bool(a == b))?,
1085                Op::IntLt => self.bin_int(|a, b| Value::Bool(a < b))?,
1086                Op::IntLe => self.bin_int(|a, b| Value::Bool(a <= b))?,
1087                Op::FloatAdd => self.bin_float(|a, b| Value::Float(a + b))?,
1088                Op::FloatSub => self.bin_float(|a, b| Value::Float(a - b))?,
1089                Op::FloatMul => self.bin_float(|a, b| Value::Float(a * b))?,
1090                Op::FloatDiv => self.bin_float(|a, b| Value::Float(a / b))?,
1091                Op::FloatNeg => {
1092                    let a = self.pop()?.as_float();
1093                    self.stack.push(Value::Float(-a));
1094                }
1095                Op::FloatEq => self.bin_float(|a, b| Value::Bool(a == b))?,
1096                Op::FloatLt => self.bin_float(|a, b| Value::Bool(a < b))?,
1097                Op::FloatLe => self.bin_float(|a, b| Value::Bool(a <= b))?,
1098                Op::NumAdd => {
1099                    // #308: `+` is overloaded — Str+Str concatenates,
1100                    // numerics add. Other arithmetic ops (-, *, /, %)
1101                    // still reject Str at the type-checker layer.
1102                    let b = self.pop()?;
1103                    let a = self.pop()?;
1104                    match (a, b) {
1105                        (Value::Int(x), Value::Int(y)) => self.stack.push(Value::Int(x + y)),
1106                        (Value::Float(x), Value::Float(y)) => self.stack.push(Value::Float(x + y)),
1107                        (Value::Str(x), Value::Str(y)) => {
1108                            let mut s = x;
1109                            s.push_str(&y);
1110                            self.stack.push(Value::Str(s));
1111                        }
1112                        (a, b) => return Err(VmError::TypeMismatch(format!("Num op: {a:?} {b:?}"))),
1113                    }
1114                }
1115                Op::NumSub => self.bin_num(|a, b| Value::Int(a - b), |a, b| Value::Float(a - b))?,
1116                Op::NumMul => self.bin_num(|a, b| Value::Int(a * b), |a, b| Value::Float(a * b))?,
1117                Op::NumDiv => self.bin_num(|a, b| Value::Int(a / b), |a, b| Value::Float(a / b))?,
1118                Op::NumMod => self.bin_int(|a, b| Value::Int(a % b))?,
1119                Op::NumNeg => {
1120                    let v = self.pop()?;
1121                    match v {
1122                        Value::Int(n) => self.stack.push(Value::Int(-n)),
1123                        Value::Float(f) => self.stack.push(Value::Float(-f)),
1124                        other => return Err(VmError::TypeMismatch(format!("NumNeg on {other:?}"))),
1125                    }
1126                }
1127                Op::NumEq => self.bin_eq()?,
1128                Op::NumLt => self.bin_ord(|a, b| Value::Bool(a < b), |a, b| Value::Bool(a < b), |a, b| Value::Bool(a < b))?,
1129                Op::NumLe => self.bin_ord(|a, b| Value::Bool(a <= b), |a, b| Value::Bool(a <= b), |a, b| Value::Bool(a <= b))?,
1130                Op::BoolAnd => {
1131                    let b = self.pop()?.as_bool();
1132                    let a = self.pop()?.as_bool();
1133                    self.stack.push(Value::Bool(a && b));
1134                }
1135                Op::BoolOr => {
1136                    let b = self.pop()?.as_bool();
1137                    let a = self.pop()?.as_bool();
1138                    self.stack.push(Value::Bool(a || b));
1139                }
1140                Op::BoolNot => {
1141                    let a = self.pop()?.as_bool();
1142                    self.stack.push(Value::Bool(!a));
1143                }
1144                Op::StrConcat => {
1145                    let b = self.pop()?;
1146                    let a = self.pop()?;
1147                    let s = format!("{}{}", a.as_str(), b.as_str());
1148                    self.stack.push(Value::Str(s));
1149                }
1150                Op::StrLen => {
1151                    let v = self.pop()?;
1152                    self.stack.push(Value::Int(v.as_str().len() as i64));
1153                }
1154                Op::StrEq => {
1155                    let b = self.pop()?;
1156                    let a = self.pop()?;
1157                    self.stack.push(Value::Bool(a.as_str() == b.as_str()));
1158                }
1159                Op::BytesLen => {
1160                    let v = self.pop()?;
1161                    match v {
1162                        Value::Bytes(b) => self.stack.push(Value::Int(b.len() as i64)),
1163                        other => return Err(VmError::TypeMismatch(format!("BytesLen on {other:?}"))),
1164                    }
1165                }
1166                Op::BytesEq => {
1167                    let b = self.pop()?;
1168                    let a = self.pop()?;
1169                    let eq = match (a, b) {
1170                        (Value::Bytes(x), Value::Bytes(y)) => x == y,
1171                        _ => return Err(VmError::TypeMismatch("BytesEq operands".into())),
1172                    };
1173                    self.stack.push(Value::Bool(eq));
1174                }
1175            }
1176        }
1177    }
1178
1179    fn pop(&mut self) -> Result<Value, VmError> {
1180        self.stack.pop().ok_or(VmError::StackUnderflow)
1181    }
1182    fn peek(&self) -> Result<&Value, VmError> {
1183        self.stack.last().ok_or(VmError::StackUnderflow)
1184    }
1185
1186    fn bin_int(&mut self, f: impl Fn(i64, i64) -> Value) -> Result<(), VmError> {
1187        let b = self.pop()?.as_int();
1188        let a = self.pop()?.as_int();
1189        self.stack.push(f(a, b));
1190        Ok(())
1191    }
1192    fn bin_float(&mut self, f: impl Fn(f64, f64) -> Value) -> Result<(), VmError> {
1193        let b = self.pop()?.as_float();
1194        let a = self.pop()?.as_float();
1195        self.stack.push(f(a, b));
1196        Ok(())
1197    }
1198    fn bin_num(
1199        &mut self,
1200        i: impl Fn(i64, i64) -> Value,
1201        f: impl Fn(f64, f64) -> Value,
1202    ) -> Result<(), VmError> {
1203        let b = self.pop()?;
1204        let a = self.pop()?;
1205        match (a, b) {
1206            (Value::Int(x), Value::Int(y)) => { self.stack.push(i(x, y)); Ok(()) }
1207            (Value::Float(x), Value::Float(y)) => { self.stack.push(f(x, y)); Ok(()) }
1208            (a, b) => Err(VmError::TypeMismatch(format!("Num op: {a:?} {b:?}"))),
1209        }
1210    }
1211
1212    /// Like `bin_num` but also handles `Str` operands via lexicographic order.
1213    /// Used by `NumLt` / `NumLe` because the type checker admits `Str < Str`
1214    /// and `>` / `>=` compile as swap+NumLt / swap+NumLe (#332).
1215    fn bin_ord(
1216        &mut self,
1217        i: impl Fn(i64, i64) -> Value,
1218        f: impl Fn(f64, f64) -> Value,
1219        s: impl Fn(&str, &str) -> Value,
1220    ) -> Result<(), VmError> {
1221        let b = self.pop()?;
1222        let a = self.pop()?;
1223        match (a, b) {
1224            (Value::Int(x), Value::Int(y)) => { self.stack.push(i(x, y)); Ok(()) }
1225            (Value::Float(x), Value::Float(y)) => { self.stack.push(f(x, y)); Ok(()) }
1226            (Value::Str(x), Value::Str(y)) => { self.stack.push(s(&x, &y)); Ok(()) }
1227            (a, b) => Err(VmError::TypeMismatch(format!("Num op: {a:?} {b:?}"))),
1228        }
1229    }
1230    fn bin_eq(&mut self) -> Result<(), VmError> {
1231        let b = self.pop()?;
1232        let a = self.pop()?;
1233        self.stack.push(Value::Bool(a == b));
1234        Ok(())
1235    }
1236}
1237
1238fn const_to_value(c: &Const) -> Value {
1239    match c {
1240        Const::Int(n) => Value::Int(*n),
1241        Const::Float(f) => Value::Float(*f),
1242        Const::Bool(b) => Value::Bool(*b),
1243        Const::Str(s) => Value::Str(s.clone()),
1244        Const::Bytes(b) => Value::Bytes(b.clone()),
1245        Const::Unit => Value::Unit,
1246        Const::FieldName(s) | Const::VariantName(s) | Const::NodeId(s) => Value::Str(s.clone()),
1247    }
1248}