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
20 KiB
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.0counts. - 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 ispowf, inexact, with nothing allocated. SoPOW(1/3, 10^9)costs nothing. Inside the bound the result is computed exactly, and it can still exceed the ceiling (3^6000is 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, so1.50^2is2.2500. A negative power is a division and has its minimal scale, unit bases included:1.0^-2is1and(-1.0)^-3is-1, as2.0^-2is0.25. Until the review of302594bfthe unit-base arm padded every power tos·|n|, so1.0^-2read1.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^0is1.0^-nisdiv/0.±1^nis±1for anyn, including one too large to compute. - A demoted power takes its sign from the exact exponent.
powfgets the base's magnitude, and the sign is set frombase < 0 && n odd. An exponent above 2^53 rounds to an even f64, so handingpowfthe 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^swith10^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'sto_f64failedexact_mixed_comparison_is_transitiveas 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.0and-0.0,MINtakes-0.0andMAXtakes0.0, as IEEE 754'sminimumandmaximumdo.
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.1written 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
0e99999999999999999999is0. - 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^ais at least2^k, sok < 127). - The forced
n/darm. Its value is a decimal, then a fraction, then the nearest f64. The ADR saysn/d"is a fraction or, once normalized, a decimal", and does not say what a forcedn/dtoo large fori128becomes. 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 forcedn/dwhose value rounds to infinity (nof 400 digits,/1) is text, exactly as the gate makes1e999text. Clause 3: "a finite lexeme must never read as infinity". One that underflows is the inexact zero, as1e-999is.is_numberandparse_numbershare 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
numtowerversion 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
NumSumcan only reach a group's numerator through its publicadd. 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
i128gcds and theBoxof aFraction. - 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.