From 34ab0fc3d33309ea620219395c02047de6aa288e Mon Sep 17 00:00:00 2001 From: "Claude (numtower)" Date: Thu, 8 Oct 2026 23:44:07 -0700 Subject: [PATCH] fix(numtower): past-K oracle and weighted NumSum properties, negative-power scale, ExactSum value equality (improvise-59nw.1) Answers the BLOCK on 302594bf (B1-B3) and its should-fix items: the past-K read and Z-set weights are now checked against a BigRational oracle (the review's F7, F8, F12, F14, F15 now fail by name), a negative power of a unit base has its minimal scale, ExactSum compares values, a forced n/d never reads as infinity, and Interim compares exactly and knows zero without storing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/numtower.md | 31 ++- context/test-evidence/numtower.md | 148 ++++++++++- crates/numtower/src/arith.rs | 34 ++- crates/numtower/src/cmp.rs | 10 + crates/numtower/src/lang.rs | 10 +- crates/numtower/src/sum.rs | 15 +- crates/numtower/tests/properties.rs | 398 +++++++++++++++++++++++----- 7 files changed, 565 insertions(+), 81 deletions(-) diff --git a/context/code/numtower.md b/context/code/numtower.md index da7e844c..ea27d290 100644 --- a/context/code/numtower.md +++ b/context/code/numtower.md @@ -53,6 +53,17 @@ the stored bound. The operators on `&Num` are one-operation evaluations: each bu (`an_intermediate_beyond_i128_comes_back_exact`). That difference is (b)'s whole point. Step 3's evaluators should evaluate through `Interim` and store once per cell. +**So `Interim` compares, and knows zero, without storing.** `IF(a*a/a = a, …)` and the +division guard both run before the stored bound. If `=` had to store its operands first, +its answer would depend on how the formula is spelled, which is what question 13 (b) +rules out. `Interim::cmp_exact` is clause 6's exact order over every pairing: two stored +values go through `Num::cmp_exact`, and anything wide compares as a bigint rational, +against an f64 by its exact binary value. `Interim::is_zero` is the one zero predicate. A +wide value is never zero, because zero always fits a stored arm. The comparison through +storing is the rival (`an_interim_compares_exactly_without_storing_catches_storing_first`): +`a` and `a ± 1/(3·10^30)` store to one f64 and compare equal. The review of `302594bf` +asked for this or for a note naming it as a step-3 item; the crate does the first. + ### Contagion, and how many roundings a demotion has An inexact operand makes the result inexact. Each exact operand is converted by the @@ -116,7 +127,11 @@ rule, because the ADR decided it and because it needs no second division. ceiling's stated exception. - **The scale.** A positive power of a decimal takes clause 4's stored scale with nominal `s·n`, the scale repeated multiplication would give, so `1.50^2` is `2.2500`. A - negative power is a division and has its minimal scale. + negative power is a division and has its minimal scale, **unit bases included**: + `1.0^-2` is `1` and `(-1.0)^-3` is `-1`, as `2.0^-2` is `0.25`. Until the review of + `302594bf` the unit-base arm padded every power to `s·|n|`, so `1.0^-2` read `1.00` + (Copilot on PR #337). The power property now checks the scale of every decimal result + against this rule, with unit bases among its seeds. - **The edge values.** `0^0` is `1`. `0^-n` is `div/0`. `±1^n` is `±1` for any `n`, including one too large to compute. - **A demoted power takes its sign from the exact exponent.** `powf` gets the base's @@ -171,6 +186,11 @@ checks that the two agree, and that the exact value read rounds to the f64 f64**. The ADR says `n/d` "is a fraction or, once normalized, a decimal", and does not say what a forced `n/d` too large for `i128` becomes. The crate applies clause 3's order to it, as it does to every other spelled number. This is a reading, and it is flagged. + **Except that the nearest f64 must be finite.** A forced `n/d` whose value rounds to + infinity (`n` of 400 digits, `/1`) is text, exactly as the gate makes `1e999` text. + Clause 3: "a finite lexeme must never read as infinity". One that underflows is the + inexact zero, as `1e-999` is. `is_number` and `parse_number` share the one helper, + `forced_fraction`, so the gate and the parse cannot disagree on it. **The canonical text is `Display`.** An inexact finite value is `#i` followed by Rust's `{:?}` spelling, which is the shortest that reads back (`#i1e300`, `#i0.1`). @@ -230,6 +250,15 @@ on PR #309. It has three changes: It counts NaN and the infinities, so they can be retracted. A count that is nonzero in either direction is treated as present. +**`ExactSum`'s `==` compares sums, not representations.** A derived equality compared the +raw limbs and the `pending` carry counter, so `0.5 + 0.5 != 1.0`, an insert then retract +was not equal to an empty sum, and the same weights added in another order compared +unequal (Copilot on PR #337). For a type whose whole point is order-freedom, and which +ADR 009's group newtype will wrap, that equality is a trap. It now compares the +propagated limbs, which are canonical (every limb but the top in `[0, 2^32)`), plus the +three special-value counts, and ignores `pending`. `NumSum` derives no equality: compare +its `read()`. + **For ADR 009's seam crate.** `NumSum` and `ExactSum` both have the shape a Z-set group needs: `add(&Num, i64)` / `add(f64, i64)`, `insert`, `retract`, `read`/`value`, `Clone`. `ExactSum` also has `absorb`, to merge two accumulators. Neither implements a group trait diff --git a/context/test-evidence/numtower.md b/context/test-evidence/numtower.md index 705879b8..451f5a0c 100644 --- a/context/test-evidence/numtower.md +++ b/context/test-evidence/numtower.md @@ -154,11 +154,25 @@ argument orders, the order-independence property the tie-breaks exist for. ### the_accumulator_is_independent_of_order -Random term lists, from every arm, are summed in one order and in a shuffled order. The -results must agree in **arm, value and scale**. The terms include fractions over a small -pool of denominators, so the exact grouped read is reached, and arbitrary fractions, so -the K cutoff is too. ADR 005 clause 8's seed case, `[0.30000000000000004, 1e12, −1e12]`, -is one of the generator's arms and is also pinned exactly. +Random **weighted** term lists, from every arm, are summed in one order and in a shuffled +order. The results must agree in **arm, value and scale**. The terms include fractions +over a pool of fourteen non-decimal denominators, so groups receive several terms and +lists often hold more than K of them, and arbitrary fractions too. ADR 005 clause 8's +seed case, `[0.30000000000000004, 1e12, −1e12]`, is one of the generator's arms and is +also pinned exactly. It caught the review's F12 (a group's old f64 not retracted), where +the stale values make the past-K read depend on the order of updates. + +**On weights** (the review of `302594bf`, B2). Until then every `NumSum` property called +`insert` and `retract` only, so every weight was ±1. That was the cause the answer to +Codex's P1 had itself named, and the generators had not changed. Two faults passed the +whole suite: F7 (the decimal partition ignored the weight's magnitude) and F8 (a group +multiplied by `weight.signum()`). Weights now come from `weight()`: +- the units 1, −1 and 0; +- −3 to 3; +- magnitudes up to 2^40; +- `±i64::MAX` and 2^30 − 1. + +`i64::MIN` is left out so a weight always negates. ### the_accumulator_is_independent_of_order_catches_decimal_addition @@ -168,23 +182,65 @@ scale 16 and the other does not. ### the_accumulator_retracts_exactly -Inserting `xs` and `ys`, then retracting `ys` in reverse order, must equal a fresh sum of -`xs` in arm, value and scale. A group whose numerator returns to zero must be dropped, or -a retracted denominator would still count toward K. +Adding weighted `xs` and `ys`, then adding each `y` again with its weight negated in +reverse order, must equal a fresh sum of `xs` in arm, value and scale. A group whose +numerator returns to zero must be dropped, or a retracted denominator would still count +toward K. With weights it catches F7 and F12 as well. ### the_accumulator_reads_the_exact_sum_or_its_stated_roundings -This is the differential test of the read against the oracle. It is the only test that -states every branch. +The differential test of the read against a `BigRational` oracle, over weighted terms. It +states the value of all three branches: - **No inexact term, at most K live groups.** The read is exact iff the exact total fits, and is then that total. Otherwise it is the total's correctly rounded f64. -- **Inexact terms present.** The exact total is rounded once, then the f64s are added and - the result is rounded once more. -- **Past K groups.** Inexact. +- **Inexact terms present.** The exact total is rounded once, then the weighted f64s are + added and the result is rounded once more. +- **Past K groups.** The nearest f64 of Σ round(live group) + round(decimal partition) + + Σ w·f. + +**Until the review of `302594bf` (B1) the past-K branch asserted only that the read was +inexact, and this entry claimed the test stated every branch. Both were wrong.** Three +faults passed the whole suite: +- F12: a group's old f64 was not retracted from `group_floats`; +- F14: the past-K read dropped the decimal partition; +- F15: the past-K read dropped every inexact term. + +The test now has the reviewer's oracle and 512 cases. Half the lists come from the +fourteen-denominator pool, up to 40 terms long, so groups receive several terms and K is +passed often. **It also counts how many cases reach each branch and fails if any branch +is reached fewer than 20 times**, so a generator change cannot silently starve one. The oracle counts **live** groups, those whose summed numerator is nonzero. The first version counted denominators ever seen, and that disagrees with the state-based count -whenever terms cancel. +whenever terms cancel. Inexact presence is likewise per bit pattern, by net weight. + +**Every fault in the review's script, re-run after the fix.** Each was injected into the +source and `cargo test -p numtower` was run on a confirmed rebuild. Each failed by name: + +| fault | failed | +|---|---| +| F1 decimal sum's scale filter dropped | `a_recomputed_result_keeps_the_stored_scale_clause_4_names`, `an_exact_result_is_never_a_rounded_one`, `contagion_is_total` | +| F2 `rust_decimal`'s `to_f64` | `exact_mixed_comparison_is_transitive`, `comparison_agrees_with_the_oracle_at_every_pairing_of_arms`, `the_f64_conversion_agrees_with_parsing_the_decimal_text`, `the_number_languages_gate_and_parse_agree` | +| F3 mixed comparison through f64 | `exact_mixed_comparison_is_transitive`, `comparison_agrees_with_the_oracle_at_every_pairing_of_arms`, `an_interim_compares_exactly_without_storing` | +| F4 one net inexact count | `the_accumulator_keeps_inexact_terms_whose_weights_cancel`, `the_accumulator_retracts_exactly`, this test | +| F5 product, not lcm | `sum::tests::the_grouped_read_combines_over_the_lcm_and_reduces_once` | +| F6 `ExactSum` rounded per limb | `exact_sum_is_the_exact_weighted_total_rounded_once`, `more_than_k_denominators_read_inexact_even_when_the_total_fits`, `the_accumulator_keeps_inexact_terms_whose_weights_cancel`, this test | +| F7 decimal weight magnitude ignored | this test, `the_accumulator_retracts_exactly` | +| F8 group weight as its sign | this test | +| F9 decimal product's scale filter dropped | `a_typed_value_and_the_same_value_computed_are_equally_exact`, `an_exact_result_is_never_a_rounded_one`, `contagion_is_total` | +| F10 `ExactSum`'s low weight digit only | `exact_sum_is_the_exact_weighted_total_rounded_once`, this test | +| F11 `POW` sign lost | `a_huge_odd_exponent_keeps_the_sign_of_a_negative_base` | +| F12 old group f64 kept | this test, `the_accumulator_is_independent_of_order`, `the_accumulator_retracts_exactly` | +| F13 mixed tie not checked exactly | `comparison_agrees_with_the_oracle_at_every_pairing_of_arms`, `exact_mixed_comparison_is_transitive`, `an_interim_compares_exactly_without_storing` | +| F14 past K, decimal partition dropped | this test | +| F15 past K, inexact terms dropped | this test | + +- **F13's text now occurs twice.** `Interim`'s comparison added a second copy of the + replaced line, so the review's script refuses to inject it. It was injected at its + original site, in `cmp.rs::mixed`. +- **The five former survivors were run twice and failed both times.** F12's set varied + between runs: `the_accumulator_retracts_exactly` failed in one run and not the other, + and this test failed in both. ### more_than_k_denominators_read_inexact_even_when_the_total_fits @@ -322,6 +378,70 @@ Allowing the value exposes a second hazard: it cannot be negated in `i128`, and `i128::MIN / 3` (an inexact, correctly rounded result), takes it through `Interim` twice and back, divides it, compares it and reads its canonical text. +### a_negative_power_has_its_minimal_scale + +Copilot on PR #337, and B3 of the review of `302594bf`. The unit-base arm of `POW` +padded every result to `s·|n|`, so `1.0^-2` was `1.00` and `(-1.0)^-3` was `-1.000`, +while `2.0^-2` was `0.25`. `context/code/numtower.md` already said "a negative power … +has its minimal scale", so the code contradicted the crate's stated rule. This test +**failed at `302594bf`** on its first assertion. It also pins that a positive power keeps +`s·n` (`(-1.0)^3 = -1.000`), so a fix that stripped every scale cannot pass. + +The broader guard is the scale assertion now in +`pow_is_exact_for_an_exact_base_and_integer_exponent_when_it_fits`, which had checked +value and exactness but never scale. Its oracle, `power_scale`, is the rule restated: +- start from the minimal scale; +- pad toward `min(s·k, 28)` for a decimal base with `k > 0` only, while the mantissa + fits 96 bits. + +Unit bases (`1.0`, `-1.0`, `1.000`, `-1`, `0.00`) are among its seeds. It **failed at +`302594bf`** with the minimal case `1.0^-1 = 1.0`. The injection G1 (a negative power +keeps the base's scale) fails both tests. + +### exact_sums_are_equal_when_their_values_are + +Copilot on PR #337, and B3 of the review. `ExactSum` derived `PartialEq` over its raw +limbs and its `pending` carry counter. This test **failed at `302594bf`** on its first +assertion, `0.5 + 0.5 == 1.0`. It also asserts: +- insert then retract equals an empty sum; +- weights `(2^30 − 1, 2)` added in either order are equal; +- different values are unequal, and a counted NaN is part of the value, so an `==` that + always answers true cannot pass. + +Injection G2, equality back on the representation, fails it. + +### a_forced_fraction_never_reads_as_infinity + +A should-fix from the review of `302594bf`. A forced `n/d` with a 400-digit `n` read as +`Inexact(inf)`, though clause 3 says a finite lexeme never reads as infinity. This test +**failed at `302594bf`**. It also asserts: +- `is_number` agrees with the parse; +- a negative one is text too; +- an underflowing `1/9…9` is still the inexact zero, so a fix that refused every inexact + forced fraction cannot pass. + +Injection G3 fails it. + +### an_interim_compares_exactly_without_storing + +New API, from a should-fix in the review: `Interim` had no comparison and no zero test +while step 3 is told to evaluate through it. For two wide products `a`, it compares `a` +against: +- another wide product; +- `a ± 1/(3·10^30)`; +- `a` itself; +- the f64 nearest `a`, and that f64's upper neighbour. + +Each comparison is made in both directions, against the `BigRational` order. It also +asserts `a − a` is zero and `a` is not. Injection G4, storing before comparing, fails it, +and so do F3 and F13, because the stored arms delegate to `Num::cmp_exact`. + +### an_interim_compares_exactly_without_storing_catches_storing_first + +The rival is the comparison the review warned step 3 would otherwise have to make: store +both operands, then compare. `a` and `a ± 1/(3·10^30)` store to one f64, so the rival +calls them equal. + --- ## `crates/numtower/src/sum.rs` diff --git a/crates/numtower/src/arith.rs b/crates/numtower/src/arith.rs index c8e9dcaf..1b19a38b 100644 --- a/crates/numtower/src/arith.rs +++ b/crates/numtower/src/arith.rs @@ -69,6 +69,29 @@ impl Interim { pub fn pow(&self, o: &Interim) -> Result { power(self.operand(), o.operand()) } + + /// Exactly zero; a wide value never is, because zero fits a stored arm. + pub fn is_zero(&self) -> bool { + match &self.0 { + Inner::Stored(n) => n.is_zero(), + Inner::Wide(_) => false, + } + } + + /// Clause 6's exact order, before the stored bound; `None` only when either is NaN. + pub fn cmp_exact(&self, o: &Interim) -> Option { + match (&self.0, &o.0) { + (Inner::Stored(a), Inner::Stored(b)) => a.cmp_exact(b), + _ => match (self.operand().big(), o.operand().big()) { + (Some(a), Some(b)) => Some(a.cmp_value(&b)), + (Some(a), None) => crate::cmp::exact_against_f64(&a, o.operand().float()), + (None, Some(b)) => { + crate::cmp::exact_against_f64(&b, self.operand().float()).map(|c| c.reverse()) + } + (None, None) => self.operand().float().partial_cmp(&o.operand().float()), + }, + } + } } impl Num { @@ -364,10 +387,13 @@ fn exact_power(a: Operand, n: &BigInt) -> Result { _ => 0, }; let k = n.magnitude(); - let target = k - .to_u32() - .and_then(|k| k.checked_mul(base_scale)) - .map_or(MAX_SCALE, |s| s.min(MAX_SCALE)); + let target = match n.is_negative() { + true => 0, + false => k + .to_u32() + .and_then(|k| k.checked_mul(base_scale)) + .map_or(MAX_SCALE, |s| s.min(MAX_SCALE)), + }; let unit = q.den.is_one() && q.num.magnitude().is_one(); match (q.num.is_zero(), n.is_negative(), unit) { (_, _, _) if n.is_zero() => Ok(Interim::from(padded(1, 0, 0))), diff --git a/crates/numtower/src/cmp.rs b/crates/numtower/src/cmp.rs index 2466b017..81cac9b7 100644 --- a/crates/numtower/src/cmp.rs +++ b/crates/numtower/src/cmp.rs @@ -64,6 +64,16 @@ fn mixed(exact: &Num, x: f64) -> Option { } } +/// A wide exact value against an f64, with no fast path: the slow, total form of `mixed`. +pub(crate) fn exact_against_f64(q: &BigQ, x: f64) -> Option { + match (x.is_nan(), x.is_infinite(), x > 0.0) { + (true, _, _) => None, + (false, true, true) => Some(Ordering::Less), + (false, true, false) => Some(Ordering::Greater), + (false, false, _) => Some(q.cmp_value(&BigQ::from_f64(x))), + } +} + fn pick<'a>(a: &'a Num, b: &'a Num, want: Ordering) -> &'a Num { match (a.cmp_exact(b), is_nan(a), is_nan(b)) { (_, true, _) => b, diff --git a/crates/numtower/src/lang.rs b/crates/numtower/src/lang.rs index e2a7d666..b5d00960 100644 --- a/crates/numtower/src/lang.rs +++ b/crates/numtower/src/lang.rs @@ -19,18 +19,24 @@ pub enum Typing { /// The gate: the strings `f64::from_str` accepts and reads as a finite value. pub fn is_number(text: &str, typing: Typing) -> bool { - gate(text) || (typing == Typing::Forced && fraction_lexeme(text).is_some()) + gate(text) || (typing == Typing::Forced && forced_fraction(text).is_some()) } /// A number if the text is one, read exactly; `None` means the text is text. pub fn parse_number(text: &str, typing: Typing) -> Option { match (gate(text), typing) { (true, _) => Lexeme::read(text).map(|lx| lx.value(text)), - (false, Typing::Forced) => fraction_lexeme(text).map(|q| Num::settle(&q)), + (false, Typing::Forced) => forced_fraction(text), (false, Typing::Inferred) => None, } } +/// A forced `n/d`; one that rounds to infinity is text, as a finite lexeme never reads as one. +fn forced_fraction(text: &str) -> Option { + let n = Num::settle(&fraction_lexeme(text)?); + n.to_f64().is_finite().then_some(n) +} + fn gate(text: &str) -> bool { text.parse::().is_ok_and(f64::is_finite) } diff --git a/crates/numtower/src/sum.rs b/crates/numtower/src/sum.rs index b5271c1a..366edbb5 100644 --- a/crates/numtower/src/sum.rs +++ b/crates/numtower/src/sum.rs @@ -20,7 +20,7 @@ const LIMBS: usize = 70; const CHUNK: u64 = 1 << 30; /// An exact sum of weighted f64s, rounded once when read (ADR 009). -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug)] pub struct ExactSum { limbs: [i64; LIMBS], pending: u64, @@ -29,6 +29,19 @@ pub struct ExactSum { neg_inf: i128, } +/// Equal sums, not equal representations: carries propagated, `pending` ignored. +impl PartialEq for ExactSum { + fn eq(&self, o: &ExactSum) -> bool { + let (mut mine, mut theirs) = (self.limbs, o.limbs); + propagate(&mut mine); + propagate(&mut theirs); + let counts = |s: &ExactSum| (s.nan, s.pos_inf, s.neg_inf); + mine == theirs && counts(self) == counts(o) + } +} + +impl Eq for ExactSum {} + impl Default for ExactSum { fn default() -> Self { ExactSum { diff --git a/crates/numtower/tests/properties.rs b/crates/numtower/tests/properties.rs index 95afebe9..b6b42e18 100644 --- a/crates/numtower/tests/properties.rs +++ b/crates/numtower/tests/properties.rs @@ -161,9 +161,30 @@ fn contagion_is_total_catches_an_integral_inexact_kept_exact() { ); } +/// 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() { - run((any_num(), -6i32..=6), 256, false, |(a, k)| { + 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"), @@ -179,6 +200,10 @@ fn pow_is_exact_for_an_exact_base_and_integer_exponent_when_it_fits() { 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(()) @@ -186,6 +211,18 @@ fn pow_is_exact_for_an_exact_base_and_integer_exponent_when_it_fits() { .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(); @@ -556,21 +593,75 @@ fn term() -> impl Strategy { ] } +/// Z-set weights: zero, unit, small, wide and both extremes (never `i64::MIN`, so it negates). +fn weight() -> impl Strategy { + 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 { + 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) -> impl Strategy { + (terms, weight()) +} + fn sum_all(xs: &[Num]) -> Num { let mut s = NumSum::new(); xs.iter().for_each(|x| s.insert(x)); s.read() } -fn check_order_free(sum: &dyn Fn(&[Num]) -> Num, deterministic: bool) -> Result<(), String> { +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"), - dec("1000000000000"), - dec("-1000000000000"), + (dec("0.30000000000000004"), 1), + (dec("1000000000000"), 1), + (dec("-1000000000000"), 1), ]; let cases = prop_oneof![ 1 => Just(seed), - 4 => prop::collection::vec(term(), 0..24), + 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)| { @@ -582,7 +673,7 @@ fn check_order_free(sum: &dyn Fn(&[Num]) -> Num, deterministic: bool) -> Result< #[test] fn the_accumulator_is_independent_of_order() { - check_order_free(&sum_all, false).unwrap(); + check_order_free(&sum_weighted, false).unwrap(); let seed = sum_all(&[ dec("0.30000000000000004"), dec("1000000000000"), @@ -592,13 +683,14 @@ fn the_accumulator_is_independent_of_order() { } /// Clause 8's rival: `Decimal`'s own `+`, demoting to f64 where it overflows. -fn decimal_fold(xs: &[Num]) -> Num { - xs.iter().fold(Num::exact(Decimal::ZERO), |acc, x| { +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) + a.checked_add(*b).map_or_else(|| &acc + &x, Num::exact) } - _ => &acc + x, + _ => &acc + &x, } }) } @@ -610,66 +702,117 @@ fn the_accumulator_is_independent_of_order_catches_decimal_addition() { #[test] fn the_accumulator_retracts_exactly() { - run( - ( - prop::collection::vec(term(), 0..16), - prop::collection::vec(term(), 0..16), - ), - 256, - false, - |(xs, ys)| { - let mut s = NumSum::new(); - xs.iter().chain(&ys).for_each(|x| s.insert(x)); - ys.iter().rev().for_each(|y| s.retract(y)); - let fresh = sum_all(&xs); - prop_assert!( - same(&s.read(), &fresh), - "{} after retraction, {} fresh", - s.read(), - fresh - ); - Ok(()) - }, - ) + 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, + inexact: std::collections::BTreeMap, + 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 { + self.groups.values().filter(|v| !v.is_zero()) + } +} + #[test] fn the_accumulator_reads_the_exact_sum_or_its_stated_roundings() { - run(prop::collection::vec(term(), 0..24), 256, false, |xs| { - let got = sum_all(&xs); - let mut groups = std::collections::BTreeMap::::new(); - let mut exact = BigRational::from_integer(BigInt::from(0)); - let mut floats = Vec::new(); - for x in &xs { - match x.kind() { - NumRef::Inexact(f) => floats.push(f), - NumRef::Fraction(r) if !decimal_denominator(*r.denom()) => { - *groups.entry(*r.denom()).or_insert_with(BigRational::zero) += q(x).unwrap(); - exact += q(x).unwrap(); - } - _ => exact += q(x).unwrap(), - } - } - let live = groups.values().filter(|v| !v.is_zero()).count(); - match (floats.is_empty(), live <= numtower::K) { - (true, true) => { - prop_assert_eq!(got.is_exact(), fits(&exact), "{}", got); - prop_assert!(!got.is_exact() || q(&got) == Some(exact.clone()), "{got}"); - prop_assert!(got.is_exact() || is_nearest(got.to_f64(), &exact), "{got}"); - } + 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, 512, 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) => { - let mut total = BigRational::from_float(exact.to_f64().unwrap()).unwrap(); - floats - .iter() - .for_each(|f| total += BigRational::from_float(*f).unwrap()); + 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) => prop_assert!(!got.is_exact(), "past K groups: {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 { @@ -1085,3 +1228,140 @@ fn the_canonical_text_reads_back_as_the_same_number() { 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; + +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 { + 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", + ); +}