From c80c3b2626d5550a5cf2c96df3257764a41e34ca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 22:48:09 +0000 Subject: [PATCH 1/6] feat(interp): an evaluator with its own stack on the heap The interpreter was a recursive tree walker: each call of the program used native stack (51 KB in a debug build, 9 KB in a release build), and the process aborted at a call depth of 161 (debug) or 907 (release) on a stack of 8 MB. Wasm reaches 16250 and gives a named trap. `eval` is now a loop with a stack of frames on the heap (owner decision 2026-11-10). An expression is lowered 1 time to code with shared parts (`Node`), so a frame can hold the part that runs next. A call uses no native stack: - 100000 calls deep give a value (0.14 s in a release build). - Calls with no end give the runtime error "call stack exhausted" at 1000000 frames, with exit code 1. The process does not abort. The behaviour of each form is the same, with the same order of evaluation and the same errors. An error leaves the environment as the recursive evaluator left it. A top-level function is lowered when its module is loaded, not copied at each call: `fib(27)` runs in 0.45 s, was 0.61 s. `Value::Closure` holds its body as `Code` (an opaque shared pointer), not as an `Expr`. No other crate reads that field. Not in this change: a `Value` still copies, prints and frees its parts with native recursion, so a program that builds a list of more than about 1000 elements can still abort. That is the next pull request. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- TEST-NEEDS.adoc | 6 +- src/ephapax-cli/tests/run_wasm.rs | 37 + src/ephapax-interp/src/lib.rs | 1340 ++++++++++++++++++----------- 3 files changed, 885 insertions(+), 498 deletions(-) diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index 7b382eb..50b65aa 100644 --- a/TEST-NEEDS.adoc +++ b/TEST-NEEDS.adoc @@ -21,9 +21,9 @@ compliance, 16 tests) performance, correctness, 13 tests) * Benchmarks: `+src/ephapax-parser/benches/parse_bench.rs+`, `+src/ephapax-vram-cache/benches/cache_bench.rs+` -* Total: *640 tests* (`+cargo test --workspace --all-targets+`); +* Total: *644 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 640 +* Documented all-target tests: 644 === CRG Testing Taxonomy — Status @@ -83,7 +83,7 @@ eliminator-fork invariant + its gated capstone, per PROOF-NEEDS.adoc §4) ==== Build & Execution * [x] cargo build — compiles -* [x] cargo test –workspace –all-targets — 640 pass, 0 fail +* [x] cargo test –workspace –all-targets — 644 pass, 0 fail * [x] idris2/ephapax-parse-tests.ipkg — compiles AND runs green (2026-07-07, idris2 0.8.0 + zig-built libephapax_tokbuf.so); CI-gated compile-only by abi-verify.yml `+idris2-parse-front+` (runtime run stays diff --git a/src/ephapax-cli/tests/run_wasm.rs b/src/ephapax-cli/tests/run_wasm.rs index 4481d60..fd46faf 100644 --- a/src/ephapax-cli/tests/run_wasm.rs +++ b/src/ephapax-cli/tests/run_wasm.rs @@ -709,6 +709,43 @@ fn a_constructor_checks_its_arguments_against_the_declaration() { } } +/// The interpreter keeps its work on the heap, so a call uses no native +/// stack. Calls 100000 deep give a value, through the frames of a `match`, +/// of a `let` and of a call. Calls with no end give a named error and the +/// exit code 1. (The recursive evaluator aborted the process at a depth +/// of about 160 in a debug build.) +#[test] +fn the_interpreter_runs_deep_calls_and_names_its_limit() { + let tmp = tempfile::tempdir().expect("tempdir"); + let write = + |name: &str, text: &str| std::fs::write(tmp.path().join(name), text).expect("write module"); + + write( + "deep.eph", + "module deep\n\ndata Step = Stop | Go(I32)\n\n\ + fn next(n: I32): Step = if n == 0 then Stop else Go(n - 1)\n\n\ + fn depth(n: I32): I32 =\n match next(n) of\n | Stop => 0\n \ + | Go(m) => let d = depth(m) in d + 1\n end\n\n\ + fn main(): I32 = depth(100000)\n", + ); + assert_eq!( + stdout_of(tmp.path(), &["run", "--interp", "deep.eph"]), + "100000" + ); + + write( + "no_end.eph", + "module no_end\n\nfn f(n: I32): I32 = 1 + f(n)\n\nfn main(): I32 = f(0)\n", + ); + let out = ephapax(tmp.path(), &["run", "--interp", "no_end.eph"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.code() == Some(1) && stderr.contains("Runtime error: call stack exhausted"), + "exit {:?}, stderr:\n{stderr}", + out.status.code() + ); +} + /// A `type` alias and an extern type follow the rule of data types (owner /// decision 2026-11-10): a module names one when it declares it, or when /// it imports the module that declares it. An alias must also be `pub`; diff --git a/src/ephapax-interp/src/lib.rs b/src/ephapax-interp/src/lib.rs index ba6a343..297a2cc 100644 --- a/src/ephapax-interp/src/lib.rs +++ b/src/ephapax-interp/src/lib.rs @@ -6,13 +6,18 @@ //! //! A simple interpreter for debugging and rapid development. //! Useful for testing before compiling to WASM. +//! +//! The evaluator is a loop with its own stack of frames on the heap +//! ([`Interpreter::eval`]). A call in the program uses no native stack, +//! so deep recursion ends with a value or with the runtime error +//! [`RuntimeError::StackOverflow`], and the process does not abort. use ephapax_syntax::{ - BaseTy, BinOp, Decl, Expr, ExprKind, Literal, MatchArm, Module, Pattern, RegionName, Ty, - UnaryOp, Var, + BaseTy, BinOp, Decl, Expr, ExprKind, Literal, Module, Pattern, RegionName, Ty, UnaryOp, Var, }; use smol_str::SmolStr; use std::collections::HashMap; +use std::sync::Arc; use thiserror::Error; /// Runtime info for a single constructor: (position, total, arity). @@ -107,11 +112,16 @@ pub enum RuntimeError { #[error("Index {index} out of bounds (length {length})")] IndexOutOfBounds { index: usize, length: usize }, + + /// The calls of the program are too deep: the evaluator has its + /// greatest number of frames. Wasm traps with this text too. + #[error("call stack exhausted")] + StackOverflow, } /// Runtime values // -// clippy::large_enum_variant — `Closure` is ~264 bytes against ~48 for the next +// clippy::large_enum_variant — `Closure` is ~168 bytes against ~48 for the next // largest variant. The fix is to box the closure payload, but that changes every // construction and match site in the interpreter, so it is deliberately NOT bundled // into the CI-revival change that first surfaced this lint. Tracked separately; @@ -137,7 +147,7 @@ pub enum Value { Closure { param: Var, param_ty: Ty, - body: Expr, + body: Code, env: Environment, }, Borrow(Box), @@ -315,8 +325,13 @@ impl Environment { } } -/// Function definition: (parameters, return type, body expression). -type FnDef = (Vec<(Var, Ty)>, Ty, Expr); +/// A top-level function, in the form that the evaluator runs. +struct Function { + params: Vec<(Var, Ty)>, + body: Arc, + /// The function as a value ([`function_value`]). + value: Value, +} /// Interpreter state pub struct Interpreter { @@ -324,9 +339,9 @@ pub struct Interpreter { /// Active regions stack regions: Vec, /// Function definitions - functions: HashMap, + functions: HashMap, /// The value expression of each top-level constant. - constants: HashMap, + constants: HashMap>, /// Data declarations from loaded modules, used by `eval_match` to /// resolve `Pattern::Constructor` against the runtime binary-sum /// encoding. @@ -353,6 +368,314 @@ fn value_matches_literal(val: &Value, lit: &Literal) -> bool { } } +/// The greatest number of frames of the evaluator. A frame holds the work +/// that waits for a value, so this is the limit of the depth of the calls +/// of a program. More frames give [`RuntimeError::StackOverflow`]. +const MAX_FRAMES: usize = 1_000_000; + +/// The code of a closure, in the form that the evaluator runs. +#[derive(Clone)] +pub struct Code(Arc); + +impl std::fmt::Debug for Code { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "") + } +} + +/// An expression in the form that the evaluator runs. Each part is +/// shared, so a frame of the evaluator can hold the part that runs next. +#[derive(Debug)] +enum Node { + Lit(Literal), + Var(Var), + StringNew { + region: RegionName, + value: String, + }, + Lambda { + param: Var, + param_ty: Ty, + body: Arc, + }, + Unary(Unary, Arc), + Binary(Binary, Arc, Arc), + Let { + name: Var, + value: Arc, + body: Arc, + }, + If { + cond: Arc, + then_branch: Arc, + else_branch: Arc, + }, + /// `left` and `right`: the name and the body of each branch. + Case { + scrutinee: Arc, + left: (Var, Arc), + right: (Var, Arc), + }, + Match { + scrutinee: Arc, + arms: Arc<[Arm]>, + }, + Region { + name: RegionName, + body: Arc, + }, + Seq(Seq, Arc<[Arc]>), + /// A form with no implementation in the interpreter: the text of the + /// error. + Unimplemented(&'static str), +} + +/// An arm of a [`Node::Match`]. +#[derive(Debug)] +struct Arm { + pattern: Pattern, + guard: Option>, + body: Arc, +} + +/// An operation on the value of 1 operand. +#[derive(Debug, Clone, Copy)] +enum Unary { + Op(UnaryOp), + StringLen, + Fst, + Snd, + Inl, + Inr, + Borrow, + Deref, + Drop, + Copy, + TupleIndex(usize), +} + +/// An operation on the values of 2 operands, the left one first. +#[derive(Debug, Clone, Copy)] +enum Binary { + Op(BinOp), + Concat, + Pair, + ListIndex, + /// The function, then its argument. + App, +} + +/// The forms with a sequence of parts, each evaluated in order. +#[derive(Debug, Clone, Copy, PartialEq)] +enum Seq { + Block, + List, + Tuple, +} + +impl Seq { + /// The value of the form from the values of its parts. For a block, + /// `values` has the value of the last part only. + fn value(self, mut values: Vec) -> Value { + match self { + Seq::Block => values.pop().unwrap_or(Value::Unit), + Seq::List => Value::List(values), + Seq::Tuple => Value::Tuple(values), + } + } +} + +/// The form of `expr` that the evaluator runs. +fn lower(expr: &Expr) -> Arc { + let unary = |op, inner: &Expr| Node::Unary(op, lower(inner)); + let binary = |op, left: &Expr, right: &Expr| Node::Binary(op, lower(left), lower(right)); + let seq = |kind, parts: &[Expr]| Node::Seq(kind, parts.iter().map(lower).collect()); + Arc::new(match &expr.kind { + ExprKind::Lit(lit) => Node::Lit(lit.clone()), + // The interpreter runs a generic function as it is: the type + // arguments select no code. + ExprKind::Var(name) | ExprKind::TypeApp { name, .. } => Node::Var(name.clone()), + ExprKind::StringNew { region, value } => Node::StringNew { + region: region.clone(), + value: value.clone(), + }, + ExprKind::StringConcat { left, right } => binary(Binary::Concat, left, right), + ExprKind::StringLen(inner) => unary(Unary::StringLen, inner), + ExprKind::Let { + name, value, body, .. + } + | ExprKind::LetLin { + name, value, body, .. + } => Node::Let { + name: name.clone(), + value: lower(value), + body: lower(body), + }, + ExprKind::Lambda { + param, + param_ty, + body, + } => Node::Lambda { + param: param.clone(), + param_ty: param_ty.clone(), + body: lower(body), + }, + ExprKind::App { func, arg } => binary(Binary::App, func, arg), + ExprKind::Pair { left, right } => binary(Binary::Pair, left, right), + ExprKind::Fst(inner) => unary(Unary::Fst, inner), + ExprKind::Snd(inner) => unary(Unary::Snd, inner), + ExprKind::Inl { value, .. } => unary(Unary::Inl, value), + ExprKind::Inr { value, .. } => unary(Unary::Inr, value), + // A value of a recursive type is the value of its unfolding: + // `fold` and `unfold` change the type only. + ExprKind::Fold { value, .. } | ExprKind::Unfold(value) => return lower(value), + ExprKind::Case { + scrutinee, + left_var, + left_body, + right_var, + right_body, + } => Node::Case { + scrutinee: lower(scrutinee), + left: (left_var.clone(), lower(left_body)), + right: (right_var.clone(), lower(right_body)), + }, + ExprKind::If { + cond, + then_branch, + else_branch, + } => Node::If { + cond: lower(cond), + then_branch: lower(then_branch), + else_branch: lower(else_branch), + }, + ExprKind::Region { name, body } => Node::Region { + name: name.clone(), + body: lower(body), + }, + ExprKind::Borrow { inner, .. } => unary(Unary::Borrow, inner), + ExprKind::Deref(inner) => unary(Unary::Deref, inner), + ExprKind::Drop(inner) => unary(Unary::Drop, inner), + ExprKind::Copy(inner) => unary(Unary::Copy, inner), + ExprKind::Block(exprs) => seq(Seq::Block, exprs), + ExprKind::BinOp { op, left, right } => binary(Binary::Op(*op), left, right), + ExprKind::UnaryOp { op, operand } => unary(Unary::Op(*op), operand), + ExprKind::ListLit(elements) => seq(Seq::List, elements), + ExprKind::ListIndex { list, index } => binary(Binary::ListIndex, list, index), + ExprKind::TupleLit(exprs) => seq(Seq::Tuple, exprs), + ExprKind::TupleIndex { tuple, index } => unary(Unary::TupleIndex(*index), tuple), + ExprKind::FFI { .. } => { + Node::Unimplemented("raw __ffi is not supported; use typed extern declarations") + } + ExprKind::Perform { .. } => { + Node::Unimplemented("effect perform not yet implemented in interpreter") + } + ExprKind::Handle { .. } => { + Node::Unimplemented("effect handle not yet implemented in interpreter") + } + ExprKind::Match { scrutinee, arms } => Node::Match { + scrutinee: lower(scrutinee), + arms: arms + .iter() + .map(|arm| Arm { + pattern: arm.pattern.clone(), + guard: arm.guard.as_deref().map(lower), + body: lower(&arm.body), + }) + .collect(), + }, + }) +} + +/// The evaluator either evaluates code or has a value for the top frame. +enum State { + Eval(Arc), + Value(Value), +} + +/// What a name had before a binding: its value and its consumed mark. +type Saved = (Var, Option, Option); + +/// Work that waits for a value. The frames are on the heap: they take the +/// place of the native stack frames of a recursive evaluator. +enum Frame { + /// The operand of a unary operation. + Unary(Unary), + /// The left operand of a binary operation; the code of the right one. + Left(Binary, Arc), + /// The right operand of a binary operation; the value of the left one. + Right(Binary, Value), + /// The value of a `let`; its name and its body. + Let(Var, Arc), + /// The condition of an `if`; its 2 branches. + If(Arc, Arc), + /// The scrutinee of a `case`; the name and the body of each branch. + Case((Var, Arc), (Var, Arc)), + /// The scrutinee of a `match`. + Match(Arc<[Arm]>), + /// The guard of arm `arm`, with the names of the arm bound. + Guard { + arms: Arc<[Arm]>, + arm: usize, + scrutinee: Value, + saved: Vec, + }, + /// Part `next - 1` of a sequence; the values of the parts before it. + Seq { + kind: Seq, + parts: Arc<[Arc]>, + next: usize, + values: Vec, + }, + /// The body of a `let`, of a `case` branch or of a match arm: its + /// names go out of scope. + Unbind(Vec), + /// The body of a closure or the value of a constant: the evaluation + /// goes back to the environment `saved`. `on_error` is set for a + /// constant, which also goes back when its value is an error. + Env { saved: Environment, on_error: bool }, + /// The body of a region: the region ends. + Region(RegionName), +} + +/// A top-level function as a value: a closure for its first +/// parameter, with a lambda in its body for each next parameter. So +/// `f(a, b)`, which is `(f a) b`, gives each parameter its argument. +/// A function with no parameter takes the unit argument of `f()`. +/// The closure starts from an empty environment: the body of a +/// top-level function sees its parameters and the top-level +/// functions, and no local name of the caller. +fn function_value(params: &[(Var, Ty)], body: &Arc) -> Value { + let mut params = params.iter().cloned(); + let (param, param_ty) = params + .next() + .unwrap_or_else(|| ("_".into(), Ty::Base(BaseTy::Unit))); + let body = params.rev().fold(body.clone(), |body, (param, param_ty)| { + Arc::new(Node::Lambda { + param, + param_ty, + body, + }) + }); + Value::Closure { + param, + param_ty, + body: Code(body), + env: Environment::new(), + } +} + +/// The element `index` of a list value. +fn list_element(elements: &[Value], index: usize) -> Result { + elements + .get(index) + .cloned() + .ok_or(RuntimeError::IndexOutOfBounds { + index, + length: elements.len(), + }) +} + impl Interpreter { pub fn new() -> Self { Self { @@ -391,19 +714,19 @@ impl Interpreter { pub fn load_module(&mut self, module: &Module) { for decl in &module.decls { if let Decl::Fn { - name, - visibility: _, - params, - ret_ty, - body, - type_params: _, + name, params, body, .. } = decl { - self.functions - .insert(name.clone(), (params.clone(), ret_ty.clone(), body.clone())); + let body = lower(body); + let function = Function { + value: function_value(params, &body), + params: params.clone(), + body, + }; + self.functions.insert(name.clone(), function); } if let Decl::Const { name, value, .. } = decl { - self.constants.insert(name.clone(), value.clone()); + self.constants.insert(name.clone(), lower(value)); } } self.data_registry.populate_from_module(module); @@ -411,142 +734,398 @@ impl Interpreter { /// Evaluate an expression pub fn eval(&mut self, expr: &Expr) -> Result { - match &expr.kind { - ExprKind::Lit(lit) => self.eval_lit(lit), - // The interpreter runs a generic function as it is: the type - // arguments select no code. - ExprKind::Var(name) | ExprKind::TypeApp { name, .. } => self.eval_var(name), - ExprKind::StringNew { region, value } => self.eval_string_new(region, value), - ExprKind::StringConcat { left, right } => self.eval_string_concat(left, right), - ExprKind::StringLen(inner) => self.eval_string_len(inner), - ExprKind::Let { - name, value, body, .. - } => self.eval_let(name, value, body), - ExprKind::LetLin { - name, value, body, .. - } => self.eval_let(name, value, body), - ExprKind::Lambda { + self.run(lower(expr)) + } + + /// Run `code` to a value. The work that waits for a value is in + /// `stack`, on the heap, so a call in the program uses no native + /// stack: the limit of the depth is [`MAX_FRAMES`], not the size of + /// the native stack. + fn run(&mut self, code: Arc) -> Result { + let mut stack: Vec = Vec::new(); + let mut state = State::Eval(code); + loop { + let step = match state { + State::Eval(code) => self.enter(&code, &mut stack), + State::Value(value) => match stack.pop() { + Some(frame) => self.resume(frame, value, &mut stack), + None => return Ok(value), + }, + }; + state = match step { + Ok(state) if stack.len() <= MAX_FRAMES => state, + step => { + // An error leaves the environment as it is at the + // error. The one exception is the value of a + // constant, which gives the environment back to the + // code that used the constant. + for frame in stack.into_iter().rev() { + if let Frame::Env { + saved, + on_error: true, + } = frame + { + self.env = saved; + } + } + return Err(step.err().unwrap_or(RuntimeError::StackOverflow)); + } + }; + } + } + + /// Start the evaluation of `code`: give its value, or push a frame for + /// the rest of its work and give the part to evaluate first. + fn enter(&mut self, code: &Arc, stack: &mut Vec) -> Result { + let first = match &**code { + Node::Lit(lit) => return Ok(State::Value(self.eval_lit(lit)?)), + Node::Var(name) => return self.enter_var(name, stack), + Node::StringNew { region, value } => { + return Ok(State::Value(self.eval_string_new(region, value)?)); + } + Node::Lambda { param, param_ty, body, - } => self.eval_lambda(param, param_ty, body), - ExprKind::App { func, arg } => self.eval_app(func, arg), - ExprKind::Pair { left, right } => self.eval_pair(left, right), - ExprKind::Fst(inner) => self.eval_fst(inner), - ExprKind::Snd(inner) => self.eval_snd(inner), - ExprKind::Inl { value, .. } => self.eval_inl(value), - ExprKind::Inr { value, .. } => self.eval_inr(value), - // A value of a recursive type is the value of its unfolding: - // `fold` and `unfold` change the type only. - ExprKind::Fold { value, .. } | ExprKind::Unfold(value) => self.eval(value), - ExprKind::Case { - scrutinee, - left_var, - left_body, - right_var, - right_body, - } => self.eval_case(scrutinee, left_var, left_body, right_var, right_body), - ExprKind::If { + } => { + return Ok(State::Value(Value::Closure { + param: param.clone(), + param_ty: param_ty.clone(), + body: Code(body.clone()), + env: self.env.clone(), + })); + } + Node::Unimplemented(what) => { + return Err(RuntimeError::Unimplemented((*what).into())); + } + Node::Unary(op, inner) => { + // A borrow of a variable reads the value and leaves it in + // place: `&s` is not a use of `s`. + if let (Unary::Borrow, Node::Var(name)) = (op, &**inner) { + if self.env.is_consumed(name) { + return Err(RuntimeError::LinearValueConsumed); + } + if let Some(value) = self.env.get(name) { + return Ok(State::Value(Value::Borrow(Box::new(value.clone())))); + } + } + stack.push(Frame::Unary(*op)); + inner + } + Node::Binary(op, left, right) => { + stack.push(Frame::Left(*op, right.clone())); + left + } + Node::Let { name, value, body } => { + stack.push(Frame::Let(name.clone(), body.clone())); + value + } + Node::If { cond, then_branch, else_branch, - } => self.eval_if(cond, then_branch, else_branch), - ExprKind::Region { name, body } => self.eval_region(name, body), - ExprKind::Borrow { inner, .. } => self.eval_borrow(inner), - ExprKind::Deref(inner) => self.eval_deref(inner), - ExprKind::Drop(inner) => self.eval_drop(inner), - ExprKind::Copy(inner) => self.eval_copy(inner), - ExprKind::Block(exprs) => self.eval_block(exprs), - ExprKind::BinOp { op, left, right } => self.eval_binop(*op, left, right), - ExprKind::UnaryOp { op, operand } => self.eval_unaryop(*op, operand), - ExprKind::ListLit(elements) => { - // Evaluate all list elements and collect into a list value. - let mut values = Vec::new(); - for expr in elements { - values.push(self.eval(expr)?); + } => { + stack.push(Frame::If(then_branch.clone(), else_branch.clone())); + cond + } + Node::Case { + scrutinee, + left, + right, + } => { + stack.push(Frame::Case(left.clone(), right.clone())); + scrutinee + } + Node::Match { scrutinee, arms } => { + stack.push(Frame::Match(arms.clone())); + scrutinee + } + Node::Region { name, body } => { + self.regions.push(name.clone()); + stack.push(Frame::Region(name.clone())); + body + } + Node::Seq(kind, parts) => match parts.first() { + Some(first) => { + stack.push(Frame::Seq { + kind: *kind, + parts: parts.clone(), + next: 1, + values: Vec::new(), + }); + first } - Ok(Value::List(values)) + None => return Ok(State::Value(kind.value(Vec::new()))), + }, + }; + Ok(State::Eval(first.clone())) + } + + /// Continue the work of `frame` with the `value` that it waits for. + fn resume( + &mut self, + frame: Frame, + value: Value, + stack: &mut Vec, + ) -> Result { + let next = match frame { + Frame::Unary(op) => return Ok(State::Value(self.unary(op, value)?)), + Frame::Left(op, right) => { + stack.push(Frame::Right(op, value)); + right } - ExprKind::ListIndex { list, index } => { - // Evaluate the list and index, then extract the element. - let list_val = self.eval(list)?; - let index_val = self.eval(index)?; - match (list_val, index_val) { - (Value::List(elements), Value::I32(i)) => { - let idx = i as usize; - if idx < elements.len() { - Ok(elements[idx].clone()) - } else { - Err(RuntimeError::IndexOutOfBounds { - index: idx, - length: elements.len(), - }) - } - } - (Value::List(elements), Value::I64(i)) => { - let idx = i as usize; - if idx < elements.len() { - Ok(elements[idx].clone()) - } else { - Err(RuntimeError::IndexOutOfBounds { - index: idx, - length: elements.len(), - }) - } + Frame::Right(op, left) => return self.binary(op, left, value, stack), + Frame::Let(name, body) => { + let saved = self.bind(vec![(name, value)]); + stack.push(Frame::Unbind(saved)); + body + } + Frame::If(then_branch, else_branch) => match value { + Value::Bool(true) => then_branch, + Value::Bool(false) => else_branch, + other => { + return Err(RuntimeError::TypeError { + expected: "Bool".to_string(), + found: other.type_name().to_string(), + }); + } + }, + Frame::Case(left, right) => { + let ((name, body), inner) = match value { + Value::Left(inner) => (left, inner), + Value::Right(inner) => (right, inner), + _ => return Err(RuntimeError::NotASum), + }; + let saved = self.bind(vec![(name, *inner)]); + stack.push(Frame::Unbind(saved)); + body + } + Frame::Match(arms) => return self.match_arms(arms, value, 0, stack), + Frame::Guard { + arms, + arm, + scrutinee, + saved, + } => match value { + Value::Bool(true) => { + stack.push(Frame::Unbind(saved)); + arms[arm].body.clone() + } + // The guard is false: the names of the arm go out of + // scope, and the next arms get the value. + Value::Bool(false) => { + self.unbind(saved); + return self.match_arms(arms, scrutinee, arm + 1, stack); + } + other => { + return Err(RuntimeError::TypeError { + expected: "Bool".into(), + found: other.type_name().into(), + }); + } + }, + Frame::Seq { + kind, + parts, + next, + mut values, + } => { + if kind == Seq::Block { + values.clear(); + } + values.push(value); + match parts.get(next).cloned() { + Some(part) => { + stack.push(Frame::Seq { + kind, + parts, + next: next + 1, + values, + }); + part } - _ => Err(RuntimeError::TypeError { - expected: "list and integer".to_string(), - found: "non-list or non-integer".to_string(), - }), + None => return Ok(State::Value(kind.value(values))), } } - ExprKind::TupleLit(exprs) => { - // Evaluate all tuple elements - let mut values = Vec::new(); - for expr in exprs { - values.push(self.eval(expr)?); + Frame::Unbind(saved) => { + self.unbind(saved); + return Ok(State::Value(value)); + } + Frame::Env { saved, .. } => { + self.env = saved; + return Ok(State::Value(value)); + } + Frame::Region(name) => { + self.regions.pop(); + // Check for region escape + if let Value::String { region, data } = &value { + if *region == name { + return Err(RuntimeError::StringEscaped(data.clone())); + } } - Ok(Value::Tuple(values)) + return Ok(State::Value(value)); } - ExprKind::TupleIndex { tuple, index } => { - let tuple_val = self.eval(tuple)?; - match tuple_val { - Value::Tuple(values) => { - if *index < values.len() { - Ok(values[*index].clone()) - } else { - Err(RuntimeError::Unimplemented(format!( - "tuple index {} out of bounds", - index - ))) - } + }; + Ok(State::Eval(next)) + } + + /// Bind each name of `bindings` in the environment. The result has + /// what each name had before, for [`Self::unbind`]. + fn bind(&mut self, bindings: Vec<(Var, Value)>) -> Vec { + // Save-restore: prevents environment leaking between nested let bindings. + // The old extend/remove pattern clobbered outer bindings on name shadowing + // and leaked intermediate env state to sibling expressions. + // See formal/DESIGN-NOTE-2026-03-28.md for the soundness proof. + let saved = bindings + .iter() + .map(|(name, _)| { + ( + name.clone(), + self.env.bindings.get(name).cloned(), + self.env.consumed.get(name).copied(), + ) + }) + .collect(); + for (name, value) in bindings { + self.env.extend(name, value); + } + saved + } + + /// Give each name of `saved` what it had before [`Self::bind`], the + /// last name first. + fn unbind(&mut self, saved: Vec) { + for (name, value, consumed) in saved.into_iter().rev() { + match value { + Some(value) => { + self.env.bindings.insert(name.clone(), value); + if let Some(consumed) = consumed { + self.env.consumed.insert(name, consumed); } - // A 2-tuple is a pair. - Value::Pair(left, _) if *index == 0 => Ok(*left), - Value::Pair(_, right) if *index == 1 => Ok(*right), - _ => Err(RuntimeError::Unimplemented( - "tuple index on non-tuple".to_string(), - )), } + None => self.env.remove(&name), } - ExprKind::FFI { .. } => Err(RuntimeError::Unimplemented( - "raw __ffi is not supported; use typed extern declarations".into(), - )), - - ExprKind::Perform { .. } => Err(RuntimeError::Unimplemented( - "effect perform not yet implemented in interpreter".into(), - )), - ExprKind::Handle { .. } => Err(RuntimeError::Unimplemented( - "effect handle not yet implemented in interpreter".into(), - )), - ExprKind::Match { scrutinee, arms } => self.eval_match(scrutinee, arms), } } - fn eval_binop(&mut self, op: BinOp, left: &Expr, right: &Expr) -> Result { - let left_val = self.eval(left)?; - let right_val = self.eval(right)?; + /// The result of `op` on the value of its operand. + fn unary(&self, op: Unary, val: Value) -> Result { + match op { + Unary::Op(op) => self.eval_unaryop(op, val), + Unary::StringLen => match &val { + Value::String { data, .. } => Ok(Value::I32(data.len() as i32)), + Value::Borrow(inner) => match inner.as_ref() { + Value::String { data, .. } => Ok(Value::I32(data.len() as i32)), + _ => Err(RuntimeError::TypeError { + expected: "String".to_string(), + found: val.type_name().to_string(), + }), + }, + _ => Err(RuntimeError::TypeError { + expected: "String".to_string(), + found: val.type_name().to_string(), + }), + }, + Unary::Fst => match val { + Value::Pair(left, _) => Ok(*left), + _ => Err(RuntimeError::NotAPair), + }, + Unary::Snd => match val { + Value::Pair(_, right) => Ok(*right), + _ => Err(RuntimeError::NotAPair), + }, + Unary::Inl => Ok(Value::Left(Box::new(val))), + Unary::Inr => Ok(Value::Right(Box::new(val))), + Unary::Borrow => Ok(Value::Borrow(Box::new(val))), + Unary::Deref => match val { + Value::Borrow(inner) => Ok(*inner), + _ => Err(RuntimeError::TypeError { + expected: "Borrow".to_string(), + found: val.type_name().to_string(), + }), + }, + // Evaluate and discard + Unary::Drop => Ok(Value::Unit), + Unary::Copy => { + // Only unrestricted types can be copied - linear types (String, etc.) cannot + if val.is_linear() { + return Err(RuntimeError::TypeError { + expected: "unrestricted (copyable) type".to_string(), + found: format!("linear type {}", val.type_name()), + }); + } + // For unrestricted types, return a pair of copies + Ok(Value::Pair(Box::new(val.clone()), Box::new(val))) + } + Unary::TupleIndex(index) => match val { + Value::Tuple(values) => { + if index < values.len() { + Ok(values[index].clone()) + } else { + Err(RuntimeError::Unimplemented(format!( + "tuple index {} out of bounds", + index + ))) + } + } + // A 2-tuple is a pair. + Value::Pair(left, _) if index == 0 => Ok(*left), + Value::Pair(_, right) if index == 1 => Ok(*right), + _ => Err(RuntimeError::Unimplemented( + "tuple index on non-tuple".to_string(), + )), + }, + } + } + /// The result of `op` on the values of its 2 operands. A call gives + /// the body of the function, to evaluate in the environment of the + /// closure. + fn binary( + &mut self, + op: Binary, + left: Value, + right: Value, + stack: &mut Vec, + ) -> Result { + Ok(State::Value(match op { + Binary::Op(op) => self.eval_binop(op, left, right)?, + Binary::Concat => self.eval_string_concat(left, right)?, + Binary::Pair => Value::Pair(Box::new(left), Box::new(right)), + Binary::ListIndex => match (left, right) { + (Value::List(elements), Value::I32(i)) => list_element(&elements, i as usize)?, + (Value::List(elements), Value::I64(i)) => list_element(&elements, i as usize)?, + _ => { + return Err(RuntimeError::TypeError { + expected: "list and integer".to_string(), + found: "non-list or non-integer".to_string(), + }); + } + }, + Binary::App => match left { + Value::Closure { + param, body, env, .. + } => { + let mut callee = env; + callee.extend(param, right); + let caller = std::mem::replace(&mut self.env, callee); + stack.push(Frame::Env { + saved: caller, + on_error: false, + }); + return Ok(State::Eval(body.0)); + } + _ => return Err(RuntimeError::NotAFunction), + }, + })) + } + + fn eval_binop( + &self, + op: BinOp, + left_val: Value, + right_val: Value, + ) -> Result { // Integer arithmetic wraps, as the compiled wasm does. `/` and `%` // by zero, and `MIN / -1`, are errors (wasm traps); `MIN % -1` is 0. match op { @@ -680,8 +1259,7 @@ impl Interpreter { } } - fn eval_unaryop(&mut self, op: UnaryOp, operand: &Expr) -> Result { - let val = self.eval(operand)?; + fn eval_unaryop(&self, op: UnaryOp, val: Value) -> Result { match op { UnaryOp::Not => match val { Value::Bool(b) => Ok(Value::Bool(!b)), @@ -721,50 +1299,24 @@ impl Interpreter { }) } - /// A top-level function as a value: a closure for its first - /// parameter, with a lambda in its body for each next parameter. So - /// `f(a, b)`, which is `(f a) b`, gives each parameter its argument. - /// A function with no parameter takes the unit argument of `f()`. - /// The closure starts from an empty environment: the body of a - /// top-level function sees its parameters and the top-level - /// functions, and no local name of the caller. - fn function_value(&self, params: Vec<(Var, Ty)>, body: Expr) -> Value { - let mut params = params.into_iter(); - let (param, param_ty) = params - .next() - .unwrap_or_else(|| ("_".into(), Ty::Base(BaseTy::Unit))); - let body = params.rev().fold(body, |body, (param, param_ty)| { - let span = body.span; - let lambda = ExprKind::Lambda { - param, - param_ty, - body: Box::new(body), - }; - Expr::new(lambda, span) - }); - Value::Closure { - param, - param_ty, - body, - env: Environment::new(), - } - } - - fn eval_var(&mut self, name: &Var) -> Result { + /// The value of the name `name`. A constant gives its value + /// expression, to evaluate in an empty environment. + fn enter_var(&mut self, name: &Var, stack: &mut Vec) -> Result { // A parameter or a `let` of this name shadows a top-level // function, also after its value was consumed. let local = self.env.get(name).is_some() || self.env.is_consumed(name); if !local { - if let Some((params, _ret_ty, body)) = self.functions.get(name).cloned() { - return Ok(self.function_value(params, body)); + if let Some(function) = self.functions.get(name) { + return Ok(State::Value(function.value.clone())); } // A constant: its value, from an empty environment, at each // use of the name (as wasm, which calls a function). if let Some(value) = self.constants.get(name).cloned() { - let caller = std::mem::replace(&mut self.env, Environment::new()); - let result = self.eval(&value); - self.env = caller; - return result; + stack.push(Frame::Env { + saved: std::mem::take(&mut self.env), + on_error: true, + }); + return Ok(State::Eval(value)); } } @@ -784,7 +1336,7 @@ impl Interpreter { self.env.mark_consumed(name); } - Ok(value) + Ok(State::Value(value)) } fn eval_string_new(&self, region: &RegionName, value: &str) -> Result { @@ -797,10 +1349,7 @@ impl Interpreter { }) } - fn eval_string_concat(&mut self, left: &Expr, right: &Expr) -> Result { - let left_val = self.eval(left)?; - let right_val = self.eval(right)?; - + fn eval_string_concat(&self, left_val: Value, right_val: Value) -> Result { match (left_val, right_val) { ( Value::String { @@ -830,184 +1379,43 @@ impl Interpreter { } } - fn eval_string_len(&mut self, inner: &Expr) -> Result { - let val = self.eval(inner)?; - match &val { - Value::String { data, .. } => Ok(Value::I32(data.len() as i32)), - Value::Borrow(inner) => match inner.as_ref() { - Value::String { data, .. } => Ok(Value::I32(data.len() as i32)), - _ => Err(RuntimeError::TypeError { - expected: "String".to_string(), - found: val.type_name().to_string(), - }), - }, - _ => Err(RuntimeError::TypeError { - expected: "String".to_string(), - found: val.type_name().to_string(), - }), - } - } - - fn eval_let(&mut self, name: &Var, value: &Expr, body: &Expr) -> Result { - let val = self.eval(value)?; - // Save-restore: prevents environment leaking between nested let bindings. - // The old extend/remove pattern clobbered outer bindings on name shadowing - // and leaked intermediate env state to sibling expressions. - // See formal/DESIGN-NOTE-2026-03-28.md for the soundness proof. - let prev = self.env.bindings.get(name).cloned(); - let prev_consumed = self.env.consumed.get(name).copied(); - self.env.extend(name.clone(), val); - let result = self.eval(body)?; - // Restore previous binding (or remove if there was none) - match prev { - Some(old_val) => { - self.env.bindings.insert(name.clone(), old_val); - if let Some(c) = prev_consumed { - self.env.consumed.insert(name.clone(), c); - } - } - None => { - self.env.remove(name); - } - } - Ok(result) - } - - fn eval_lambda(&self, param: &Var, param_ty: &Ty, body: &Expr) -> Result { - Ok(Value::Closure { - param: param.clone(), - param_ty: param_ty.clone(), - body: body.clone(), - env: self.env.clone(), - }) - } - - fn eval_app(&mut self, func: &Expr, arg: &Expr) -> Result { - let func_val = self.eval(func)?; - let arg_val = self.eval(arg)?; - - match func_val { - Value::Closure { - param, body, env, .. - } => { - let mut new_env = env; - new_env.extend(param, arg_val); - let old_env = std::mem::replace(&mut self.env, new_env); - let result = self.eval(&body)?; - self.env = old_env; - Ok(result) - } - _ => Err(RuntimeError::NotAFunction), - } - } - - fn eval_pair(&mut self, left: &Expr, right: &Expr) -> Result { - let left_val = self.eval(left)?; - let right_val = self.eval(right)?; - Ok(Value::Pair(Box::new(left_val), Box::new(right_val))) - } - - fn eval_fst(&mut self, inner: &Expr) -> Result { - let val = self.eval(inner)?; - match val { - Value::Pair(left, _) => Ok(*left), - _ => Err(RuntimeError::NotAPair), - } - } - - fn eval_snd(&mut self, inner: &Expr) -> Result { - let val = self.eval(inner)?; - match val { - Value::Pair(_, right) => Ok(*right), - _ => Err(RuntimeError::NotAPair), - } - } - - fn eval_inl(&mut self, value: &Expr) -> Result { - let val = self.eval(value)?; - Ok(Value::Left(Box::new(val))) - } - - fn eval_inr(&mut self, value: &Expr) -> Result { - let val = self.eval(value)?; - Ok(Value::Right(Box::new(val))) - } - - /// Evaluate a core `ExprKind::Match`. + /// Give `scrutinee` to the arms of a match, from arm `start`. /// /// Tries each arm in order; on the first matching pattern, binds - /// captured names in the environment, evaluates the arm body, - /// and restores prior bindings. No arms matching is a runtime + /// captured names in the environment and gives the guard of the arm, + /// or its body, to evaluate. No arms matching is a runtime /// error — the typechecker's exhaustiveness pass should prevent /// this when the module is well-typed. - fn eval_match(&mut self, scrutinee: &Expr, arms: &[MatchArm]) -> Result { - let scrut_val = self.eval(scrutinee)?; - for arm in arms { - let mut new_bindings: Vec<(Var, Value)> = Vec::new(); - if self.try_match_pattern(&scrut_val, &arm.pattern, &mut new_bindings)? { - let saved: Vec<(Var, Option, Option)> = new_bindings - .iter() - .map(|(name, _)| { - ( - name.clone(), - self.env.bindings.get(name).cloned(), - self.env.consumed.get(name).copied(), - ) - }) - .collect(); - for (name, value) in new_bindings { - self.env.extend(name, value); - } - - // Evaluate guard (if any) under the arm's pattern - // bindings. If it returns `false`, restore prior - // bindings and try the next arm. - let guard_ok = match &arm.guard { - None => true, - Some(g) => match self.eval(g)? { - Value::Bool(b) => b, - other => { - return Err(RuntimeError::TypeError { - expected: "Bool".into(), - found: other.type_name().into(), - }); - } - }, - }; - - if !guard_ok { - for (name, prev_val, prev_consumed) in saved.iter().rev() { - match prev_val { - Some(v) => { - self.env.bindings.insert(name.clone(), v.clone()); - if let Some(c) = prev_consumed { - self.env.consumed.insert(name.clone(), *c); - } - } - None => { - self.env.remove(name); - } - } - } - continue; + fn match_arms( + &mut self, + arms: Arc<[Arm]>, + scrutinee: Value, + start: usize, + stack: &mut Vec, + ) -> Result { + for (index, arm) in arms.iter().enumerate().skip(start) { + let mut bindings: Vec<(Var, Value)> = Vec::new(); + if !self.try_match_pattern(&scrutinee, &arm.pattern, &mut bindings)? { + continue; + } + let saved = self.bind(bindings); + return Ok(State::Eval(match &arm.guard { + None => { + stack.push(Frame::Unbind(saved)); + arm.body.clone() } - - let result = self.eval(&arm.body)?; - for (name, prev_val, prev_consumed) in saved.iter().rev() { - match prev_val { - Some(v) => { - self.env.bindings.insert(name.clone(), v.clone()); - if let Some(c) = prev_consumed { - self.env.consumed.insert(name.clone(), *c); - } - } - None => { - self.env.remove(name); - } - } + // The guard runs under the pattern bindings of the arm. + Some(guard) => { + let guard = guard.clone(); + stack.push(Frame::Guard { + arms: arms.clone(), + arm: index, + scrutinee, + saved, + }); + guard } - return Ok(result); - } + })); } Err(RuntimeError::PatternMatchFailed) } @@ -1114,143 +1522,6 @@ impl Interpreter { } } - fn eval_case( - &mut self, - scrutinee: &Expr, - left_var: &Var, - left_body: &Expr, - right_var: &Var, - right_body: &Expr, - ) -> Result { - let scrutinee_val = self.eval(scrutinee)?; - - match scrutinee_val { - Value::Left(inner) => { - let prev = self.env.bindings.get(left_var).cloned(); - let prev_c = self.env.consumed.get(left_var).copied(); - self.env.extend(left_var.clone(), *inner); - let result = self.eval(left_body)?; - match prev { - Some(v) => { - self.env.bindings.insert(left_var.clone(), v); - if let Some(c) = prev_c { - self.env.consumed.insert(left_var.clone(), c); - } - } - None => { - self.env.remove(left_var); - } - } - Ok(result) - } - Value::Right(inner) => { - let prev = self.env.bindings.get(right_var).cloned(); - let prev_c = self.env.consumed.get(right_var).copied(); - self.env.extend(right_var.clone(), *inner); - let result = self.eval(right_body)?; - match prev { - Some(v) => { - self.env.bindings.insert(right_var.clone(), v); - if let Some(c) = prev_c { - self.env.consumed.insert(right_var.clone(), c); - } - } - None => { - self.env.remove(right_var); - } - } - Ok(result) - } - _ => Err(RuntimeError::NotASum), - } - } - - fn eval_if( - &mut self, - cond: &Expr, - then_branch: &Expr, - else_branch: &Expr, - ) -> Result { - let cond_val = self.eval(cond)?; - - match cond_val { - Value::Bool(true) => self.eval(then_branch), - Value::Bool(false) => self.eval(else_branch), - _ => Err(RuntimeError::TypeError { - expected: "Bool".to_string(), - found: cond_val.type_name().to_string(), - }), - } - } - - fn eval_region(&mut self, name: &RegionName, body: &Expr) -> Result { - self.regions.push(name.clone()); - let result = self.eval(body)?; - self.regions.pop(); - - // Check for region escape - if let Value::String { region, data } = &result { - if region == name { - return Err(RuntimeError::StringEscaped(data.clone())); - } - } - - Ok(result) - } - - fn eval_borrow(&mut self, inner: &Expr) -> Result { - // A borrow of a variable reads the value and leaves it in place: - // `&s` is not a use of `s`. - if let ExprKind::Var(name) = &inner.kind { - if self.env.is_consumed(name) { - return Err(RuntimeError::LinearValueConsumed); - } - if let Some(value) = self.env.get(name) { - return Ok(Value::Borrow(Box::new(value.clone()))); - } - } - let val = self.eval(inner)?; - Ok(Value::Borrow(Box::new(val))) - } - - fn eval_deref(&mut self, inner: &Expr) -> Result { - let val = self.eval(inner)?; - match val { - Value::Borrow(inner) => Ok(*inner), - _ => Err(RuntimeError::TypeError { - expected: "Borrow".to_string(), - found: val.type_name().to_string(), - }), - } - } - - fn eval_drop(&mut self, inner: &Expr) -> Result { - // Evaluate and discard - let _ = self.eval(inner)?; - Ok(Value::Unit) - } - - fn eval_copy(&mut self, inner: &Expr) -> Result { - let val = self.eval(inner)?; - // Only unrestricted types can be copied - linear types (String, etc.) cannot - if val.is_linear() { - return Err(RuntimeError::TypeError { - expected: "unrestricted (copyable) type".to_string(), - found: format!("linear type {}", val.type_name()), - }); - } - // For unrestricted types, return a pair of copies - Ok(Value::Pair(Box::new(val.clone()), Box::new(val))) - } - - fn eval_block(&mut self, exprs: &[Expr]) -> Result { - let mut result = Value::Unit; - for expr in exprs { - result = self.eval(expr)?; - } - Ok(result) - } - /// Get a binding from the environment pub fn get_binding(&self, name: &str) -> Option<&Value> { let name: SmolStr = name.into(); @@ -1267,32 +1538,31 @@ impl Interpreter { pub fn call_main(&mut self) -> Result { let name: SmolStr = "main".into(); - let (params, _ret_ty, body) = self + let main = self .functions .get(&name) - .ok_or_else(|| RuntimeError::UnboundVariable(name.clone()))? - .clone(); + .ok_or_else(|| RuntimeError::UnboundVariable(name.clone()))?; // Main should have no parameters - if !params.is_empty() { + if !main.params.is_empty() { return Err(RuntimeError::TypeError { expected: "main with no parameters".to_string(), - found: format!("main with {} parameters", params.len()), + found: format!("main with {} parameters", main.params.len()), }); } - self.eval(&body) + self.run(main.body.clone()) } /// Call a named function with arguments pub fn call(&mut self, name: &str, args: Vec) -> Result { let name: SmolStr = name.into(); - let (params, _ret_ty, body) = self + let function = self .functions .get(&name) - .ok_or_else(|| RuntimeError::UnboundVariable(name.clone()))? - .clone(); + .ok_or_else(|| RuntimeError::UnboundVariable(name.clone()))?; + let (params, body) = (function.params.clone(), function.body.clone()); if args.len() != params.len() { return Err(RuntimeError::TypeError { @@ -1311,7 +1581,7 @@ impl Interpreter { } // Evaluate body - let result = self.eval(&body)?; + let result = self.run(body)?; // Restore previous environment self.env = saved_env; @@ -1881,6 +2151,86 @@ mod tests { let result = interp.eval(&expr).expect("fall-through should evaluate"); assert!(matches!(result, Value::I32(999)), "got {:?}", result); } + + /// A module with `fn f(n: I32): I32 = if n == 0 then 0 else 1 + f(n - step)` + /// and the constant `K = 1 / 0`. + fn recursion_module(step: i32) -> Module { + let var = |name: &str| dummy_expr(ExprKind::Var(name.into())); + let int = |n: i32| dummy_expr(ExprKind::Lit(Literal::I32(n))); + let op = |op, left: Expr, right: Expr| { + dummy_expr(ExprKind::BinOp { + op, + left: Box::new(left), + right: Box::new(right), + }) + }; + let call = dummy_expr(ExprKind::App { + func: Box::new(var("f")), + arg: Box::new(op(BinOp::Sub, var("n"), int(step))), + }); + let body = dummy_expr(ExprKind::If { + cond: Box::new(op(BinOp::Eq, var("n"), int(0))), + then_branch: Box::new(int(0)), + else_branch: Box::new(op(BinOp::Add, int(1), call)), + }); + Module { + name: "m".into(), + imports: Vec::new(), + decls: vec![ + Decl::Fn { + name: "f".into(), + visibility: Default::default(), + type_params: Vec::new(), + params: vec![("n".into(), Ty::Base(BaseTy::I32))], + ret_ty: Ty::Base(BaseTy::I32), + body, + }, + Decl::Const { + name: "K".into(), + ty: None, + value: op(BinOp::Div, int(1), int(0)), + }, + ], + } + } + + /// A call uses no native stack: 100000 calls deep give a value on the + /// 2 MB stack of a test thread. (The recursive evaluator aborted the + /// process at a depth of about 160 on a stack of 8 MB.) + #[test] + fn deep_calls_use_no_native_stack() { + let mut interp = Interpreter::new(); + interp.load_module(&recursion_module(1)); + let result = interp.call("f", vec![Value::I32(100_000)]); + assert!(matches!(result, Ok(Value::I32(100_000))), "{result:?}"); + } + + /// Calls with no end give the named error at the limit of the frames. + #[test] + fn calls_with_no_end_are_a_named_error() { + let mut interp = Interpreter::new(); + interp.load_module(&recursion_module(0)); + let result = interp.call("f", vec![Value::I32(1)]); + assert!( + matches!(result, Err(RuntimeError::StackOverflow)), + "{result:?}" + ); + } + + /// An error in the value of a constant gives the environment back to + /// the code that used the constant. + #[test] + fn an_error_in_a_constant_keeps_the_environment_of_its_user() { + let mut interp = Interpreter::new(); + interp.load_module(&recursion_module(1)); + interp.env.extend("v".into(), Value::I32(100)); + let result = interp.eval(&dummy_expr(ExprKind::Var("K".into()))); + assert!( + matches!(result, Err(RuntimeError::DivisionByZero)), + "{result:?}" + ); + assert!(matches!(interp.get_binding("v"), Some(Value::I32(100)))); + } } #[cfg(test)] From 57b80c292086e58d696d5a14befa779a648dfe2a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 22:54:28 +0000 Subject: [PATCH 2/6] feat(interp): a value shares its parts A `Value` held its parts in boxes, and it copied, printed and freed them with native recursion. A list is a chain of 2 parts for each element, so a long list overflowed the native stack: the copy at 1000 (debug) to 6700 (release) elements, the print at 800 to 18000, and the free at 10000 to 64000. Each read of a variable copied the value, so a function on a list of n elements needed time and memory of the order n * n: a list of 2000 elements took 2.95 s and 3 GB. The parts of a value are now shared pointers (`Arc`): - A copy is 1 step, for each size of value. - `Drop for Value` frees the parts with a loop. - `Display` prints with a loop. `Debug` is the printed form. - `is_linear` uses a loop. `to_type` gives the type to 64 levels of parts, and the placeholder `()` below that. - A closure is `Value::Closure(Arc)`. Measured in a release build: a list of 2000 elements takes 0.01 s and 12 MB; a list of 100000 elements takes 0.33 s and 179 MB; `fib(27)` takes 0.41 s (0.49 s before this change, 0.61 s with the recursive evaluator). With the evaluator of the pull request before this one, no step of the interpreter uses native recursion on the depth of the calls or on the size of a value. The recursion that remains is on the nesting of the source text (`lower`, the patterns of a `match`), as in the parser and in the type checker. No crate outside the interpreter builds or takes apart a value with parts. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- TEST-NEEDS.adoc | 6 +- src/ephapax-cli/tests/recursive_types.rs | 31 ++ src/ephapax-interp/src/lib.rs | 442 ++++++++++++++++------- 3 files changed, 340 insertions(+), 139 deletions(-) diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index 50b65aa..e560a7e 100644 --- a/TEST-NEEDS.adoc +++ b/TEST-NEEDS.adoc @@ -21,9 +21,9 @@ compliance, 16 tests) performance, correctness, 13 tests) * Benchmarks: `+src/ephapax-parser/benches/parse_bench.rs+`, `+src/ephapax-vram-cache/benches/cache_bench.rs+` -* Total: *644 tests* (`+cargo test --workspace --all-targets+`); +* Total: *647 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 644 +* Documented all-target tests: 647 === CRG Testing Taxonomy — Status @@ -83,7 +83,7 @@ eliminator-fork invariant + its gated capstone, per PROOF-NEEDS.adoc §4) ==== Build & Execution * [x] cargo build — compiles -* [x] cargo test –workspace –all-targets — 644 pass, 0 fail +* [x] cargo test –workspace –all-targets — 647 pass, 0 fail * [x] idris2/ephapax-parse-tests.ipkg — compiles AND runs green (2026-07-07, idris2 0.8.0 + zig-built libephapax_tokbuf.so); CI-gated compile-only by abi-verify.yml `+idris2-parse-front+` (runtime run stays diff --git a/src/ephapax-cli/tests/recursive_types.rs b/src/ephapax-cli/tests/recursive_types.rs index 38cd5bc..8b3d3fc 100644 --- a/src/ephapax-cli/tests/recursive_types.rs +++ b/src/ephapax-cli/tests/recursive_types.rs @@ -550,6 +550,37 @@ fn data_types_that_refer_to_each_other_run() { } } +/// The interpreter builds and reads a list of 100000 elements. A value +/// shares its parts, so the read of a variable does not copy the list, +/// and no step uses native recursion. (With boxed parts, a list of 2000 +/// elements needed 3 GB, and one of 20000 aborted the process.) +#[test] +fn the_interpreter_runs_a_long_list() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join("long.eph"), + "data List = Nil | Cons(I32, List)\n\n\ + fn build(n: I32): List = if n == 0 then Nil else Cons(n, build(n - 1))\n\n\ + fn length(l: List): I32 =\n match l of\n | Nil => 0\n \ + | Cons(x, rest) => 1 + length(rest)\n end\n\n\ + fn main(): I32 = length(build(100000))\n", + ) + .expect("write program"); + let out = ephapax( + tmp.path(), + &["run", "--interp", "--unstable-core", "long.eph"], + ); + assert_eq!( + ( + out.status.code(), + String::from_utf8_lossy(&out.stdout).trim() + ), + (Some(0), "100000"), + "stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); +} + /// The recursive forms that have no lowering yet are refused by name. #[test] fn a_recursive_form_with_no_lowering_is_refused_by_name() { diff --git a/src/ephapax-interp/src/lib.rs b/src/ephapax-interp/src/lib.rs index 297a2cc..ad39889 100644 --- a/src/ephapax-interp/src/lib.rs +++ b/src/ephapax-interp/src/lib.rs @@ -120,14 +120,12 @@ pub enum RuntimeError { } /// Runtime values -// -// clippy::large_enum_variant — `Closure` is ~168 bytes against ~48 for the next -// largest variant. The fix is to box the closure payload, but that changes every -// construction and match site in the interpreter, so it is deliberately NOT bundled -// into the CI-revival change that first surfaced this lint. Tracked separately; -// remove this allow when the boxing lands. -#[allow(clippy::large_enum_variant)] -#[derive(Debug, Clone)] +/// +/// A value shares its parts (`Arc`), so a copy of a value is 1 step for +/// each size of value. A long list is a deep chain of parts: the copy, +/// the print and the free of a value use no native recursion, so the +/// length of a list has no limit from the native stack. +#[derive(Clone)] pub enum Value { Unit, Bool(bool), @@ -135,25 +133,56 @@ pub enum Value { I64(i64), F32(f32), F64(f64), - String { - data: String, - region: RegionName, - }, - Pair(Box, Box), - Tuple(Vec), - List(Vec), - Left(Box), - Right(Box), - Closure { - param: Var, - param_ty: Ty, - body: Code, - env: Environment, - }, - Borrow(Box), + String { data: String, region: RegionName }, + Pair(Arc, Arc), + Tuple(Arc<[Value]>), + List(Arc<[Value]>), + Left(Arc), + Right(Arc), + Closure(Arc), + Borrow(Arc), +} + +/// A function value: its parameter, its body, and the environment of +/// the place that made it. +#[derive(Debug)] +pub struct Closure { + pub param: Var, + pub param_ty: Ty, + pub body: Code, + pub env: Environment, +} + +/// The value of a part: the part itself when no other value shares it, +/// else a copy (which shares the parts of the part). +fn take(part: &mut Arc) -> Value { + match Arc::get_mut(part) { + Some(value) => std::mem::replace(value, Value::Unit), + None => Value::clone(part), + } } impl Value { + /// The pair of `left` and `right`. + pub fn pair(left: Value, right: Value) -> Value { + Value::Pair(Arc::new(left), Arc::new(right)) + } + + /// The left injection of `value`. + pub fn left(value: Value) -> Value { + Value::Left(Arc::new(value)) + } + + /// The right injection of `value`. + pub fn right(value: Value) -> Value { + Value::Right(Arc::new(value)) + } + + /// A borrow of `value`. + pub fn borrow(value: Value) -> Value { + Value::Borrow(Arc::new(value)) + } + pub fn type_name(&self) -> &'static str { match self { Value::Unit => "Unit", @@ -168,43 +197,54 @@ impl Value { Value::List(_) => "List", Value::Left(_) => "Left", Value::Right(_) => "Right", - Value::Closure { .. } => "Function", + Value::Closure(_) => "Function", Value::Borrow(_) => "Borrow", } } /// Check if this value is linear (must be used exactly once) pub fn is_linear(&self) -> bool { - match self { - // Strings are linear resources - Value::String { .. } => true, - // Pairs are linear if either component is linear - Value::Pair(l, r) => l.is_linear() || r.is_linear(), - // Tuples are linear if any element is linear - Value::Tuple(elements) => elements.iter().any(|v| v.is_linear()), - // Lists are linear if any element is linear - Value::List(elements) => elements.iter().any(|v| v.is_linear()), - // Sum types are linear if either variant type is linear - Value::Left(v) | Value::Right(v) => v.is_linear(), - // Closures that capture linear values are linear - // (Conservative: treat all closures as potentially linear) - Value::Closure { .. } => false, // Could be refined with capture analysis - // Base types are unrestricted - Value::Unit - | Value::Bool(_) - | Value::I32(_) - | Value::I64(_) - | Value::F32(_) - | Value::F64(_) => false, - // Borrows are second-class (not linear in the sense of consumption) - Value::Borrow(_) => false, + let mut work = vec![self]; + while let Some(value) = work.pop() { + match value { + // Strings are linear resources + Value::String { .. } => return true, + // Pairs are linear if either component is linear + Value::Pair(l, r) => work.extend([&**l, &**r]), + // Tuples and lists are linear if any element is linear + Value::Tuple(elements) | Value::List(elements) => work.extend(elements.iter()), + // Sum types are linear if either variant type is linear + Value::Left(v) | Value::Right(v) => work.push(v), + // Closures that capture linear values are linear + // (Conservative: treat all closures as potentially linear) + // Could be refined with capture analysis. + // Borrows are second-class (not linear in the sense of consumption). + // Base types are unrestricted. + _ => {} + } } + false } /// Get the inferred type of this value pub fn to_type(&self) -> Ty { + self.type_to_depth(TYPE_DEPTH) + } + + /// The type of this value, to `depth` levels of parts. A part below + /// that has the placeholder type `()`, which this function also uses + /// for the side of a sum that the value does not have. + // snip: a depth limit, because `Ty` is a boxed tree and a long list + // gives a type as deep as the list; upgrade to the declared type of + // the value when the interpreter has types. + fn type_to_depth(&self, depth: usize) -> Ty { + let unit = || Ty::Base(BaseTy::Unit); + let Some(depth) = depth.checked_sub(1) else { + return unit(); + }; + let part = |value: &Value| Box::new(value.type_to_depth(depth)); match self { - Value::Unit => Ty::Base(BaseTy::Unit), + Value::Unit => unit(), Value::Bool(_) => Ty::Base(BaseTy::Bool), Value::I32(_) => Ty::Base(BaseTy::I32), Value::I64(_) => Ty::Base(BaseTy::I64), @@ -212,73 +252,162 @@ impl Value { Value::F64(_) => Ty::Base(BaseTy::F64), Value::String { region, .. } => Ty::String(region.clone()), Value::Pair(l, r) => Ty::Prod { - left: Box::new(l.to_type()), - right: Box::new(r.to_type()), + left: part(l), + right: part(r), }, - Value::Tuple(elements) => Ty::Tuple(elements.iter().map(|v| v.to_type()).collect()), - Value::List(elements) => { - let elem_ty = elements - .first() - .map(|v| v.to_type()) - .unwrap_or(Ty::Base(BaseTy::Unit)); - Ty::List(Box::new(elem_ty)) - } + Value::Tuple(elements) => Ty::Tuple(elements.iter().map(|v| *part(v)).collect()), + Value::List(elements) => Ty::List(elements.first().map_or(Box::new(unit()), part)), Value::Left(v) => Ty::Sum { - left: Box::new(v.to_type()), - right: Box::new(Ty::Base(BaseTy::Unit)), + left: part(v), + right: Box::new(unit()), }, Value::Right(v) => Ty::Sum { - left: Box::new(Ty::Base(BaseTy::Unit)), - right: Box::new(v.to_type()), + left: Box::new(unit()), + right: part(v), }, - Value::Closure { param_ty, .. } => Ty::Fun { - param: Box::new(param_ty.clone()), - ret: Box::new(Ty::Base(BaseTy::Unit)), // Unknown without evaluation + Value::Closure(closure) => Ty::Fun { + param: Box::new(closure.param_ty.clone()), + ret: Box::new(unit()), // Unknown without evaluation }, Value::Borrow(inner) => Ty::Borrow { - inner: Box::new(inner.to_type()), + inner: part(inner), mutable: false, }, } } + + /// Move each part of this value that has parts of its own, and that + /// no other value shares, into `out`. + fn take_parts(&mut self, out: &mut Vec) { + let mut take = |part: &mut Value| { + if !matches!( + part, + Value::Unit + | Value::Bool(_) + | Value::I32(_) + | Value::I64(_) + | Value::F32(_) + | Value::F64(_) + | Value::String { .. } + ) { + out.push(std::mem::replace(part, Value::Unit)); + } + }; + match self { + Value::Pair(left, right) => { + Arc::get_mut(left).map(&mut take); + Arc::get_mut(right).map(&mut take); + } + Value::Left(part) | Value::Right(part) | Value::Borrow(part) => { + Arc::get_mut(part).map(&mut take); + } + Value::Tuple(parts) | Value::List(parts) => { + if let Some(parts) = Arc::get_mut(parts) { + parts.iter_mut().for_each(&mut take); + } + } + Value::Closure(closure) => { + if let Some(closure) = Arc::get_mut(closure) { + closure.env.bindings.values_mut().for_each(&mut take); + } + } + _ => {} + } + } +} + +/// The depth of parts to which [`Value::to_type`] gives a type. +const TYPE_DEPTH: usize = 64; + +impl Drop for Value { + /// Free the parts with a loop. The default drop goes 1 native call + /// deeper for each part of a chain, and a long list is a long chain. + fn drop(&mut self) { + let mut parts = Vec::new(); + self.take_parts(&mut parts); + while let Some(mut part) = parts.pop() { + part.take_parts(&mut parts); + // `part` has no part with parts now: its drop is 1 step. + } + } } impl std::fmt::Display for Value { + /// Print with a loop: the work list has the values and the texts + /// that are still to print, the next one last. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Value::Unit => write!(f, "()"), - Value::Bool(b) => write!(f, "{}", b), - Value::I32(n) => write!(f, "{}", n), - Value::I64(n) => write!(f, "{}i64", n), - Value::F32(n) => write!(f, "{}f32", n), - Value::F64(n) => write!(f, "{}", n), - Value::String { data, region } => write!(f, "\"{}\"@{}", data, region), - Value::Pair(l, r) => write!(f, "({}, {})", l, r), - Value::Tuple(elements) => { - write!(f, "(")?; - for (i, elem) in elements.iter().enumerate() { - if i > 0 { - write!(f, ", ")?; - } - write!(f, "{}", elem)?; + enum Item<'a> { + Value(&'a Value), + Text(&'static str), + } + // The parts of a sequence, with ", " between them. + fn sequence<'a>(work: &mut Vec>, open: &'static str, parts: &'a [Value]) { + for (i, part) in parts.iter().enumerate().rev() { + work.push(Item::Value(part)); + if i > 0 { + work.push(Item::Text(", ")); } - write!(f, ")") } - Value::List(elements) => { - write!(f, "[")?; - for (i, elem) in elements.iter().enumerate() { - if i > 0 { - write!(f, ", ")?; - } - write!(f, "{}", elem)?; + work.push(Item::Text(open)); + } + let mut work = vec![Item::Value(self)]; + while let Some(item) = work.pop() { + let value = match item { + Item::Text(text) => { + f.write_str(text)?; + continue; + } + Item::Value(value) => value, + }; + match value { + Value::Unit => write!(f, "()")?, + Value::Bool(b) => write!(f, "{}", b)?, + Value::I32(n) => write!(f, "{}", n)?, + Value::I64(n) => write!(f, "{}i64", n)?, + Value::F32(n) => write!(f, "{}f32", n)?, + Value::F64(n) => write!(f, "{}", n)?, + Value::String { data, region } => write!(f, "\"{}\"@{}", data, region)?, + Value::Pair(l, r) => { + work.push(Item::Text(")")); + work.push(Item::Value(r)); + work.push(Item::Text(", ")); + work.push(Item::Value(l)); + work.push(Item::Text("(")); + } + Value::Tuple(elements) => { + work.push(Item::Text(")")); + sequence(&mut work, "(", elements); + } + Value::List(elements) => { + work.push(Item::Text("]")); + sequence(&mut work, "[", elements); + } + Value::Left(v) => { + work.push(Item::Text(")")); + work.push(Item::Value(v)); + work.push(Item::Text("inl(")); + } + Value::Right(v) => { + work.push(Item::Text(")")); + work.push(Item::Value(v)); + work.push(Item::Text("inr(")); + } + Value::Closure(closure) => write!(f, "", closure.param)?, + Value::Borrow(v) => { + work.push(Item::Value(v)); + work.push(Item::Text("&")); } - write!(f, "]") } - Value::Left(v) => write!(f, "inl({})", v), - Value::Right(v) => write!(f, "inr({})", v), - Value::Closure { param, .. } => write!(f, "", param), - Value::Borrow(v) => write!(f, "&{}", v), } + Ok(()) + } +} + +/// The debug form is the printed form: a derived form goes 1 native call +/// deeper for each part. +impl std::fmt::Debug for Value { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(self, f) } } @@ -479,8 +608,8 @@ impl Seq { fn value(self, mut values: Vec) -> Value { match self { Seq::Block => values.pop().unwrap_or(Value::Unit), - Seq::List => Value::List(values), - Seq::Tuple => Value::Tuple(values), + Seq::List => Value::List(values.into()), + Seq::Tuple => Value::Tuple(values.into()), } } } @@ -657,12 +786,12 @@ fn function_value(params: &[(Var, Ty)], body: &Arc) -> Value { body, }) }); - Value::Closure { + Value::Closure(Arc::new(Closure { param, param_ty, body: Code(body), env: Environment::new(), - } + })) } /// The element `index` of a list value. @@ -788,12 +917,12 @@ impl Interpreter { param_ty, body, } => { - return Ok(State::Value(Value::Closure { + return Ok(State::Value(Value::Closure(Arc::new(Closure { param: param.clone(), param_ty: param_ty.clone(), body: Code(body.clone()), env: self.env.clone(), - })); + })))); } Node::Unimplemented(what) => { return Err(RuntimeError::Unimplemented((*what).into())); @@ -806,7 +935,7 @@ impl Interpreter { return Err(RuntimeError::LinearValueConsumed); } if let Some(value) = self.env.get(name) { - return Ok(State::Value(Value::Borrow(Box::new(value.clone())))); + return Ok(State::Value(Value::borrow(value.clone()))); } } stack.push(Frame::Unary(*op)); @@ -865,7 +994,7 @@ impl Interpreter { fn resume( &mut self, frame: Frame, - value: Value, + mut value: Value, stack: &mut Vec, ) -> Result { let next = match frame { @@ -891,12 +1020,12 @@ impl Interpreter { } }, Frame::Case(left, right) => { - let ((name, body), inner) = match value { - Value::Left(inner) => (left, inner), - Value::Right(inner) => (right, inner), + let ((name, body), inner) = match &mut value { + Value::Left(inner) => (left, take(inner)), + Value::Right(inner) => (right, take(inner)), _ => return Err(RuntimeError::NotASum), }; - let saved = self.bind(vec![(name, *inner)]); + let saved = self.bind(vec![(name, inner)]); stack.push(Frame::Unbind(saved)); body } @@ -1009,7 +1138,7 @@ impl Interpreter { } /// The result of `op` on the value of its operand. - fn unary(&self, op: Unary, val: Value) -> Result { + fn unary(&self, op: Unary, mut val: Value) -> Result { match op { Unary::Op(op) => self.eval_unaryop(op, val), Unary::StringLen => match &val { @@ -1026,19 +1155,19 @@ impl Interpreter { found: val.type_name().to_string(), }), }, - Unary::Fst => match val { - Value::Pair(left, _) => Ok(*left), + Unary::Fst => match &mut val { + Value::Pair(left, _) => Ok(take(left)), _ => Err(RuntimeError::NotAPair), }, - Unary::Snd => match val { - Value::Pair(_, right) => Ok(*right), + Unary::Snd => match &mut val { + Value::Pair(_, right) => Ok(take(right)), _ => Err(RuntimeError::NotAPair), }, - Unary::Inl => Ok(Value::Left(Box::new(val))), - Unary::Inr => Ok(Value::Right(Box::new(val))), - Unary::Borrow => Ok(Value::Borrow(Box::new(val))), - Unary::Deref => match val { - Value::Borrow(inner) => Ok(*inner), + Unary::Inl => Ok(Value::left(val)), + Unary::Inr => Ok(Value::right(val)), + Unary::Borrow => Ok(Value::borrow(val)), + Unary::Deref => match &mut val { + Value::Borrow(inner) => Ok(take(inner)), _ => Err(RuntimeError::TypeError { expected: "Borrow".to_string(), found: val.type_name().to_string(), @@ -1055,9 +1184,9 @@ impl Interpreter { }); } // For unrestricted types, return a pair of copies - Ok(Value::Pair(Box::new(val.clone()), Box::new(val))) + Ok(Value::pair(val.clone(), val)) } - Unary::TupleIndex(index) => match val { + Unary::TupleIndex(index) => match &mut val { Value::Tuple(values) => { if index < values.len() { Ok(values[index].clone()) @@ -1069,8 +1198,8 @@ impl Interpreter { } } // A 2-tuple is a pair. - Value::Pair(left, _) if index == 0 => Ok(*left), - Value::Pair(_, right) if index == 1 => Ok(*right), + Value::Pair(left, _) if index == 0 => Ok(take(left)), + Value::Pair(_, right) if index == 1 => Ok(take(right)), _ => Err(RuntimeError::Unimplemented( "tuple index on non-tuple".to_string(), )), @@ -1091,10 +1220,10 @@ impl Interpreter { Ok(State::Value(match op { Binary::Op(op) => self.eval_binop(op, left, right)?, Binary::Concat => self.eval_string_concat(left, right)?, - Binary::Pair => Value::Pair(Box::new(left), Box::new(right)), - Binary::ListIndex => match (left, right) { - (Value::List(elements), Value::I32(i)) => list_element(&elements, i as usize)?, - (Value::List(elements), Value::I64(i)) => list_element(&elements, i as usize)?, + Binary::Pair => Value::pair(left, right), + Binary::ListIndex => match (&left, &right) { + (Value::List(elements), Value::I32(i)) => list_element(elements, *i as usize)?, + (Value::List(elements), Value::I64(i)) => list_element(elements, *i as usize)?, _ => { return Err(RuntimeError::TypeError { expected: "list and integer".to_string(), @@ -1102,18 +1231,16 @@ impl Interpreter { }); } }, - Binary::App => match left { - Value::Closure { - param, body, env, .. - } => { - let mut callee = env; - callee.extend(param, right); + Binary::App => match &left { + Value::Closure(closure) => { + let mut callee = closure.env.clone(); + callee.extend(closure.param.clone(), right); let caller = std::mem::replace(&mut self.env, callee); stack.push(Frame::Env { saved: caller, on_error: false, }); - return Ok(State::Eval(body.0)); + return Ok(State::Eval(closure.body.0.clone())); } _ => return Err(RuntimeError::NotAFunction), }, @@ -1350,7 +1477,7 @@ impl Interpreter { } fn eval_string_concat(&self, left_val: Value, right_val: Value) -> Result { - match (left_val, right_val) { + match (&left_val, &right_val) { ( Value::String { data: d1, @@ -1368,8 +1495,8 @@ impl Interpreter { }); } Ok(Value::String { - data: d1 + &d2, - region: r1, + data: format!("{d1}{d2}"), + region: r1.clone(), }) } (l, r) => Err(RuntimeError::TypeError { @@ -2217,6 +2344,49 @@ mod tests { ); } + /// The printed form of each kind of value. + #[test] + fn a_value_prints_each_of_its_parts() { + let int = Value::I32; + let string = Value::String { + data: "a".into(), + region: "r".into(), + }; + let list = Value::List(vec![int(2), int(3)].into()); + let nested = Value::pair(int(1), Value::left(Value::borrow(list))); + assert_eq!(nested.to_string(), "(1, inl(&[2, 3]))"); + let tuple = Value::Tuple(vec![Value::Unit, Value::right(string), Value::I64(5)].into()); + assert_eq!(tuple.to_string(), "((), inr(\"a\"@r), 5i64)"); + assert_eq!(Value::Tuple(Vec::new().into()).to_string(), "()"); + assert_eq!(Value::List(Vec::new().into()).to_string(), "[]"); + assert_eq!(format!("{:?}", Value::F32(1.5)), "1.5f32"); + assert_eq!( + function_value(&[], &lower(&lit_i32_expr(1))).to_string(), + "" + ); + } + + /// A list of 1000000 elements is a chain of 2000000 parts. Its copy, + /// its print, its type, its linearity and its free use no native + /// recursion: they run on the 2 MB stack of a test thread. (With + /// boxed parts, the copy of 1000 elements overflowed that stack.) + #[test] + fn a_long_list_value_uses_no_native_stack() { + let mut list = Value::left(Value::Unit); + for i in 0..1_000_000 { + list = Value::right(Value::pair(Value::I32(i), list)); + } + let copy = list.clone(); + assert!(!list.is_linear()); + assert!(matches!(list.to_type(), Ty::Sum { .. })); + // "inr((" + number + ", " for each element, "inl(())" at the end, + // and "))" for each element. + let digits: usize = (0..1_000_000).map(|i: i32| i.to_string().len()).sum(); + assert_eq!(list.to_string().len(), digits + 9 * 1_000_000 + 7); + drop(list); + drop(copy); + } + /// An error in the value of a constant gives the environment back to /// the code that used the constant. #[test] From 10dd10f50301d80922141b0cb540944d678d59a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:01:32 +0000 Subject: [PATCH 3/6] fix(interp): an error ends each frame of the evaluator After an error, the environment stayed as it was at the error: the names of the call that failed stayed bound, and a region stayed active. The recursive evaluator did the same, but calls with no end are now an error that a caller can catch (the REPL), so the state is visible. An error now ends each frame, the top one first: the names, the environment and the regions are as they were before the evaluation. `Interpreter::call` also restores its environment after an error. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- src/ephapax-interp/src/lib.rs | 54 +++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/src/ephapax-interp/src/lib.rs b/src/ephapax-interp/src/lib.rs index 297a2cc..fa8c32e 100644 --- a/src/ephapax-interp/src/lib.rs +++ b/src/ephapax-interp/src/lib.rs @@ -631,9 +631,8 @@ enum Frame { /// names go out of scope. Unbind(Vec), /// The body of a closure or the value of a constant: the evaluation - /// goes back to the environment `saved`. `on_error` is set for a - /// constant, which also goes back when its value is an error. - Env { saved: Environment, on_error: bool }, + /// goes back to this environment. + Env(Environment), /// The body of a region: the region ends. Region(RegionName), } @@ -755,17 +754,19 @@ impl Interpreter { state = match step { Ok(state) if stack.len() <= MAX_FRAMES => state, step => { - // An error leaves the environment as it is at the - // error. The one exception is the value of a - // constant, which gives the environment back to the - // code that used the constant. + // An error ends each frame, the top one first: the + // names, the environment and the regions are as they + // were before the evaluation. So a caller that keeps + // the interpreter after an error (the REPL) does not + // see the names of the call that failed. for frame in stack.into_iter().rev() { - if let Frame::Env { - saved, - on_error: true, - } = frame - { - self.env = saved; + match frame { + Frame::Env(saved) => self.env = saved, + Frame::Unbind(saved) | Frame::Guard { saved, .. } => self.unbind(saved), + Frame::Region(_) => { + self.regions.pop(); + } + _ => {} } } return Err(step.err().unwrap_or(RuntimeError::StackOverflow)); @@ -951,7 +952,7 @@ impl Interpreter { self.unbind(saved); return Ok(State::Value(value)); } - Frame::Env { saved, .. } => { + Frame::Env(saved) => { self.env = saved; return Ok(State::Value(value)); } @@ -1109,10 +1110,7 @@ impl Interpreter { let mut callee = env; callee.extend(param, right); let caller = std::mem::replace(&mut self.env, callee); - stack.push(Frame::Env { - saved: caller, - on_error: false, - }); + stack.push(Frame::Env(caller)); return Ok(State::Eval(body.0)); } _ => return Err(RuntimeError::NotAFunction), @@ -1312,10 +1310,7 @@ impl Interpreter { // A constant: its value, from an empty environment, at each // use of the name (as wasm, which calls a function). if let Some(value) = self.constants.get(name).cloned() { - stack.push(Frame::Env { - saved: std::mem::take(&mut self.env), - on_error: true, - }); + stack.push(Frame::Env(std::mem::take(&mut self.env))); return Ok(State::Eval(value)); } } @@ -1581,12 +1576,12 @@ impl Interpreter { } // Evaluate body - let result = self.run(body)?; + let result = self.run(body); // Restore previous environment self.env = saved_env; - Ok(result) + result } } @@ -2215,6 +2210,17 @@ mod tests { matches!(result, Err(RuntimeError::StackOverflow)), "{result:?}" ); + // The error ends the calls: the parameter of `f` is not bound. + assert!(interp.get_binding("n").is_none()); + let call = dummy_expr(ExprKind::App { + func: Box::new(dummy_expr(ExprKind::Var("f".into()))), + arg: Box::new(lit_i32_expr(1)), + }); + assert!(matches!( + interp.eval(&call), + Err(RuntimeError::StackOverflow) + )); + assert!(interp.get_binding("n").is_none()); } /// An error in the value of a constant gives the environment back to From b53a0eb8a19cd7da981b698f51bf68951cb1cb2b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:02:49 +0000 Subject: [PATCH 4/6] docs(interp): state the limit that remains for a long value Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- src/ephapax-interp/src/lib.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ephapax-interp/src/lib.rs b/src/ephapax-interp/src/lib.rs index fa8c32e..88ebdd0 100644 --- a/src/ephapax-interp/src/lib.rs +++ b/src/ephapax-interp/src/lib.rs @@ -9,8 +9,10 @@ //! //! The evaluator is a loop with its own stack of frames on the heap //! ([`Interpreter::eval`]). A call in the program uses no native stack, -//! so deep recursion ends with a value or with the runtime error -//! [`RuntimeError::StackOverflow`], and the process does not abort. +//! so deep calls end with a value or with the runtime error +//! [`RuntimeError::StackOverflow`]. A [`Value`] still copies, prints and +//! frees its parts with native recursion, so a value with a long chain +//! of parts (a long list) can exhaust the native stack. use ephapax_syntax::{ BaseTy, BinOp, Decl, Expr, ExprKind, Literal, Module, Pattern, RegionName, Ty, UnaryOp, Var, From 9e124c7ac403d0d0467fe5dda829bee2b95da156 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:02:55 +0000 Subject: [PATCH 5/6] docs(interp): a long value has no limit from the native stack Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- src/ephapax-interp/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ephapax-interp/src/lib.rs b/src/ephapax-interp/src/lib.rs index 67757ac..a5d9264 100644 --- a/src/ephapax-interp/src/lib.rs +++ b/src/ephapax-interp/src/lib.rs @@ -10,9 +10,9 @@ //! The evaluator is a loop with its own stack of frames on the heap //! ([`Interpreter::eval`]). A call in the program uses no native stack, //! so deep calls end with a value or with the runtime error -//! [`RuntimeError::StackOverflow`]. A [`Value`] still copies, prints and -//! frees its parts with native recursion, so a value with a long chain -//! of parts (a long list) can exhaust the native stack. +//! [`RuntimeError::StackOverflow`]. A [`Value`] shares its parts, and +//! its copy, its print and its free use a loop, so a long list does not +//! exhaust the native stack. use ephapax_syntax::{ BaseTy, BinOp, Decl, Expr, ExprKind, Literal, Module, Pattern, RegionName, Ty, UnaryOp, Var, From f5734ae5fafaf05c97e7a221eed64283ba2bef3c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:05:08 +0000 Subject: [PATCH 6/6] feat(interp): a tail call adds no frame A call in tail position found a frame on top that gives the environment back already. The call now adds no frame of its own, so a loop written as a tail call has no depth limit: 1200000 calls run in 15 MB. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- src/ephapax-cli/tests/run_wasm.rs | 13 +++++++++++++ src/ephapax-interp/src/lib.rs | 7 ++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/ephapax-cli/tests/run_wasm.rs b/src/ephapax-cli/tests/run_wasm.rs index fd46faf..e622d1c 100644 --- a/src/ephapax-cli/tests/run_wasm.rs +++ b/src/ephapax-cli/tests/run_wasm.rs @@ -733,6 +733,19 @@ fn the_interpreter_runs_deep_calls_and_names_its_limit() { "100000" ); + // A tail call adds no frame: 1200000 calls are more than the limit + // of the frames. + write( + "tail.eph", + "module tail\n\nfn go(n: I32, acc: I32): I32 =\n \ + if n == 0 then acc else go(n - 1, acc + 1)\n\n\ + fn main(): I32 = go(1200000, 0)\n", + ); + assert_eq!( + stdout_of(tmp.path(), &["run", "--interp", "tail.eph"]), + "1200000" + ); + write( "no_end.eph", "module no_end\n\nfn f(n: I32): I32 = 1 + f(n)\n\nfn main(): I32 = f(0)\n", diff --git a/src/ephapax-interp/src/lib.rs b/src/ephapax-interp/src/lib.rs index 88ebdd0..e190e44 100644 --- a/src/ephapax-interp/src/lib.rs +++ b/src/ephapax-interp/src/lib.rs @@ -1112,7 +1112,12 @@ impl Interpreter { let mut callee = env; callee.extend(param, right); let caller = std::mem::replace(&mut self.env, callee); - stack.push(Frame::Env(caller)); + // A tail call: the frame on top gives the environment + // back already, so this call adds no frame, and a + // loop written as a tail call has no depth limit. + if !matches!(stack.last(), Some(Frame::Env(_))) { + stack.push(Frame::Env(caller)); + } return Ok(State::Eval(body.0)); } _ => return Err(RuntimeError::NotAFunction),