fix(functions): avoid overflow in median midpoints (#1579) (#1585)

This commit is contained in:
Mex
2026-10-17 21:13:03 -06:00
committed by GitHub
parent 19f4387cca
commit c30fa3f1cf
3 changed files with 76 additions and 2 deletions
+28
View File
@@ -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);
}
}
+41 -1
View File
@@ -54,7 +54,7 @@ fn median(v: Vec<f64>) -> R<f64> {
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::<Vec<_>>()), 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);
+7 -1
View File
@@ -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) |