Merge pull request #337 from fiddlerwoaroof/adr005/numtower

feat(numtower): ADR 005 step 1, the numeric tower crate (improvise-59nw.1)
This commit is contained in:
Ed Langley
2026-10-09 20:47:05 -07:00
committed by GitHub
19 changed files with 4605 additions and 3 deletions
+328
View File
@@ -0,0 +1,328 @@
# `numtower`: the reasoning
`crates/numtower` is ADR 005 migration step 1 (improvise-59nw.1): the numeric tower,
first and alone. Nothing in improvise depends on it yet, so it changes no behaviour.
The decisions are the ADR's (`context/adr/005-exact-decimal-arithmetic.md`). This file
records how the crate reads them, the places the ADR left a choice open or said two
things, and the measurements. The evidence for each test is in
`context/test-evidence/numtower.md`.
## The shape
| type | what it is | ADR |
|---|---|---|
| `Num` | the stored value: an exact `Decimal`, an exact `Ratio<i128>`, or an `f64` | clauses 1, 2 |
| `NumRef` | a borrowed view of a `Num`'s arm, for exhaustive matching | — |
| `Interim` | a value inside one evaluation; exact intermediates widen to a bigint rational | question 13 (b) |
| `NumSum` | a retractable, order-free `SUM` over any `Num` | clause 8, question 16 |
| `ExactSum` | ADR 009's f64 superaccumulator | question 15 (a) |
| `parse_number`, `is_number`, `Typing` | clause 3's number language, gate and parse | clause 3, question 12 |
| `Display` / `Num::from_canonical` | the canonical text: `12.50`, `10/3`, `#i0.1`, `inf`, `-inf`, `nan` | clauses 10, 11, 14 |
| `Num::round_to_places` | display rounding, the mode a parameter | clause 12 "Format" |
**`Num` is a struct with private arms, not the ADR's `pub enum`.** The ADR's sketch
(clause 2) shows `pub enum Num { Exact(Decimal), Fraction(Box<Ratio<i128>>),
Inexact(f64) }`. A public enum would let any caller build values the tower never
produces: `Fraction(1/2)` (which must be the decimal `0.5`), an unreduced fraction, or an
exact `-0`. Each would break exact equality or the fingerprint of clause 9. So the arms
are private, every constructor normalizes, and callers match on `NumRef`. That is
`context/design-principles.md` §3, smart constructors. It is also what clause 14's table
asks for, since improvise calls "`Num`'s accessors for exactness, mantissa, scale and
fraction parts". The `Box` lives in one constructor, as `AGENTS.md` asks. `Num` is 24
bytes, as the ADR measured, and `a_num_is_24_bytes` pins it.
**The normal form.** An exact zero has no sign. A fraction is reduced and has a positive
denominator. A value whose reduced denominator is `2^p·5^q`, with `k = max(p, q) <= 28`
and a mantissa that fits 96 bits, is always the `Decimal`, never a `Fraction`. So
`Fraction(n/1)` exists only when `n` does not fit 96 bits, as with `10^35/1`. Every `i128`
is a valid numerator, `i128::MIN` included, because the arm is `Ratio<i128>`. That one
value cannot be negated in `i128`, so `Operand::parts` keeps it out of the `i128` fast
path and its arithmetic takes the bigint path (Copilot on PR #337).
## Arithmetic
### Why there are two types: `Num` and `Interim`
Question 13 (b) bounds only the **stored** result: "every intermediate inside one formula
evaluation is unbounded up to the ceiling". The bounded `Num` cannot be such an
intermediate, so `Interim` is the widened type. It is either a stored `Num` or an exact
`BigQ` (a reduced `BigInt` fraction) that does not fit `i128`. `Interim::store` applies
the stored bound. The operators on `&Num` are one-operation evaluations: each builds the
`Interim`, applies the operation and stores the result. So `(a * a) / a` returns
`a` exactly when computed through `Interim`, and is inexact when computed through `Num`
(`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
correctly rounded conversion, and then the f64 operation runs. That is two roundings,
which is clause 4's "computed in f64".
**An exact ∘ exact result that does not fit is rounded once.** It is the correctly rounded
f64 of the exact value. Clause 4 still says a demoted sum or product is "rounded twice,
once per operand and once in the operation". That text predates option (d). Option (d)'s
"Operations" and question 13 (b) both say that a result failing the stored bound is "the
correctly rounded f64 of the exact fraction", and under (b) the exact value is always in
hand. The crate follows (d), because (d) is decided and later. Both texts cannot hold for
one operation, so this is a reading, and it is flagged.
### Clause 4: the hint, the recompute, the stored scale
`Decimal + Decimal` and `Decimal × Decimal` first try `rust_decimal`'s checked operation.
The result is trusted only when its scale equals the nominal one (`max(sa, sb)` for `+`
and `−`, `sa + sb` for `×`). A lower scale is the **hint**, and so is a `None`. On the
hint the exact integer result is recomputed in a 256-bit integer, and its trailing zeros
are stripped. If the stripped mantissa fits 96 bits at a scale of at most 28, the result
is a decimal at the **stored scale**, `min(nominal, 28)`, padded back with zeros while the
mantissa fits. Otherwise the result goes to the fraction arm, then to a wide `Interim`.
The ADR's own example holds: `1.2500000000000000 × 4.0000000000000000` is `5` at scale 28
(`a_recomputed_result_keeps_the_stored_scale_clause_4_names`). A `None` counts as the
hint, so that an overflow `rust_decimal` reports is never mistaken for an exact result.
### Division: the direct test, one implementation
Clause 5 is normative: write the quotient as a reduced `n / (2^p·5^q)`, then it is a
decimal iff `k <= 28` and the padded mantissa fits. The crate does this the same way for
every exact pairing, not only for decimals. It reduces the quotient (in `i128` when that
cannot overflow, and in a bigint otherwise), then normalizes. `small_ratio` and
`decimal_of` are the one normalization, so a decimal reached through any path has the
same representation. Multiplying back is never used.
**A quotient carries its minimal scale** (`10.00 / 2` is `5`, `1 / 4` is `0.25`). The ADR
does not say what scale a quotient has. A quotient's scale is not a function of its
operands' scales (clause 5), and a result that passed through the fraction arm carries
its minimal scale (option (d)), so the minimal scale is the consistent choice. It is a
reading, and it is flagged.
**The ADR's argument against multiplying back is weaker than it says.** Clause 5 rejects
it as incomplete, on `1e-28 / 0.5`: `q × 0.5` "needs scale 29, so the check flags it".
That holds under revision 6's rule, which demotes on the hint
(`division_decides_fit_directly_catches_multiplying_back` keeps that rival). But under
revision 7's recompute the product `1e-28` is found exact after its zeros are stripped.
So multiplying back by `Num`'s own `*` accepts the ADR's counterexample. In 20 runs of the
division property (5,120 random cases plus the three seeds), it agreed with the direct
test every time. That was an experiment and is not committed. The direct test stays the
rule, because the ADR decided it and because it needs no second division.
### `POW`
- **The exponent.** It is exact when it is an exact **integer value**, so `2.0` counts.
- **The size, decided before any multiplication.** The result's numerator has at least
`|n|·(bits(num) − 1)` bits, and likewise its denominator. If either lower bound passes
the ceiling, the result is `powf`, inexact, with nothing allocated. So
`POW(1/3, 10^9)` costs nothing. Inside the bound the result is computed exactly, and it
can still exceed the ceiling (`3^6000` is 9,510 bits). It then demotes, which is the
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, **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
magnitude, and the sign is set from `base < 0 && n odd`. An exponent above 2^53 rounds
to an even f64, so handing `powf` the signed base made `(-2)^(2^53 + 1)` read `+inf`
(Copilot on PR #337).
### The ceiling
`CEILING_BITS = 8,192`. It applies to the bit length of an intermediate's numerator *or*
denominator. The ADR states the ceiling as a width, not as which of the two it measures.
Past it, the intermediate becomes the correctly rounded f64 of its exact value.
## Comparison
- **Exact with exact.** Cross-multiplication in 256 bits. A decimal is `m / 10^s` with
`10^28 < 2^94`, so every product fits.
- **Exact with inexact.** The ADR's method: convert the exact operand, and the rounded
value decides unless it lands on the f64 itself. A tie is resolved against the f64's
exact binary value, in a bigint.
- **That fast path is sound only because the conversion is correctly rounded.** Injecting
`rust_decimal`'s `to_f64` failed `exact_mixed_comparison_is_transitive` as well as the
conversion test. A future integer conversion (clause 4 allows one) must keep that.
`MIN` and `MAX` skip NaN. Equal values break their tie as follows. Only the first rule is
the ADR's (question 11); the other two are this crate's, and they make the result
independent of argument order:
- the exact value is preferred to the inexact one (question 11);
- of two equal decimals, the larger scale is kept, for the same reason: it keeps more of
what was typed;
- of `0.0` and `-0.0`, `MIN` takes `-0.0` and `MAX` takes `0.0`, as IEEE 754's
`minimum` and `maximum` do.
## The number language
**The gate is literally `f64::from_str` reading a finite value.** The parse is the
crate's own lexer, and it never goes through an f64 on an exact path. The property test
checks that the two agree, and that the exact value read rounds to the f64
`f64::from_str` reads.
- **A lexeme's scale.** It is the scale the lexeme implies (`1.50` → 2, `1.5e3` → 0). When
the implied scale is above 28 but the value fits a decimal (`0.1` written with 29
places), clause 4's stored-scale rule applies: strip the zeros, then pad toward 28.
The ADR only says "with the scale the lexeme implies", which cannot exceed 28.
- **Zero.** It is exact, unsigned, at the implied scale clamped to 0–28, so
`0e99999999999999999999` is `0`.
- **An exponent too long to read** saturates, because no exact arm can reach it. A
significand of more than 180 digits, or a leading digit outside 10^-130 to 10^40, goes
straight to f64. Those bounds are wider than the fraction arm can reach (a reduced
denominator `10^k / 2^a` is at least `2^k`, so `k < 127`).
- **The forced `n/d` arm.** Its value is a decimal, then a fraction, then **the nearest
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`).
`from_canonical` reads all six spellings back, and reads an exact spelling only as an
exact value. Clause 14's list puts "`#i…`" in the crate, while its table says "the `#i`
prefix and the grammar are improvise's". Those two contradict each other. The crate
holds the prefix, because the wire and the file both need the one spelling, and step 7
may move the prefix out if the grammar wants it.
## Rounding to places
Every arm goes through one exact path: the value as a bigint fraction,
`n·10^places / d`, then rounded by the caller's mode (`HalfAwayFromZero` or `HalfEven`).
An inexact value is first read exactly from its shortest round-trip spelling, which is
improvise-felo.9's fix, so a typed-then-computed `1.005` shows `1.01`. A result that
rounds to zero has no sign (`-0.001` at two places is `0.00`). The ADR does not decide
that, and today's f64 path would print `-0.00`.
## Sums
`NumSum` keeps five things, and every one is an abelian group, so the state is a function
of the multiset of terms and not of their order.
| part | holds | width |
|---|---|---|
| the decimal partition | every decimal, and every fraction whose denominator is `2^p·5^q` with `k <= 28`, as an integer at scale 28 | 320 bits |
| the groups | per other reduced denominator, the summed numerator | 256 bits each, in a `BTreeMap` |
| `group_floats` | an `ExactSum` of each group's correctly rounded value | ADR 009's |
| `inexact` | an `ExactSum` of the f64 terms | ADR 009's |
| `inexact_terms` | the net weight per f64 bit pattern, entries at zero dropped | a `BTreeMap` |
**The read.** It is exact when no inexact term is present and at most K = 8 groups
remain. The groups and the decimal partition are then combined **over the lcm of their
denominators, and reduced once**, and the stored bound applies to the result. Past K the
read is the f64 accumulators: `group_floats`, the decimal partition converted once, and
the inexact terms. That reading rounds each group, then the total. With inexact terms and
at most K groups, the exact total is converted once and added to the inexact terms. A
group whose numerator returns to zero is dropped, so "how many groups" is a function of
state.
**"Is an inexact term present" is asked per value, never of the net weight.** The first
version kept one net count. Inserting `1.0` and retracting `2.0` leaves the count at zero
while the accumulator holds −1.0, so the read took the exact branch and dropped it
(ChatGPT Codex, P1 on PR #337). Presence is now a map from f64 bit pattern to net weight,
dropped at zero, the same rule the groups follow. It costs O(distinct inexact values),
the same order as the groups. The K cutoff overrides representability: nine denominators whose exact total fits
still read inexact (`more_than_k_denominators_read_inexact_even_when_the_total_fits`).
That is question 13's second exception, and the ADR's flagged reading.
**`ExactSum` is ADR 009's prototype**, from `crates/improvise-core/examples/retraction_drift.rs`
on PR #309. It has three changes:
- it splits a weight into base-2^30 digits, as ADR 009 says the `numtower` version must,
instead of looping per chunk;
- it holds 70 limbs, not 67, because a digit's shift adds up to 60 bits;
- it reads through the crate's one rational-to-f64 rounding.
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
yet, because `zset` does not exist yet. The newtype of question 15 is where that goes.
## The wide integers
`Fixed<N>` is a two's-complement integer of `N` 64-bit limbs that never allocates. It is
used for the clause 4 recompute (256 bits), exact comparison (256 bits), the decimal
partition (320 bits) and the group numerators (256 bits). Clause 14 also names "256-bit
helpers for the division reduction". Division reduces in `i128` when the products cannot
overflow, and otherwise in a bigint. It does not have a hand-rolled 256-bit gcd. That
path is the slow path, and it is measured below.
## Dependencies
The normal edges are `rust_decimal` 1.43.0 (std only), `num-rational` 0.4.2 (no default
features), `num-bigint` 0.4.8, `num-integer` 0.1.47 and `num-traits` 0.2.19. The last two
are direct dependencies, used for `gcd`/`lcm` and the numeric traits. They were already
in the tree through `num-rational` and `num-bigint`, so they add no crate. The ADR names
only the first three. There is no serde, and `cargo tree --edges normal` shows no
`borsh`. `BigRational` is a **dev**-dependency feature only, for the oracle, so the
oracle and the implementation do not share a rational type.
## Measured
On an Apple M4 Max (64 GB), rustc 1.98.1, `cargo run --release -p numtower --example
bench`. Each range is two runs.
| what | figure |
|---|---|
| SUM, 18k and 90k typed two-place values, ns per element | f64 0.53; `rust_decimal` fold 2.7–3.0; `Num` `+` fold 7.8–8.2; `NumSum` insert and read 7.9–8.1 |
| per operation over 18,000 typed pairs, ns | `Num` add 3.1–3.3, mul 3.0–3.2, div 146–153; `rust_decimal` checked add 1.4–1.5, mul 1.2, div 24.7–25.3 |
| `(Rev − Cost) / Rev`, ns per record | 151–153 |
| `to_f64` of a decimal (the text path), ns | 36.7–37.1 |
| `round_to_places(2)`, ns | 370–382 |
| `NumSum` read, K distinct 126-bit denominators, numerators of about 190 bits | K = 2: 5.2–5.4 µs; K = 8: 41.4–42.0 µs |
| `ExactSum`, 1M values | 1.78–1.81 ns per insert, 0.29 µs per read |
Here is how these sit against the ADR:
- **The grouped read.** At K = 8 it is inside the ADR's 52–127 µs. The ADR measured
250-bit numerators, and these are about 190 bits, because `NumSum` can only reach a
group's numerator through its public `add`.
- **`ExactSum`.** It agrees with ADR 009's 2.0 ns per update and 0.58 µs per read.
- **Division.** At about 150 ns it is above the ADR's *estimate* of 60–110 ns. Most
quotients of typed values do not terminate, so each one pays two `i128` gcds and the
`Box` of a `Fraction`.
- **The rest.** The ADR's whole-recompute measurement at 18k and 90k cells waits for
step 3, as the ADR says.
**The wasm32 cost** comes from a probe outside the repository, built exactly as the
ADR's appendix builds one: a `cdylib`, `opt-level = "s"`, LTO, `wasm-opt -Oz
--enable-bulk-memory --enable-nontrapping-float-to-int`, `gzip -9`. The baseline already
parses and prints an f64.
| probe | after `wasm-opt -Oz` | gzipped |
|---|---|---|
| f64 only | 48,331 B | 29,162 B |
| the ADR's five operations (parse, `+`, `−`, `/`, print) through `numtower` | +89,375 B | **+36,714 B** |
| plus `NumSum`, `cmp_exact` and `round_to_places` | +119,277 B | **+48,221 B** |
The narrow figure confirms the ADR's estimate of "up to about +36 KB together". The full
surface is about +47 KB gzipped. That is what the bundle pays once step 4 sums through
the crate and step 6 rounds through it. It is more than the +15.4 KB the user answered
question 8 against, and the ADR says question 8 "reopens" if the figure changes the
answer. Neither figure is the real bundle, which shares allocator and formatting code
with everything else.