From c30fa3f1cf324603f3bfc0171689b1d4083b40e5 Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:13:03 +0200 Subject: [PATCH] fix(functions): avoid overflow in median midpoints (#1579) (#1585) --- crates/calc/src/tests.rs | 28 ++++++++++++++++++++++++ crates/functions/src/stat.rs | 42 +++++++++++++++++++++++++++++++++++- docs/function-parity.md | 8 ++++++- 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 196c57de7..97f7a8350 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1266,3 +1266,31 @@ fn offset_extreme_arguments_return_ref() { assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{formula}"); } } + +#[test] +fn median_large_values_recalculate_dependents() { + let mut t = T::new(); + t.set("A1", "1e308"); + t.set("A2", "1.6e308"); + t.set("B1", "=MEDIAN(A1:A2)"); + t.set("B2", "=AGGREGATE(12,0,A1:A2)"); + t.set("C1", "=B1/1e308"); + t.set("C2", "=B2/1e308"); + for cell in ["B1", "B2"] { + assert!((t.num(cell) / 1e308 - 1.3).abs() < 1e-14); + } + for cell in ["C1", "C2"] { + assert!((t.num(cell) - 1.3).abs() < 1e-14); + } + t.set("A2", "-1e308"); + for cell in ["B1", "B2", "C1", "C2"] { + assert_eq!(t.num(cell), 0.0); + } + t.set("A1", "-1.6e308"); + for cell in ["B1", "B2"] { + assert!((t.num(cell) / 1e308 + 1.3).abs() < 1e-14); + } + for cell in ["C1", "C2"] { + assert!((t.num(cell) + 1.3).abs() < 1e-14); + } +} diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index 7c50abeaa..9d8642e24 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -54,7 +54,7 @@ fn median(v: Vec) -> R { let s = sorted(v); let n = s.len(); let hi = s.get(n / 2).copied().unwrap_or(0.0); - if n % 2 == 1 { Ok(hi) } else { Ok((s.get(n / 2 - 1).copied().unwrap_or(0.0) + hi) / 2.0) } + if n % 2 == 1 { Ok(hi) } else { Ok(s.get(n / 2 - 1).copied().unwrap_or(0.0).midpoint(hi)) } } /// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count. @@ -2255,6 +2255,46 @@ mod tests { av(col(xs)) } + // The two middle values are finite and their midpoint is representable even when + // adding them first would overflow. Exercise both callers of the shared helper. + #[test] + fn median_finite_midpoint_does_not_overflow() { + for (values, expected) in [ + (vec![1e308, 1e308], 1e308), + (vec![-1e308, -1e308], -1e308), + (vec![1.6e308, 1e308], 1.3e308), + (vec![-1e308, -1.6e308], -1.3e308), + (vec![f64::MAX, f64::MAX], f64::MAX), + (vec![-f64::MAX, -f64::MAX], -f64::MAX), + (vec![-f64::MAX, f64::MAX], 0.0), + (vec![1.7e308, -1.7e308, 1e308, 1.6e308], 1.3e308), + ] { + close(ev("MEDIAN", values.iter().copied().map(n).collect()), expected); + close(ev("MEDIAN", vec![rf(col(&values))]), expected); + close(aggregate_values(12, &values.iter().copied().map(nv).collect::>()), expected); + } + } + + #[test] + fn median_midpoint_keeps_small_values_and_argument_semantics() { + // Halving each operand unconditionally can lose the smallest numbers. Use exact + // equality here: the usual aggregate-test tolerance would accept zero. + for value in [f64::MIN_POSITIVE, -f64::MIN_POSITIVE, f64::from_bits(1), -f64::from_bits(1)] { + assert_eq!(ev("MEDIAN", vec![n(value), n(value)]), nv(value)); + assert_eq!(aggregate_values(12, &[nv(value), nv(value)]), nv(value)); + } + close(ev("MEDIAN", vec![c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]), 3.5); + close(ev("MEDIAN", vec![c(&[1e308, -1e308, 3.0])]), 3.0); + close(ev("MEDIAN", vec![n(1e308)]), 1e308); + close(ev("MEDIAN", vec![t("4"), b(true)]), 2.5); + close(ev("MEDIAN", vec![rf(arr(vec![vec![nv(1.0), tv("ignored"), Value::Empty, Value::Bool(true), nv(6.0)]]))]), 3.5); + is_err(ev("MEDIAN", vec![t("invalid")]), CellError::Value); + is_err(ev("MEDIAN", vec![e(CellError::NA), n(1e308)]), CellError::NA); + is_err(ev("MEDIAN", vec![rf(arr(vec![vec![tv("ignored"), Value::Empty]]))]), CellError::Num); + is_err(aggregate_values(12, &[]), CellError::Num); + is_err(aggregate_values(12, &[nv(1e308), Value::Error(CellError::Div0)]), CellError::Div0); + } + #[test] fn averages_and_counts() { close(ev("AVERAGE", vec![n(1.0), n(2.0), n(6.0)]), 3.0); diff --git a/docs/function-parity.md b/docs/function-parity.md index 274800699..2343e0de9 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (clearing large sparse ranges recalculates every changed cell's dependents, #1032) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (MEDIAN and AGGREGATE median avoid intermediate overflow) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -101,6 +101,11 @@ fraction sections with literal text (`#\:#=/=#`, `#_#/#`), zero fractions, zero- denominators, `?` placeholders padding the thousands separator, and booleans through the text section (`TEXT(TRUE,";;;\@@\@")` = `@TRUE@`). +`MEDIAN` and `AGGREGATE` function number 12 use an overflow-safe midpoint for even-sized +inputs, so large finite middle values do not become `#NUM!` merely because their sum overflows (#1579). +Sorting, argument coercion and error propagation are unchanged; this is a numerical correction +under the documented median rule, not an extreme-value comparison against desktop Excel. + Work to close (16–26 h in total): the in-scope missing functions (10–16 h with the table above), an oracle corpus of Excel-computed results per function (owner runs Excel on generated workbooks; results checked in as data, not Excel files) and fixing what it finds (6–10 h). @@ -109,6 +114,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | MEDIAN and AGGREGATE median use an overflow-safe midpoint (#1579); regressions cover finite extremes, tiny values and dependent recalculation | | 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) | | 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) |