fix(core): summaries are tested for exact merges, impossible parts are refused, and the bypass list is complete (improvise-qddo)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
committed by
Edward Langley
parent
4a49398f1a
commit
8ba4bd0a6c
@@ -52,20 +52,28 @@ impl Numbers {
|
||||
}
|
||||
|
||||
/// Numbers from what an engine computed: how many, their exact sum and the least and
|
||||
/// greatest that are not NaN. Extremes that contradict the count or are NaN are dropped.
|
||||
/// greatest that are not NaN. A state `add` cannot reach is refused, not repaired.
|
||||
pub fn from_parts(
|
||||
count: u64,
|
||||
sum: ExactSum,
|
||||
least: Option<f64>,
|
||||
greatest: Option<f64>,
|
||||
) -> Self {
|
||||
let held = |x: Option<f64>| x.filter(|x| count > 0 && !x.is_nan());
|
||||
Numbers {
|
||||
) -> Result<Self, BadParts> {
|
||||
let bad = |why| Err(BadParts(why));
|
||||
match (least, greatest) {
|
||||
(None, None) => {}
|
||||
(Some(_), Some(_)) if count == 0 => return bad("extremes with no numbers"),
|
||||
(Some(l), Some(g)) if l.is_nan() || g.is_nan() => return bad("a NaN extreme"),
|
||||
(Some(l), Some(g)) if l.total_cmp(&g).is_gt() => return bad("least above greatest"),
|
||||
(Some(_), Some(_)) => {}
|
||||
_ => return bad("one extreme without the other"),
|
||||
}
|
||||
Ok(Numbers {
|
||||
count,
|
||||
sum,
|
||||
least: held(least),
|
||||
greatest: held(greatest),
|
||||
}
|
||||
least,
|
||||
greatest,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add(&mut self, x: f64) {
|
||||
@@ -121,6 +129,10 @@ fn merged(a: Option<f64>, b: Option<f64>, pick: fn(f64, f64) -> f64) -> Option<f
|
||||
}
|
||||
}
|
||||
|
||||
/// Parts of a summary that no sequence of values could have produced.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BadParts(pub &'static str);
|
||||
|
||||
/// What the values that are not numbers agree on: nothing held, one value, or disagreement.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub enum Agreement {
|
||||
@@ -146,8 +158,11 @@ impl Summary {
|
||||
}
|
||||
|
||||
/// A summary from what an engine computed: the numbers, and what the rest agree on.
|
||||
pub fn from_parts(numbers: Numbers, others: Agreement) -> Self {
|
||||
Summary { numbers, others }
|
||||
pub fn from_parts(numbers: Numbers, others: Agreement) -> Result<Self, BadParts> {
|
||||
match &others {
|
||||
Agreement::One(v) if v.as_f64().is_some() => Err(BadParts("a number among the others")),
|
||||
_ => Ok(Summary { numbers, others }),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(&mut self, value: &CellValue) {
|
||||
@@ -324,6 +339,11 @@ mod tests {
|
||||
CellValue::Number(0.0),
|
||||
CellValue::Number(f64::NAN),
|
||||
CellValue::Number(f64::INFINITY),
|
||||
CellValue::Number(1e16),
|
||||
CellValue::Number(1.0),
|
||||
CellValue::Number(-1e16),
|
||||
CellValue::Number(0.1),
|
||||
CellValue::Number(0.2),
|
||||
text("a"),
|
||||
text("b"),
|
||||
CellValue::Error("e".into()),
|
||||
@@ -416,23 +436,41 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn numbers_pool_what_the_formula_aggregate_pools() {
|
||||
let xs = [1.0, -0.0, f64::NAN, 3.0, 0.0];
|
||||
let xs = [1e16, 1.0, -1e16, 0.1, 0.2, -0.0, 0.0, 3.0];
|
||||
for split in 0..=xs.len() {
|
||||
let mut pooled = Numbers::of(xs[..split].iter().copied());
|
||||
pooled.merge(&Numbers::of(xs[split..].iter().copied()));
|
||||
let whole = Numbers::of(xs);
|
||||
for func in &FUNCS {
|
||||
let (a, b) = (pooled.reduce(func), whole.reduce(func));
|
||||
let equal = a.map(f64::to_bits) == b.map(f64::to_bits)
|
||||
|| a.zip(b).is_some_and(|(x, y)| x.is_nan() && y.is_nan());
|
||||
assert!(equal, "{func:?} split at {split}: {a:?} != {b:?}");
|
||||
assert_eq!(
|
||||
a.map(f64::to_bits),
|
||||
b.map(f64::to_bits),
|
||||
"{func:?} split at {split}"
|
||||
);
|
||||
}
|
||||
assert_eq!(pooled.total().to_bits(), whole.total().to_bits());
|
||||
assert_eq!(
|
||||
pooled.total().to_bits(),
|
||||
whole.total().to_bits(),
|
||||
"split at {split}"
|
||||
);
|
||||
}
|
||||
assert_eq!(Numbers::default().total().to_bits(), 0.0f64.to_bits());
|
||||
assert_eq!(Numbers::default().reduce(&AggFunc::Sum), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_exact_sum_of_a_pool_of_cancelling_values_is_not_the_sum_of_rounded_parts() {
|
||||
let rounded = [1e16, 1.0].iter().sum::<f64>() + [-1e16].iter().sum::<f64>();
|
||||
let mut pooled = Numbers::of([1e16, 1.0]);
|
||||
pooled.merge(&Numbers::of([-1e16]));
|
||||
assert_eq!(
|
||||
rounded, 0.0,
|
||||
"the fixture is inexact: summing rounded parts loses the 1"
|
||||
);
|
||||
assert_eq!(pooled.total(), 1.0);
|
||||
}
|
||||
|
||||
fn pair(c: &str, i: &str) -> (String, String) {
|
||||
(c.to_string(), i.to_string())
|
||||
}
|
||||
@@ -597,8 +635,8 @@ mod tests {
|
||||
}
|
||||
Some(_) => Agreement::Mixed,
|
||||
};
|
||||
let parts = Numbers::from_parts(numbers.len() as u64, sum, least, greatest);
|
||||
Summary::from_parts(parts, agreement)
|
||||
let parts = Numbers::from_parts(numbers.len() as u64, sum, least, greatest).unwrap();
|
||||
Summary::from_parts(parts, agreement).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -617,12 +655,61 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parts_that_contradict_themselves_are_made_consistent() {
|
||||
let none = Numbers::from_parts(0, ExactSum::new(), Some(1.0), Some(f64::NAN));
|
||||
assert_eq!(none.reduce(&AggFunc::Min), None, "no numbers, no extremes");
|
||||
let nan = Numbers::from_parts(2, ExactSum::new(), Some(f64::NAN), None);
|
||||
let min = nan.reduce(&AggFunc::Min).unwrap();
|
||||
assert!(min.is_nan(), "a NaN is never an extreme");
|
||||
fn summaries_built_from_parts_merge_as_the_union_does() {
|
||||
let all = sequences(2);
|
||||
for (a, b) in all.iter().flat_map(|a| all.iter().map(move |b| (a, b))) {
|
||||
let mut merged = parts_of(a);
|
||||
merged.merge(&Summary::of(b));
|
||||
let mut both = parts_of(a);
|
||||
both.merge(&parts_of(b));
|
||||
let union: Vec<&CellValue> = a.iter().chain(b).collect();
|
||||
for func in &FUNCS {
|
||||
let want = reference(&union, func);
|
||||
assert!(
|
||||
same(&want, &merged.reduce(func)),
|
||||
"{func:?} of {a:?} + {b:?}"
|
||||
);
|
||||
assert!(
|
||||
same(&want, &both.reduce(func)),
|
||||
"{func:?} of {a:?} + {b:?}, both from parts"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parts_no_sequence_of_values_could_produce_are_refused() {
|
||||
let sum = ExactSum::new;
|
||||
let refused = |r: Result<Numbers, BadParts>| r.expect_err("must be refused").0;
|
||||
assert_eq!(
|
||||
refused(Numbers::from_parts(0, sum(), Some(1.0), Some(1.0))),
|
||||
"extremes with no numbers"
|
||||
);
|
||||
assert_eq!(
|
||||
refused(Numbers::from_parts(2, sum(), Some(f64::NAN), Some(1.0))),
|
||||
"a NaN extreme"
|
||||
);
|
||||
assert_eq!(
|
||||
refused(Numbers::from_parts(2, sum(), Some(2.0), Some(1.0))),
|
||||
"least above greatest"
|
||||
);
|
||||
assert_eq!(
|
||||
refused(Numbers::from_parts(2, sum(), Some(0.0), Some(-0.0))),
|
||||
"least above greatest"
|
||||
);
|
||||
assert_eq!(
|
||||
refused(Numbers::from_parts(2, sum(), Some(1.0), None)),
|
||||
"one extreme without the other"
|
||||
);
|
||||
let ok = Numbers::from_parts(2, sum(), None, None).unwrap();
|
||||
assert!(
|
||||
ok.reduce(&AggFunc::Min).unwrap().is_nan(),
|
||||
"all NaN: no extremes, a count"
|
||||
);
|
||||
let number = Agreement::One(CellValue::Number(1.0));
|
||||
let bad = Summary::from_parts(ok.clone(), number).expect_err("a number is not an other");
|
||||
assert_eq!(bad.0, "a number among the others");
|
||||
assert!(Summary::from_parts(ok, Agreement::One(CellValue::Text("t".into()))).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user