docs(adr): ADR 005 adds option (d), bounded exact fractions, to question 2 (improvise-l2b8)

At the user's request. Specified as a hybrid (Decimal, boxed Ratio<i128>, f64) and measured against rust_decimal and f64: per-operation cost, 18k/90k SUMs, and overflow rates for margins, sums of ratios, percentages, averages and compound growth. Sums group by denominator. The question 2 recommendation changes from (a) to (d); new question 12 on typing a fraction.

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 23:18:04 -07:00
committed by Edward Langley
parent 54c2fa36c8
commit e4acf6435a
+232 -8
View File
@@ -29,6 +29,14 @@ correct, and both were reproduced here before the text changed: an exact `SUM` w
integer), and `rust_decimal`'s conversion to f64 is **not** correctly rounded (clause 4
now names one that is). Questions 1–9 keep their numbers; 10 and 11 are new.
**Revision 3** adds option (d) to question 2, at the user's request: "I think (d): exact
fractions with a size limit. should be added." It is specified and measured in
[Option (d) for question 2](#option-d-for-question-2-bounded-exact-fractions). **The
recommendation for question 2 changes from (a) to (d)**, in a hybrid form: the decimal
arm stays, and a bounded fraction arm is added only for the values (a) would have made
inexact. The clauses below are still written to (a). That section lists what changes in
them if the user answers (d). Question 12 is new.
## The decision this ADR writes down
1. **Two levels, not four.** A number is **exact** (a decimal) or **inexact** (an f64).
@@ -388,8 +396,10 @@ of clause 4 applies). When it does not, the result is the correctly rounded f64
fraction, through clause 4's conversion of each operand and one f64 division, so two
roundings, as for any demotion.
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.
Scheme would answer `1/3` exactly. Unbounded rationals are ruled out by the measurement
above. Question 2 puts the alternatives to the user, and its option (d), a bounded exact
fraction, keeps `1/3` exact (see the section on it). This clause is written to option
(a).
**`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`,
@@ -689,6 +699,190 @@ accepts JSON numbers, so the wire adaptor does not delegate to it (clause 11).
`num-rational` is rejected on measurement (above). `bigdecimal` is the fallback if the
bound ever matters. Other crates (`fastnum`, `dashu`) were not evaluated.
## Option (d) for question 2: bounded exact fractions
The user asked for this option (revision 3): "I think (d): exact fractions with a size
limit. should be added." It is specified here and measured the same way as the others:
same machine (Apple M4 Max, 64 GB, rustc 1.98.1, release), same typed two-place data
(0.01–999,999.99, SplitMix64), and `num-rational` 0.4.2's checked operations. Those
reduce by the gcd on every operation.
### The number
An exact non-decimal value is a **reduced fraction `n/d` with `n` and `d` each an
`i128`** and `d > 0`. The alternative bound, `i64`, was measured and is too small:
| what stops being exact | `i64` | `i128` | `rust_decimal` |
|---|---|---|---|
| running sum of 18k or 90k margins `(Rev − Cost) / Rev`, first overflowing term, 8 seeds | 2nd–3rd | **4th–5th** | not exact (a non-terminating quotient) |
| `1.05^k` (5% compound growth) | k = 15 | **k = 29** | k = 15 |
`i128` doubles every horizon, for 2–3× the cost of `i64` (below). A value is 32 bytes.
**Decimals are a subset, and they keep their own arm.** A decimal is a fraction whose
denominator is `2^p · 5^q`, so (d) could hold everything as fractions. It should not. A
fraction has no scale, so `12.50` would need a scale field beside it. And every
operation would pay the gcd, which the measurements put at 30–300× a decimal add. So
the recommended shape is a **hybrid**:
```rust
pub enum Num {
Exact(Decimal), // typed values, money, `12.50`: scale as today
Fraction(Box<Ratio<i128>>), // exact, not a finite decimal: `10/3`
Inexact(f64),
}
```
- A `Fraction` is only ever produced where option (a) would have produced `Inexact`:
- a non-terminating quotient;
- a decimal operation that exceeds 28 digits but whose reduced fraction fits (`1.05^k`
for 15 ≤ k ≤ 28).
- A `Fraction` whose denominator turns out to be `2^p · 5^q` and fits is normalized back
to `Exact(Decimal)`, so each value has one representation.
- **Scale lives only on the decimal arm.** Typed values are decimals, so a fraction was
never typed and has no spelling to preserve. That answers "how is scale carried"
without a scale field.
- **The fraction arm is boxed.** That keeps `Num` at 24 bytes, the same as option (a);
measured, an inline `Ratio<i128>` makes it 48. The heap allocation falls only on
fraction values, which are formula results. Per `AGENTS.md`, the `Box` handling lives
in `Num`'s constructors, apart from the arithmetic.
### Operations
- **Division stays exact.** `10/3` is `Fraction(10/3)`, and `10/3 × 3` is exactly `10`,
which normalizes back to `Exact(10)`. Both measured.
- **Overflow demotes.** Every operation uses checked `i128` arithmetic after the gcd
reduction (`num-rational`'s `checked_add`/`checked_mul`/`checked_div` do exactly
this). A `None` means the reduced result does not fit, and the value demotes to
`Inexact`. The demotion is the correctly rounded f64 of the exact fraction.
`Ratio<i128>::to_f64` agreed with `BigRational`'s conversion on 200,000 of 200,000
random fractions, but its correct rounding was not independently proved here.
Migration step 1's property test covers it.
- **Contagion is unchanged.** Exact ∘ fraction is a fraction (or a decimal after
normalization, or f64 on overflow). Anything with an inexact operand is inexact.
- **`POW`** with an integer exponent is exact while it fits. That is why `1.05^k`
reaches k = 28.
- **Comparison** is exact. Two fractions are compared by cross-multiplying in 256 bits
(an `i128` times an `i128` fits). A fraction against an f64 follows clause 6's exact
rule.
- **Display** rounds the exact fraction to the view's places, half away from zero,
exactly: `n · 10^places / d`, computed in 256 bits, then rounded. So `10/3` at two
places shows `3.33`, and `2/3` shows `0.67`.
### Sums: the one real design choice
Clause 8's accumulator holds decimals only: an integer at the fixed scale 28. A
fraction whose denominator is not `2^p · 5^q` has no finite value at any fixed scale.
There are three ways to sum one:
1. **Demote each fraction term to f64** into ADR 009's accumulator. Simple, and
independent of order. But `SUM(v / 3)` is then inexact, even though `SUM(v) / 3`
is exact.
2. **Group by denominator.** Keep one wide integer numerator per distinct reduced
denominator; decimals are the one group clause 8 already has. Adding or retracting a
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.
Both paths are deterministic and independent of the order the terms arrived in.
The cost is memory. It is O(distinct denominators) per summed group, bounded by
the cells, the same order as ADR 009's multisets.
3. **Demote the whole sum on mixed denominators.** Rejected. Whether two denominators
ever met depends on nothing a user can see.
**Recommendation: (2).** It is the only one of the three that keeps
`SUM(v / 3) = SUM(v) / 3` exact, which the measurement confirmed (below). And it costs
nothing where it cannot help. The sum of margins has one denominator per record: 17,996
to 17,999 distinct at 18k, and 89,923 to 89,956 at 90k. The read's combination
overflows by the fifth, so the result is inexact exactly as under (a).
### The file and the wire
- **`.improv`.** A fraction is written `n/d`, with a sign on `n`, for example `-10/3`.
The `number` rule does not accept `/`, and an old reader would take `10/3` as **text**
(`bare_value`), silently. So it needs a grammar arm ordered before `bare_value`, and
the new version literal. That is the same bump as `#i` (question 5), so (d) adds no
second bump.
- **The wire.** A fraction is a **string** `"10/3"` in the exact arm of clause 11's
adaptor. An old peer tries it as a non-finite token and refuses the frame, loudly.
- **Typing `3/4`** stays text. That is question 12.
### Measured
**Cost per operation**, 18,000 operand pairs, ns, best of 20, the range over two runs:
| | add | mul | div |
|---|---|---|---|
| `rust_decimal` (checked) | 0.9–1.3 | 1.5–2.2 | 40–55 |
| `Ratio<i64>`, two-place operands | 55–68 | 144–179 | 78–113 |
| `Ratio<i128>`, two-place operands | 123–178 | 324–472 | 198–288 |
| `Ratio<i128>`, margin operands (unrelated denominators) | 313–457 | 407–652 | 223–325 |
f64 is not in the table, because this harness's f64 loop was optimized away. The SUM
figures below give f64's per-add cost. `num-rational`'s gcd over `i128` is generic. A
hand-written one with a 64-bit fast path would likely be several times faster, but
that was **not measured**.
**SUM over typed two-place values, as fractions**, ns per element, best of 20:
| cells | f64 | `rust_decimal` | `Ratio<i64>` | `Ratio<i128>` |
|---|---|---|---|---|
| 18,000 | 0.8–1.2 | 4.6–6.4 | 65–76 | 155–226 |
| 90,000 | 0.8–1.2 | 4.5–6.5 | 69–87 | 163–239 |
Both bounds held the whole sum with no overflow. The sum was the same in both orders
and equal to the decimal sum. But `Ratio<i128>` is 25–50× slower than `rust_decimal`:
14–22 ms for a 90k SUM, against 0.4–0.6 ms. **That is why the hybrid keeps decimals in
their own arm.** Under it, this SUM costs what it costs under (a).
**How often realistic formulas overflow `i128`**, 8 seeds, 18k and 90k records:
| formula | overflows |
|---|---|
| `(Rev − Cost) / Rev` per record | **never** (864,000 records: 8 seeds × (18k + 90k), both bounds) |
| running `SUM` of those margins | **at the 4th–5th term** in every seed (`i64`: 2nd–3rd) |
| `v / SUM(v) × 100` per cell (a percentage of the total) | **never** (0 of 144,000 and 0 of 720,000, both bounds) |
| `SUM(v) / N` (the average) | never |
| `SUM(v / 3)` against `SUM(v) / 3` | never; **equal exactly** |
| `1.05^k` | at k = 29 (`i64` and `rust_decimal`: k = 15) |
### Where (d) beats (a), and where it does not
**(d) is better:**
- a non-terminating quotient stays exact: `10/3 × 3 = 10` (measured), and so
`Margin × Revenue = Profit` holds exactly;
- the average over any count;
- `SUM(v / 3) = SUM(v) / 3`;
- exact comparison of ratios;
- compound growth exact to k = 28 rather than 14.
**(d) is no better:**
- the sum of ratios, which overflows by the fifth term and ends inexact as under (a);
- anything already exact under (a), which is nearly all typed data.
**(d) costs:**
- a third numeric arm;
- a grouped sum accumulator;
- a grammar arm and the wire spelling `n/d`;
- question 12;
- 120–650 ns per fraction operation, paid only where (a) would have demoted.
### What changes in the clauses if the answer is (d)
- **Clause 1:** three levels: exact decimal, exact bounded fraction, inexact.
- **Clause 2:** `Num` gains the boxed `Fraction` arm.
- **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.
- **Clause 6:** fraction comparison is by cross-multiplication.
- **Clause 8:** sums group by denominator, as above.
- **Clause 10:** the `n/d` spelling.
- **Clause 11:** the wire spells a fraction `"n/d"`.
- **Question 12** decides typed input.
## Alternatives rejected
- **Exact rationals as the default (Scheme proper).** Measured above: an 18,000-record
@@ -752,12 +946,23 @@ the user decided", as ADRs 004 and 007 did.
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.
exact (SQL `NUMERIC`); (c) an exact rational (Scheme proper); (d) an exact fraction
with a size limit, `i128` numerator and denominator, demoting to f64 on overflow
(added in revision 3 at the user's request).
*Recommendation (changed in revision 3):* **(d), in the hybrid form** specified in
[its own section](#option-d-for-question-2-bounded-exact-fractions). Decimals stay
`Decimal`, and a boxed `Ratio<i128>` arm holds only what does not terminate. What the
measurements show:
- (d) keeps `10/3 × 3 = 10`, `SUM(v / 3) = SUM(v) / 3`, `AVG` over any count, and
every percentage-of-total exact, with **no** overflow in 864,000 cells.
- It gains nothing on the sum of ratios. That still overflows `i128` by the fifth
term and ends inexact, as under (a).
- Its operations cost 120–650 ns against 1–55 ns for a decimal. That cost falls only
on values (a) would have made inexact.
The earlier reasons still stand against (b) and (c): (c) does not finish an average
of ratios, and (b) makes "exact" false after every division, silently. (a) remains
the simpler choice, and the right one if a third numeric arm, a grammar arm for
`10/3` and a grouped sum accumulator are judged too much for this.
3. **Tolerance on inexact equality.** With the epsilon deleted, should `=` between two
*inexact* values keep a tolerance?
@@ -824,6 +1029,15 @@ the user decided", as ADRs 004 and 007 did.
exactness depend on store order, which is the gotcha 18 class this ADR exists to
remove. Preferring exact keeps the most information, and it is a total rule.
12. **Typing a fraction** (added in revision 3; it matters only if question 2 is (d)).
Should `typed()` read `3/4` as the exact fraction 0.75, or keep it as text, as today?
*Recommendation:* keep it as text. People type `3/4` as a date or a label, and today
it is text. The one case that needs the fraction spelling is an editor opened on a
fraction value. There, the editor opens on `10/3`, and an **untouched** commit
writes nothing, on both front ends. The browser already skips an untouched commit.
The TUI must be held to the same rule (the improvise-umi4 lesson), or opening and
closing the editor on `10/3` would turn the value into text.
## Consequences
- Typed values and their `+ - *` are exact. `0.1 + 0.2 = 0.3`, `1234.56 - 1234.55 =
@@ -858,6 +1072,8 @@ the user decided", as ADRs 004 and 007 did.
- **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.
- **If question 2 is answered (d)**, add the consequences listed in its section: a
third numeric arm, a grouped sum accumulator, the `n/d` spelling, and exact quotients.
## Appendix: the measurements
@@ -915,3 +1131,11 @@ bundle. Profile `opt-level = "s"`, `lto = true`, `codegen-units = 1`,
Everywhere this appendix converts a decimal total to f64 ("the typed total"), it does
so through `to_string().parse::<f64>()`, the correctly rounded path, not `to_f64`. So
the accumulator table above does not depend on the conversion B2 found wrong.
**Option (d)** (revision 3) uses the same crate, with `num-rational` 0.4.2's
`Ratio<i64>` and `Ratio<i128>` checked operations. Operation costs are measured over
18,000 pairs of typed values: seeds 21 and 22 for two-place operands, and those pairs'
margins for unrelated denominators. The overflow cases use seeds 1–8, drawing Revenue
and Cost from seeds `2s` and `2s + 1`. Sizes are `size_of` of the three `Num` shapes.
The `to_f64` check compares `Ratio<i128>::to_f64` with `BigRational::to_f64` on 200,000
fractions (seed 5). Every figure is the range over two runs on the machine above.