docs(adr): ADR 005 folds in the user's answers to questions 10 and 13-15 and answers the review of 33833f28 (improvise-l2b8)

Revision 6. The user decided question 10 (a) with a 320-bit decimal
partition, question 13 (b), question 14 `numtower`, and question 15 (a).

N1: the number language's gate is the finite results of f64::from_str,
which is #321's gate; nan, inf and 1e999 stay text at import as
improvise-jc95 decided, and reversing jc95 is the rejected alternative.
N2: entry tries the fraction arm before f64, so a typed value is as exact
as the same value computed.

Also: raw_value's restructuring cost is stated; stale text is corrected;
the ceiling is sized to K*127+256; the exact grouped read is measured
(0.67-1.9 ms at K = 32, about 50x the estimate), which adds question 16
on the value of K; and the last-bit miss condition is stated on the
terms, matching ADR 009 at 4973845f.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Claude (agent-adr005)
2026-09-25 12:08:41 -07:00
committed by Edward Langley
parent 33833f2822
commit 02bcfcb158
+300 -86
View File
@@ -20,7 +20,7 @@ ADR 007 was first filed under this number. They belong to ADR 007, not to this d
**Citations.** Every `file:line` below is at `gh/main` `b639266b`. Line numbers are
dated, so re-derive them before citing this document elsewhere. "ADR 009" means the
head of PR #309 at `25ebf641`.
head of PR #309 at `4973845f` (revision 6; it was `25ebf641` through revision 5).
**Revision 2** answers the adversarial review of `1fe3a85d` (a BLOCK) and the four
ChatGPT Codex findings on the same head. The review's two blocking points are both
@@ -55,6 +55,23 @@ type is forced. The same revision answers the rest of that review (B2–B4, D1's
correction, question 13, the crate homes). Questions 10 and 13–15 stay open, and nothing
is renumbered.
**Revision 6** folds in **the user's answers to questions 10, 13, 14 and 15**, left
inline on `33833f28` at 17:45–17:48 UTC on 2026-09-25: the 320-bit decimal partition
and option (a) for question 10, (b) for question 13, `numtower` for question 14, and (a)
for question 15. It answers the review of `33833f28` (issuecomment-5836990563):
- **N1.** The number language's gate is now the **finite** results of `f64::from_str`,
so it is #321's gate. `nan`, `inf` and `1e999` stay text at import, as improvise-jc95
decided. Reversing jc95 is named as the rejected alternative (clause 3).
- **N2.** Entry tries the **fraction arm before f64**, so a typed value is exactly as
exact as the same value computed (clause 3).
- The should-fix items: `raw_value`'s cost to the import pipeline is stated (clause 12,
step 5); the stale text is corrected; the ceiling is sized to the grouped read; and
the exact read's cost is now **measured**. It is about 50× the revision 5 estimate,
which is why question 16 is new.
It also corrects when a last-bit miss is visible to `=`. The bound is on the **terms**,
not the total, as ADR 009 now says at `4973845f` (Codex on #309).
## What the user decided
The user answered inline on `e4acf643`, one comment per question. Each answer is quoted
@@ -132,13 +149,26 @@ Decided: **accept the skew**. The handshake version is filed as **improvise-szi8
> "Don't we need a much bigger accumulator (2000ish bits) to handle exact accumulation
> generically?"
This is a question back to the ADR, not a ruling, so question 10 stays open. It is
answered with numbers in clause 8, "How wide, generically". In short:
That was a question back to the ADR, answered with numbers in clause 8, "How wide,
generically". In short:
- **Decimals need 320 bits**, because their range is small.
- **f64 values need about 2,100 bits.** That is ADR 009's accumulator.
- **Fractions have no fixed generic width.** Combining k arbitrary `i128` denominators
needs up to 127·k bits. The user's ~2,000 bits is exactly k = 16.
Then, on `33833f28`, on the line "decimals need 320 bits":
> "320bits is fine"
and on option (a):
> "(a)"
Decided: **(a)**. A 320-bit decimal partition, a 256-bit numerator per fraction group,
and a read that combines at most K groups in a bigint rational and then applies the
stored bound. One figure changed after the answer, and it is flagged here: the exact
read was an estimate of 16–34 µs at K = 32, and it measures **0.67–1.9 ms** in the worst
case (option (d), "What that costs"). K is a constant inside `numtower`, not a format
or wire fact, so its value is split out as question 16 rather than reopening this one.
**11. The `MIN`/`MAX` tie-break.**
> "Yeah, exact."
@@ -153,6 +183,25 @@ is forced.** So a typed or imported `3/4` whose type is being guessed stays text
keeps dates. A column the user has forced to a measure reads `3/4` as a fraction.
Clause 12 defines the one number language this uses.
**13. Where the fraction bound applies.**
> "(b)"
Decided: **(b)**. The bound applies to the **stored** result, and every intermediate
inside one formula evaluation is unbounded up to the ceiling. The ceiling is the one
exception, and it is now sized to the grouped read (question 13).
**14. The numeric crate's name.**
> "this is fine"
Decided: **`numtower`** (clause 14).
**15. Where ADR 009's `ExactSum` lives.**
> "(a)"
Decided: **(a)**, in `numtower`, beside the exact decimal accumulator. ADR 009's seam
crate adapts it to `zset`'s group trait through a newtype, and `numtower` can land
holding `ExactSum` alone, ahead of this ADR (clause 14).
## The decision this ADR writes down
1. **Three levels** (decided, question 1). A number is an **exact decimal**, an **exact
@@ -160,7 +209,8 @@ Clause 12 defines the one number language this uses.
There are no unbounded rationals. They were measured, and they fail on the most
ordinary pivot-table question, the sum of ratios (clause 1).
2. **Exact is the default.** Every number a person types, every number in a `.improv`
file, every CSV or JSON number and every formula literal is exact when it fits.
file, every CSV or JSON number and every formula literal is exact when it fits
either exact arm: a decimal first, then a bounded fraction (clause 3).
3. **Exactness is lost only in named places.** `+`, `-`, `*` and `/` keep it: a
quotient is a decimal when it terminates and fits, and otherwise a bounded fraction
(decided, question 2). `POW` keeps it for an integer exponent. A non-integer exponent,
@@ -173,7 +223,7 @@ Clause 12 defines the one number language this uses.
5. **The stored type is `CellValue::Number(Num)`**, where `Num` is `Exact(Decimal)`,
`Fraction(Box<Ratio<i128>>)` or `Inexact(f64)`. The crates are `rust_decimal` and
`num-rational`. `Num` and everything numeric live in a
problem-agnostic workspace crate of their own, proposed as `numtower` (clause 14).
problem-agnostic workspace crate of their own, `numtower` (decided, question 14; clause 14).
6. **Each kind of number has its own exact accumulator.** ADR 009's f64 accumulator
makes `SUM` over *inexact* values order-free, and this ADR does not re-specify it.
The *exact* values get a wide fixed-point integer of their own (clause 8), because
@@ -260,8 +310,9 @@ bug (improvise-shyt), fixed by pinning iteration order rather than by removing i
**The epsilon is a patch, and it does not scale.** It was chosen because it "absorbs
accumulated f64 rounding noise" (`context/code/core.md` §4). But one ulp of 1,000,000 is
1.16e-10. So above about 2¹⁹ (524,288), a one-ulp error is already larger than the
epsilon, and `=` sees it. Below about 1e-10, the epsilon calls different numbers equal.
1.16e-10. So once a value is above about 2¹⁹ (524,288), a one-ulp error in it is
already larger than the epsilon, and `=` sees it. Below about 1e-10, the epsilon calls
different numbers equal.
### What ADR 009's exact accumulator fixes, and what it does not
@@ -305,12 +356,28 @@ corrected here. For two summands a superaccumulator is exactly `+`, because one
addition is already correctly rounded. So `0.1 + 0.2` is still `0.30000000000000004`
under the accumulator.
**The misses are in the last bit, so no display format shows them.** A person reading a
three-line subtotal at two places sees the right number. The miss shows in two places:
in `=` against a typed value, once the epsilon is gone or once the magnitude is above
2¹⁹, and in the editor, which opens on the shortest round-trip spelling
(`0.30000000000000004`). That is where 16% of positive 3-term sums, and about 40% of
signed sums of any length, go wrong.
**The misses are in the last bit of the largest term, not of the total.** Each stored
term is off from its typed value by at most half an ulp of that term. So the exact sum of
the stored terms is off from the typed total by at most about ½·Σ ulp(term), plus the
total's own half-ulp at the final rounding (revision 6; revision 5 put the condition on
the total, and ADR 009 made the same correction at `4973845f`, from ChatGPT Codex on
#309). When terms cancel, a miss can be far larger than the total's ulp:
- **The large-term, small-total case.** `1000000000000000.10 + (-1000000000000000.00)`
has a typed total of 0.10. The first term is stored as `1e15 + 0.125`, because the ulp
at 1e15 is 0.125. So the exact sum of the stored terms is **0.125**, and the miss is
0.025 on a total of 0.1.
- **When `=` sees one today.** `=` is an absolute `1e-10` (gotcha 17), so a miss can
reach it only when ½·Σ ulp(term) reaches `1e-10`. That needs at least one term of
about 2¹⁹ ≈ 5.2e5 or larger, or many terms just below it. It does not depend on the
total. The ±1,000 values are far below 2¹⁹, so today's epsilon hides their misses;
the positive values up to 999,999.99 pass it.
- **Once this ADR deletes the epsilon** (clause 6), every miss is visible to `=`.
A person reading a subtotal at two places usually sees the right number, because the
miss is below the displayed digits unless the terms are large. The miss also shows in
the editor, which opens on the shortest round-trip spelling (`0.30000000000000004`).
That is where 16% of positive 3-term sums, and about 40% of signed sums of any length,
go wrong.
So the division of labour is:
@@ -328,7 +395,7 @@ So the division of labour is:
summed, it is summed by ADR 009's accumulator, and this ADR only says so. ADR 009's
"what each exact domain covers" table says that accumulator makes `=` against a typed
decimal right "only for sums". The measurement above narrows that to *large same-sign*
sums. ADR 009's head `8ebcf889` now says "no, in general" and cites both distributions,
sums. ADR 009 says "no, in general" since `8ebcf889`, and cites both distributions,
so the two tables agree.
### The crates
@@ -419,8 +486,12 @@ stays, as the explicitly lossy accessor for the seams that want a binary float (
`Expr::Number(f64)` becomes **`Expr::Number(Num)`**, not `Expr::Number(Decimal)`. The
formula grammar has no digit bound, so a literal such as
`999999999999999999999999999999` is legal formula text and has no `Decimal`. It takes
clause 3's fallback, like every other spelled number: exact when it fits, else the
nearest f64. Bounding the grammar instead was rejected, because it would make a formula
clause 3's order, like every other spelled number: a decimal if it fits, else a fraction
if it fits (that literal is below 2¹²⁷, so it is the exact `Fraction` n/1), else the
nearest f64. A literal is a number by the grammar, so clause 3's gate, which decides
number-or-text for data, does not apply to it: a literal that overflows f64 (`1e999`)
keeps today's meaning, inexact infinity, the same as an inexact overflow in `*`.
Bounding the grammar instead was rejected, because it would make a formula
that parses today stop parsing, over a limit of the representation rather than of the
language. (Codex, P2 on `1fe3a85d`.)
@@ -435,16 +506,16 @@ values.
decimals a person typed, and "decimal math by default" is the user's instruction. So
Scheme's `#e` is improvise's default.
A spelled number is **inexact** only when it cannot be exact:
- it is `inf`, `-inf` or `nan` (clause 7);
- it has more than 28 significant digits, or its magnitude is outside `Decimal`'s range
(for example `1e300` or `1e-40`). Then it reads as the nearest f64.
A spelled number is **inexact** only when it cannot be exact: it fits neither a
`Decimal` nor an `i128` fraction (for example `1e300`, `1e-40`, or a 40-digit integer).
Then it reads as the nearest f64. The tokens `inf`, `-inf` and `nan` are inexact too,
but they are clause 7's tokens, not numbers of the language (below).
**The parse must refuse, not round.** `Decimal::from_str` rounds a 30-digit input to 28
digits **silently** (`0.12345678901234567890123456789` comes back as
`0.1234567890123456789012345679`). That is a rounded value calling itself exact, the one
outcome clause 4 forbids.
outcome clause 4 forbids. Under the order below, that input is the exact fraction
`12345678901234567890123456789/10²⁹` instead.
**One number language, for every entry point** (revision 5, answering B4 of the review
of `87ae21ca`). Revision 4 said "parse with `from_str_exact`", but the gate that decides
@@ -453,21 +524,43 @@ whether a string is a number at all accepts different strings. `from_str_exact`
language is defined once, and the gate and the parse cannot disagree. `numtower` owns
it.
- **What is a number.** The gate is exactly the strings Rust's `f64::from_str`
accepts, which is #321's `number_in` gate. That is an optional sign; digits with an
optional fraction (`1.`, `.5`); an optional exponent `e`/`E` with its own sign; and
the tokens `inf`, `infinity` and `nan` in any case. **`1_000` is not a number, so it
is text.**
- **How it parses.** A finite lexeme is parsed **exactly** from its digits and exponent:
a mantissa and a scale shift, never through an f64.
- If the exact value fits a `Decimal`, it is `Exact`, with the scale the lexeme
implies: `1.50` has scale 2, `1.5e3` is `1500` at scale 0, and `1.5e-3` is
`0.0015`.
- If it does not fit, it is the nearest f64 (`f64::from_str`, which is correctly
rounded): `Inexact`.
- **What is a number.** The gate is the strings Rust's `f64::from_str` accepts **and
reads as a finite value** (revision 6, N1 of the review of `33833f28`). That is #321's
`number_in` gate, which drops anything `Number::from_f64` cannot hold. So a number is
an optional sign; digits with an optional fraction (`1.`, `.5`); and an optional
exponent `e`/`E` with its own sign, whose value does not overflow f64.
- **`1_000` is not a number**, so it is text.
- **`nan`, `inf`, `infinity` in any case, and a finite lexeme that overflows f64**
(`1e999`, `-1e999`) are not numbers either, so they are text. That is
improvise-jc95's decision, which #321's
`a_nonfinite_numeric_token_stays_text_rather_than_becoming_zero` pins for `NaN`,
`inf`, `-inf`, `1e999` and `-1e999`. A finite lexeme must never read as infinity.
- A lexeme that **underflows** (`1e-999`) passes, because f64 reads it as a finite
zero. It is then too small for either exact arm, and it reads as that zero,
inexact. #321 treats it the same way.
- **The non-finite values are clause 7's tokens, not numbers.** `typed()`, the `.improv`
reader and the wire accept exactly `inf`, `-inf` and `nan`, the three spellings
`nonfinite_token` writes (`cell.rs:65`), as inexact values. Import does not, so a
CSV `nan` stays text, as jc95 decided. This is a behaviour change for `typed()`, and
it is stated: today it hands any string to `str::parse::<f64>` (`resolve.rs:1438`), so
a typed `Infinity` or `1e999` becomes infinity and a typed `NaN` becomes NaN. Under
this clause each is text.
- **How it parses.** A lexeme that passes the gate is parsed **exactly** from its digits
and exponent: a mantissa and a scale shift, never through an f64. Then it takes the
first arm it fits, in this order (revision 6, N2 of the same review):
- **A decimal.** If the exact value fits a `Decimal`, it is `Exact`, with the scale
the lexeme implies: `1.50` has scale 2, `1.5e3` is `1500` at scale 0, and `1.5e-3`
is `0.0015`.
- **A fraction.** Otherwise, if its reduced value `m / 10^k` fits `Ratio<i128>`, it is
a `Fraction`. So a typed `1e-30` is the exact `1/10³⁰`, and an integer between
`Decimal`'s maximum (about 7.9 × 10²⁸) and 2¹²⁷ is the exact `n/1`. This is the
order clause 4 already gives arithmetic, so the same value is equally exact typed
and computed: `1e-15 * 1e-15` was already the exact `1/10³⁰`. A fraction carries
no scale (the option (d) section).
- **The nearest f64.** Otherwise it is `f64::from_str`'s correctly rounded value,
`Inexact`. The gate has already made it finite.
- A lexeme that passes the gate is never refused. **Failing to fit falls back; failing
to parse cannot happen.**
- The non-finite tokens are inexact (clause 7).
- **Zero has no sign at the exact level.** `-0.0` reads as exact `0.0`. The sign of zero
is an f64 artefact. This is a behaviour change, and it is stated: an f64 `-0.0` never
survives entry as exact.
@@ -482,14 +575,22 @@ it.
deciding a column's kind. That keeps `3/4` as a date or a label.
The entry points are `typed()`, the `.improv` reader (whose `number` rule is a subset of
the gate), formula literals, the wire and import.
the gate before the finite check), formula literals, the wire and import.
**Reversing jc95 was rejected.** The alternative was a gate of every string
`f64::from_str` accepts, so that a CSV `nan` imports as `Inexact(NaN)` and a CSV `1e999`
as `Inexact(inf)`. It would make a finite lexeme read silently as infinity, which is the
invented-value class jc95 closed, and it would flip #321's test. The gain, a measure
column that can hold a NaN, is not worth either: a column carrying `nan` is shown as
text in the wizard, where the user can see it (jc95's close reason).
**A JSON number is read from its source lexeme, never through its f64** (decided,
question 6: "the round trip through f64 seems dangerous"). `serde_json` normally parses
a number to f64 before improvise sees it. The import therefore reads each record's
values with `serde_json`'s `raw_value` feature, which hands over the number's exact
source text, and parses that text by the language above. So a JSON `12.50` keeps its
scale, and a 20-digit JSON literal stays exact. `arbitrary_precision` was the other way
scale, and a 20-digit JSON literal stays exact. That costs a restructuring of the import
pipeline, stated in clause 12. `arbitrary_precision` was the other way
to do this, and it stays rejected, because it breaks serde's buffered deserialization of
numbers (the user's answer to question 6). Revision 4's shortest-spelling recovery of a
JSON number is withdrawn.
@@ -680,7 +781,8 @@ well (Consequences).
An exact number is always finite. `inf`, `-inf` and `nan` are inexact, as in Scheme
(`+inf.0`, `+nan.0`). They keep their one spelling (improvise-55v2) in the file, on the
wire, and in `typed()`. Where they come from is unchanged: stored data, and inexact
wire, and in `typed()`: `inf`, `-inf` and `nan`, and no other (clause 3). Import keeps
them as text (improvise-jc95). Where they come from is unchanged: stored data, and inexact
overflow in `*` or `POW`. Exact overflow becomes inexact (clause 4), and it may overflow
further to `inf` only at the inexact level.
@@ -759,7 +861,9 @@ each group is an integer:
- a group's numerator is at most `i128` × an `i64` weight × 2⁶⁴ terms, so **256 bits**
per group;
- the only unbounded step is the **read**, which combines the groups over their lcm;
- with at most K = 32 groups that lcm is at most about 4,100 bits, or 2,030 at K = 16.
- with at most K = 32 groups that lcm is at most about 4,100 bits, or 2,030 at K = 16,
and the combined numerator at most K·127 + 256 ≈ 4,320 bits (measured: 3,924 and
4,053 bits for 32 random 126-bit denominators and 250-bit numerators).
Under question 13 (b) that read runs in a bigint rational (`num-bigint`), so it needs no
fixed register at all. Its result is then held to the stored bound: it is exact if it
@@ -825,9 +929,12 @@ use the crate's `Hash`. It hashes the representation explicitly.
### 10. `.improv` persistence and migration
**Reading.** Every `number` token reads as clause 3 says: exact if it fits, else inexact.
The grammar's `number` rule (`improv.pest:110-112`) already accepts everything a decimal
needs, including an exponent.
**Reading.** Every `number` token reads as clause 3 says: a decimal if it fits, else a
fraction if it fits, else the nearest f64. The grammar's `number` rule
(`improv.pest:110-112`) already accepts everything a decimal needs, including an
exponent. A `number` token that overflows f64 (`1e999`, which only a hand-edited file
can hold) is text, as a CSV field is (clause 3). `inf`, `-inf` and `nan` read through
the `bare_value` arm as clause 7's tokens, as they do today (`parse.rs:268-273`).
**Writing.** An exact decimal is written as its decimal, with its scale. An exact
fraction is written `n/d`, with the sign on `n` (`-10/3`), through the grammar's
@@ -924,11 +1031,12 @@ aligns the contract with PR #321.
- **CSV.** PR #321 keeps every non-empty CSV field as its verbatim text
(`Value::String`, carried as `Scalars::Text`). It does so to preserve numeric-looking
category identifiers. So a CSV measure is parsed **from that text** by clause 3's
number language. #321's `number_in` gate *is* that language's gate, so the analyzer and
the parse agree on every string: `1.5e3` is the exact `1500`, and `1_000` is text. It
keeps its significant digits and its **scale**, so a CSV `12.50` opens in the editor
as `12.50`. It falls back to the nearest f64 only when it does not fit a `Decimal`,
never because it failed to parse.
number language. #321's `number_in` gate *is* that language's gate, finite values only
(revision 6), so the analyzer and the parse agree on every string: `1.5e3` is the
exact `1500`, and `1_000`, `nan`, `inf` and `1e999` are text. It keeps its significant
digits and its **scale**, so a CSV `12.50` opens in the editor as `12.50`. It falls
back to the nearest f64 only when it fits neither exact arm, never because it failed
to parse.
- **Inferred and forced columns differ on `n/d`** (decided, question 12). While the
analyzer is inferring a column's kind, `3/4` is text, so a column of dates stays a
column of dates. Once the user **forces** a column to a measure (a wizard override,
@@ -942,6 +1050,18 @@ aligns the contract with PR #321.
`raw_value`, and never through the f64 `as_f64` (`wizard.rs:425`) reads today
(decided, question 6; clause 3). So a JSON `12.50` keeps its scale, and a 19-digit
integer stays exact. Revision 4's shortest-spelling rule for JSON is withdrawn.
- **This restructures the JSON half of the import pipeline** (revision 6, from the
review of `33833f28`). `ImportPipeline` carries `serde_json::Value` (ten uses in
`wizard.rs`, and more in `analyzer.rs`), and a `Value`'s numbers are already f64.
`raw_value` hands over a lexeme only to a reader that deserializes into `RawValue`
through a hand-written visitor. It does not work through a `Value`, or through any
buffered path such as an untagged enum. So the JSON reader becomes a hand-written
deserializer that keeps each number as its source text, and the pipeline carries
that text to the number language, as #321 already does for CSV.
- That is the restructuring revision 3 cited as a reason not to carry the text. It is
paid here because the user ruled out the round trip through f64 (question 6), and
this is the only way to honour that for JSON. It is migration step 5's largest
part.
The collision "sum" policy (`wizard.rs:307`) then sums exactly, by clause 8.
@@ -957,7 +1077,7 @@ stays improvise's and does not become the crate's.
### 13. The crate: `rust_decimal`
Chosen on the table above. Four reasons:
Chosen on the table above. Three reasons:
- **Size and speed.** It is `Copy` and 16 bytes, with no heap. So a `CellValue` does
not start allocating for every number. It is about 2.5× faster than `bigdecimal` on
@@ -967,12 +1087,15 @@ Chosen on the table above. Four reasons:
significant digits holds any money amount and any count. `bigdecimal`'s unbounded
add costs a heap allocation for a precision no pivot table uses. Its division still
rounds, so it does not buy exact division either.
- **Serde.** Its serializer is a decimal string by default, which is clause 11's wire
spelling.
Its serde support is not a reason. `numtower` has no serde (clause 14), and the wire
spells a value in its own encoding (clause 11). Revision 5 still listed it, and that
contradicted both.
The cost is three duties the crate does not do for us, each named where it applies:
detecting rounding at the bound (clause 4), refusing an over-long parse with
`from_str_exact` (clause 3), and converting to f64 by a correctly rounded path rather
detecting rounding at the bound (clause 4), parsing (clause 3 parses the digits and
exponent itself, because `from_str` rounds silently and `from_str_exact` rejects
`1.5e3`), and converting to f64 by a correctly rounded path rather
than `to_f64` (clause 4). A fourth is avoided rather than done: its serde deserializer
accepts JSON numbers, so the wire does not use it. The wire parses its own string
encoding through `numtower` (clause 11).
@@ -986,8 +1109,8 @@ At the user's direction (revision 4): "I think ADR 005 should implement the math
operations in a separate crate". The numbers are a **problem-agnostic workspace crate**,
like `gp-tree` and ADR 009's `zset`, and it knows nothing about improvise.
**Name.** Proposed: **`numtower`**. It names the idea, Scheme's numeric tower, rather
than this program. That is question 14.
**Name.** **`numtower`** (decided, question 14). It names the idea, Scheme's numeric
tower, rather than this program.
**What it holds:**
- `Num`, with its `Exact(Decimal)`, `Fraction` (question 2, decided (d)) and `Inexact(f64)`
@@ -1004,8 +1127,8 @@ than this program. That is question 14.
(clause 12, "Format", and improvise-felo.9);
- the **f64 → decimal shortest-spelling** conversion, which the display rounding and the
`#i` spelling on the wire and in the file all use;
- parsing that refuses rather than rounds (`from_str_exact` and the f64 fallback,
clause 3);
- parsing that never rounds silently: digits and exponent to an exact value, then the
decimal, fraction and f64 arms in that order (clause 3);
- the canonical text of a value: the decimal with its scale, `n/d`, `#i…`, and the
non-finite tokens;
- clause 3's **number language**, both its gate and its parse, including the forced
@@ -1073,8 +1196,8 @@ normal ones. That is what lets it be committed at all (appendix).
**ADR 009's `ExactSum`** is an f64 superaccumulator, in the proposed `zset` crate.
Clause 8 needs it for the inexact partition, and a mixed sum's read combines it with the
exact partition. The recommendation (question 15) is that **`ExactSum` moves into
`numtower`**, the one home for exact summation. `zset` stays independent of numbers: it
exact partition. **`ExactSum` moves into `numtower`** (decided, question 15 (a)), the
one home for exact summation. `zset` stays independent of numbers: it
keeps its abelian-group trait, and ADR 009's seam crate (`improvise-incremental`, its
question 2) wraps `numtower`'s accumulators in a newtype that implements that trait. The
orphan rule forbids implementing `zset`'s trait for `numtower`'s type anywhere else, and
@@ -1186,10 +1309,12 @@ There are three ways to sum one:
term touches only its own group. So the state is independent of order, and
retraction is exact.
At read, the groups are combined in ascending order of denominator with checked
`i128` arithmetic. If that fits, the sum is exact. If it overflows, the sum is the
inexact total: each group's correctly rounded f64, summed in ADR 009's accumulator.
How it is read is the recommendation below: exactly, in a bigint rational, when
there are at most K non-decimal groups (question 13 (b)); otherwise from a maintained
f64 accumulator of the groups' values, with the decimal partition converted once.
Both paths are deterministic and independent of the order the terms arrived in.
(Revision 5 still described a checked-`i128` read here, with a fallback that summed
each group's f64 at read. That contradicted the maintained accumulator below.)
The cost is memory. It is O(distinct denominators) per summed group, bounded by
the cells, the same order as ADR 009's multisets.
@@ -1233,11 +1358,29 @@ integer and reads in O(1). The review proposed a mitigation, and it is adopted:
numerator over an `i128` denominator, estimated at about 1 µs through a bigint
rational. Plus two accumulator updates at 2.0–3.6 ns (ADR 009's figures). An update
of the decimal partition is one 320-bit add.
- **Exact read (an estimate).** At most 32 combinations. The groups hold 256-bit
numerators, and under question 13 (b) the read runs in a **bigint rational**. At the
measured `BigRational` rate of 490–1,060 ns per add (the SUM benchmark), that is about
**16–34 µs**. Revision 4's "at most about 15 µs" assumed `Ratio<i128>` operands, which
the groups are not.
- **Exact read (measured in revision 6).** At most K combinations of groups that hold
256-bit numerators, in a **bigint rational** (question 13 (b)). The worst case is K
distinct denominators near `i128`'s size, where the lcm grows about 127 bits per
group. Measured with 250-bit numerators and random 126-bit denominators, 200 reads
each (appendix):
| K | lcm bits | `BigRational` add per group | lcm, then one reduce |
|---|---|---|---|
| 2 | 252 | 16 µs | 7 µs |
| 8 | 988 | 127 µs | 52 µs |
| 16 | 1,957 | 448 µs | 174 µs |
| 32 | 3,924 | 1.9 ms | 0.67 ms |
- Revision 5 estimated **16–34 µs** at K = 32 from the SUM benchmark's rate. Those
operands had small typed denominators, so the estimate was about **50× too low** at
this size. The cost grows roughly as K², because each step works on an lcm that
grows linearly.
- **Combining over the lcm and reducing once** is 2.3–2.8× faster than reducing after
every add, and it gives the same value (the benchmark asserts it). `numtower`'s read
should do that.
- Denominators a person produces (`/ 3`, `/ 12`, a percentage) are small, and the read
is then far cheaper. The table is the bound, not the common case. The value of K is
question 16.
- **f64 read:** ADR 009's 0.6–1.1 µs.
- **Memory is still O(distinct denominators)**, because the map must survive
retraction to know when the count falls back under K. For a 90k-record margin sum
@@ -1248,8 +1391,9 @@ integer and reads in O(1). The review proposed a mitigation, and it is adopted:
This keeps (d)'s wins, because `SUM(v / 3)`, averages and percentages have one or a
handful of denominators, and it bounds the rest. The sum of margins reads the f64
accumulator and is inexact, as under (a). Its exact combination would overflow by the
6th term anyway (below). The 32 is a judgement: large enough for a `/ N` over a few
different Ns, and small enough to bound the read. It is not tuned.
6th term anyway (below). The 32 was a judgement: large enough for a `/ N` over a few
different Ns, and small enough to bound the read. It was not tuned, and the read's
measured cost above is why question 16 asks for a smaller one.
### The file and the wire
@@ -1343,6 +1487,8 @@ the user answered question 2 "(d)":
- **Clause 1:** three levels: exact decimal, exact bounded fraction, inexact.
- **Clause 2:** `Num` gains the boxed `Fraction` arm.
- **Clause 3:** entry tries the fraction arm before f64, as arithmetic does
(revision 6).
- **Clause 4:** a decimal operation that exceeds 28 digits tries the fraction arm
before f64.
- **Clause 5:** a non-terminating quotient is a `Fraction`. `AVG` is exact.
@@ -1382,8 +1528,8 @@ In order. Each step lands on its own, with the suite green.
detection. It holds the correctly rounded conversion to f64, the division and `POW`
rules (clause 5), exact comparison (clause 6), the exact-sum accumulator (clause 8),
rounding to places, and clause 3's number language. It also holds the fraction arm and
its grouped sums (question 2 is decided (d)), and the widening rule if question 13
chooses unbounded intermediates. Nothing in improvise depends on it yet, so it lands
its grouped sums (question 2 is decided (d)), and the widening rule with its 8,192-bit
ceiling (question 13 is decided (b)). Nothing in improvise depends on it yet, so it lands
with its own tests and changes no behaviour. It may be preceded by a `numtower`
holding only `ExactSum`, for ADR 009 (clause 14).
@@ -1402,8 +1548,18 @@ In order. Each step lands on its own, with the suite green.
- division's direct fit test: `1/2^40` and `1/5^30` are fractions, `1e-28 / 0.5` is
the exact decimal `2e-28`, and multiplying back is not used (clause 5);
- the number language's gate and parse agree on every generated lexeme. The seed
cases are `1.5e3` (exact `1500`), `1_000` (text), `-0.0` (exact `0.0`), and `3/4`
(text when inferred, a fraction when forced).
cases are:
- `1.5e3` (exact `1500`), `1_000` (text), `-0.0` (exact `0.0`), and `3/4` (text
when inferred, a fraction when forced);
- `1e999` and `-1e999` (text: a finite lexeme never reads as infinity), `nan`,
`inf` and `Infinity` (text in the gate), and `1e-999` (an inexact zero);
- `1e-30` (the exact fraction `1/10³⁰`), `0.12345678901234567890123456789` (an
exact fraction, not a rounded decimal), an integer between `Decimal`'s maximum
and 2¹²⁷ (an exact `n/1`), and `1e-40` (inexact: it fits neither arm);
- a typed value and the same value computed are equally exact: `1e-30` parsed
equals `1e-15 * 1e-15`, arm and value both;
- the grouped read combines over the lcm and reduces once, and agrees with a
`BigRational` sum of the groups.
The differential tests run against a `BigRational` oracle. The benchmark goes in
`numtower/examples/`. Add the crate to the `wasm` CI job. Measure one whole recompute
@@ -1423,10 +1579,17 @@ In order. Each step lands on its own, with the suite green.
`[0.30000000000000004, 1e12, −1e12]` through the evaluator. `Decimal`'s own `+` must
fail it.
5. **Entry points:** `typed()`, the import pipeline's measure read, formula literals
(clauses 3 and 12). The import tests assert:
(clauses 3 and 12). The largest part is the JSON reader: a hand-written deserializer
that keeps each number's source text through `raw_value`, because a
`serde_json::Value` has already made it an f64 (clause 12). The import tests assert:
- a CSV `12.50` and a JSON `12.50` both keep their scale, because both are parsed
from their lexeme;
- `1.5e3` imports as the exact `1500`, and `1_000` imports as text;
- `nan`, `inf`, `1e999` and `-1e999` import as text, so #321's
`a_nonfinite_numeric_token_stays_text_rather_than_becoming_zero` stays green
unchanged;
- `typed()` reads `inf`, `-inf` and `nan` as clause 7's tokens, and `Infinity`,
`NaN` and `1e999` as text (the stated behaviour change);
- `3/4` is text in an inferred column and the fraction `3/4` in a column forced to
a measure.
6. **Display and edit text** (clause 9). The `1.005` case is the regression test, and
@@ -1439,9 +1602,10 @@ In order. Each step lands on its own, with the suite green.
## Open questions
The user answered questions 1–9, 11 and 12 inline on `e4acf643`. Each is marked
**decided** below, and quoted in full under "What the user decided". Questions 10, 13, 14
and 15 are open. Nothing is renumbered.
The user answered questions 1–9, 11 and 12 inline on `e4acf643`, and 10, 13, 14 and
15 inline on `33833f28`. Each is marked **decided** below, and quoted in full under
"What the user decided". Question 16 is new in revision 6, and it is the only open one.
Nothing is renumbered.
1. **The tower's shape.** **Decided (the user): three levels**: exact decimal, exact
bounded fraction, inexact f64 (clause 1). The original recommendation, two levels,
@@ -1483,7 +1647,10 @@ and 15 are open. Nothing is renumbered.
9. **Version skew on the wire.** **Decided (the user): accept it**, and the handshake
version is filed as **improvise-szi8** (clause 11).
10. **The exact-sum accumulator's width** (revised in revision 5, after the user asked:
10. **The exact-sum accumulator's width.** **Decided (the user): (a)**, with "320bits
is fine" for the decimal partition. The value of K is split out as question 16,
because the read's cost was measured after the answer. The question as it was put
(revised in revision 5, after the user asked:
"Don't we need a much bigger accumulator (2000ish bits) to handle exact accumulation
generically?"). Clause 8's "How wide, generically" answers with numbers:
- **decimals need 320 bits**;
@@ -1504,7 +1671,8 @@ and 15 are open. Nothing is renumbered.
- 2,048 bits pays 256 bytes per summed group for a combination that rarely fits the
stored bound anyway.
- The bigint read is paid only when a sum holds 2 to K distinct fraction
denominators: about 16–34 µs at K = 32, an estimate.
denominators: about 16–34 µs at K = 32, an estimate. (Revision 6 measured it:
0.67–1.9 ms at K = 32 in the worst case. See question 16.)
- (c) breaks order-independence (revision 2).
11. **The `MIN`/`MAX` tie-break.** **Decided (the user): prefer the exact value**
@@ -1518,7 +1686,8 @@ and 15 are open. Nothing is renumbered.
(`resolve.rs:1421`); the TUI must be held to the same rule (the improvise-umi4
lesson).
13. **Where the fraction bound applies** (open; added in revision 4). Is the `i128`
13. **Where the fraction bound applies.** **Decided (the user): (b)**. Added in
revision 4, and put as follows. Is the `i128`
bound checked (a) after every operation, or (b) only on the **stored** result, with
every intermediate inside one formula evaluation unbounded? Under (b) an
intermediate uses the `i128` fast path and widens to a bigint rational only when a
@@ -1534,8 +1703,13 @@ and 15 are open. Nothing is renumbered.
**The rule has one exception, and it is stated rather than hedged** (revision 5,
the review of `87ae21ca`):
- **The ceiling.** Widening carries a size ceiling, for example 4,096 bits, so that a
pathological expression cannot grow a bigint without limit. Past it, the
- **The ceiling.** Widening carries a size ceiling so that a pathological
expression cannot grow a bigint without limit. It must be at least
K·127 + 256 bits, so that the grouped read (below) never reaches it: 4,320 bits
at K = 32. Revision 5's example of 4,096 bits was below that, and a worst-case
K = 32 read would have hit it (the review of `33833f28`). Proposed: **8,192
bits**, which covers K = 32 with room, and costs nothing until a value is that
large. Past it, the
intermediate demotes, and by contagion the stored result is inexact **even when
the exact result would fit**. That is spelling-dependent, so distributivity fails
past the ceiling. It is the one exception to "exact iff it fits", and no ledger
@@ -1553,15 +1727,18 @@ and 15 are open. Nothing is renumbered.
**Cost.** This is an estimate, not measured on this path.
- The common case is unchanged: no intermediate overflows `i128`.
- Only a cell whose expression overflows `i128` part-way through pays for bigint
rational steps. `BigRational` addition measured 490–1,060 ns per add, so about a
microsecond per widened step.
rational steps. A widened step works on operands near 256 bits, not on the SUM
benchmark's small typed values: combining two such fractions measured 7–16 µs
(option (d), "What that costs"). So a widened step costs microseconds, not the
490–1,060 ns revision 5 cited.
- Such a cell usually demotes at the store anyway.
14. **The numeric crate's name** (open; added in revision 4). *Recommendation:*
`numtower`. It is problem-agnostic like `gp-tree` and `zset`, so it should not carry
14. **The numeric crate's name.** **Decided (the user): `numtower`** ("this is
fine"). Added in revision 4, with the recommendation `numtower`. It is problem-agnostic like `gp-tree` and `zset`, so it should not carry
improvise's name.
15. **Where ADR 009's `ExactSum` lives** (open; added in revision 4).
15. **Where ADR 009's `ExactSum` lives.** **Decided (the user): (a)**. Added in
revision 4, and put as follows.
- (a) In `numtower`, beside the exact decimal accumulator, with ADR 009's seam crate
adapting it to `zset`'s group trait through a newtype.
- (b) In `zset`, as ADR 009 proposes, with `numtower` depending on `zset` to
@@ -1572,6 +1749,27 @@ and 15 are open. Nothing is renumbered.
`ExactSum` alone, ahead of this ADR, so ADR 009 does not wait for it (clause 14).
This question is shared with ADR 009 (PR #309).
16. **The value of K** (open; added in revision 6). K is the most non-decimal groups a
`SUM` reads exactly (question 10). Above it the read is the inexact f64
accumulator. Revision 5 proposed K = 32 on an estimate of 16–34 µs per read, and
the measured worst case is **0.67–1.9 ms** (option (d), "What that costs"). Under
ADR 009 a maintained `SUM` reads after every edit that touches it, so that is a
per-edit cost, paid by each such cell.
- **(a)** K = 32, as decided with question 10: worst case 0.67–1.9 ms per read.
- **(b)** K = 8: worst case 52–127 µs per read. A `SUM` over more than 8 distinct
denominators is inexact.
- **(c)** K = 16: worst case 174–448 µs per read.
*Recommendation:* **(b), K = 8**, with the read combining over the lcm and reducing
once (the lower figure in each range).
- The ceiling (question 13) stays at 8,192 bits under any of the three.
- The common sums keep exactness under K = 8: `SUM(v / 3)` has one denominator, and
an average or a percentage has a handful.
- The sum of margins has one denominator per record, so it passes any of these Ks
long before it is large, and reads inexact under each of them.
- K is a constant inside `numtower`, not a format or wire fact, so it can be tuned
later against the evaluator benchmark (migration step 1) without a new ADR.
## Consequences
- Typed values and their `+ - *` are exact. `0.1 + 0.2 = 0.3`, `1234.56 - 1234.55 =
@@ -1616,6 +1814,13 @@ and 15 are open. Nothing is renumbered.
language are shared.
- `n/d` is text wherever a type is inferred and a fraction wherever it is forced
(decided, question 12).
- `nan`, `inf` and a finite lexeme that overflows f64 (`1e999`) are text at import, as
improvise-jc95 decided. `typed()` narrows to the three canonical tokens, so a typed
`Infinity`, `NaN` or `1e999` becomes text where today it is a number (clause 3).
- A value typed past `Decimal`'s range is an exact fraction when it fits `i128`
(`1e-30`, a 30-digit integer), as the same value computed already is (clause 3).
- A `SUM` reads exactly over at most K distinct fraction denominators, at a worst-case
cost that grows about as K² (question 16), and past K it is inexact.
- Formulas gain `EQUAL(a, b, epsilon)`, and `EQUAL` becomes a keyword the writer quotes.
- **Costs:** `Num` is 24 bytes where an `f64` is 8, but **`CellValue` stays 32 bytes**
under both (a) and (d), because its `String` arm already sets the size (the review
@@ -1698,3 +1903,12 @@ The `to_f64` check compared `Ratio<i128>::to_f64` with `BigRational::to_f64` on
fractions (seed 5). That comparison is **circular**, because the two share one routine,
so the ADR now relies on the review's independent interval check instead (option (d),
"Operations"). Every figure is the range over two runs on the machine above.
**The grouped read** (revision 6), in the same crate. For K = 2, 8, 16 and 32, 200 reads
each. A read combines K groups whose numerators are random 250-bit integers and whose
denominators are random odd 126-bit integers (xorshift64, one fixed seed), sorted
ascending by denominator. Two methods are timed on the same groups: `BigRational`
addition group by group, which reduces by a gcd after every add, and an lcm
accumulation with one gcd reduction at the end. The two results are asserted equal.
One run on the machine above; a second run agreed within 10%. Where the text gives a
range such as 0.67–1.9 ms, it runs from the lcm method to the per-add method.