collective

Browse / github.com/storytold/gridcraft / patch

LAMBDA tail recursion reaches Excel's depth (5,459)

LAMBDA tail recursion reaches Excel's depth (5,459) ## What was wrong A recursive LAMBDA that calls itself in tail position (`IF(n=0,0,me(me,n-1))`) stopped at about 139 calls and answered `#NUM!`. Excel computes the same formula up to n=5,459 and answers `#NUM!` from 5,460. ## Cause Every LAMBDA call nested under the previous one on the native stack, so the call count had to stay low (`MAX_LAMBDA_CALLS` = 140) to keep a 1 MiB thread and wasm from overflowing. ## Fix `call_lambda_values` now follows the body through the tail positions (`IF` branches, parentheses, applied lambdas) and, when the value is another LAMBDA call, evaluates its argum

Status
open
Score
0.00
Adopters
0 other operators
Author
Harrison-Booth (imported from pull request #2660, open)
Base
11bb67386c9bb19b0717e21e98adc608162ae2e5
Size
+176 −15 in 5 files
Review
pending
Shared
2026-10-28
Id
f9e34fc3855add5c6375704b

How it went

No other operator has reported using it yet.

Diff

raw · crates/calc/src/eval.rs crates/calc/src/eval/call.rs crates/calc/src/eval/lambda.rs crates/calc/src/eval/special.rs crates/calc/src/tests.rs

diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs--- a/crates/calc/src/eval.rs+++ b/crates/calc/src/eval.rs@@ -53,6 +53,28 @@ pub struct Lambda {     pub env: Vec<(String, Ev)>, } +/// What the tail of a LAMBDA body came to.+enum Tail {+    Done(Ev),+    /// A call to make in place of the current one, its arguments evaluated.+    Call(Arc<Lambda>, Vec<(String, Ev)>),+}++/// A tail call can pass a closure that captures the previous one, thousands deep. Releasing the+/// chain is a loop, not a recursion, so it cannot overflow the native stack.+impl Drop for Lambda {+    fn drop(&mut self) {+        let mut pending = std::mem::take(&mut self.env);+        while let Some((_, ev)) = pending.pop() {+            if let Ev::L(l) = ev+                && let Ok(mut inner) = Arc::try_unwrap(l)+            {+                pending.append(&mut inner.env);+            }+        }+    }+}+ #[derive(Clone, Debug)] pub enum Ev {     V(Value),@@ -75,6 +97,11 @@ fn err(e: CellError) -> Ev { /// calls in progress, so it still runs. const MAX_LAMBDA_CALLS: usize = 140; +/// Tail calls one LAMBDA call may chain. They run in a loop on one native frame, so this only keeps+/// an endless tail recursion finite. Excel computes a tail countdown to 5,459 and answers `#NUM!`+/// for 5,460, which is 5,460 invocations in the chain.+const MAX_TAIL_CALLS: usize = 5460;+ /// Native stack, in bytes, that one formula's evaluation may use below its first `eval`. Past it, /// `eval` answers `#NUM!`. It guards the stack and leaves 128 KiB of a 1 MiB thread for the frames /// above the formula. A cell that another cell's formula computes on demand shares the budget of thediff --git a/crates/calc/src/eval/call.rs b/crates/calc/src/eval/call.rs--- a/crates/calc/src/eval/call.rs+++ b/crates/calc/src/eval/call.rs@@ -4,6 +4,17 @@ use super::special::{ev_shape, resized_sum_range, resizes_sum_range}; use super::*;  impl<'h> Evaluator<'h> {+    /// `IF` with an array condition: element-wise.+    pub(super) fn if_elementwise(&mut self, arr: &Array, args: &[Expr]) -> Ev {+        let t = self.arg_val(args, 1).unwrap_or(Value::Bool(true));+        let f = if args.len() > 2 { self.arg_val(args, 2).unwrap_or(Value::Bool(false)) } else { Value::Bool(false) };+        Ev::V(zip3(arr, &t, &f, |c, t, f| match c.to_bool() {+            Ok(true) => t.clone(),+            Ok(false) => f.clone(),+            Err(e) => Value::Error(e),+        }))+    }+     pub(super) fn call(&mut self, name: &str, args: &[Expr]) -> Ev {         match name {             "IF" => {@@ -12,14 +23,7 @@ impl<'h> Evaluator<'h> {                 }                 let c = self.arg_val(args, 0).unwrap_or_default();                 if let Value::Array(arr) = &c {-                    // Array condition: element-wise.-                    let t = self.arg_val(args, 1).unwrap_or(Value::Bool(true));-                    let f = if args.len() > 2 { self.arg_val(args, 2).unwrap_or(Value::Bool(false)) } else { Value::Bool(false) };-                    return Ev::V(zip3(arr, &t, &f, |c, t, f| match c.to_bool() {-                        Ok(true) => t.clone(),-                        Ok(false) => f.clone(),-                        Err(e) => Value::Error(e),-                    }));+                    return self.if_elementwise(arr, args);                 }                 match c.to_bool() {                     Ok(true) => match args.get(1) {diff --git a/crates/calc/src/eval/lambda.rs b/crates/calc/src/eval/lambda.rs--- a/crates/calc/src/eval/lambda.rs+++ b/crates/calc/src/eval/lambda.rs@@ -1,15 +1,24 @@ use super::array::{broadcast_len, single};+use super::special::is_special; use super::*;  impl<'h> Evaluator<'h> {     pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev {+        match self.bind_args(l, args) {+            Ok(bound) => self.call_lambda_values(l, bound),+            Err(e) => e,+        }+    }++    /// The arguments of a call to `l`, evaluated in the current scope.+    fn bind_args(&mut self, l: &Lambda, args: &[Expr]) -> Result<Vec<(String, Ev)>, Ev> {         // Too many or too few argument positions is #VALUE!. An explicitly empty position, as in         // `(1,)`, is `Expr::Missing` and still counts as a position (it binds as omitted). `f()`         // parses to no arguments, which for a one-parameter lambda can also be read as one empty         // position, so that call still binds the parameter as omitted.         let positions_ok = args.len() == l.params.len() || (args.is_empty() && l.params.len() == 1);         if !positions_ok {-            return err(CellError::Value);+            return Err(err(CellError::Value));         }         let mut bound = Vec::with_capacity(l.params.len());         for (i, p) in l.params.iter().enumerate() {@@ -19,7 +28,56 @@ impl<'h> Evaluator<'h> {             };             bound.push((p.clone(), v));         }-        self.call_lambda_values(l, bound)+        Ok(bound)+    }++    /// Follows `e` through the positions whose value is the value of a LAMBDA call (`IF` branches,+    /// parentheses), and returns the call with its arguments evaluated instead of making it, so+    /// that `call_lambda_values` can loop. Anything else is evaluated here.+    fn tail_step(&mut self, e: &Expr) -> Tail {+        match e {+            Expr::Paren(x) => self.tail_step(x),+            Expr::Call(name, args) if name == "IF" && (2..=3).contains(&args.len()) => {+                let c = self.arg_val(args, 0).unwrap_or_default();+                if let Value::Array(arr) = &c {+                    return Tail::Done(self.if_elementwise(arr, args));+                }+                match c.to_bool() {+                    Ok(true) => self.tail_step_branch(args.get(1)),+                    Ok(false) => match args.get(2) {+                        Some(_) => self.tail_step_branch(args.get(2)),+                        None => Tail::Done(Ev::V(Value::Bool(false))),+                    },+                    Err(x) => Tail::Done(err(x)),+                }+            }+            Expr::Call(name, args) if gridcraft_functions::lookup(name).is_none() && !is_special(name) => match self.name(name) {+                Ev::L(l) => self.tail_call(l, args),+                Ev::V(Value::Error(x)) => Tail::Done(err(x)),+                _ => Tail::Done(err(CellError::Name)),+            },+            Expr::Invoke(callee, args) => match self.eval(callee) {+                Ev::L(l) => self.tail_call(l, args),+                Ev::V(Value::Error(x)) => Tail::Done(err(x)),+                _ => Tail::Done(err(CellError::Value)),+            },+            _ => Tail::Done(self.eval(e)),+        }+    }++    /// An `IF` branch; an omitted one (`IF(c,,1)`) is 0.+    fn tail_step_branch(&mut self, branch: Option<&Expr>) -> Tail {+        match branch {+            Some(Expr::Missing) | None => Tail::Done(Ev::V(Value::Number(0.0))),+            Some(b) => self.tail_step(b),+        }+    }++    fn tail_call(&mut self, l: Arc<Lambda>, args: &[Expr]) -> Tail {+        match self.bind_args(&l, args) {+            Ok(bound) => Tail::Call(l, bound),+            Err(e) => Tail::Done(e),+        }     }      pub(super) fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev {@@ -36,7 +94,29 @@ impl<'h> Evaluator<'h> {         self.calls += 1;         let saved = std::mem::replace(&mut self.env, l.env.clone());         self.env.extend(bound);-        let r = self.eval(&l.body);+        // A call in tail position replaces this one instead of nesting under it, so a tail recursion+        // uses no more native stack at 5,000 deep than at 5. Each link still spends fuel.+        let mut tails = 0;+        let mut step = self.tail_step(&l.body);+        let r = loop {+            match step {+                Tail::Done(v) => break v,+                Tail::Call(next, bound) => {+                    tails += 1;+                    if tails >= MAX_TAIL_CALLS {+                        break err(CellError::Num);+                    }+                    self.fuel = match self.fuel.checked_sub(1) {+                        Some(f) => f,+                        None => break err(CellError::Calc),+                    };+                    self.env.clear();+                    self.env.extend(next.env.iter().cloned());+                    self.env.extend(bound);+                    step = self.tail_step(&next.body);+                }+            }+        };         self.env = saved;         self.calls -= 1;         rdiff --git a/crates/calc/src/eval/special.rs b/crates/calc/src/eval/special.rs--- a/crates/calc/src/eval/special.rs+++ b/crates/calc/src/eval/special.rs@@ -84,7 +84,7 @@ pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec<Area>, bool) {     (out, dynamic) } -fn is_special(n: &str) -> bool {+pub(super) fn is_special(n: &str) -> bool {     matches!(         n,         "IF" | "IFS"diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs--- a/crates/calc/src/tests.rs+++ b/crates/calc/src/tests.rs@@ -1097,17 +1097,67 @@ fn a_recursive_lambda_stops_at_the_same_call_on_any_stack_size() {                     comment: String::new(),                     hidden: false,                 });-                // F(F,n) has n + 1 calls in progress at its deepest; W adds one more call per step.+                // W's call to `f` is a tail call, so a wrapped call level costs one call in progress, not two. A tail call reuses its frame, so the tail chain is bounded by `MAX_TAIL_CALLS`, as in Excel+                // (5,459 ok, 5,460 #NUM!). F(F,n) otherwise has n + 1 calls in progress at its deepest; W adds+                // one more call per step.                 let tail = |n: u32| format!("=LET(F,LAMBDA(me,n,IF(n=0,0,me(me,n-1))),F(F,{n}))");                 let wrapped = |n: u32| format!("=LET(W,LAMBDA(f,k,f(f,k)),F,LAMBDA(me,n,IF(n<=0,0,1+W(me,n-1))),F(F,{n}))");-                [tail(139), tail(140), wrapped(69), wrapped(70), "=CNT(131)".to_string()].map(|f| crate::evaluate(&wb, 0, c("B1"), &f))+                [tail(5459), tail(5460), wrapped(69), wrapped(70), "=CNT(131)".to_string()].map(|f| crate::evaluate(&wb, 0, c("B1"), &f))             })             .expect("spawn")             .join()             .expect("recursion must not overflow the stack")     };     let small = run(1 << 20);-    assert_eq!(small, [Value::Number(0.0), Value::Error(CellError::Num), Value::Number(69.0), Value::Error(CellError::Num), Value::Number(131.0)]);+    assert_eq!(small, [Value::Number(0.0), Value::Error(CellError::Num), Value::Number(69.0), Value::Number(70.0), Value::Number(131.0)]);+    assert_eq!(run(64 << 20), small);+}++#[test]+fn a_tail_recursive_lambda_reaches_excels_depth_on_a_small_stack() {+    // Excel (measured): a tail countdown is 0 at 5,000 and 5,459 and #NUM! at 5,460 and 10,000.+    let run = |stack: usize| {+        std::thread::Builder::new()+            .stack_size(stack)+            .spawn(|| {+                let mut wb = Workbook::new();+                wb.names.push(gridcraft_model::DefinedName {+                    name: "TAILN".into(),+                    scope: None,+                    formula: "LAMBDA(n,IF(n=0,0,TAILN(n-1)))".into(),+                    comment: String::new(),+                    hidden: false,+                });+                let tail = |n: u32| format!("=LET(F,LAMBDA(me,n,IF(n=0,0,me(me,n-1))),F(F,{n}))");+                // The same chain through parentheses and an applied lambda, and one that returns the base case's value.+                let paren = "=LET(F,LAMBDA(me,n,IF(n=0,7,(me(me,n-1)))),F(F,5000))".to_string();+                let invoke = "=LET(F,LAMBDA(me,n,IF(n=0,7,LAMBDA(a,b,me(me,a))(n-1,0))),F(F,2000))".to_string();+                let endless = "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))".to_string();+                // Each call passes a closure that captures the last, 5,000 deep: releasing them must not recurse.+                let closures = "=LET(F,LAMBDA(me,n,acc,IF(n=0,0,me(me,n-1,LAMBDA(x,acc)))),F(F,5000,0))".to_string();+                [tail(5000), tail(5459), tail(5460), tail(10000), paren, invoke, endless, "=TAILN(5000)".to_string(), closures]+                    .map(|f| crate::evaluate(&wb, 0, c("B1"), &f))+            })+            .expect("spawn")+            .join()+            .expect("tail recursion must not overflow the stack")+    };+    let num = Value::Error(CellError::Num);+    let small = run(1 << 20);+    assert_eq!(+        small,+        [+            Value::Number(0.0),+            Value::Number(0.0),+            num.clone(),+            num.clone(),+            Value::Number(7.0),+            Value::Number(7.0),+            num,+            Value::Number(0.0),+            Value::Number(0.0)+        ]+    );     assert_eq!(run(64 << 20), small); }