Files
improvise/crates/numtower/tests/properties.rs
T
Claude (numtower) f11d27377d test(numtower): property tests run at the 256-case budget (improvise-59nw.1)
Answers Copilot on PR #337 (context/design-principles.md section 7). Every fault in the review's table still fails by name at 256 cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-10-09 00:14:57 -07:00

1368 lines
44 KiB
Rust

//! ADR 005 migration step 1's property tests, each beside the rival it must catch.
//! What each rival is and why it matters: `context/test-evidence/numtower.md`.
mod oracle;
use std::cmp::Ordering;
use std::str::FromStr;
use num_bigint::BigInt;
use num_rational::BigRational;
use num_traits::{ToPrimitive, Zero};
use numtower::{Decimal, Interim, Num, NumRef, NumSum, Typing, is_number, parse_number};
use oracle::*;
use proptest::prelude::*;
fn typed(s: &str) -> Num {
parse_number(s, Typing::Inferred).unwrap_or_else(|| panic!("{s} is a number"))
}
fn dec(s: &str) -> Num {
Num::exact(Decimal::from_str(s).unwrap())
}
// ---------------------------------------------------------------------------
// An Exact result is never a rounded one.
type BinOp<'a> = &'a dyn Fn(&Num, &Num) -> Option<Num>;
fn exact_op(name: &str) -> fn(&BigRational, &BigRational) -> Option<BigRational> {
match name {
"+" => |a, b| Some(a + b),
"-" => |a, b| Some(a - b),
"*" => |a, b| Some(a * b),
_ => |a, b| (b.numer() != &BigInt::from(0)).then(|| a / b),
}
}
fn check_never_rounded(name: &str, op: BinOp, deterministic: bool) -> Result<(), String> {
let oracle_op = exact_op(name);
run((exact(), exact()), 256, deterministic, |(a, b)| {
if let Some(r) = op(&a, &b).filter(Num::is_exact) {
let want = oracle_op(&q(&a).unwrap(), &q(&b).unwrap());
prop_assert!(
q(&r) == want,
"an exact result was rounded: {a} {name} {b} = {r}"
);
}
Ok(())
})
}
fn real(name: &str) -> impl Fn(&Num, &Num) -> Option<Num> + '_ {
move |a: &Num, b: &Num| match name {
"+" => Some(a + b),
"-" => Some(a - b),
"*" => Some(a * b),
_ => a.checked_div(b).ok(),
}
}
#[test]
fn an_exact_result_is_never_a_rounded_one() {
for name in ["+", "-", "*", "/"] {
check_never_rounded(name, &real(name), false).unwrap();
}
let third = dec("0.3333333333333333333333333333");
let sum = &dec("1000") + &third;
assert!(sum.is_exact(), "{sum}");
assert_eq!(q(&sum), Some(q(&dec("1000")).unwrap() + q(&third).unwrap()));
}
/// `rust_decimal`'s own `+`, trusted as exact: it rounds past 28 digits and says nothing.
fn silently_rounding_add(a: &Num, b: &Num) -> Option<Num> {
match (a.kind(), b.kind()) {
(NumRef::Exact(x), NumRef::Exact(y)) => {
Some(x.checked_add(*y).map_or_else(|| a + b, Num::exact))
}
_ => Some(a + b),
}
}
#[test]
fn an_exact_result_is_never_a_rounded_one_catches_a_silently_rounding_add() {
assert_caught(
check_never_rounded("+", &silently_rounding_add, true),
"an exact result was rounded",
);
}
// ---------------------------------------------------------------------------
// Contagion is total.
fn check_contagion(add: BinOp, deterministic: bool) -> Result<(), String> {
let seed = Just((dec("2"), Num::inexact(3.0)));
let cases = prop_oneof![1 => seed, 8 => (any_num(), any_num())];
run(cases, 256, deterministic, |(a, b)| {
let results = [
("+", add(&a, &b)),
("-", Some(&a - &b)),
("*", Some(&a * &b)),
("/", a.checked_div(&b).ok()),
];
for (name, r) in results {
match (r, a.is_exact() && b.is_exact()) {
(None, _) => prop_assert!(
name == "/" && b.is_zero(),
"contagion: {a} {name} {b} refused"
),
(Some(r), false) => {
prop_assert!(!r.is_exact(), "contagion: {a} {name} {b} = {r} is exact")
}
(Some(r), true) => {
let v = exact_op(name)(&q(&a).unwrap(), &q(&b).unwrap()).unwrap();
prop_assert_eq!(
r.is_exact(),
fits(&v),
"contagion: {} {} {} = {}",
a,
name,
b,
r
);
prop_assert!(
r.is_exact() || is_nearest(r.to_f64(), &v),
"contagion: {a} {name} {b} = {r}"
);
}
}
}
prop_assert_eq!(
a.checked_div(&b).is_err(),
b.is_zero(),
"contagion: div/0 for {} / {}",
a,
b
);
Ok(())
})
}
#[test]
fn contagion_is_total() {
check_contagion(&|a, b| Some(a + b), false).unwrap();
}
/// Keeps an integral f64 exact, as a "harmless" promotion would.
fn integral_inexact_kept_exact(a: &Num, b: &Num) -> Option<Num> {
match (a.is_exact(), b.kind()) {
(true, NumRef::Inexact(x)) if x.is_finite() && x.fract() == 0.0 && x.abs() < 1e15 => {
Some(a + &Num::exact(Decimal::from(x as i64)))
}
_ => Some(a + b),
}
}
#[test]
fn contagion_is_total_catches_an_integral_inexact_kept_exact() {
assert_caught(
check_contagion(&integral_inexact_kept_exact, true),
"contagion",
);
}
/// The scale a decimal power is stored at: minimal, padded toward `min(s·k, 28)` for k > 0 only.
fn power_scale(base: &Num, k: i32, v: &BigRational) -> u32 {
let scaled = |s: u32| v * BigRational::from_integer(BigInt::from(10).pow(s));
let fits_at =
|s: u32| scaled(s).is_integer() && scaled(s).to_integer().magnitude().bits() <= 96;
let minimal = (0..=28)
.find(|&s| scaled(s).is_integer())
.expect("a decimal result");
let target = match (base.kind(), k > 0) {
(NumRef::Exact(d), true) => (d.scale() * k as u32).min(28),
_ => 0,
};
(minimal..=target.max(minimal))
.rev()
.find(|&s| fits_at(s))
.unwrap_or(minimal)
}
#[test]
fn pow_is_exact_for_an_exact_base_and_integer_exponent_when_it_fits() {
let units =
prop::sample::select(vec!["1.0", "-1.0", "1.000", "-1", "0.00", "2.0"]).prop_map(dec);
let bases = prop_oneof![4 => any_num(), 1 => units];
run((bases, -6i32..=6), 256, false, |(a, k)| {
let r = a.pow(&Num::exact(Decimal::from(k)));
match (q(&a).filter(|_| a.is_exact()), r) {
(_, Err(_)) => prop_assert!(a.is_zero() && k < 0, "{a}^{k} refused"),
(None, Ok(r)) => prop_assert!(!r.is_exact(), "{a}^{k} = {r} is exact"),
(Some(v), Ok(r)) => {
let want = v.pow(k);
prop_assert_eq!(r.is_exact(), fits(&want), "{}^{} = {}", a, k, r);
prop_assert!(
!r.is_exact() || q(&r) == Some(want.clone()),
"{a}^{k} = {r}"
);
prop_assert!(
r.is_exact() || is_nearest(r.to_f64(), &want),
"{a}^{k} = {r}"
);
if let NumRef::Exact(d) = r.kind() {
let scale = power_scale(&a, k, &want);
prop_assert_eq!(d.scale(), scale, "power scale: {}^{} = {}", a, k, r);
}
}
}
Ok(())
})
.unwrap();
}
#[test]
fn a_negative_power_has_its_minimal_scale() {
let at = |base: &str, k: i64| dec(base).pow(&Num::exact(Decimal::from(k))).unwrap();
assert!(same(&at("1.0", -2), &dec("1")), "{}", at("1.0", -2));
assert!(same(&at("-1.0", -3), &dec("-1")), "{}", at("-1.0", -3));
assert!(same(&at("2.0", -2), &dec("0.25")));
assert!(
same(&at("-1.0", 3), &dec("-1.000")),
"a positive power keeps s·n"
);
}
#[test]
fn pow_decides_its_size_before_computing_and_honours_the_ceiling() {
let third = Num::ratio(1, 3).unwrap();
let huge = third
.pow(&Num::exact(Decimal::from(1_000_000_000)))
.unwrap();
assert!(!huge.is_exact() && huge.to_f64() == 0.0, "{huge}");
let past = third.pow(&Num::exact(Decimal::from(6000))).unwrap();
assert!(
!past.is_exact(),
"3^6000 has 9,510 bits, past the 8,192-bit ceiling"
);
let growth = dec("1.05").pow(&Num::exact(Decimal::from(28))).unwrap();
assert!(growth.is_exact(), "1.05^28 is exact under (d)");
assert!(
!dec("1.05")
.pow(&Num::exact(Decimal::from(29)))
.unwrap()
.is_exact()
);
let unit = dec("-1.0")
.pow(&Num::exact(Decimal::from_i128_with_scale(
10i128.pow(20),
0,
)))
.unwrap();
assert!(
same(&unit, &dec("1.0000000000000000000000000000")),
"{unit}"
);
assert!(dec("0").pow(&dec("-1")).is_err());
assert!(!dec("2").pow(&dec("0.5")).unwrap().is_exact());
assert!(same(&dec("2").pow(&dec("-2")).unwrap(), &dec("0.25")));
assert!(same(&dec("1.50").pow(&dec("2.0")).unwrap(), &dec("2.2500")));
assert!(same(&dec("0").pow(&dec("0")).unwrap(), &dec("1")));
}
// ---------------------------------------------------------------------------
// The conversion to f64 agrees with str::parse of the decimal text.
fn check_to_f64(
conv: &dyn Fn(&Decimal) -> f64,
range: i128,
deterministic: bool,
) -> Result<(), String> {
run((-range..=range, 0u32..=28), 256, deterministic, |(m, s)| {
let d = Decimal::from_i128_with_scale(m, s);
let want: f64 = decimal_text(m, s).parse().unwrap();
prop_assert_eq!(
conv(&d).to_bits(),
want.to_bits(),
"to_f64 disagrees with the text of {}",
d
);
Ok(())
})
}
const FULL: i128 = (1 << 96) - 1;
#[test]
fn the_f64_conversion_agrees_with_parsing_the_decimal_text() {
check_to_f64(&|d| Num::exact(*d).to_f64(), FULL, false).unwrap();
let review = dec("63294.607176721429540940386554");
assert_eq!(
review.to_f64(),
"63294.607176721429540940386554".parse::<f64>().unwrap()
);
}
fn crate_to_f64(d: &Decimal) -> f64 {
rust_decimal::prelude::ToPrimitive::to_f64(d).unwrap()
}
#[test]
fn the_f64_conversion_agrees_with_parsing_the_decimal_text_catches_rust_decimal_to_f64() {
assert_caught(check_to_f64(&crate_to_f64, FULL, true), "to_f64 disagrees");
let review = Decimal::from_str("63294.607176721429540940386554").unwrap();
assert_ne!(
crate_to_f64(&review),
"63294.607176721429540940386554".parse::<f64>().unwrap()
);
}
#[test]
fn rust_decimal_to_f64_agrees_on_fifteen_digit_mantissas_so_a_narrow_generator_is_vacuous() {
let narrow = 10i128.pow(15) - 1;
let result = run((-narrow..=narrow, 0u32..=15), 256, true, |(m, s)| {
let d = Decimal::from_i128_with_scale(m, s);
prop_assert_eq!(
crate_to_f64(&d).to_bits(),
decimal_text(m, s).parse::<f64>().unwrap().to_bits()
);
Ok(())
});
result.unwrap();
}
// ---------------------------------------------------------------------------
// A fraction's conversion lies inside the round-to-nearest-even interval.
fn wide_fraction() -> impl Strategy<Value = Num> {
(
20u32..=126,
20u32..=126,
any::<u128>(),
any::<u128>(),
any::<bool>(),
)
.prop_map(|(a, b, x, y, neg)| {
let n = ((x >> (128 - a)) | (1 << (a - 1))) as i128;
let d = ((y >> (128 - b)) | (1 << (b - 1))) as i128;
Num::ratio(if neg { -n } else { n }, d).unwrap()
})
}
fn check_fraction_conversion(
conv: &dyn Fn(&Num) -> f64,
deterministic: bool,
) -> Result<(), String> {
run(
(wide_fraction(), wide_fraction()),
256,
deterministic,
|(x, y)| {
prop_assert!(
is_nearest(conv(&x), &q(&x).unwrap()),
"outside the interval: {x}"
);
let wide = (&Interim::from(x.clone()) * &Interim::from(y.clone())).store();
let exact = q(&x).unwrap() * q(&y).unwrap();
prop_assert!(
wide.is_exact() || is_nearest(wide.to_f64(), &exact),
"outside the interval: {x} * {y}"
);
Ok(())
},
)
}
#[test]
fn a_fractions_conversion_lies_inside_the_round_to_nearest_even_interval() {
check_fraction_conversion(&Num::to_f64, false).unwrap();
}
fn divide_in_f64(x: &Num) -> f64 {
match x.kind() {
NumRef::Fraction(r) => *r.numer() as f64 / *r.denom() as f64,
_ => x.to_f64(),
}
}
#[test]
fn a_fractions_conversion_lies_inside_the_round_to_nearest_even_interval_catches_an_f64_division() {
assert_caught(
check_fraction_conversion(&divide_in_f64, true),
"outside the interval",
);
}
// ---------------------------------------------------------------------------
// Exact mixed comparison is transitive.
/// Values crowded around one f64: equal-looking decimals, the f64 and its neighbours.
fn crowd() -> impl Strategy<Value = Vec<Num>> {
(-1e6f64..1e6, 1i64..9).prop_map(|(x, k)| {
let shortest = typed(&format!("{x:?}"));
let nudge = Num::exact(Decimal::new(k, 25));
vec![
&shortest + &nudge,
Num::inexact(x),
&shortest - &nudge,
shortest.clone(),
Num::inexact(x.next_up()),
Num::inexact(x.next_down()),
shortest.checked_div(&Num::exact(Decimal::from(3))).unwrap(),
]
})
}
type Cmp<'a> = &'a dyn Fn(&Num, &Num) -> Option<Ordering>;
fn check_transitive(cmp: Cmp, deterministic: bool) -> Result<(), String> {
run(crowd(), 256, deterministic, |xs| {
for a in &xs {
for b in &xs {
let reversed = cmp(b, a).map(Ordering::reverse);
prop_assert_eq!(
cmp(a, b),
reversed,
"comparison: not antisymmetric: {} vs {}",
a,
b
);
for c in &xs {
let (ab, bc, ac) = (cmp(a, b), cmp(b, c), cmp(a, c));
let chained = ab.is_some_and(|o| o != Ordering::Greater)
&& bc.is_some_and(|o| o != Ordering::Greater);
let strict = ab == Some(Ordering::Less) || bc == Some(Ordering::Less);
let want = if strict {
Ordering::Less
} else {
Ordering::Equal
};
prop_assert!(
!chained || ac == Some(want),
"comparison: not transitive: {a}, {b}, {c}"
);
}
}
}
Ok(())
})
}
#[test]
fn exact_mixed_comparison_is_transitive() {
check_transitive(&Num::cmp_exact, false).unwrap();
assert_ne!(dec("0.1"), Num::inexact(0.1));
}
#[test]
fn comparison_agrees_with_the_oracle_at_every_pairing_of_arms() {
let finite = prop_oneof![exact(), finite_f64().prop_map(Num::inexact)];
let pools = prop_oneof![crowd(), prop::collection::vec(finite, 2..6)];
run(pools, 256, false, |xs| {
for a in &xs {
for b in &xs {
prop_assert_eq!(a.cmp_exact(b), oracle_cmp(a, b), "{} vs {}", a, b);
}
}
Ok(())
})
.unwrap();
assert_eq!(Num::inexact(f64::NAN).cmp_exact(&dec("1")), None);
assert_eq!(
dec("1").cmp_exact(&Num::inexact(f64::INFINITY)),
Some(Ordering::Less)
);
}
/// Revision 1's rule: the exact operand converted to f64, then compared.
fn compare_through_f64(a: &Num, b: &Num) -> Option<Ordering> {
match a.is_exact() == b.is_exact() {
true => a.cmp_exact(b),
false => a.to_f64().partial_cmp(&b.to_f64()),
}
}
#[test]
fn exact_mixed_comparison_is_transitive_catches_comparing_through_f64() {
assert_caught(
check_transitive(&compare_through_f64, true),
"comparison: not transitive",
);
}
#[test]
fn min_and_max_prefer_the_exact_value_and_do_not_depend_on_order() {
let half = dec("0.50");
let pairs = [
(half.clone(), Num::inexact(0.5)),
(dec("1.5"), dec("1.50")),
(Num::inexact(0.0), Num::inexact(-0.0)),
(Num::inexact(f64::NAN), dec("2")),
];
for (a, b) in &pairs {
assert!(same(a.min_of(b), b.min_of(a)), "min of {a}, {b}");
assert!(same(a.max_of(b), b.max_of(a)), "max of {a}, {b}");
}
assert!(same(half.min_of(&Num::inexact(0.5)), &half));
assert!(same(dec("1.5").max_of(&dec("1.50")), &dec("1.50")));
assert!(
Num::inexact(0.0)
.min_of(&Num::inexact(-0.0))
.to_f64()
.is_sign_negative()
);
assert!(
!Num::inexact(-0.0)
.max_of(&Num::inexact(0.0))
.to_f64()
.is_sign_negative()
);
assert!(same(Num::inexact(f64::NAN).min_of(&dec("2")), &dec("2")));
}
#[test]
fn the_accumulator_keeps_inexact_terms_whose_weights_cancel() {
let mut s = NumSum::new();
s.add(&Num::inexact(1.0), 1);
s.add(&Num::inexact(2.0), -1);
s.insert(&dec("0.5"));
let read = s.read();
assert!(!read.is_exact(), "an inexact term is still present: {read}");
assert_eq!(read.to_f64(), -0.5);
s.add(&Num::inexact(1.0), -1);
s.add(&Num::inexact(2.0), 1);
assert!(same(&s.read(), &dec("0.5")), "every inexact term retracted");
}
#[test]
fn a_huge_odd_exponent_keeps_the_sign_of_a_negative_base() {
let odd = Num::exact(Decimal::from(9_007_199_254_740_993i64));
let up = dec("-2").pow(&odd).unwrap();
assert!(!up.is_exact() && up.to_f64() == f64::NEG_INFINITY, "{up}");
let down = dec("-2").pow(&-&odd).unwrap();
assert!(
!down.is_exact() && down.to_f64() == 0.0 && down.to_f64().is_sign_negative(),
"{down}"
);
let third = Num::ratio(-1, 3).unwrap().pow(&odd).unwrap();
assert!(third.to_f64().is_sign_negative(), "{third}");
let even = &odd + &dec("1");
assert_eq!(dec("-2").pow(&even).unwrap().to_f64(), f64::INFINITY);
}
#[test]
fn i128_min_is_an_exact_fraction_numerator() {
let min = Num::ratio(i128::MIN, 1).unwrap();
assert!(
matches!(min.kind(), NumRef::Fraction(r) if *r.numer() == i128::MIN),
"{min}"
);
let third = Num::ratio(i128::MIN, 3).unwrap();
assert!(matches!(third.kind(), NumRef::Fraction(_)), "{third}");
let negated = -&third;
let want = -q(&third).unwrap();
assert!(
!negated.is_exact() && is_nearest(negated.to_f64(), &want),
"{negated}"
);
let back =
&(&Interim::from(third.clone()) * &Interim::from(dec("-1"))) * &Interim::from(dec("-1"));
assert!(
same(&back.store(), &third),
"through the wide path and back"
);
let halved = third.checked_div(&dec("2")).unwrap();
assert_eq!(
q(&halved),
Some(q(&third).unwrap() / BigRational::from_integer(BigInt::from(2)))
);
assert_eq!(
min.cmp_exact(&Num::ratio(i128::MIN + 1, 1).unwrap()),
Some(Ordering::Less)
);
assert_eq!(
Num::from_canonical(&min.to_string()).map(|n| same(&n, &min)),
Some(true)
);
}
// ---------------------------------------------------------------------------
// The accumulator is independent of order.
fn term() -> impl Strategy<Value = Num> {
let pooled = (
-1000i128..1000,
prop::sample::select(vec![3i128, 7, 12, 49, 1 << 40, 3 * (1 << 33)]),
)
.prop_map(|(n, d)| Num::ratio(n, d).unwrap());
prop_oneof![
4 => decimal(),
2 => pooled,
1 => fraction(),
1 => finite_f64().prop_map(Num::inexact),
]
}
/// Z-set weights: zero, unit, small, wide and both extremes (never `i64::MIN`, so it negates).
fn weight() -> impl Strategy<Value = i64> {
prop_oneof![
3 => Just(1i64),
1 => Just(-1i64),
1 => Just(0i64),
2 => -3i64..=3,
2 => (-(1i64 << 40))..(1i64 << 40),
1 => prop_oneof![Just(i64::MAX), Just(-i64::MAX), Just((1i64 << 30) - 1)],
]
}
/// Many non-decimal denominators, so a list often holds more than K groups, several terms each.
const POOL: [i128; 14] = [
3,
7,
11,
12,
13,
17,
19,
23,
29,
31,
37,
49,
1 << 40,
3 * (1 << 33),
];
fn pooled_term() -> impl Strategy<Value = Num> {
let pooled = (-1000i128..1000, prop::sample::select(POOL.to_vec()))
.prop_map(|(n, d)| Num::ratio(n, d).unwrap());
prop_oneof![
3 => decimal(),
4 => pooled,
1 => fraction(),
2 => finite_f64().prop_map(Num::inexact),
]
}
fn weighted(terms: impl Strategy<Value = Num>) -> impl Strategy<Value = (Num, i64)> {
(terms, weight())
}
fn sum_all(xs: &[Num]) -> Num {
let mut s = NumSum::new();
xs.iter().for_each(|x| s.insert(x));
s.read()
}
fn sum_weighted(xs: &[(Num, i64)]) -> Num {
let mut s = NumSum::new();
xs.iter().for_each(|(x, w)| s.add(x, *w));
s.read()
}
type WeightedSum<'a> = &'a dyn Fn(&[(Num, i64)]) -> Num;
fn check_order_free(sum: WeightedSum, deterministic: bool) -> Result<(), String> {
let seed = vec![
(dec("0.30000000000000004"), 1),
(dec("1000000000000"), 1),
(dec("-1000000000000"), 1),
];
let cases = prop_oneof![
1 => Just(seed),
4 => prop::collection::vec(weighted(term()), 0..24),
2 => prop::collection::vec(weighted(pooled_term()), 0..40),
];
let shuffled = cases.prop_flat_map(|xs| (Just(xs.clone()), Just(xs).prop_shuffle()));
run(shuffled, 256, deterministic, |(xs, ys)| {
let (a, b) = (sum(&xs), sum(&ys));
prop_assert!(same(&a, &b), "depends on order: {a} against {b}");
Ok(())
})
}
#[test]
fn the_accumulator_is_independent_of_order() {
check_order_free(&sum_weighted, false).unwrap();
let seed = sum_all(&[
dec("0.30000000000000004"),
dec("1000000000000"),
dec("-1000000000000"),
]);
assert!(same(&seed, &dec("0.30000000000000004")), "{seed}");
}
/// Clause 8's rival: `Decimal`'s own `+`, demoting to f64 where it overflows.
fn decimal_fold(xs: &[(Num, i64)]) -> Num {
xs.iter().fold(Num::exact(Decimal::ZERO), |acc, (x, w)| {
let x = x * &Num::exact(Decimal::from(*w));
match (acc.kind(), x.kind()) {
(NumRef::Exact(a), NumRef::Exact(b)) => {
a.checked_add(*b).map_or_else(|| &acc + &x, Num::exact)
}
_ => &acc + &x,
}
})
}
#[test]
fn the_accumulator_is_independent_of_order_catches_decimal_addition() {
assert_caught(check_order_free(&decimal_fold, true), "depends on order");
}
#[test]
fn the_accumulator_retracts_exactly() {
let terms = || prop::collection::vec(weighted(pooled_term()), 0..20);
run((terms(), terms()), 256, false, |(xs, ys)| {
let mut s = NumSum::new();
xs.iter().chain(&ys).for_each(|(x, w)| s.add(x, *w));
ys.iter().rev().for_each(|(y, w)| s.add(y, -w));
let fresh = sum_weighted(&xs);
prop_assert!(
same(&s.read(), &fresh),
"{} after retraction, {} fresh",
s.read(),
fresh
);
Ok(())
})
.unwrap();
}
/// The oracle's partition of a weighted list, exactly as clause 8 and option (d) state it.
struct Expected {
decimal_part: BigRational,
groups: std::collections::BTreeMap<i128, BigRational>,
inexact: std::collections::BTreeMap<u64, i128>,
inexact_total: BigRational,
}
fn ratio_of(i: i128) -> BigRational {
BigRational::from_integer(BigInt::from(i))
}
fn rounded(v: &BigRational) -> BigRational {
BigRational::from_float(v.to_f64().unwrap()).unwrap()
}
impl Expected {
fn of(xs: &[(Num, i64)]) -> Expected {
let zero = || ratio_of(0);
let mut e = Expected {
decimal_part: zero(),
groups: Default::default(),
inexact: Default::default(),
inexact_total: zero(),
};
for (x, w) in xs {
let weighted = q(x).unwrap() * ratio_of(*w as i128);
match x.kind() {
NumRef::Inexact(f) => {
*e.inexact.entry(f.to_bits()).or_insert(0) += *w as i128;
e.inexact_total += weighted;
}
NumRef::Fraction(r) if !decimal_denominator(*r.denom()) => {
*e.groups.entry(*r.denom()).or_insert_with(zero) += weighted;
}
_ => e.decimal_part += weighted,
}
}
e
}
fn live(&self) -> impl Iterator<Item = &BigRational> {
self.groups.values().filter(|v| !v.is_zero())
}
}
#[test]
fn the_accumulator_reads_the_exact_sum_or_its_stated_roundings() {
let arms = std::cell::Cell::new([0u32; 3]);
let lists = prop_oneof![
prop::collection::vec(weighted(term()), 0..24),
prop::collection::vec(weighted(pooled_term()), 0..40),
];
run(lists, 256, false, |xs| {
let got = sum_weighted(&xs);
let e = Expected::of(&xs);
let present = e.inexact.values().any(|w| *w != 0);
let exact_total = e.live().fold(e.decimal_part.clone(), |s, g| s + g);
let mut seen = arms.get();
match (present, e.live().count() <= numtower::K) {
(false, true) => {
seen[0] += 1;
prop_assert_eq!(got.is_exact(), fits(&exact_total), "{}", got);
prop_assert!(
!got.is_exact() || q(&got) == Some(exact_total.clone()),
"{got}"
);
prop_assert!(
got.is_exact() || is_nearest(got.to_f64(), &exact_total),
"{got}"
);
}
(true, true) => {
seen[1] += 1;
let total = rounded(&exact_total) + &e.inexact_total;
prop_assert!(!got.is_exact() && is_nearest(got.to_f64(), &total), "{got}");
}
(_, false) => {
seen[2] += 1;
let groups = e.live().map(rounded).fold(ratio_of(0), |s, g| s + g);
let total = groups + rounded(&e.decimal_part) + &e.inexact_total;
let near = is_nearest(got.to_f64(), &total);
prop_assert!(!got.is_exact() && near, "past K groups: {got} for {total}");
}
}
arms.set(seen);
Ok(())
})
.unwrap();
let seen = arms.get();
assert!(
seen.iter().all(|n| *n >= 20),
"every branch must be reached often: {seen:?}"
);
}
fn decimal_denominator(d: i128) -> bool {
let p = d.trailing_zeros();
let mut rest = d >> p;
let mut q5 = 0;
while rest % 5 == 0 {
rest /= 5;
q5 += 1;
}
rest == 1 && p.max(q5) <= 28
}
#[test]
fn more_than_k_denominators_read_inexact_even_when_the_total_fits() {
let primes = [3i128, 7, 11, 13, 17, 19, 23, 29, 31];
let terms: Vec<Num> = primes.iter().map(|&p| Num::ratio(1, p).unwrap()).collect();
let eight = sum_all(&terms[..8]);
assert!(matches!(eight.kind(), NumRef::Fraction(_)), "{eight}");
let nine = sum_all(&terms);
assert!(!nine.is_exact(), "{nine}");
let exact: BigRational = terms.iter().map(|t| q(t).unwrap()).sum();
assert!(
fits(&exact),
"the exact total fits, so the cutoff is what demotes"
);
let each_rounded: BigRational = terms
.iter()
.map(|t| BigRational::from_float(q(t).unwrap().to_f64().unwrap()).unwrap())
.sum();
assert!(
is_nearest(nine.to_f64(), &each_rounded),
"each group rounded, then the sum once"
);
}
#[test]
fn exact_sum_is_the_exact_weighted_total_rounded_once() {
let weights = prop_oneof![-3i64..=3, any::<i64>(), (-(1i64 << 40))..(1i64 << 40)];
run(
prop::collection::vec((finite_f64(), weights), 0..20),
256,
false,
|terms| {
let mut acc = numtower::ExactSum::new();
let mut exact = BigRational::from_integer(BigInt::from(0));
for (x, w) in &terms {
acc.add(*x, *w);
exact += BigRational::from_float(*x).unwrap()
* BigRational::from_integer(BigInt::from(*w));
}
prop_assert!(
is_nearest(acc.value(), &exact),
"{} for {:?}",
acc.value(),
terms
);
Ok(())
},
)
.unwrap();
}
// ---------------------------------------------------------------------------
// Division's direct fit test.
fn check_division(div: BinOp, deterministic: bool) -> Result<(), String> {
let seeds = prop_oneof![
Just((dec("1"), dec("1099511627776"))),
Just((dec("1"), dec("931322574615478515625"))),
Just((dec("0.0000000000000000000000000001"), dec("0.5"))),
];
let cases = prop_oneof![1 => seeds, 3 => (decimal(), decimal())];
run(cases, 256, deterministic, |(a, b)| {
if let (Some(r), Some(v)) = (div(&a, &b), exact_op("/")(&q(&a).unwrap(), &q(&b).unwrap())) {
prop_assert_eq!(
r.is_exact(),
fits(&v),
"division fit: {} / {} = {}",
a,
b,
r
);
prop_assert!(
!r.is_exact() || q(&r) == Some(v.clone()),
"division fit: {a} / {b} = {r}"
);
let decimal = (0..=28u32).any(|s| {
let scaled = &v * BigRational::from_integer(BigInt::from(10).pow(s));
scaled.is_integer() && scaled.to_integer().magnitude().bits() <= 96
});
prop_assert_eq!(
matches!(r.kind(), NumRef::Exact(_)),
decimal,
"division fit: {} / {} = {}",
a,
b,
r
);
}
Ok(())
})
}
#[test]
fn division_decides_fit_directly() {
check_division(&|a, b| a.checked_div(b).ok(), false).unwrap();
let frac = |n: &Num| matches!(n.kind(), NumRef::Fraction(_));
assert!(
frac(&dec("1").checked_div(&dec("1099511627776")).unwrap()),
"1/2^40"
);
assert!(
frac(&dec("1").checked_div(&dec("931322574615478515625")).unwrap()),
"1/5^30"
);
let tiny = dec("0.0000000000000000000000000001")
.checked_div(&dec("0.5"))
.unwrap();
assert!(
same(&tiny, &dec("0.0000000000000000000000000002")),
"{tiny}"
);
let avg = &(&(&dec("10.00") + &dec("20.00")) + &dec("40.00"));
assert!(frac(&avg.checked_div(&dec("3")).unwrap()));
assert!(same(
&dec("1").checked_div(&dec("4")).unwrap(),
&dec("0.25")
));
let third = Num::ratio(1, 3).unwrap();
assert!(
same(&(&third * &dec("3")), &dec("1")),
"1/3 * 3 is exactly 1"
);
assert_eq!(
Num::inexact(1.0).checked_div(&Num::inexact(-0.0)),
Err(numtower::DivisionByZero)
);
}
/// Multiplying back, with revision 6's scale test: sound, and incomplete.
fn multiply_back(a: &Num, b: &Num) -> Option<Num> {
match (a.kind(), b.kind()) {
(NumRef::Exact(x), NumRef::Exact(y)) if !y.is_zero() => {
let candidate = x.checked_div(*y)?;
let back = candidate.checked_mul(*y)?;
let kept = back.scale() == candidate.scale() + y.scale() && back == *x;
Some(match kept {
true => Num::exact(candidate),
false => Num::inexact(a.to_f64() / b.to_f64()),
})
}
_ => a.checked_div(b).ok(),
}
}
#[test]
fn division_decides_fit_directly_catches_multiplying_back() {
assert_caught(check_division(&multiply_back, true), "division fit");
}
// ---------------------------------------------------------------------------
// The number language: the gate and the parse agree.
fn lexeme() -> impl Strategy<Value = String> {
let digits = "[0-9]{0,40}";
let structured = (
prop::option::of("[+-]"),
digits,
prop::option::of(("\\.", digits)),
prop::option::of(("[eE]", prop::option::of("[+-]"), "[0-9]{0,4}")),
)
.prop_map(|(sign, int, frac, exp)| {
let frac = frac.map(|(p, f)| format!("{p}{f}")).unwrap_or_default();
let exp = exp
.map(|(e, s, d)| format!("{e}{}{d}", s.unwrap_or_default()))
.unwrap_or_default();
format!("{}{int}{frac}{exp}", sign.unwrap_or_default())
});
prop_oneof![
4 => structured,
1 => "[0-9eE+\\-._/aifnIN ]{0,10}",
1 => "[+-]?[0-9]{1,30}/[0-9]{1,30}",
1 => prop::sample::select(vec!["inf", "-inf", "nan", "NaN", "Infinity", "-infinity", "1_000", "+.5", "1.", ".", "e5"]).prop_map(String::from),
]
}
fn check_language(
parse: &dyn Fn(&str, Typing) -> Option<Num>,
deterministic: bool,
) -> Result<(), String> {
run(lexeme(), 256, deterministic, |s| {
let gate = s.parse::<f64>().is_ok_and(f64::is_finite);
let parsed = parse(&s, Typing::Inferred);
prop_assert_eq!(
parsed.is_some(),
gate,
"the gate and the parse disagree on {:?}",
s
);
prop_assert_eq!(
is_number(&s, Typing::Inferred),
gate,
"the gate and the parse disagree on {:?}",
s
);
if let Some(n) = &parsed {
let nearest: f64 = s.parse().unwrap();
prop_assert!(
n.to_f64() == nearest,
"the gate and the parse disagree on the value of {s:?}: {n}"
);
let v = q(n).unwrap();
prop_assert!(
!n.is_exact() || is_nearest(nearest, &v),
"the gate and the parse disagree on {s:?}"
);
}
let forced = parse(&s, Typing::Forced);
prop_assert_eq!(
forced.is_some(),
is_number(&s, Typing::Forced),
"the gate and the parse disagree on forced {:?}",
s
);
Ok(())
})
}
#[test]
fn the_number_languages_gate_and_parse_agree() {
check_language(&parse_number, false).unwrap();
}
/// `rust_decimal`'s exact parse: it refuses `1.5e3` and accepts `1_000`.
fn from_str_exact(s: &str, typing: Typing) -> Option<Num> {
match Decimal::from_str_exact(s) {
Ok(d) => Some(Num::exact(d)),
Err(_) if typing == Typing::Forced => parse_number(s, typing),
Err(_) => None,
}
}
#[test]
fn the_number_languages_gate_and_parse_agree_catches_from_str_exact() {
assert_caught(
check_language(&from_str_exact, true),
"the gate and the parse disagree",
);
}
#[test]
fn the_number_language_seed_cases() {
let inferred = |s: &str| parse_number(s, Typing::Inferred);
let forced = |s: &str| parse_number(s, Typing::Forced);
assert!(same(&typed("1.5e3"), &dec("1500")));
assert!(same(&typed("1.50"), &dec("1.50")));
assert_eq!(inferred("1_000").map(|n| n.to_string()), None);
let zero = typed("-0.0");
assert!(same(&zero, &dec("0.0")) && !zero.to_f64().is_sign_negative());
assert!(inferred("3/4").is_none());
assert!(same(&forced("3/4").unwrap(), &dec("0.75")));
assert!(matches!(
forced("-10/3").unwrap().kind(),
NumRef::Fraction(_)
));
assert!(forced("3/0").is_none());
for text in ["1e999", "-1e999", "nan", "inf", "Infinity", "NaN", "-inf"] {
assert!(
inferred(text).is_none() && forced(text).is_none(),
"{text} is text"
);
}
let underflow = typed("1e-999");
assert!(!underflow.is_exact() && underflow.to_f64() == 0.0);
assert_eq!(typed("1e-30"), Num::ratio(1, 10i128.pow(30)).unwrap());
assert!(matches!(typed("1e-30").kind(), NumRef::Fraction(_)));
let long = typed("0.12345678901234567890123456789");
let want = BigRational::new(
BigInt::from(12345678901234567890123456789i128),
BigInt::from(10).pow(29),
);
assert!(matches!(long.kind(), NumRef::Fraction(_)) && q(&long) == Some(want));
let big = (1i128 << 120).to_string();
let n = typed(&big);
assert!(matches!(n.kind(), NumRef::Fraction(r) if *r.denom() == 1 && *r.numer() == 1 << 120));
assert!(!typed("1e-40").is_exact());
assert!(!typed("1234567890123456789012345678901234567890").is_exact());
assert!(same(&typed("0e99999999999999999999"), &dec("0")));
}
// ---------------------------------------------------------------------------
// A typed value and the same value computed are equally exact.
fn small_decimal() -> impl Strategy<Value = Num> {
let mantissa = prop_oneof![-1000i128..1000, mantissa96()];
(mantissa, 0u32..=28).prop_map(|(m, s)| Num::exact(Decimal::from_i128_with_scale(m, s)))
}
type Mul<'a> = &'a dyn Fn(&Num, &Num) -> Num;
fn check_typed_equals_computed(mul: Mul, deterministic: bool) -> Result<(), String> {
let seed = Just((dec("0.000000000000001"), dec("0.000000000000001")));
run(
prop_oneof![1 => seed, 3 => (small_decimal(), small_decimal())],
256,
deterministic,
|(a, b)| {
let v = q(&a).unwrap() * q(&b).unwrap();
let ten = |k: u32| BigRational::from_integer(BigInt::from(10).pow(k));
let k = (0..=60u32)
.find(|&k| (&v * ten(k)).is_integer())
.expect("a decimal product");
let text = format!("{}e-{k}", (&v * ten(k)).to_integer());
let parsed = typed(&text);
let computed = mul(&a, &b);
prop_assert_eq!(
arm(&parsed),
arm(&computed),
"typed and computed differ: {} against {} * {}",
text,
a,
b
);
prop_assert!(
parsed.cmp_exact(&computed) == Some(Ordering::Equal),
"typed and computed differ: {text} against {computed}"
);
Ok(())
},
)
}
#[test]
fn a_typed_value_and_the_same_value_computed_are_equally_exact() {
check_typed_equals_computed(&|a, b| a * b, false).unwrap();
let computed = &typed("1e-15") * &typed("1e-15");
assert!(same(&typed("1e-30"), &computed), "{computed}");
}
/// Revision 6's rule: demote whenever the returned scale drops.
fn demote_on_the_hint(a: &Num, b: &Num) -> Num {
match (a.kind(), b.kind()) {
(NumRef::Exact(x), NumRef::Exact(y)) => match x.checked_mul(*y) {
Some(r) if r.scale() == x.scale() + y.scale() => Num::exact(r),
_ => Num::inexact(a.to_f64() * b.to_f64()),
},
_ => a * b,
}
}
#[test]
fn a_typed_value_and_the_same_value_computed_are_equally_exact_catches_demoting_on_the_hint() {
assert_caught(
check_typed_equals_computed(&demote_on_the_hint, true),
"typed and computed differ",
);
}
// ---------------------------------------------------------------------------
// Clause 4's stored scale, question 13 (b)'s widening, and the canonical text.
#[test]
fn a_recomputed_result_keeps_the_stored_scale_clause_4_names() {
let product = &dec("1.2500000000000000") * &dec("4.0000000000000000");
assert!(
same(&product, &dec("5.0000000000000000000000000000")),
"{product}"
);
let common = &dec("12.50") + &dec("1.5");
assert!(same(&common, &dec("14.00")), "{common}");
let rounded = &dec("1000") + &dec("0.3333333333333333333333333333");
assert!(matches!(rounded.kind(), NumRef::Fraction(_)), "{rounded}");
assert!(same(&(&dec("-1.5") + &dec("1.5")), &dec("0.0")));
}
#[test]
fn an_intermediate_beyond_i128_comes_back_exact() {
let a = Num::ratio(10i128.pow(30), 7).unwrap();
let wide = &Interim::from(a.clone()) * &Interim::from(a.clone());
assert!(
wide.is_exact(),
"an intermediate is unbounded up to the ceiling"
);
let back = wide.checked_div(&Interim::from(a.clone())).unwrap().store();
assert!(same(&back, &a), "{back}");
let stored = (&a * &a).to_f64();
assert!(!(&a * &a).is_exact() && is_nearest(stored, &(q(&a).unwrap() * q(&a).unwrap())));
}
#[test]
fn the_canonical_text_reads_back_as_the_same_number() {
run(any_num(), 256, false, |n| {
let text = n.to_string();
let back = Num::from_canonical(&text);
prop_assert!(
back.as_ref()
.is_some_and(|b| same(b, &n) || (n.to_f64().is_nan() && b.to_f64().is_nan())),
"{text}"
);
Ok(())
})
.unwrap();
assert_eq!(dec("12.50").to_string(), "12.50");
assert_eq!(Num::ratio(-10, 3).unwrap().to_string(), "-10/3");
assert_eq!(Num::inexact(1.0 / 3.0).to_string(), "#i0.3333333333333333");
assert_eq!(Num::inexact(f64::NEG_INFINITY).to_string(), "-inf");
assert!(Num::from_canonical("#inan").is_none());
assert!(
Num::from_canonical("1e-999").is_none(),
"canonical exact text never reads inexact"
);
let ten35 = Num::ratio(10i128.pow(35), 1).unwrap();
assert_eq!(ten35.to_string(), format!("{}/1", 10i128.pow(35)));
}
#[test]
fn exact_sums_are_equal_when_their_values_are() {
let of = |terms: &[(f64, i64)]| {
let mut s = numtower::ExactSum::new();
terms.iter().for_each(|(x, w)| s.add(*x, *w));
s
};
assert_eq!(
of(&[(0.5, 1), (0.5, 1)]),
of(&[(1.0, 1)]),
"0.5 + 0.5 = 1.0"
);
assert_eq!(
of(&[(3.25, 1), (3.25, -1)]),
numtower::ExactSum::new(),
"insert then retract"
);
let big = (1i64 << 30) - 1;
assert_eq!(
of(&[(1.5, big), (0.25, 2)]),
of(&[(0.25, 2), (1.5, big)]),
"order"
);
assert_ne!(of(&[(1.0, 1)]), of(&[(1.0, 2)]));
assert_ne!(
of(&[(f64::NAN, 1)]),
of(&[]),
"a counted NaN is part of the value"
);
}
#[test]
fn a_forced_fraction_never_reads_as_infinity() {
let huge = format!("{}/1", "9".repeat(400));
assert_eq!(
parse_number(&huge, Typing::Forced).map(|n| n.to_string()),
None
);
assert!(
!is_number(&huge, Typing::Forced),
"the gate and the parse agree"
);
let negative = format!("-{huge}");
assert!(parse_number(&negative, Typing::Forced).is_none());
let tiny = format!("1/{}", "9".repeat(400));
let zero = parse_number(&tiny, Typing::Forced).unwrap();
assert!(
!zero.is_exact() && zero.to_f64() == 0.0,
"an underflow is a finite zero, as 1e-999"
);
assert!(is_number(&tiny, Typing::Forced));
}
type InterimCmp<'a> = &'a dyn Fn(&Interim, &Interim) -> Option<Ordering>;
fn check_interim_order(cmp: InterimCmp, deterministic: bool) -> Result<(), String> {
let eps = Interim::from(Num::ratio(1, 3 * 10i128.pow(30)).unwrap());
run(
(
wide_fraction(),
wide_fraction(),
wide_fraction(),
wide_fraction(),
),
256,
deterministic,
|(x1, x2, y1, y2)| {
let a = &Interim::from(x1.clone()) * &Interim::from(x2.clone());
let av = q(&x1).unwrap() * q(&x2).unwrap();
let near = av.to_f64().unwrap();
let eps_v =
BigRational::new(BigInt::from(1), BigInt::from(3) * BigInt::from(10).pow(30));
let others = [
(
&Interim::from(y1.clone()) * &Interim::from(y2.clone()),
q(&y1).unwrap() * q(&y2).unwrap(),
),
(&a + &eps, &av + &eps_v),
(&a - &eps, &av - &eps_v),
(a.clone(), av.clone()),
(
Interim::from(Num::inexact(near)),
BigRational::from_float(near).unwrap(),
),
(
Interim::from(Num::inexact(near.next_up())),
BigRational::from_float(near.next_up()).unwrap(),
),
];
for (b, bv) in &others {
prop_assert_eq!(
cmp(&a, b),
Some(av.cmp(bv)),
"interim order: {:?} vs {:?}",
a,
b
);
prop_assert_eq!(
cmp(b, &a),
Some(bv.cmp(&av)),
"interim order: {:?} vs {:?}",
b,
a
);
}
let twin = a.clone();
prop_assert!(
!a.is_zero() && (&a - &twin).is_zero(),
"interim zero: {a:?}"
);
Ok(())
},
)
}
#[test]
fn an_interim_compares_exactly_without_storing() {
check_interim_order(&Interim::cmp_exact, false).unwrap();
assert_eq!(
Interim::from(Num::inexact(f64::NAN)).cmp_exact(&Interim::from(dec("1"))),
None
);
}
/// Storing first: what `=` would do if `Interim` could not compare.
fn store_then_compare(a: &Interim, b: &Interim) -> Option<Ordering> {
a.clone().store().cmp_exact(&b.clone().store())
}
#[test]
fn an_interim_compares_exactly_without_storing_catches_storing_first() {
assert_caught(
check_interim_order(&store_then_compare, true),
"interim order",
);
}