From baffd3c78d9fc57e2d0f0abff0f0a3e86f18309f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 13:30:47 -0400 Subject: [PATCH] Fix-up #325: cap Forecast Sheet history and forecast rows The default periods (a third of the history, up to ~33k) bypassed MAX_PERIODS, and each forecast row's three ETS formulas refit over the whole history, so 100k history rows x 10k periods froze the app. History is now at most 10,000 rows, periods at most 10,000, and history x periods at most 500,000 (about 3 us per history point per fit: a few seconds of recalc at the cap). The default is clamped to the cap; explicit periods or an end past it are refused. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/forecast.rs | 69 +++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs index 5bcd3d78e..f53ab6603 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -16,9 +16,25 @@ use crate::selection::current_region; const CMD: &str = "data.forecastSheet"; /// Most history rows a forecast sheet copies. -const MAX_HISTORY: u64 = 100_000; +const MAX_HISTORY: u64 = 10_000; /// Most forecast rows. const MAX_PERIODS: u32 = 10_000; +/// Most history rows × forecast rows. Every forecast row holds three ETS formulas that each refit +/// the model over the whole history (about 3 µs per history point in a release build), so this +/// bounds a sheet's recalculation to a few seconds: 10,000 rows of history get 50 forecast rows, +/// 50 rows of history the full 10,000. +const MAX_WORK: u64 = 500_000; + +/// Forecast rows by default: a third of the history, within [`max_periods`]. +fn default_periods(history: usize) -> u32 { + u32::try_from(history.div_ceil(3)).unwrap_or(u32::MAX).clamp(1, max_periods(history)) +} + +/// Most forecast rows for `history` rows of history. +fn max_periods(history: usize) -> u32 { + let h = u64::try_from(history).unwrap_or(u64::MAX).max(1); + u32::try_from(MAX_WORK / h).unwrap_or(u32::MAX).clamp(1, MAX_PERIODS) +} pub fn specs() -> Vec { vec![cmd!( @@ -26,7 +42,7 @@ pub fn specs() -> Vec { "Forecast Sheet", ["Data", "Forecast"], None, - "{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.", + "{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.", has_doc, forecast_sheet )] @@ -215,12 +231,13 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { }, _ => None, }; + let cap = max_periods(rows.len()); let periods = match end { - Some(end) => steps_to(&timeline, end)?, + Some(end) => steps_to(&timeline, end, cap)?, None => match number(p, "periods")? { - Some(n) if n >= 1.0 && n <= f64::from(MAX_PERIODS) => n.ceil() as u32, - Some(_) => return Err(bad(CMD, format!("`periods` is from 1 to {MAX_PERIODS}"))), - None => (rows.len() as u32).div_ceil(3).max(1), + Some(n) if n >= 1.0 && n.ceil() <= f64::from(cap) => n.ceil() as u32, + Some(_) => return Err(bad(CMD, format!("`periods` is from 1 to {cap} for {} rows of history", rows.len()))), + None => default_periods(rows.len()), }, }; let future: Vec = @@ -392,14 +409,14 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { } /// Forecast rows needed to reach `end` (rounded up to whole steps). -fn steps_to(timeline: &Timeline, end: f64) -> Result { +fn steps_to(timeline: &Timeline, end: f64, cap: u32) -> Result { let steps = timeline.steps_after_last(end).ok_or_else(|| bad(CMD, "`end` is not on the timeline"))?; if steps < 1e-9 { return Err(EngineError::Other("The forecast end must be after the last point of the timeline.".into())); } let steps = steps.ceil(); - if steps > f64::from(MAX_PERIODS) { - return Err(bad(CMD, format!("at most {MAX_PERIODS} forecast steps"))); + if steps > f64::from(cap) { + return Err(bad(CMD, format!("at most {cap} forecast steps for this much history"))); } Ok(steps as u32) } @@ -469,6 +486,40 @@ mod tests { assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets - 1); } + #[test] + fn history_and_periods_are_capped() { + use super::{MAX_HISTORY, MAX_PERIODS, MAX_WORK, default_periods, max_periods}; + // The default (a third of the history) never exceeds the caps. + assert_eq!(default_periods(16), 6); + assert_eq!(default_periods(1_200), 400); + assert_eq!(default_periods(3_000), 166); + assert_eq!(default_periods(100_000), 5); + assert_eq!(max_periods(10), MAX_PERIODS); + assert_eq!(max_periods(0), MAX_PERIODS); + for h in [1usize, 2, 3, 50, 51, 1_000, 1_300, 3_000, 10_000, 100_000, usize::MAX] { + let (d, m) = (default_periods(h), max_periods(h)); + assert!(d >= 1 && d <= m && m <= MAX_PERIODS, "{h}: {d} {m}"); + assert!(u64::try_from(h).unwrap_or(u64::MAX).saturating_mul(u64::from(m)) <= MAX_WORK.max(u64::try_from(h).unwrap_or(u64::MAX)), "{h}"); + } + // Explicit periods or an end past the cap are refused before anything is built. + let mut s = Session::new(); + s.new_workbook(); + let mut rows = vec![json!(["t", "y"])]; + rows.extend((1..=1_300).map(|t| json!([t, (t % 7) as f64 + t as f64 * 0.5]))); + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + let sheets = s.doc().unwrap().wb.sheets.len(); + let cap = max_periods(1_300); // 384 + let err = |r: crate::Result| r.err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "periods": cap + 1}))).contains("from 1 to 384")); + assert!(err(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "end": 1_300 + cap + 1}))).contains("at most 384")); + assert!(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "periods": 10_000})).is_err()); + assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets); + // More history rows than MAX_HISTORY are refused. + let big = MAX_HISTORY + 2; + let r = s.execute("data.forecastSheet", json!({"timeline": format!("A1:A{big}"), "values": format!("B1:B{big}")})); + assert!(r.is_err()); + } + #[test] fn forecast_sheet_parameters_and_errors() { let mut s = session_with_history();