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:
committed by
Edward Langley
parent
54c2fa36c8
commit
e4acf6435a
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user