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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Claude (numtower)
2026-10-08 23:44:07 -07:00
committed by Edward Langley
parent 302594bf5a
commit 34ab0fc3d3
7 changed files with 565 additions and 81 deletions
+30 -1
View File
@@ -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
+134 -14
View File
@@ -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`
+30 -4
View File
@@ -69,6 +69,29 @@ impl Interim {
pub fn pow(&self, o: &Interim) -> Result<Interim, DivisionByZero> {
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<std::cmp::Ordering> {
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<Interim, DivisionByZero> {
_ => 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))),
+10
View File
@@ -64,6 +64,16 @@ fn mixed(exact: &Num, x: f64) -> Option<Ordering> {
}
}
/// 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<Ordering> {
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,
+8 -2
View File
@@ -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<Num> {
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<Num> {
let n = Num::settle(&fraction_lexeme(text)?);
n.to_f64().is_finite().then_some(n)
}
fn gate(text: &str) -> bool {
text.parse::<f64>().is_ok_and(f64::is_finite)
}
+14 -1
View File
@@ -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 {
+339 -59
View File
@@ -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<Value = Num> {
]
}
/// 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 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<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() {
run(prop::collection::vec(term(), 0..24), 256, false, |xs| {
let got = sum_all(&xs);
let mut groups = std::collections::BTreeMap::<i128, BigRational>::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<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",
);
}