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:
committed by
Edward Langley
parent
302594bf5a
commit
34ab0fc3d3
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user