f11d27377d
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
1368 lines
44 KiB
Rust
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(÷_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",
|
|
);
|
|
}
|