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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-11 13:30:47 -04:00
co-authored by Claude Opus 5.5
parent 5360a77505
commit baffd3c78d
+60 -9
View File
@@ -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<CommandSpec> {
vec![cmd!(
@@ -26,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Json> {
},
_ => 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<f64> =
@@ -392,14 +409,14 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
/// Forecast rows needed to reach `end` (rounded up to whole steps).
fn steps_to(timeline: &Timeline, end: f64) -> Result<u32> {
fn steps_to(timeline: &Timeline, end: f64, cap: u32) -> Result<u32> {
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<serde_json::Value>| 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();