docs(adr): ADR 005 exact decimal arithmetic (proposed) (improvise-l2b8)

A two-level numeric tower: exact decimals (rust_decimal) by default, inexact f64 by Scheme's contagion rule. Measured against f64, bigdecimal and num-rational, and against ADR 009's exact f64 accumulator. Registered on the doc site.

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-24 22:10:43 -07:00
committed by Edward Langley
parent b639266b0d
commit 1fe3a85d1e
2 changed files with 657 additions and 0 deletions
+656
View File
@@ -0,0 +1,656 @@
# ADR 005 — Exact decimal arithmetic: a two-level numeric tower for formulas
**Status:** proposed. Only the user accepts an ADR. The numbered questions at the end
are theirs to answer on the PR. ·
**Ticket:** improvise-l2b8 (parent improvise-felo) ·
**Direction from:** the user, 2026-09-22 (below) ·
**Relates to:** ADR 009 (incremental evaluation, improvise-felo.4, PR #309), which now
carries an exact f64 accumulator; ADR 007 (stable identities), whose new file-version
literal this ADR may share ·
**Governed by:** `context/design-principles.md` §3 and §5
> "Formulas should default to decimal math (ideally following Scheme's numeric tower
> rules for promotion)."
>
> — the user, 2026-09-22, recorded on improvise-l2b8
**Numbering.** This is the ADR that improvise-l2b8 claimed as 005 before ADR 007 was
written. improvise-8tk5 and improvise-80rm say "ADR 005" in their titles only because
ADR 007 was first filed under this number. They belong to ADR 007, not to this document.
**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 `a2836f44`.
## The decision this ADR writes down
1. **Two levels, not four.** A number is **exact** (a decimal) or **inexact** (an f64).
Scheme's exact integers and exact decimals are one level here, and there are no exact
rationals. Rationals 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.
3. **Exactness is lost only in named places.** `+`, `-` and `*` keep it. Division keeps
it when the quotient is a finite decimal. `POW` keeps it for an integer exponent. A
non-integer exponent, a non-terminating quotient, an overflow, and any inexact operand
make the result inexact. That is Scheme's contagion rule.
4. **The epsilon goes.** `=` and `!=` compare exactly, and the division-by-zero guard
tests for exactly zero. The two still share one predicate, which is the property
gotcha 17 exists to keep.
5. **The stored type is `CellValue::Number(Num)`**, where `Num` is `Exact(Decimal)` or
`Inexact(f64)`. The crate is `rust_decimal`.
6. **This ADR does not cover summation order.** ADR 009's exact f64 accumulator already
makes `SUM` over f64 values order-free. What this ADR adds is everything that
accumulator cannot reach: per-cell arithmetic, typed money-like values, exact
comparison, and showing a typed value back as it was typed. Clause 8 states the
boundary, and the measurement behind it.
## Context
### How a number flows today
Every number is an `f64` from the moment it is parsed until the moment it is drawn.
| where | what | `file:line` |
|---|---|---|
| the stored type | `CellValue::Number(f64)`, serialized through `wire_f64` | `crates/improvise-core/src/model/cell.rs:116-122` |
| non-finite tokens | `nonfinite_token` / `parse_nonfinite` (`inf`, `-inf`, `nan`) | `cell.rs:63-82` |
| the wire adaptor | JSON number when finite, token string when not (improvise-55v2) | `cell.rs:84-114` |
| editor seed | `CellValue::edit_text`: Rust's shortest round-trip spelling (improvise-umi4) | `cell.rs:137-145` |
| screen spelling | `Display`: integer, else `{:.4}` | `cell.rs:148-161` |
| cell aggregation | `apply_agg` over `&[f64]`; `aggregate_cell_values` filters `as_f64` | `crates/improvise-core/src/model/types.rs:20-44` |
| formula aggregation | `eval_agg`, `values.iter().sum()` in store order | `types.rs:57-87` |
| the epsilon | `FLOAT_EQ_EPSILON = 1e-10` (improvise-0bf) | `types.rs:16` |
| record-level eval | `eval_expr`: `lv + rv`, `lv / rv` behind the epsilon, `lv.powf(rv)` | `types.rs:229-282` |
| comparison | `eval_bool`: `(lv - rv).abs() < FLOAT_EQ_EPSILON` | `types.rs:314-315` |
| the cached twin | `eval_expr_cached`, the same arithmetic again | `types.rs:1090-1104`, `1156-1157` |
| formula literal | `Expr::Number(f64)`, from `number = digits ("." digits)?` | `crates/improvise-formula/src/ast.rs:35`, `formula.pest:105-108`, `parser.rs:192` |
| typed input | `typed()`: `text.parse::<f64>()`, else text (improvise-j7v6) | `crates/improvise-protocol/src/resolve.rs:1438-1446` |
| display | `round_half_away` scales by `10^d` in f64, then `format!` | `crates/improvise-core/src/format.rs:125-160` |
| `.improv` write | `format_number`: integer, else shortest `Display`, else `{:?}` | `crates/improvise-io/src/persistence/format.rs:38-54` |
| `.improv` grammar | `number = "-"? digits ("." digits)? (exponent)?` | `crates/improvise-io/src/persistence/improv.pest:110-112` |
| `.improv` read | `parse_number_cell`: `s.parse()` into f64 | `crates/improvise-io/src/persistence/parse.rs:141-146` |
| CSV field | `i64`, else `f64`, into a `serde_json::Number` | `crates/improvise-io/src/import/csv_parser.rs:167-186` |
| import measures | read back with `as_f64` | `crates/improvise-io/src/import/wizard.rs:425` |
| JSON import | `serde_json::Value`, numbers already f64 (`float_roundtrip`) | `src/main.rs:365`, `crates/improvise-command/src/ui/effect.rs:1151`, `1280` |
| import collisions | the "sum" policy adds in f64, in record order (improvise-cgqt) | `crates/improvise-io/src/import/wizard.rs:307`, `330` |
| the wire | `ModelCommand::SetCell { value: CellValue }`, `CellDisplay::value`, `SetYanked` | `crates/improvise-protocol/src/reduce.rs:33`, `88-92`; `render.rs:88-104` |
| gp seam | `cell_text`: a number crosses **as text**, `n.to_string()` | `crates/improvise-gp/src/dataset.rs:50-56` |
`CellValue::Number` appears 660 times across 43 files (`grep -rn`, tests included).
That is the blast radius of any change to the stored type.
### What is already wrong, measured
The measurements in this ADR were taken on an **Apple M4 Max (64 GB), rustc 1.98.1,
release builds**, by a scratch program described in the appendix. They were not
committed, for the reason the appendix gives.
**Per-cell arithmetic is wrong in the last place, and the user sees it.**
- `0.1 + 0.2` is `0.30000000000000004`, and ten `0.1`s sum to `0.9999999999999999`.
- `1234.56 - 1234.55` is `0.009999999999990905`, and `100.10 - 100` is
`0.09999999999999432`.
A display format hides most of this. It does not hide it from `=`, from an editor, or
from the next formula.
**Display rounding is wrong for a typed value.** `format.rs:127-136` rounds by scaling
the f64. A typed `1.005` is stored as `1.00499999999999989…`, so at two places the grid
shows **`1.00`**. A person who typed `1.005` and asked for two places expects `1.01`.
Half-away-from-zero on the decimal gives `1.01`. `2.675`, `0.125` and `1.115` happen to
agree on both paths. So the bug is real but intermittent, which is the worst kind for a
spreadsheet.
**`SUM` depends on order, and on how far it is from what was typed.** SUM over
18,000 typed two-place decimals (0.01 to 999,999.99, seed 1):
- the forward and the reverse f64 fold differ by **5.0e-5**;
- the forward fold is **5.3e-5** from the exact decimal total (1.4e-4 at 90,000 cells);
- the decimal sum (`rust_decimal`) is identical in both orders.
ADR 009's own measurement found the same thing with five-place values: the eager sum is
up to 5.8e-10 from the exact decimal sum, above `FLOAT_EQ_EPSILON` at 7 of 8
checkpoints (improvise-l2b8's comment). Gotcha 18 recorded the order dependence as a real
bug (improvise-shyt), fixed by pinning iteration order rather than by removing it.
**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.
### What ADR 009's exact accumulator fixes, and what it does not
ADR 009 now proposes a **superaccumulator**: a fixed-point integer wide enough for every
finite f64. It sums f64 values exactly and rounds once, when the sum is read. It makes
`SUM` independent of order, and its retraction exact, **without this ADR**. That changes
what this ADR is for, so it was measured rather than assumed.
The question: after a perfect f64 accumulator, does the sum of typed decimals equal the
typed total? The stand-in for the accumulator was an exact rational sum of the stored
f64 values, rounded once (appendix).
| typed two-place values summed | trials | accumulator ≠ typed total | naive f64 fold ≠ typed total |
|---|---|---|---|
| 2 | 10,000 | **2,120** (21%) | 2,120 (21%) |
| 3 | 10,000 | **1,621** (16%) | 2,683 (27%) |
| 10 | 10,000 | **752** (7.5%) | 4,039 (40%) |
| 100 | 10,000 | **250** (2.5%) | 7,944 (79%) |
| 18,000 | 8 seeds | **0** | (all 8: see above) |
| 90,000 | 8 seeds | **0** | (all 8) |
**The accumulator solves the large sum and not the small one.** Over thousands of cells
the input rounding errors cancel far below the total's ulp, so the one rounding lands on
the typed total. Over two or three cells they do not. For two summands a
superaccumulator is exactly `+`, because one f64 addition is already correctly rounded.
So `0.1 + 0.2` is still `0.30000000000000004` under the accumulator. A subtotal of three
line items is the sum a person checks by eye, and it misses in 16% of cases.
So the division of labour is:
| | ADR 009 accumulator | this ADR |
|---|---|---|
| `SUM`/`AVG` independent of order, exact retraction | **yes** | yes for exact values; it uses the accumulator for inexact ones (clause 8) |
| a large `SUM` equal to its typed total | yes, measured above | yes |
| a small `SUM` equal to its typed total | **no**: 16% of 3-term sums miss | yes |
| per-cell arithmetic (`Revenue - Cost`, `* 1.1`, `0.1 + 0.2`) | **no** | yes |
| exact `=` against a typed value, with no epsilon | only where the sum happens to round right | yes |
| a typed value shown back as typed (`12.50`, `1.005` at two places) | no | yes |
| cost | 568 bytes per group, about 3.2 ns per update | 16 bytes per value; about 5–8 ns per add, 60–110 ns per division |
**This ADR does not re-specify the accumulator.** Where an inexact value must be summed,
it is summed by ADR 009's accumulator, and this ADR only says so.
### The crates
Three crates were evaluated. All three build for `wasm32-unknown-unknown`.
| | `rust_decimal` 1.43.0 | `bigdecimal` 0.4.10 | `num-rational` 0.4.2 (`BigRational`) |
|---|---|---|---|
| value | 96-bit integer and a scale up to 28 | `BigInt` and an `i64` scale | `BigInt` / `BigInt` |
| inline size | **16 bytes, `Copy`**, no heap | 40 bytes and a heap buffer | 64 bytes and two heap buffers |
| `+ - *` | exact within 28 significant digits; beyond that it **rounds silently** (1000 + 1/3 comes back at scale 25) | always exact | always exact |
| `/` | rounds to 28 significant digits (`1/3*3 ≠ 1`) | rounds to 100 digits by default | exact |
| normal-edge deps | `arrayvec`, `num-traits`, `serde` | `libm`, `num-bigint`, `num-integer`, `num-traits` | `num-bigint`, `num-integer`, `num-traits` |
| wasm size, marginal | **+35.9 KB, +15.4 KB gzipped** | +77.9 KB, +30.7 KB gzipped | +59.6 KB, +20.8 KB gzipped |
| SUM, ns per element (18k / 90k) | **5.7–8.1 / 4.5–8.5** | 14.5–22.9 / 10.8–20.7 | 609–1,057 / 491–989 |
| `(Rev − Cost) / Rev`, summed, ns per record | **65–111 / 58–112** | 2,429–5,353 | **5.3–6.8 ms**, abandoned (below) |
The f64 references are 0.54–1.18 ns per element for SUM and 0.56–1.24 ns per record for
the formula. Each range is two full runs. `1.5 == 1.50` and the two hash equally in
`rust_decimal`. The wasm figures are `wasm-opt -Oz` over a module that already carries
f64 parse and print, as the real bundle does, so they are the marginal cost. They are
from a five-operation probe, not the real bundle, and the real cost should be re-read
when the dependency lands (question 8).
**Exact rationals fail on the sum of ratios, and it is not close.** The ticket asked
what bounds denominator growth. Summing `(Revenue − Cost) / Revenue` over random typed
records, the denominator of the running sum grew as follows:
| ratios summed | 256 | 512 | 1,024 | 2,048 | stopped (20 s budget) |
|---|---|---|---|---|---|
| denominator bits | 4,699 | 9,103 | 17,015 | 31,697 | 44–55 k, after 2,936–3,741 records |
Nothing bounds it. Each new revenue brings new prime factors into the least common
denominator, so it grows linearly in bits and each addition costs more than the last.
At the stopping point one addition cost milliseconds. **An 18,000-record average margin
does not finish.** That is the most ordinary question a pivot table is asked, so exact
rationals as the *default* are rejected on measurement, not taste.
## Decision
### 1. Two levels: exact decimal and inexact binary
Scheme's tower is integer ⊂ rational ⊂ real ⊂ complex, and each level has an exact and an
inexact form. Improvise keeps **two** levels:
- **Exact:** a decimal, `rust_decimal::Decimal`. Integers are decimals with scale 0.
- **Inexact:** an `f64`, Scheme's flonum.
No exact rationals, for the reason measured above. No complex numbers, because nothing
produces one. The exact level is **bounded**: 28 significant digits and a magnitude
below about 7.9 × 10²⁸. Scheme's exact numbers are unbounded, so this is the one place
the tower deviates from Scheme by design. Clause 4 says what happens at the bound.
### 2. The stored type: `CellValue::Number(Num)`
```rust
pub enum Num {
Exact(Decimal),
Inexact(f64),
}
pub enum CellValue {
Number(Num),
Text(String),
Error(String),
}
```
`Num` lives in `improvise-core` beside `CellValue`. The arithmetic and the contagion
rules are methods on `Num`, in one module. Both evaluators (`eval_expr` and
`eval_expr_cached`) call the same methods, so the rules cannot drift between them the
way the epsilon once did (improvise-0bf).
A new arm beside `Number(f64)` was rejected. It would double every numeric match at 660
sites, and "is this cell a number" could then be asked two ways. `CellValue::as_f64`
stays, as the explicitly lossy accessor for the seams that want a binary float (clause
12).
`Expr::Number(f64)` becomes `Expr::Number(Decimal)`. A formula literal is always exact,
because it is decimal text (clause 3).
### 3. What is exact on entry
**Everything a person or a file spells in decimal is exact, when it fits.** That covers
typed input (`typed()`), `.improv` numbers, CSV fields, formula literals and pasted
values.
**This deviates from Scheme on purpose.** Scheme reads `1.5` as *inexact* and needs
`#e1.5` for exact. Improvise reads `1.5` as exact, because a pivot table's inputs are
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 JSON number** arrives as an f64, because `serde_json` parses it before improvise sees
it. It is read as the exact decimal of its **shortest round-trip spelling**. The shortest
spelling of `0.1_f64` is `0.1`, not `0.1000000000000000055…`. With `float_roundtrip`
enabled workspace-wide, that recovers the *value* the file spelled whenever the literal
had at most 15 significant digits, though not its trailing zeros (clause 12). A longer
literal may come back different in its last digits. Enabling `serde_json`'s
`arbitrary_precision` instead is question 6. The import pipeline carries CSV fields as
`serde_json` numbers too, so the same rule reads them.
### 4. How exactness propagates
| operation | exact ∘ exact | any inexact operand |
|---|---|---|
| `+`, `-`, `*`, unary `-` | exact, **if** the result is exactly representable; else inexact | inexact |
| `/` | clause 5 | inexact |
| `POW` | clause 5 | inexact |
| `IS`, `IF` | not arithmetic: `IS` yields exact 0 or 1 | — |
**Contagion is Scheme's rule.** An inexact operand makes the result inexact, and an
exact result never comes from an inexact input.
**"If exactly representable" needs care, because `rust_decimal` rounds silently.** An add
whose result needs more than 28 significant digits comes back rounded, with no signal.
The measurement caught it: `1000 + 0.3333333333333333333333333333` returns scale 25.
Overflow of the magnitude is reported (`checked_add` returns `None`), but precision loss
is not. So `Num`'s arithmetic must **detect** either one and return `Inexact`, computed in
f64 from the operands. It must never return a rounded `Exact`. A rounded value that
calls itself exact is the one outcome this design exists to prevent. Under clause 5 no
computed exact value carries a long fraction, so this arises only from a typed value with
more than about 20 decimal places, or from magnitudes near 10²⁸.
### 5. Division, `AVG` and `POW`
**Division.** Exact ÷ exact is **exact when the quotient is a finite decimal** that fits
(`1 / 4 = 0.25`, `Profit / Revenue` when it happens to terminate). Otherwise it is
**inexact**: the f64 nearest the true quotient. So `1 / 3` is inexact, and `1 / 3 * 3`
is the f64 `1.0` or a neighbour, exactly as today. The implementation must decide
termination without a check that itself rounds. For example, multiplying the quotient
back is unsound, because that multiply can round too.
Scheme would answer `1/3` exactly. That is the choice the rational measurement rules
out, and question 2 puts it to the user with the alternatives.
**`AVG`** is `SUM / COUNT` under the division rule. The average of `10.00`, `20.00` and
`40.00` is 70 / 3, which does not terminate, so it is inexact. The average of `10.00`,
`20.00`, `30.00` and `40.00` is exactly `25`. **Many averages will be inexact.** That is
honest: they are not finite decimals. They display exactly as today.
**`POW`.** `x ^ n` with exact `x` and an exact integer `n ≥ 0` is exact (repeated
multiplication, with clause 4's overflow rule). A negative integer `n` is `1 / x^|n|`,
under the division rule. Any non-integer exponent, or any inexact operand, is `powf`,
inexact. This is the ticket's "obvious candidate" for an inexact operation, and it is
the only one besides division: improvise has no transcendental functions.
**Division by zero** is `Error("div/0")` for every combination of exactness, as today.
The test is **exactly zero**, with no epsilon (clause 6).
### 6. Comparison, and the end of `FLOAT_EQ_EPSILON`
`=`, `!=`, `<`, `>`, `<=` and `>=` compare **exactly**. `FLOAT_EQ_EPSILON` and its six
uses are deleted.
- **Exact with exact:** decimal comparison. `1.5 = 1.50` is true, because scale is
presentation (clause 9), not value.
- **Inexact with anything:** the exact operand is converted to f64, and the two f64s are
compared exactly. That is the arithmetic contagion rule applied to comparison. It can
be non-transitive at the last bit, as mixed comparison is in most Schemes. Question 3
asks whether inexact `=` should keep a tolerance instead.
**Gotcha 17's invariant survives in a new form.** The epsilon was shared by `=` and the
division guard so that `IF(x = 0, a, y / x)` is coherent: the condition and the guard
must agree on what zero is (improvise-0bf). Under this ADR they agree because both test
exactly zero. The property is "one predicate for zero", not "one epsilon". The test that
pins it keeps its name and its `x = 1e-11` case, which is now non-zero on both sides.
### 7. NaN and the infinities live only at the inexact level
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
overflow in `*` or `POW`. Exact overflow becomes inexact (clause 4), and it may overflow
further to `inf` only at the inexact level.
### 8. `SUM` and the other aggregates
`apply_agg` and `eval_agg` partition their values by exactness:
- **Exact values are summed exactly**, as a decimal. The sum is independent of order.
That removes gotcha 18's order-dependent `SUM` as a class for exact data, which is
nearly all data.
- **Inexact values are summed by ADR 009's exact f64 accumulator** when it lands, and by
today's fold until then.
- **The result is exact if every value was exact.** Otherwise it is inexact: the f64 of
the exact part plus the accumulator's part, rounded once. **It must not be a fold with
contagion part-way through.** Such a fold switches to f64 at the first inexact value
in store order, so the answer would depend on where that value sits.
`COUNT` is exact (an integer). `MIN` and `MAX` compare by clause 6 and return the chosen
value with its own exactness. Text unanimity and `mixed` are unchanged (gotcha 7).
**For ADR 009:** its accumulator stays the mechanism for inexact values. The exact part is
an abelian group without it: decimal addition is exact under clause 4's detect-or-demote
rule. So a maintained `Sum` over exact values is bit-equal to eager on every path, with
no accumulator at all.
### 9. Display, edit text and typed scale
**Display** of an exact value rounds the decimal, half away from zero, with
`round_dp_with_strategy(d, MidpointAwayFromZero)`, then groups. So a typed `1.005` shows
as `1.01` at two places. An inexact value displays exactly as today. `format_value` and
`NumberFormat::number` take `&Num`. Gotcha 8 is unchanged: rounding is view-only and
never fed back.
**Edit text** of an exact value is its decimal **with its scale**. A typed `12.50` opens
as `12.50`, not `12.5`, so a typed value is shown back as it was typed, up to canonical
spelling: `+1`, `1e3` and `012` become `1`, `1000` and `12`. An inexact value keeps
today's shortest round-trip spelling (improvise-umi4). A non-finite value keeps its token.
**Scale is presentation, not value.** `12.5` and `12.50` are equal under `=` and under
`CellValue`'s `PartialEq`. So rewriting one as the other does not bump the record's
version in ADR 001's record tier. `rust_decimal` hashes them equally, so that property
holds for free.
### 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.
**Writing.** An exact number is written as its decimal, with its scale. An inexact
*finite* number needs a spelling that reads back as inexact, or a save and reload would
silently make it exact. The format must say which one it holds: the ticket's `1/3`
against `0.333…` point. This ADR proposes Scheme's own marker, **`#i`**:
`#i0.3333333333333333`. The non-finite tokens are already inexact and need no marker.
The spelling is question 4.
**Inexact stored values are rare.** A `## Data` line holds raw cells: typed, imported or
pasted. None of them is inexact unless it was pasted from an inexact formula result, is
non-finite, or is out of `Decimal`'s range. The marker is a correctness requirement,
not a common sight.
**Migration: an existing number becomes the exact decimal it spells.** Every existing
`.improv` loads unchanged in the sense that matters: every value reads as the number its
text names. That is a behaviour change at the 17th significant digit, and it goes
toward what the text says. An old file containing `0.30000000000000004` (possible where a
computed f64 was pasted and saved) loads as exactly that decimal, not as the binary
fraction the old program held. No document changes in any digit a person can see.
**The version literal.** `#i` is a grammar change. An old reader would take
`#i0.5` as text (`bare_value`), so a file that uses it must carry a new version literal.
ADR 007 clause 10 already introduces one ("the regime is chosen only by the first line").
If both ADRs land close together, they share one bump. A file with no inexact finite
number can be written under either literal, because it reads the same under both. That
choice is question 5.
**The round-trip tests** must be re-read, as the ticket says. `format_number`'s three
spellings collapse to two (decimal, `#i` shortest), and the grammar-walking generator
gains the `#i` arm. The property `parse(format(parse(x)))` stable is unchanged, and it
is now joined by **exactness preserved**, which the old format could not state.
### 11. The wire
`CellValue` crosses the wire in `ModelCommand::SetCell`, `SetYanked` and
`CellDisplay::value`. `CellDisplay::text` and `edit_text` are strings and are unaffected.
So a thin client *is* affected, and the ticket was right to ask for confirmation rather
than assume.
`Num` serializes through the existing untagged adaptor, extended by one arm:
| value | JSON |
|---|---|
| exact | a **string** of its decimal: `"12.50"` |
| inexact, finite | a **number**, as today: `0.3333333333333333` |
| inexact, non-finite | the token string, as today: `"inf"` |
A decimal string never collides with a token, because `inf`, `-inf` and `nan` are not
decimals. A finite inexact cell's bytes are unchanged.
**Version skew fails loudly in one direction and silently in the other.** An old peer
reading `"12.50"` tries it as a token and refuses the frame, which is loud. A new peer
reading an old peer's JSON number takes it as inexact, which is silent. That only
matters when a deployed browser page talks to a `ws-server` of a different version. The
protocol has no version handshake today. This ADR does not add one, and names the hole.
`typed()` stays the one home of text → value (improvise-j7v6). It parses exact first,
so a browser composes exact values with no second rule.
### 12. The seams
**gp-tree is out of scope and needs no change.** A number crosses into a fit **as text**
(`dataset.rs:50-56`), which `preprocess` parses into f32. An exact `12.50` crosses as
`"12.50"`, and it parses to the same f32. The emitted thresholds are decimal text
(`improvise-gp/src/lib.rs:285-298`), so they become exact literals, and exact comparison
against exact data routes the same way as the f64 comparison does today, except within
f32 rounding of a threshold. That boundary difference already exists between f32 and
f64. `scoring_known_answers.rs`'s bit patterns (gotcha 37c) are untouched, because
gp-tree never sees a `Num`.
**Import.** The import pipeline carries every record as a `serde_json::Value`, CSV
included: `parse_csv_field` (`csv_parser.rs:167-186`) turns a field into an `i64` or an
`f64` `Number`, and the pipeline reads measures back with `as_f64` (`wizard.rs:425`). A
`Value` has no decimal. So the one change is at that read: a measure becomes a `Num` by
clause 3's shortest-spelling rule, for CSV and JSON alike. A CSV field of at most 15
significant digits is recovered exactly. Carrying the field's text through the pipeline
instead would restructure the analyzer for digits no CSV of money carries. One thing is
lost and stated: the **scale**. A CSV `12.50` becomes the f64 `12.5`, whose shortest
spelling is `12.5`, so an imported value's editor opens on `12.5`. Its value is exact.
Only typed and `.improv` values keep their trailing zeros. The collision "sum" policy
(`wizard.rs:307`) then sums exactly, by clause 8.
**Format.** `format_f64` stays for inexact values and gains a decimal twin. `FormatPlan`
is unaffected: it chooses a format, not a value.
### 13. The crate: `rust_decimal`
Chosen on the table above. Four 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
SUM, and about 40× faster on the formula workload.
- **Bundle.** It is the smallest marginal addition to the wasm bundle.
- **Bound.** It is bounded, and the bound is the right size for this domain. 28
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.
The cost is clause 4's detection duty, because it rounds silently at the bound.
`num-rational` is rejected on measurement (above). `bigdecimal` is the fallback if the
bound ever matters. Other crates (`fastnum`, `dashu`) were not evaluated.
## Alternatives rejected
- **Exact rationals as the default (Scheme proper).** Measured above: an 18,000-record
average of ratios does not finish. It would be the right answer for a symbolic
calculator and the wrong one for a pivot table.
- **A rounded decimal that still calls itself exact** (SQL `NUMERIC`: `1/3` becomes
`0.3333…` at 28 places, marked exact). It keeps "decimal math" true everywhere, but
then "exact" means "exact except after a division". Equality, ADR 009's group law and
every future reasoning about exactness would carry that exception silently. It is
question 2's option (b), for the user to overrule.
- **Keep f64 and adopt only ADR 009's accumulator.** It fixes large sums. It leaves
per-cell arithmetic, small sums (16% of 3-term sums), display of typed values and the
epsilon exactly as they are (Context).
- **Three levels (exact decimal, inexact decimal, f64).** An "inexact decimal" arm for
rounded quotients would add a third arm to every numeric match. It would buy only a
28-digit rather than a 17-digit approximation of a number that is not a decimal
anyway.
- **A new `CellValue` arm beside `Number(f64)`.** Rejected in clause 2.
## Migration
In order. Each step lands on its own, with the suite green.
1. **`Num` and its arithmetic**, in `improvise-core`, with the contagion table (clause 4),
the division and `POW` rules (clause 5) and exact comparison (clause 6). Property
tests: exact addition is associative and commutative; contagion is total; an `Exact`
result is never a rounded one (inject a silently-rounding add and watch it fail).
Measure one whole recompute at 18,000 and 90,000 cells against today, and name the
machine. The arithmetic figures above are a microbenchmark, not the evaluator.
2. **`CellValue::Number(Num)`** across the 660 sites. This is mechanical, and `as_f64`
keeps the seams compiling.
3. **Both evaluators** move to `Num`. `FLOAT_EQ_EPSILON` is deleted. The zero-predicate
test is re-pointed (clause 6).
4. **Aggregates** partition by exactness (clause 8).
5. **Entry points:** `typed()`, the import pipeline's measure read, formula literals
(clauses 3 and 12).
6. **Display and edit text** (clause 9). The `1.005` case is the regression test, and
it fails at HEAD today.
7. **`.improv`:** read exact, write with scale, `#i` for inexact finite, the version
literal (clause 10).
8. **The wire** adaptor (clause 11), with the retired-spelling discipline of gotcha 42:
assert the error kind, not merely that an error occurred.
## Open questions
Each question has a recommendation. The user's answers will be folded in under "What
the user decided", as ADRs 004 and 007 did.
1. **The tower's shape.** Two levels, exact decimal and inexact f64, with Scheme's
contagion and a bounded exact level (clauses 1 and 4)?
*Recommendation:* yes. It is the smallest tower that makes typed input and its
arithmetic exact, and the measurements rule out the larger one.
2. **Non-terminating exact division.** What is `1 / 3` for two exact operands?
(a) inexact f64, the nearest binary value; (b) a 28-digit decimal that stays marked
exact (SQL `NUMERIC`); (c) an exact rational (Scheme proper).
*Recommendation:* (a). (c) does not finish an average of ratios (measured). (b) makes
"exact" false after every division, silently, and it breaks ADR 009's group law for
those values: in the measurement, a `rust_decimal` sum of ratios differed between
forward and reverse order. (a) is Scheme's own answer for any operation that cannot
stay exact.
3. **Tolerance on inexact equality.** With the epsilon deleted, should `=` between two
*inexact* values keep a tolerance?
*Recommendation:* no. Exact comparison everywhere, as Scheme does. A tolerance at a
fixed absolute size is wrong at large magnitudes (Context), and a relative one is a
new semantic that belongs in its own function (for example `APPROX(a, b, tol)`) if it
is ever wanted. Exact values, which are nearly all values, need none.
4. **The spelling of an inexact finite number in `.improv`.** `#i0.333…` (Scheme's
marker), `~0.333…`, or no marker at all (an inexact value reloads as exact)?
*Recommendation:* `#i`. It is Scheme's spelling, and the user asked for Scheme's
rules. No marker is rejected: a save and reload would change a value's exactness,
and then the exactness-preserved round-trip property could not be stated.
5. **The version literal.** Share ADR 007 clause 10's new literal, or take its own?
*Recommendation:* share it if the two land within one release, and take its own
otherwise. Either way the first line alone picks the regime, as ADR 007 decided.
6. **JSON import precision.** Read a JSON number through its shortest f64 spelling
(clause 3), or enable `serde_json`'s `arbitrary_precision` so the literal survives?
*Recommendation:* shortest spelling. `arbitrary_precision` is a workspace-unified
feature. It would change `serde_json::Number` for the wire and every other JSON user,
including the browser bundle, to fix a literal of more than 15 significant digits in
an import file.
7. **Mixed-exactness comparison.** Compare an exact and an inexact value by converting
the exact one to f64 (clause 6), or exactly, by converting the f64 to its exact
binary expansion?
*Recommendation:* convert to f64. It is the same rule as arithmetic, and the
non-transitive corner needs a value that is exactly between two f64s.
8. **The dependency.** `rust_decimal` in `improvise-core`, a browser-bundle crate, at a
marginal +35.9 KB (+15.4 KB gzipped) by the probe?
*Recommendation:* yes, with the real bundle re-measured in migration step 1, and
checked with `cargo tree --edges normal` per crate (the repo map's Wasm section).
9. **Version skew on the wire.** Accept clause 11's silent direction (an old peer's
number read as inexact), or add a protocol version to the handshake first?
*Recommendation:* accept it, and file the handshake version as its own ticket. The
skew needs a deployed page and a `ws-server` of different versions, and inexact is
the honest reading of a value an old peer computed in f64.
## Consequences
- Typed values and their `+ - *` are exact. `0.1 + 0.2 = 0.3`, `1234.56 - 1234.55 =
0.01`, and a typed `1.005` shows as `1.01` at two places.
- `SUM` over exact data is independent of order, with no accumulator. Gotcha 18's `SUM`
drift goes as a class for exact data, not just by pinned iteration order.
- `FLOAT_EQ_EPSILON` is gone, and gotcha 17 is rewritten as "one zero predicate".
- A typed `12.50` stays `12.50` in the editor.
- Division that does not terminate, `AVG` over most counts, and `POW` with a fractional
exponent are inexact, and display as today.
- `.improv` gains `#i` and a version literal. Existing files load as the decimals they
spell.
- The wire spells exact values as strings. A thin client needs no new rule, because
`typed()` is shared.
- **Costs:** a 16-byte number where there was an 8-byte one, and `rust_decimal` in the
bundle (+15 KB gzipped, probed). Arithmetic is about 8× slower per add and about 100×
per division than f64 (measured, microbenchmark). A 90,000-record formula that
divides for every record pays about 5–10 ms in arithmetic alone. Whether the
evaluator notices is migration step 1's measurement, and it is **not** measured here.
- **Not covered:** the order-free sum of *inexact* values, which is ADR 009's
accumulator; a protocol version; conversion functions such as `EXACT()`/`INEXACT()`,
which no ticket asks for.
## Appendix: the measurements
A scratch crate outside the workspace, run with `cargo run --release`. It was not
committed. Committing it would add three decimal crates to the workspace's dev
dependencies, and every `cargo test --workspace` would then build them, to hold a
proposal's evidence. If the user accepts this ADR, migration step 1 commits the
`rust_decimal` half as an example in `improvise-core`, with no new dependency beyond the
one the ADR adds.
**Data.** A typed value is a two-place decimal from 0.01 to 999,999.99, drawn by
SplitMix64 (the same generator as ADR 009's `retraction_drift.rs`), and spelled as the
text a person types. Each representation parses that text: f64 by `str::parse`,
`Decimal`/`BigDecimal` by `from_str`, and `BigRational` as mantissa over 10^scale.
**SUM.** A fold over N parsed values, N = 18,000 and 90,000, best of 50 runs (20 for
`bigdecimal`, 5 for `BigRational`), reported per element. The order check folds forward
and reversed.
**Formula.** For N records with independent Revenue and Cost, `SUM((Rev − Cost) / Rev)`.
`bigdecimal` is run at its default precision (100 digits) and at 28. `BigRational` is
summed incrementally, printing the running denominator's bit length at powers of two,
and abandoned after 20 seconds.
**Accumulator stand-in.** Each stored f64 is converted to its exact rational
(`BigRational::from_float`), summed exactly, and rounded once to f64. That is precisely
what a correct superaccumulator returns. It is compared with the typed total, the
decimal sum of the texts converted to f64 once. Small N: 10,000 trials each at
N = 2, 3, 10 and 100, seed 7. Large N: seeds 1 to 8.
**Display.** `format.rs:127-136`'s `round_half_away`, copied verbatim, against
`Decimal::round_dp_with_strategy(2, MidpointAwayFromZero)`, for `2.675`, `1.005`,
`0.125` and `1.115`.
**Wasm size.** A `cdylib` exporting one function that parses two strings, then does
`(a + b) − b`, divides by `b` and prints, in each representation. It also parses and
prints an f64 in every variant, so f64 formatting is in the baseline as it is in the real
bundle. Profile `opt-level = "s"`, `lto = true`, `codegen-units = 1`,
`panic = "abort"`. Then `wasm-opt -Oz --enable-bulk-memory
--enable-nontrapping-float-to-int`, and `gzip -9` for the compressed figure.
+1
View File
@@ -45,6 +45,7 @@ const SECTIONS = [
["context/adr/002-naming-a-cell.md", "adr/002-naming-a-cell", "ADR 002 — Naming a cell"],
["context/adr/003-live-backed-models.md", "adr/003-live-backed-models", "ADR 003 — Live-backed models"],
["context/adr/004-faces-and-themes.md", "adr/004-faces-and-themes", "ADR 004 — Faces"],
["context/adr/005-exact-decimal-arithmetic.md", "adr/005-exact-decimal-arithmetic", "ADR 005 — Exact decimal arithmetic (proposed)"],
["context/adr/007-stable-identities.md", "adr/007-stable-identities", "ADR 007 — Stable identities"],
],
},