Source: context/code/numtower.md
On this page
- numtower: the reasoning
- The shape
- Arithmetic
- Why there are two types: Num and Interim
- Contagion, and how many roundings a demotion has
- Clause 4: the hint, the recompute, the stored scale
- Division: the direct test, one implementation
- POW
- The ceiling
- Comparison
- The number language
- Rounding to places
- Sums
- The wide integers
- Dependencies
- Measured
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().
improvise-core and improvise-incremental sum through ExactSum (improvise-hrjz): the sign rule is the one already in it, and the reasoning for the two callers is context/code/improvise-incremental.md, step 6.
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.