fix: add depth limit to formula evaluation, propagate errors

Circular or self-referencing formulas now return CellValue::Error
instead of stack overflowing. eval_expr uses Result<f64, String>
internally so errors (circular refs, div/0, missing refs) propagate
immediately through the expression tree via ?. The depth limit (16)
is checked per evaluate_depth call — normal 1-2 level chains are
unaffected.

Also adds CellValue::Error variant for displaying ERR:reason in the
grid, and handles it in format, persistence, and search.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-04-09 01:35:05 -07:00
parent 32d215f3d6
commit 737d14a5c0
5 changed files with 1324 additions and 139 deletions

View File

@ -806,6 +806,7 @@ impl Cmd for SearchNavigate {
let s = match ctx.model.evaluate_aggregated(&key, ctx.none_cats()) { let s = match ctx.model.evaluate_aggregated(&key, ctx.none_cats()) {
Some(CellValue::Number(n)) => format!("{n}"), Some(CellValue::Number(n)) => format!("{n}"),
Some(CellValue::Text(t)) => t, Some(CellValue::Text(t)) => t,
Some(CellValue::Error(e)) => format!("ERR:{e}"),
None => String::new(), None => String::new(),
}; };
s.to_lowercase().contains(&query) s.to_lowercase().contains(&query)

View File

@ -5,6 +5,7 @@ pub fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String
match v { match v {
Some(CellValue::Number(n)) => format_f64(*n, comma, decimals), Some(CellValue::Number(n)) => format_f64(*n, comma, decimals),
Some(CellValue::Text(s)) => s.clone(), Some(CellValue::Text(s)) => s.clone(),
Some(CellValue::Error(e)) => format!("ERR:{e}"),
None => String::new(), None => String::new(),
} }
} }

View File

@ -62,15 +62,21 @@ impl std::fmt::Display for CellKey {
pub enum CellValue { pub enum CellValue {
Number(f64), Number(f64),
Text(String), Text(String),
/// Evaluation error (circular reference, depth overflow, etc.)
Error(String),
} }
impl CellValue { impl CellValue {
pub fn as_f64(&self) -> Option<f64> { pub fn as_f64(&self) -> Option<f64> {
match self { match self {
CellValue::Number(n) => Some(*n), CellValue::Number(n) => Some(*n),
CellValue::Text(_) => None, _ => None,
} }
} }
pub fn is_error(&self) -> bool {
matches!(self, CellValue::Error(_))
}
} }
impl std::fmt::Display for CellValue { impl std::fmt::Display for CellValue {
@ -84,6 +90,7 @@ impl std::fmt::Display for CellValue {
} }
} }
CellValue::Text(s) => write!(f, "{s}"), CellValue::Text(s) => write!(f, "{s}"),
CellValue::Error(msg) => write!(f, "ERR:{msg}"),
} }
} }
} }

View File

@ -261,11 +261,22 @@ impl Model {
/// Evaluate a computed value at a given key, considering formulas. /// Evaluate a computed value at a given key, considering formulas.
/// Returns None when the cell is empty (no stored value, no applicable formula). /// Returns None when the cell is empty (no stored value, no applicable formula).
/// Maximum formula evaluation depth. Circular references return None
/// instead of stack-overflowing.
const MAX_EVAL_DEPTH: u8 = 16;
pub fn evaluate(&self, key: &CellKey) -> Option<CellValue> { pub fn evaluate(&self, key: &CellKey) -> Option<CellValue> {
self.evaluate_depth(key, Self::MAX_EVAL_DEPTH)
}
fn evaluate_depth(&self, key: &CellKey, depth: u8) -> Option<CellValue> {
if depth == 0 {
return Some(CellValue::Error("circular".into()));
}
for formula in &self.formulas { for formula in &self.formulas {
if let Some(item_val) = key.get(&formula.target_category) { if let Some(item_val) = key.get(&formula.target_category) {
if item_val == formula.target { if item_val == formula.target {
return self.eval_formula(formula, key); return self.eval_formula_depth(formula, key, depth - 1);
} }
} }
} }
@ -326,6 +337,15 @@ impl Model {
} }
fn eval_formula(&self, formula: &Formula, context: &CellKey) -> Option<CellValue> { fn eval_formula(&self, formula: &Formula, context: &CellKey) -> Option<CellValue> {
self.eval_formula_depth(formula, context, Self::MAX_EVAL_DEPTH)
}
fn eval_formula_depth(
&self,
formula: &Formula,
context: &CellKey,
depth: u8,
) -> Option<CellValue> {
use crate::formula::{AggFunc, Expr}; use crate::formula::{AggFunc, Expr};
// Check WHERE filter first // Check WHERE filter first
@ -348,42 +368,50 @@ impl Model {
None None
} }
/// Evaluate an expression, returning Ok(f64) or Err(reason).
/// Errors propagate immediately — a circular reference in any
/// sub-expression short-circuits the entire formula.
fn eval_expr( fn eval_expr(
expr: &Expr, expr: &Expr,
context: &CellKey, context: &CellKey,
model: &Model, model: &Model,
target_category: &str, target_category: &str,
) -> Option<f64> { depth: u8,
) -> Result<f64, String> {
match expr { match expr {
Expr::Number(n) => Some(*n), Expr::Number(n) => Ok(*n),
Expr::Ref(name) => { Expr::Ref(name) => {
let cat = find_item_category(model, name)?; let cat = find_item_category(model, name)
.ok_or_else(|| format!("ref:{name}"))?;
let new_key = context.clone().with(cat, name); let new_key = context.clone().with(cat, name);
model.evaluate(&new_key).and_then(|v| v.as_f64()) match model.evaluate_depth(&new_key, depth) {
Some(CellValue::Number(n)) => Ok(n),
Some(CellValue::Error(e)) => Err(e),
_ => Err(format!("ref:{name}")),
}
} }
Expr::BinOp(op, l, r) => { Expr::BinOp(op, l, r) => {
use crate::formula::BinOp; use crate::formula::BinOp;
let lv = eval_expr(l, context, model, target_category)?; let lv = eval_expr(l, context, model, target_category, depth)?;
let rv = eval_expr(r, context, model, target_category)?; let rv = eval_expr(r, context, model, target_category, depth)?;
Some(match op { match op {
BinOp::Add => lv + rv, BinOp::Add => Ok(lv + rv),
BinOp::Sub => lv - rv, BinOp::Sub => Ok(lv - rv),
BinOp::Mul => lv * rv, BinOp::Mul => Ok(lv * rv),
BinOp::Div => { BinOp::Div => {
if rv == 0.0 { if rv == 0.0 {
return None; Err("div/0".into())
} else {
Ok(lv / rv)
} }
lv / rv
} }
BinOp::Pow => lv.powf(rv), BinOp::Pow => Ok(lv.powf(rv)),
// Comparison operators are handled by eval_bool; reaching
// here means a comparison was used where a number is expected.
BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => { BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => {
return None Err("type".into())
} }
}) }
} }
Expr::UnaryMinus(e) => Some(-eval_expr(e, context, model, target_category)?), Expr::UnaryMinus(e) => Ok(-eval_expr(e, context, model, target_category, depth)?),
Expr::Agg(func, inner, agg_filter) => { Expr::Agg(func, inner, agg_filter) => {
let mut partial = context.without(target_category); let mut partial = context.without(target_category);
if let Expr::Ref(item_name) = inner.as_ref() { if let Expr::Ref(item_name) = inner.as_ref() {
@ -401,25 +429,25 @@ impl Model {
.filter_map(|v| v.as_f64()) .filter_map(|v| v.as_f64())
.collect(); .collect();
match func { match func {
AggFunc::Sum => Some(values.iter().sum()), AggFunc::Sum => Ok(values.iter().sum()),
AggFunc::Avg => { AggFunc::Avg => {
if values.is_empty() { if values.is_empty() {
None Err("empty".into())
} else { } else {
Some(values.iter().sum::<f64>() / values.len() as f64) Ok(values.iter().sum::<f64>() / values.len() as f64)
} }
} }
AggFunc::Min => values.iter().cloned().reduce(f64::min), AggFunc::Min => values.iter().cloned().reduce(f64::min).ok_or_else(|| "empty".into()),
AggFunc::Max => values.iter().cloned().reduce(f64::max), AggFunc::Max => values.iter().cloned().reduce(f64::max).ok_or_else(|| "empty".into()),
AggFunc::Count => Some(values.len() as f64), AggFunc::Count => Ok(values.len() as f64),
} }
} }
Expr::If(cond, then, else_) => { Expr::If(cond, then, else_) => {
let cv = eval_bool(cond, context, model, target_category)?; let cv = eval_bool(cond, context, model, target_category, depth)?;
if cv { if cv {
eval_expr(then, context, model, target_category) eval_expr(then, context, model, target_category, depth)
} else { } else {
eval_expr(else_, context, model, target_category) eval_expr(else_, context, model, target_category, depth)
} }
} }
} }
@ -430,30 +458,33 @@ impl Model {
context: &CellKey, context: &CellKey,
model: &Model, model: &Model,
target_category: &str, target_category: &str,
) -> Option<bool> { depth: u8,
) -> Result<bool, String> {
use crate::formula::BinOp; use crate::formula::BinOp;
match expr { match expr {
Expr::BinOp(op, l, r) => { Expr::BinOp(op, l, r) => {
let lv = eval_expr(l, context, model, target_category)?; let lv = eval_expr(l, context, model, target_category, depth)?;
let rv = eval_expr(r, context, model, target_category)?; let rv = eval_expr(r, context, model, target_category, depth)?;
Some(match op { match op {
BinOp::Eq => (lv - rv).abs() < 1e-10, BinOp::Eq => Ok((lv - rv).abs() < 1e-10),
BinOp::Ne => (lv - rv).abs() >= 1e-10, BinOp::Ne => Ok((lv - rv).abs() >= 1e-10),
BinOp::Lt => lv < rv, BinOp::Lt => Ok(lv < rv),
BinOp::Gt => lv > rv, BinOp::Gt => Ok(lv > rv),
BinOp::Le => lv <= rv, BinOp::Le => Ok(lv <= rv),
BinOp::Ge => lv >= rv, BinOp::Ge => Ok(lv >= rv),
// Arithmetic operators are not comparisons
BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Pow => { BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Pow => {
return None Err("type".into())
} }
}) }
} }
_ => None, _ => Err("type".into()),
} }
} }
eval_expr(&formula.expr, context, self, &formula.target_category).map(CellValue::Number) match eval_expr(&formula.expr, context, self, &formula.target_category, depth) {
Ok(n) => Some(CellValue::Number(n)),
Err(e) => Some(CellValue::Error(e)),
}
} }
} }
@ -862,10 +893,10 @@ mod formula_tests {
CellValue::Number(0.0), CellValue::Number(0.0),
); );
m.add_formula(parse_formula("Result = Revenue / Zero", "Measure").unwrap()); m.add_formula(parse_formula("Result = Revenue / Zero", "Measure").unwrap());
// Division by zero must yield Empty, not 0, so the user sees a blank not a misleading zero. // Division by zero yields an error, not a blank or misleading zero.
assert_eq!( assert_eq!(
m.evaluate(&coord(&[("Measure", "Result"), ("Region", "East")])), m.evaluate(&coord(&[("Measure", "Result"), ("Region", "East")])),
None Some(CellValue::Error("div/0".into()))
); );
} }
@ -904,7 +935,47 @@ mod formula_tests {
cat.add_item("Ghost"); cat.add_item("Ghost");
} }
let k = coord(&[("Measure", "Ghost"), ("Region", "East")]); let k = coord(&[("Measure", "Ghost"), ("Region", "East")]);
assert_eq!(m.evaluate(&k), None); assert!(
matches!(m.evaluate(&k), Some(CellValue::Error(_))),
"missing ref should produce an error, got: {:?}",
m.evaluate(&k)
);
}
/// Circular formula references must produce an error, not stack overflow.
#[test]
fn circular_formula_returns_error() {
let mut m = Model::new("Test");
m.add_category("Measure").unwrap();
if let Some(cat) = m.category_mut("Measure") {
cat.add_item("A");
cat.add_item("B");
}
m.add_formula(parse_formula("A = B + 1", "Measure").unwrap());
m.add_formula(parse_formula("B = A + 1", "Measure").unwrap());
let result = m.evaluate(&coord(&[("Measure", "A")]));
assert!(
matches!(result, Some(CellValue::Error(_))),
"circular reference should produce an error, got: {:?}",
result
);
}
/// Self-referencing formula must produce an error.
#[test]
fn self_referencing_formula_returns_error() {
let mut m = Model::new("Test");
m.add_category("Measure").unwrap();
if let Some(cat) = m.category_mut("Measure") {
cat.add_item("X");
}
m.add_formula(parse_formula("X = X + 1", "Measure").unwrap());
let result = m.evaluate(&coord(&[("Measure", "X")]));
assert!(
matches!(result, Some(CellValue::Error(_))),
"self-reference should produce an error, got: {:?}",
result
);
} }
#[test] #[test]

File diff suppressed because it is too large Load Diff