mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-28 04:36:51 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2f630f043 |
+74
-3
@@ -22,6 +22,11 @@ const FULL_SIZE_CELLS: u64 = 16_384;
|
|||||||
/// range, so that formulas over them stay fast.
|
/// range, so that formulas over them stay fast.
|
||||||
const FULL_SIZE_BOUNDED_CELLS: u64 = 1_048_576;
|
const FULL_SIZE_BOUNDED_CELLS: u64 = 1_048_576;
|
||||||
const MAX_DEPTH: usize = 400;
|
const MAX_DEPTH: usize = 400;
|
||||||
|
/// Lambda invocations one cell may make. A semantically bounded formula in Excel can invoke
|
||||||
|
/// its lambda at most once per cell of an array (capped here by `MAX_CELLS`), so this covers
|
||||||
|
/// every legitimate call; hostile work that grows past one dimension (a LAMBDA that recurses
|
||||||
|
/// or nest `MAP` into `MAP`) then stops at the same bound instead of squaring.
|
||||||
|
const LAMBDA_FUEL: u64 = MAX_CELLS;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
pub struct Area {
|
pub struct Area {
|
||||||
@@ -88,6 +93,11 @@ pub struct Evaluator<'h> {
|
|||||||
pub at: CellRef,
|
pub at: CellRef,
|
||||||
env: Vec<(String, Ev)>,
|
env: Vec<(String, Ev)>,
|
||||||
depth: usize,
|
depth: usize,
|
||||||
|
/// Lambda invocations this cell still has left. Every call spends one charge, so a
|
||||||
|
/// compact hostile formula (a LAMBDA that calls itself, nested `MAP`s over big arrays)
|
||||||
|
/// runs out of work after the same number of calls that a legitimately wide one could
|
||||||
|
/// make, instead of an amount of work that squares with array nesting (#2382).
|
||||||
|
fuel: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FnCtx<'a, 'h> {
|
struct FnCtx<'a, 'h> {
|
||||||
@@ -127,8 +137,11 @@ impl Ctx for FnCtx<'_, '_> {
|
|||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth };
|
let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth, fuel: self.ev.fuel };
|
||||||
Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true)))
|
let v = matches!(ev.value(&shifted).scalar().to_bool(), Ok(true));
|
||||||
|
// The child's spend counts against this cell's budget.
|
||||||
|
self.ev.fuel = ev.fuel;
|
||||||
|
Some(v)
|
||||||
}
|
}
|
||||||
fn has_lambda(&self) -> bool {
|
fn has_lambda(&self) -> bool {
|
||||||
self.lambda.is_some()
|
self.lambda.is_some()
|
||||||
@@ -147,7 +160,7 @@ impl Ctx for FnCtx<'_, '_> {
|
|||||||
|
|
||||||
impl<'h> Evaluator<'h> {
|
impl<'h> Evaluator<'h> {
|
||||||
pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self {
|
pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self {
|
||||||
Evaluator { host, sheet, at, env: Vec::new(), depth: 0 }
|
Evaluator { host, sheet, at, env: Vec::new(), depth: 0, fuel: LAMBDA_FUEL }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluates to a value (references are dereferenced; multi-cell → array).
|
/// Evaluates to a value (references are dereferenced; multi-cell → array).
|
||||||
@@ -983,6 +996,13 @@ impl<'h> Evaluator<'h> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev {
|
fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev {
|
||||||
|
// #2382: one charge per invocation, so a cell's total work stays bounded however the
|
||||||
|
// lambdas nest. Foregrounding the charge here means every path into a body (direct
|
||||||
|
// `(2,3)` application, `MAP`/`REDUCE`/`BYROW` helpers, D-function criteria) meters.
|
||||||
|
self.fuel = match self.fuel.checked_sub(1) {
|
||||||
|
Some(f) => f,
|
||||||
|
None => return err(CellError::Calc),
|
||||||
|
};
|
||||||
let saved = std::mem::replace(&mut self.env, l.env.clone());
|
let saved = std::mem::replace(&mut self.env, l.env.clone());
|
||||||
self.env.extend(bound);
|
self.env.extend(bound);
|
||||||
let r = self.eval(&l.body);
|
let r = self.eval(&l.body);
|
||||||
@@ -1984,3 +2004,54 @@ pub fn is_known_function(n: &str) -> bool {
|
|||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn unused(_: RefKind) {}
|
fn unused(_: RefKind) {}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod fuel_tests {
|
||||||
|
use super::*;
|
||||||
|
use gridcraft_model::Workbook;
|
||||||
|
|
||||||
|
struct Empty {
|
||||||
|
wb: Workbook,
|
||||||
|
}
|
||||||
|
impl Host for Empty {
|
||||||
|
fn workbook(&self) -> &Workbook {
|
||||||
|
&self.wb
|
||||||
|
}
|
||||||
|
fn cell_value(&mut self, _sheet: usize, _c: CellRef) -> Value {
|
||||||
|
Value::Empty
|
||||||
|
}
|
||||||
|
fn now_serial(&self) -> f64 {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
fn random(&mut self) -> f64 {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eval_with(fuel: u64, src: &str) -> Value {
|
||||||
|
let mut host = Empty { wb: Workbook::default() };
|
||||||
|
let mut ev = Evaluator::new(&mut host, 0, CellRef::new(0, 0));
|
||||||
|
ev.fuel = fuel;
|
||||||
|
ev.value(&gridcraft_formula::parser::parse(src).unwrap_or_else(|e| panic!("{e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lambda_calls_spend_fuel_and_run_out_quietly() {
|
||||||
|
// Three applications, one charge each: 3 inside the budget. (A nested MAP wouldn't do
|
||||||
|
// here: an inner MAP result is an array, and MAP of array elements is #CALC! on shape,
|
||||||
|
// which happens well before the budget does.)
|
||||||
|
let r = eval_with(3, "=LAMBDA(x,LAMBDA(y,LAMBDA(z,z+1)(3))(2))(1)");
|
||||||
|
assert_eq!(r, Value::number(4.0), "{r:?}");
|
||||||
|
// One charge short: the nest bottoms out in #CALC! instead of running unbounded.
|
||||||
|
let r = eval_with(2, "=LAMBDA(x,LAMBDA(y,LAMBDA(z,z+1)(3))(2))(1)");
|
||||||
|
assert!(matches!(r, Value::Error(CellError::Calc)), "{r:?}");
|
||||||
|
// One charge short of a single application: #CALC! with a zero budget.
|
||||||
|
let r = eval_with(0, "=LAMBDA(x,x+1)(2)");
|
||||||
|
assert!(matches!(r, Value::Error(CellError::Calc)), "{r:?}");
|
||||||
|
let r = eval_with(1, "=LAMBDA(x,x+1)(2)");
|
||||||
|
assert_eq!(r, Value::number(3.0));
|
||||||
|
// A flat helper map keeps working (3 of 3 invokes inside the budget).
|
||||||
|
let r = eval_with(3, "=MAP({1,2,3},LAMBDA(x,x*2))");
|
||||||
|
assert!(matches!(r, Value::Array(_)) && !matches!(&r, Value::Array(a) if a.data.iter().any(|v| matches!(v, Value::Error(_)))), "{r:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -570,6 +570,24 @@ fn lambda_helpers_check_shapes_and_parameters() {
|
|||||||
assert_eq!(t.num("B7"), 2.0);
|
assert_eq!(t.num("B7"), 2.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lambda_calls_spend_one_work_budget_per_cell() {
|
||||||
|
// #2382: lambda nesting multiplies invocations, and the budget meters every one of them.
|
||||||
|
// Exhaustion itself is tested on a mock host in `eval::fuel_tests` (burning the real 16M
|
||||||
|
// budget in a full recalc needs tens of millions of calls, far too slow for a unit test);
|
||||||
|
// here the same nested shape runs inside it: 6 × 9 ≈ 59k scalar REDUCE invocations.
|
||||||
|
let mut t = T::new();
|
||||||
|
t.set(
|
||||||
|
"A1",
|
||||||
|
"=LET(s,SEQUENCE(9),REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,a+v)))))))))))",
|
||||||
|
);
|
||||||
|
// The bodies are the same inner reduce, so every level sums to 45 while the invocation
|
||||||
|
// count multiplies by 9 per level (9, 81, 729, 6561, 59049 calls).
|
||||||
|
assert_eq!(t.num("A1"), 45.0);
|
||||||
|
t.set("A2", "=REDUCE(0,SEQUENCE(4000),LAMBDA(a,v,a+v))");
|
||||||
|
assert_eq!(t.num("A2"), 4000.0 * 4001.0 / 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accounting_negative_text_coerces() {
|
fn accounting_negative_text_coerces() {
|
||||||
let mut t = T::new();
|
let mut t = T::new();
|
||||||
|
|||||||
@@ -264,15 +264,27 @@ pub(crate) fn has_wildcards(pattern: &str) -> bool {
|
|||||||
pub(crate) fn wildcard_match(pattern: &str, text: &str) -> bool {
|
pub(crate) fn wildcard_match(pattern: &str, text: &str) -> bool {
|
||||||
let p = tokenize(pattern);
|
let p = tokenize(pattern);
|
||||||
let t = lower_chars(text);
|
let t = lower_chars(text);
|
||||||
match_from(&p, &t, true).is_some()
|
let mut budget = MAX_STEPS;
|
||||||
|
match_from(&p, &t, true, &mut budget).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Steps one `match_from` call may take. An honest match against Excel's 32,767-character
|
||||||
|
/// cell ceiling costs a few hundred thousand; a hostile one (star churn) is capped instead
|
||||||
|
/// of quadratic — a call that spends more gives up and reports no match, quiet degradation
|
||||||
|
/// like the other work caps on far-past-realistic input.
|
||||||
|
const MAX_STEPS: u64 = 1 << 26;
|
||||||
|
|
||||||
/// Greedy-free matcher: returns the matched length of `t` when `p` matches a prefix of `t`
|
/// Greedy-free matcher: returns the matched length of `t` when `p` matches a prefix of `t`
|
||||||
/// (or all of `t` when `whole`). Iterative with backtracking on the last star (linear-ish).
|
/// (or all of `t` when `whole`). Iterative with backtracking on the last star (linear-ish,
|
||||||
fn match_from(p: &[Tok], t: &[char], whole: bool) -> Option<usize> {
|
/// quadratic at worst). `(budget, whole)`: the budget is spent one per loop step; a call that
|
||||||
|
/// would spend more than it has gives up and reports no match — quiet degradation, as with the
|
||||||
|
/// other work caps on far-past-realistic input (an honest match against Excel's 32,767-char
|
||||||
|
/// cell ceiling costs a few hundred thousand steps).
|
||||||
|
fn match_from(p: &[Tok], t: &[char], whole: bool, budget: &mut u64) -> Option<usize> {
|
||||||
let (mut pi, mut ti) = (0usize, 0usize);
|
let (mut pi, mut ti) = (0usize, 0usize);
|
||||||
let mut star: Option<(usize, usize)> = None;
|
let mut star: Option<(usize, usize)> = None;
|
||||||
loop {
|
loop {
|
||||||
|
*budget = budget.checked_sub(1)?;
|
||||||
if pi == p.len() && (!whole || ti == t.len()) {
|
if pi == p.len() && (!whole || ti == t.len()) {
|
||||||
return Some(ti);
|
return Some(ti);
|
||||||
}
|
}
|
||||||
@@ -307,10 +319,32 @@ fn match_from(p: &[Tok], t: &[char], whole: bool) -> Option<usize> {
|
|||||||
pub(crate) fn wildcard_find(pattern: &str, text: &str, start: usize) -> Option<usize> {
|
pub(crate) fn wildcard_find(pattern: &str, text: &str, start: usize) -> Option<usize> {
|
||||||
let p = tokenize(pattern);
|
let p = tokenize(pattern);
|
||||||
let t = lower_chars(text);
|
let t = lower_chars(text);
|
||||||
|
// A leading run of stars never matters for a prefix match (it consumes nothing), so
|
||||||
|
// dropping it lets the anchor below skip linearly through the text (#2382).
|
||||||
|
let skip = p.iter().take_while(|tk| matches!(tk, Tok::Star)).count();
|
||||||
|
let p = &p[skip.min(p.len())..];
|
||||||
if p.is_empty() {
|
if p.is_empty() {
|
||||||
return if start <= t.len() { Some(start) } else { None };
|
return if start <= t.len() { Some(start) } else { None };
|
||||||
}
|
}
|
||||||
(start..t.len()).find(|&i| t.get(i..).is_some_and(|rest| match_from(&p, rest, false).is_some()))
|
// The first token that consumes a character anchors the scan: a literal skips straight
|
||||||
|
// to its occurrences, and after leading `?`s each attempt only pays for the `?` run and
|
||||||
|
// one suffix try. Every attempt shares one budget, so the total work stays flat.
|
||||||
|
let anys = p.iter().take_while(|tk| matches!(tk, Tok::Any)).count();
|
||||||
|
let anchor = p.get(anys);
|
||||||
|
let mut budget = MAX_STEPS;
|
||||||
|
let mut i = start;
|
||||||
|
while i < t.len() {
|
||||||
|
let hit = match (anchor, t.get(i + anys)) {
|
||||||
|
(Some(Tok::Char(c)), Some(rest)) => *rest == *c && match_from(p, &t[i..], false, &mut budget).is_some(),
|
||||||
|
(Some(Tok::Char(_)), None) => break, // the anchor char no longer exists ahead
|
||||||
|
_ => match_from(p, &t[i..], false, &mut budget).is_some(),
|
||||||
|
};
|
||||||
|
if hit {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exact-or-wildcard equality used by MATCH/VLOOKUP/XLOOKUP exact modes: text with wildcards
|
/// Exact-or-wildcard equality used by MATCH/VLOOKUP/XLOOKUP exact modes: text with wildcards
|
||||||
|
|||||||
Reference in New Issue
Block a user