Merge gh/main 5f8c3f8c into adr007/slugify-amendment
This commit is contained in:
@@ -25,7 +25,7 @@ them here would let the copy drift:
|
||||
| Source | What it holds |
|
||||
|---|---|
|
||||
| `context/design-principles.md` | The "why" — functional-first, polymorphism over conditionals, correctness by construction, layering, §7 testing doctrine |
|
||||
| `context/repo-map.md` | The "where", plus **numbered gotchas** — read the ones your diff touches |
|
||||
| `context/repo-map.md` | The "where", plus a **numbered gotcha digest** (the full text is `context/gotchas.md`) — read the long form of the ones your diff touches |
|
||||
| `prolog-sessions/kb.pl` | Executable knowledge: `invariant/2`, `doctrine/2`, `forbidden/2`, `decision/3`, and prior `work_done/3` |
|
||||
| `AGENTS.md` | Working rules: bug-first tests, `Option`/`Result`/`Box` at the edges, history reconciled only by merge, a new conventional commit by default (amend only an unpushed HEAD, for a small fix), and nothing that needs a force push |
|
||||
| `.agents/skills/wave/agent-rules.md`, "GitHub" | The reviewer's rule: what to read before your first comment, how to answer each existing review, and what every review you post must state. Read it before reviewing a PR |
|
||||
|
||||
@@ -341,7 +341,8 @@ out on purpose:
|
||||
## Files you must not write
|
||||
|
||||
`.beads/**` (never commit `issues.jsonl`, never `git checkout` it),
|
||||
`prolog-sessions/kb.pl`, `context/repo-map.md`: `AGENTS.md`, "Who owns what".
|
||||
`prolog-sessions/kb.pl`, `context/repo-map.md`, `context/gotchas.md`,
|
||||
`context/file-inventory.md`: `AGENTS.md`, "Who owns what".
|
||||
Also `AGENTS.md`, unless your brief grants you a named section. Report proposed
|
||||
text for any of these in your hand-back.
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ that costs to get right. Everything in it was learned by getting it wrong first.
|
||||
|
||||
### Who owns what
|
||||
|
||||
Three files are **orchestrator-only**, and the rule exists because they are
|
||||
These files are **orchestrator-only**, and the rule exists because they are
|
||||
guaranteed conflicts — every agent has a reason to touch them and they merge
|
||||
badly:
|
||||
|
||||
@@ -160,8 +160,9 @@ badly:
|
||||
| `.beads/` (incl. `issues.jsonl`) | bd owns it. See "Never hand-edit" below. |
|
||||
| `prolog-sessions/kb.pl` | Written by `scripts/kb` only. See `context/code/kb-tool.md`. |
|
||||
| `context/repo-map.md` | Every agent wants to add a gotcha; every wave would conflict. |
|
||||
| `context/gotchas.md`, `context/file-inventory.md` | The long forms the map digests; same conflict, same owner. |
|
||||
|
||||
Wave agents **read** all three freely (`bd show`, `bd ready`, grep `kb.pl`) and
|
||||
Wave agents **read** all of them freely (`bd show`, `bd ready`, grep `kb.pl`) and
|
||||
**write** none of them. They report proposed repo-map lines and `kb.pl` facts as
|
||||
*text* in their hand-back, and the orchestrator applies them. Repo-map lines
|
||||
are batched at the end of the wave, after the review, because they conflict.
|
||||
|
||||
Generated
+165
-2
@@ -97,6 +97,12 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "atomic"
|
||||
version = "0.6.1"
|
||||
@@ -178,6 +184,30 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
|
||||
dependencies = [
|
||||
"borsh-derive",
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh-derive"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -705,6 +735,17 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -1216,6 +1257,16 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -1233,6 +1284,26 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -1251,6 +1322,18 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numtower"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
"proptest",
|
||||
"rust_decimal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -1449,6 +1532,15 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
@@ -1469,7 +1561,7 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"num-traits",
|
||||
"rand 0.9.5",
|
||||
"rand_chacha",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
"rusty-fork",
|
||||
@@ -1510,6 +1602,8 @@ version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
@@ -1519,7 +1613,7 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
@@ -1534,6 +1628,16 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
@@ -1549,6 +1653,9 @@ name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
@@ -1713,6 +1820,23 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.43.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"borsh",
|
||||
"bytes",
|
||||
"num-traits",
|
||||
"rand 0.8.8",
|
||||
"rand 0.9.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -2161,6 +2285,36 @@ dependencies = [
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.17+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.30.0"
|
||||
@@ -2525,6 +2679,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = [".", "crates/improvise-formula", "crates/improvise-core", "crates/improvise-protocol", "crates/improvise-io", "crates/improvise-command", "crates/improvise-wasm-client", "crates/improvise-ws-server", "crates/improvise-worker-server", "crates/gp-tree", "crates/improvise-gp"]
|
||||
members = [".", "crates/improvise-formula", "crates/improvise-core", "crates/improvise-protocol", "crates/improvise-io", "crates/improvise-command", "crates/improvise-wasm-client", "crates/improvise-ws-server", "crates/improvise-worker-server", "crates/gp-tree", "crates/improvise-gp", "crates/numtower"]
|
||||
|
||||
[package]
|
||||
name = "improvise"
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
# ADR 009 — Incremental evaluation: an in-house DBSP core, plugged in behind a trait
|
||||
|
||||
**Status:** proposed. ·
|
||||
**Ticket:** improvise-felo.4 (the record of this ADR; implementation goes to its own
|
||||
epic) ·
|
||||
**Relates to:** ADR 001 (the basis a write claims), ADR 002 (surrogates on the wire),
|
||||
ADR 003, ADR 005, ADR 006, ADR 007, ADR 008
|
||||
|
||||
## Problem
|
||||
|
||||
Every change recomputes every formula cell (`Model::recompute_formulas`, called by
|
||||
`rebuild_layout` after each batch), and raw aggregates are re-folded on every read. That
|
||||
does not scale to a live source (ADR 003) streaming changes into a large table, nor to
|
||||
ADR 008's virtualized grid, which needs occupancy, totals and widths maintained without
|
||||
building the grid. A change should cost in proportion to what it touches.
|
||||
|
||||
## Decision
|
||||
|
||||
Formulas are evaluated incrementally by an in-house implementation of the DBSP core. A
|
||||
cell write becomes a delta, aggregates are maintained, and only the formula cells whose
|
||||
inputs moved are re-evaluated by the existing evaluator. The incremental results must
|
||||
equal today's eager results exactly.
|
||||
|
||||
**Principle: everything is as incremental as possible**, so the work done per model
|
||||
mutation is minimised. Where this ADR does work that grows with more than the
|
||||
mutation, it says so and names the incremental path that normally applies.
|
||||
|
||||
### 1. The `zset` crate
|
||||
|
||||
A problem-agnostic workspace crate with no dependencies beyond `std` (so it is
|
||||
wasm32-clean and serde-free). It provides:
|
||||
- `ZSet<K>`, a map from `K: Ord` to a non-zero `i64` weight, which is an abelian group;
|
||||
- a synchronous, single-threaded `Circuit` per process, with `delay`, `integrate`,
|
||||
`differentiate`, `map`, `filter`, `flat_map`, `index_by`, the bilinear delta join
|
||||
`join_indexed`, `distinct` and `aggregate`;
|
||||
- resumable rebuilds: building an operator from a snapshot is a `step(deadline)`, so it
|
||||
can run as a sliced job;
|
||||
- operator state behind one trace interface (look up, read a range, apply a delta), so
|
||||
later store-memory work can page or compress it.
|
||||
|
||||
**Operators are self-contained.** An operator's state and behaviour depend only on its
|
||||
inputs and its own state, never on a shared in-process global or on another operator's
|
||||
internals. That keeps the graph distributable: in principle the model could run across a
|
||||
cluster of machines. Nothing more is designed for that here.
|
||||
|
||||
There are no nested streams, no recursion operator and no timestamps beyond one clock.
|
||||
**The formula language is the query language.** What a request asks of the model beyond
|
||||
its axes and window (filters, aggregation, derived values) is written in formula text
|
||||
over ids (ADR 007). It goes through the same evaluator and circuit as a document
|
||||
formula, so there is no second query language.
|
||||
|
||||
### 2. The plug
|
||||
|
||||
improvise-core gains a trait, `Derived`, for the two things the evaluator reads: the
|
||||
formula result at a key, and the raw fold of the cells that match a partial key. The
|
||||
incremental backend implements it in a seam crate, `improvise-incremental`, which
|
||||
depends on `zset` and improvise-core. improvise-core never depends on `zset`.
|
||||
|
||||
A live source (ADR 003) that reports its changes feeds cell deltas like `DataStore`. A
|
||||
source that answers aggregate queries but reports no changes implements the derived-value
|
||||
operator directly, by query pushdown, with no change feed.
|
||||
|
||||
The circuit decides *which* formula cells to re-evaluate. The one existing evaluator
|
||||
computes each value, so formulas are never compiled into operators.
|
||||
|
||||
**`Eager` is temporary.** Today's code moves behind the trait as `Eager`, unchanged. It
|
||||
exists only to show discrepancies between the new evaluator and the old one: a
|
||||
differential property test applies one random edit sequence to both backends and
|
||||
compares every drawn cell, total and occupancy count. Eventually it is deleted, and
|
||||
nothing depends on it surviving. Long-term tests compare against exact references, such
|
||||
as `ExactSum` against rational sums, not against `Eager`.
|
||||
|
||||
### 3. The cell delta
|
||||
|
||||
`DataStore::set` and `DataStore::remove` emit a `ZSet<(CellKey, CellValue)>`:
|
||||
- `+(k, v)` for a new cell;
|
||||
- `−(k, v₀) + (k, v₁)` for an overwrite;
|
||||
- `−(k, v₀)` for a removal.
|
||||
|
||||
Values compare by normal float rules, except that every NaN is one canonical value:
|
||||
`-0.0` equals `+0.0`, and an equal-value overwrite consolidates to nothing. NaN payloads
|
||||
are not preserved, so nothing can be smuggled through them. Today's record tier compares
|
||||
bits, so overwriting `0.0` with `-0.0` stops counting as a change. Multisets order values
|
||||
by `f64::total_cmp` after mapping every NaN to the canonical one and `-0.0` to `+0.0`. `CellKey` gains `Ord`, which is its canonical order: the
|
||||
natural order of ids (ADR 007 amendment 1, clause 4), the order eager folds in. The
|
||||
circuit steps once per model effect.
|
||||
|
||||
The record tier and the dirty flag read this delta instead of scanning the store. The
|
||||
dirty flag fires on a non-empty `(key, value)` delta, or when a fingerprint of the
|
||||
formula set moves, because formula edits move no cell.
|
||||
|
||||
Under ADR 007 and its amendment, `CellKey` pairs are `(CategoryId, ItemHandle)`. So a
|
||||
rename is a display write and emits no delta. The formula-set fingerprint must use the id
|
||||
form too, or a rename reads as a formula change.
|
||||
|
||||
### 4. Aggregates
|
||||
|
||||
A group is the cell key minus the None categories. One aggregation circuit serves every
|
||||
shape key with the same None list, which removes the per-session model clone.
|
||||
|
||||
| aggregate | state per group | per delta element |
|
||||
|---|---|---|
|
||||
| `Count` | count of values, count of numbers | O(1) |
|
||||
| `Sum`, `Avg` | an exact accumulator (clause 8), and the count | O(1) |
|
||||
| `Min`, `Max`, text unanimity | a multiset of values | O(log d) |
|
||||
|
||||
A group is read with eager's rules, including NaN and signed zero. `LAST`/`FIRST` and
|
||||
semi-additive measures (improvise-felo.7, felo.8) reuse the multiset shape, keyed by the
|
||||
ordering category's order. Those tickets choose the key. It must keep a reorder or an
|
||||
insert incremental: only the entries under the moved items change, and nothing is
|
||||
rebuilt.
|
||||
|
||||
### 5. Formulas
|
||||
|
||||
**Stems** are `distinct` over the delta, keyed by "cell key minus the target and None
|
||||
categories". A formula counts at a stem where its top-level `WHERE` matches or raw data
|
||||
exists at the target.
|
||||
|
||||
**Inputs.** A formula cell is re-evaluated when one of its inputs moved:
|
||||
- a `Ref(R)`: the group at the stem with `R`'s category set to `R`;
|
||||
- an `Agg`: the group at the stem;
|
||||
- an `Agg` whose inner `WHERE` names `Cat = Item`: the group at the stem with `Cat` set
|
||||
to `Item`. This is the one join: stems indexed by "stem minus `Cat`".
|
||||
|
||||
The affected pairs are evaluated in the formula graph's topological order.
|
||||
|
||||
**An aggregate over an expression** (`SUM(Revenue * Price)`) is a pipeline:
|
||||
1. A per-record stage retracts and reinserts each touched record. It reads the record's
|
||||
previous cells as the store minus the delta, so it keeps no copy of the values. The
|
||||
store is the `integrate` operator's trace over the cell delta, read as an input of
|
||||
the stage, so the stage stays self-contained.
|
||||
2. A map produces `(context, order key, expr)`. The order key is (context rank, record
|
||||
key in `CellKey` order), which is eager's concatenation order.
|
||||
3. The group aggregate, with an error channel that reads the first `Err` in order.
|
||||
|
||||
No cross product is built. A formula whose expression reads other records is evaluated
|
||||
over the **occupied contexts** only, and `Eager` uses the same enumeration. The answer is
|
||||
unchanged, because a context with no records contributes nothing. One exception: an
|
||||
inner bare ref that names no item errors at every context, so for it the ref is checked
|
||||
before enumerating. This replaces `Expansion::members`.
|
||||
|
||||
### 6. Circular references
|
||||
|
||||
DBSP's least fixed point is not improvise's circularity: `X = X + 1` has none, and the
|
||||
answer must be `Error("circular")`. The formula graph's strongly connected components
|
||||
are maintained incrementally as formulas change. Acyclic formulas are evaluated once
|
||||
each, in topological order. A cyclic component is maintained incrementally too: a change
|
||||
re-iterates only the cells it reaches. A component that does not converge within the
|
||||
existing bound of sixteen passes (`MAX_EVAL_DEPTH`) propagates `Error("circular")`, as
|
||||
today. An optimisation that speeds convergence is allowed only where it is exact: it
|
||||
gives the same result as the plain iteration, and a test compares the two.
|
||||
|
||||
### 7. Operator state follows the request's shape key
|
||||
|
||||
A projection request's **shape key** selects operator state, shared by every request
|
||||
with the same key, from any client. The key is:
|
||||
- the categories on each axis, by level;
|
||||
- the None categories;
|
||||
- the filter keys: each filter category's item, and the hidden items of categories on
|
||||
None, Page or Filter;
|
||||
- the page categories, so each page is a maintained slice and paging rebuilds nothing;
|
||||
- `measure_agg`.
|
||||
|
||||
The key uses the canonical parsed id form of the request's query text, so two spellings
|
||||
of one query share their operator state. Everything else in a request is applied when output is read: hidden and collapsed items
|
||||
on axis categories, item order, the page selection and prune. Occupancy and column
|
||||
aggregates are keyed by axis **items**, so a collapse, hide, show, reorder or added item
|
||||
changes no operator. The visible axis is a remap.
|
||||
|
||||
- **A new key** builds its operators as a sliced job under ADR 008's job runner. Deltas
|
||||
arriving during the build are buffered and added at install. The finished result is the
|
||||
response to the request that asked for it, not a notice.
|
||||
- **A formula edit** is a formula delta fed to the circuit.
|
||||
- **A document swap** rebuilds everything, and a new key's first build is a full pass.
|
||||
These are the only rebuilds; every other change is a delta.
|
||||
- **Saving a view definition** touches no operator.
|
||||
- **Live keys are a cache.** A key is evicted after a period in which no request names
|
||||
it. The period is a local duration on the host, set by measurement, and never compared
|
||||
with another machine's clock. Every eviction is announced (clause 9).
|
||||
|
||||
**Column totals and hidden rows.** Totals are kept over all rows, because the shape key
|
||||
is shared and a hide is one client's. Whether hidden items travel in a request is ADR
|
||||
008's open question. If they do not, a total is read as it is kept. If they do, a total
|
||||
must leave the hidden rows out, so a read subtracts the request's H hidden cells as a
|
||||
temporary overlay:
|
||||
- `Count` subtracts;
|
||||
- `Min` and `Max` skip keys whose whole count is hidden;
|
||||
- text counts the distinct keys left;
|
||||
- `Sum` subtracts exactly through the accumulator.
|
||||
|
||||
That is O(H log d) per read for every aggregate, with no view-store state in the model
|
||||
store.
|
||||
Aggregate-over-expression totals index their entries by row tuple for the same
|
||||
subtraction.
|
||||
|
||||
### 8. Exact sums
|
||||
|
||||
An f64 sum maintained by `+v` and `−v` drifts. Measured by
|
||||
`crates/improvise-core/examples/retraction_drift.rs`, at 18,000 cells after a million
|
||||
cycles, it is 1.7e-9 from eager, past `FLOAT_EQ_EPSILON`, so `=` could answer
|
||||
differently on the two paths. So every `Sum` and `Avg` uses **`ExactSum`**, in both
|
||||
backends:
|
||||
- a fixed-point integer covering every finite f64, with counts of NaN and ±∞, which
|
||||
makes it an abelian group that rounds once, on read;
|
||||
- measured bit-equal to a fresh sum after a million cycles, and exact against rational
|
||||
sums on 20,000 cases;
|
||||
- 568 bytes per group, and about 1.3× the cost of an f64 add per update.
|
||||
|
||||
The sign of a zero sum follows IEEE rules for the same addends: it is `-0.0` only if
|
||||
every addend is `-0.0`, and an exact cancellation gives `+0.0`. It lives in ADR 005's
|
||||
`numtower` crate. **User-visible change:** displayed sums change in their last bits,
|
||||
because they become correctly rounded.
|
||||
|
||||
### 9. Delivery
|
||||
|
||||
The **model store** (the document model and the DBSP graph) and the **view store** (the
|
||||
cursor, the window and unsaved view definitions) are conceptual boxes, not a network
|
||||
boundary. Where a piece of state physically lives (a client, the host or a worker) is
|
||||
an implementation detail. The model store's decisions depend only on the model. A
|
||||
server-side twin of a client's state, such as its filter, watermark or window, may be
|
||||
held at the serving end of an edge where that makes the graph genuinely simpler. It is
|
||||
then part of the edge, not of the model store, and it is disposable: losing it never
|
||||
gives wrong data. A client that keeps its own watermark recovers with a correction from
|
||||
it. A client that relied on the twin for its watermark recovers with a full refetch.
|
||||
|
||||
**Every edge may cross a network.** An edge, meaning the stream from one operator to the
|
||||
next, may be in-process or remote. No edge assumes a function call or shared
|
||||
memory. An operator that sits at a wire boundary is just one implementation of an edge,
|
||||
an **edge adapter**. So every edge has the same semantics:
|
||||
- **A wire form.** Every element type that flows on an edge has a serializable form,
|
||||
defined in `improvise-protocol`. The adapter converts to and from it, and `zset` stays
|
||||
free of serde. `CellKey` is not `Serialize`, so on the wire a cell is named by its
|
||||
tuple of ids, its position or an ADR 002 surrogate.
|
||||
- **The wire form is lossless.** Nothing is lost in transport, numeric precision above
|
||||
all:
|
||||
- a value never crosses as a JSON or JS double. It crosses in ADR 005's canonical text:
|
||||
an inexact finite value as `#i` with its shortest round-trip spelling, which keeps
|
||||
the sign of zero, infinities as `inf` and `-inf`, and every NaN as the one token
|
||||
`nan`;
|
||||
- positions and counts below 2⁵³ are exact as JSON numbers. Wider integers cross as
|
||||
strings;
|
||||
- text crosses byte-exact, with no normalisation;
|
||||
- the delta is the unit that crosses, never `ExactSum`'s limbs. A receiver that needs
|
||||
a sum gets the host's exact read or rebuilds it from the exact deltas.
|
||||
|
||||
The stamp rule detects lost messages, but nothing would detect a lossy encoding. So
|
||||
every element type has a round-trip property test, where encoding then decoding is
|
||||
the identity for every value, including `-0.0`, infinities, 28-digit decimals and
|
||||
`i128`-wide fractions. A NaN round-trips as the canonical NaN.
|
||||
- **The clock is causal, never a wall clock.** Ordering, staleness and loss detection
|
||||
use only the stamp sequence (below), a logical clock the host advances once per
|
||||
committed command. Each process runs its own synchronous sub-circuit, and across an
|
||||
edge the stamped commands are the clock. No decision compares wall-clock times across
|
||||
machines, so clock skew cannot affect it. A delta crosses a wire only after its command
|
||||
commits, so a remote operator never sees a delta that is later rolled back.
|
||||
- **Ordering and backpressure.** An edge delivers in order. A slow edge coalesces
|
||||
exactly: deltas are summed, and notices merged. A merged notice carries the range of
|
||||
publications it covers, `(from, to]`, so the client that merges notices sees no gap
|
||||
where nothing was lost.
|
||||
- **An edge is a session, not a connection.** An edge may be one long-lived connection,
|
||||
or a sequence of requests carrying the same session token or cookie, for example
|
||||
several HTTP requests. The host decides nothing from the token: it is transport, and
|
||||
any later authentication is out of scope. What continues across connections is the
|
||||
client's own watermark and shape keys, which it sends with each request. Every request
|
||||
can be retried, and a resync from a watermark is idempotent. A client asks for
|
||||
"notices after my watermark", and the host answers from **one shared, bounded log
|
||||
of published notices**: a single log, not one per edge or client, holding the notices
|
||||
themselves (keys and flags, not values). A request older than the log's start is
|
||||
answered with a correction (below).
|
||||
- **Failure.** Timeouts are local monotonic durations, never compared across machines.
|
||||
On a long-lived connection, both ends keep alive, and a host-side timeout is tied to
|
||||
that open connection. On a sequence of requests, only the
|
||||
client detects failure: a request that fails or times out. In either case the client
|
||||
then asks for a correction from its watermark.
|
||||
|
||||
- **Correction is Z-set arithmetic.** A correction is `target − held`, where `target`
|
||||
is the current state for the client's window and `held` is what the client holds.
|
||||
Applying it with ordinary Z-set addition gives `held + (target − held) = target`, so
|
||||
the group structure of `ZSet` provides it, with no separate machinery. The client's
|
||||
position is a **watermark**: the whole stamp `(host instance, document_generation,
|
||||
publication)` up to which it has applied the stream, so everything at or before it is
|
||||
applied. A watermark from another host instance or generation is behind every current
|
||||
stamp. The client holds one per shape key it reads. A watermark is view-store state:
|
||||
the model store keeps none, so a client sends its own with each request unless a twin
|
||||
at the serving end holds it.
|
||||
- where the shared log covers the watermark and the covered range holds only moved
|
||||
keys, the host sends the current values of those keys, restricted to the client's
|
||||
window, and the client applies `target − held` over them. The log holds keys and
|
||||
flags, not values, so the model store keeps no view-store state;
|
||||
- where the covered range also holds axis changes, relabels or flags, which positions
|
||||
and labels depend on, the correction carries them too, in order, as a merged notice
|
||||
does, so the client re-derives positions incrementally;
|
||||
- only where the log no longer covers the watermark, or the covered range holds an
|
||||
oversized notice that lists no keys, does the host fall back to `target`
|
||||
for the whole window, and the client applies `target − held`;
|
||||
- a full refetch is `target − ∅`.
|
||||
|
||||
**Every read sees only the last committed state**, whether it is a correction, a
|
||||
fetch or `project`, and it is labelled with that state's stamp. A value a failed
|
||||
command wrote and then rolled back is never read. A correction is labelled with the
|
||||
publication range it covers, so it is applied only at the watermark it starts from,
|
||||
which makes it idempotent and resumable. Its elements are `(key, value)` pairs with
|
||||
weights, in the lossless wire form, so a client and a worker apply it with the same
|
||||
code.
|
||||
|
||||
Aggregation stays on the model side (ADR 008), so the operators that aggregate run on
|
||||
the model side. Which other edges cross a wire is a deployment choice. The engine's invalidation signal, which ADR 006's replay trigger
|
||||
relies on, is a correction trigger below, followed from a displayed cell's key back to
|
||||
the model.
|
||||
|
||||
**A request** is a shape key plus a window, given as an absolute cross-product top-left
|
||||
and dimensions. The window is view-store state, and a request costs O(visible).
|
||||
- **Positions** are bigints where they exceed 2⁵³, sent as JSON strings.
|
||||
- **Output** is keyed by tuple identity, with the position attached when sent.
|
||||
- **An item insert, removal or reorder** is sent as an axis change, and each client
|
||||
re-derives its positions.
|
||||
|
||||
**Pruning support.** Pruning is window-relative: a row or column empty within the
|
||||
displayed values is hidden. This replaces today's whole-grid `prune_empty`. The model
|
||||
answers one restricted successor query: "the next k rows after position p with a value
|
||||
in a column of S", and the same for columns. A per-column sorted index of occupied rows
|
||||
answers it in O(|S| log n + k|S|), holding O(occupied cells). Whether pruning runs on
|
||||
the client and which parameters travel are ADR 008's questions, and this design supports
|
||||
either answer. If the client prunes, it prunes its window, fills forward with that
|
||||
query, and, when scrolling up or left, finds a new top-left by galloping backwards in
|
||||
doubling jumps (about 14 to 28 probes at density 10⁻⁴).
|
||||
|
||||
**The change notice.** Each command commits on its own (ADR 006), and after each
|
||||
committed command the host emits one notice, addressed to no client. A frame
|
||||
whose later command fails keeps its earlier commands, and their notices, published. A
|
||||
notice carries:
|
||||
- the stamp `(host instance, document_generation, publication)`. The publication number
|
||||
increments once per committed command. The host instance changes on restart; it is a
|
||||
random id, never a wall-clock time;
|
||||
- axis changes, display relabels as `(id, display)`, and a flag when the formula set,
|
||||
`measure_agg`, the view definitions or the default view moved. A shared display's
|
||||
` #2` suffix is never sent: the client computes it from the displays in scope;
|
||||
- the shape keys evicted since the last notice;
|
||||
- for each live shape key, the moved output keys (cells, formula cells and totals), as
|
||||
the circuit computes them. Moved records alone would miss formulas with cross-stem
|
||||
inputs.
|
||||
|
||||
**A client receives only what it needs.** Filtering is part of the DBSP graph, not a
|
||||
separate mechanism. A client either pulls just the state it needs, or pushes a filter to
|
||||
the host's side of its connection: a filter operator on its shape's query, written in the
|
||||
query language, through which its notices pass. Whether the filter also carries the
|
||||
client's window is an optimisation, since the model store's decisions depend only on the
|
||||
model. A pushed filter is held by the open connection, or by a twin. Otherwise the
|
||||
client sends it with each request. While a connection's filter is open, it counts as a request
|
||||
naming its shape, so its shape is not evicted.
|
||||
|
||||
A notice whose key lists exceed a fixed size carries "correct" instead. That is a
|
||||
fallback, and it costs every reader a correction.
|
||||
|
||||
**What a client does.** It intersects the moved keys with its window. A fold row has no
|
||||
maintained key, so it maps member keys through its own remap first. It then fetches what
|
||||
moved. It asks for a correction when it sees a publication gap, a new
|
||||
`document_generation` or host instance, its shape in the eviction list, a notice it
|
||||
cannot read, or a dropped connection or failed request. A slow client may merge pending notices: the union of
|
||||
keys, the covered range of publications, and axis changes kept in order. Because the
|
||||
wire is lossless, outputs keep the circuit's value identity end to end.
|
||||
|
||||
**What this replaces.** Today's per-session mirrors and pushed diffs (`Sessions`,
|
||||
`ClientSession`, `grid_updates`, `Projection`, `Dirty`). The model store keeps no
|
||||
view-store state, only the shared notice log. `GridExtent` is derived by the client, and in records
|
||||
mode the record count is fetched. `Projection` also published the ADR 001 basis a write
|
||||
claims. Whether `project` returns it instead, and what replaces the layout tier, are
|
||||
ADR 008's questions.
|
||||
|
||||
**Edge adapters.** An edge that crosses a wire has one adapter at each end. Toward the
|
||||
circuit, the client's adapter is a sink for results and notices and a source of requests.
|
||||
Toward the client it is the mirror image. The client's end keeps the watermark. In
|
||||
process, an edge is a function call.
|
||||
|
||||
### 10. Column width hints
|
||||
|
||||
Every column incrementally maintains a width hint, which optimises grid sizing and
|
||||
reduces jitter. The hint is an integrated aggregate: a histogram from display width to
|
||||
count, so a retraction is O(log d). Where in the graph the hint is computed is a
|
||||
scheduling choice, made as an optimisation later.
|
||||
|
||||
### 11. Rollback and memory
|
||||
|
||||
- **Rollback.** The circuit's state joins ADR 006's per-command snapshot, which is taken
|
||||
before a command's effects and restored if the command fails. An inverse delta is not
|
||||
a rewind, because `delay` and `differentiate` keep history. The requirement is that
|
||||
this snapshot does not cost O(cells) per command against ADR 008's 8 ms budget, and
|
||||
that writes do not block reads. How is for the implementation epic.
|
||||
- **Memory.** Resident state beside the store is the multisets, `ExactSum` per summed
|
||||
group, occupancy per live shape, the successor-query index, the width histograms and
|
||||
the bounded notice log. The store itself is the integrated base and is never copied. It
|
||||
is measured at ADR 008's fixture before the store-memory work begins.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
- **Feldera's `dbsp` crate:** 67 required dependencies, including tokio and zstd, and it
|
||||
fails to build for wasm32.
|
||||
- **Compiling formulas into operators:** a second evaluator.
|
||||
- **An ad-hoc dirty-tracking cache:** one invalidation rule per derived fact, each a
|
||||
second description of evaluation.
|
||||
- **A topic or subscription mechanism beside the graph:** filtering is an operator in
|
||||
the graph (clause 9).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Good:**
|
||||
- A write's evaluation costs O(delta) plus the formula cells it reaches.
|
||||
- The store scans and the per-view model clones go.
|
||||
- ADR 008 gets occupancy, totals and widths from one evaluator.
|
||||
- Live sources that report changes plug in.
|
||||
- The model store holds no view-store state.
|
||||
|
||||
**Bad:**
|
||||
- Two backends exist until `Eager` goes.
|
||||
- Integrated state is O(cells) beside the store.
|
||||
- `ExactSum` costs 568 bytes per summed group.
|
||||
- Each command snapshots the circuit's state for rollback.
|
||||
|
||||
**Order** (each step is a ticket in the implementation epic, behind the differential
|
||||
test):
|
||||
1. `zset`;
|
||||
2. the `Derived` trait with `Eager`;
|
||||
3. the cell delta, the record tier and the dirty flag;
|
||||
4. order-free aggregates and occupancy, measured at ADR 008's fixture;
|
||||
5. formulas and the aggregate-over-expression pipeline;
|
||||
6. `Sum`, `Avg` and totals through `ExactSum`;
|
||||
7. live sources;
|
||||
8. delivery.
|
||||
|
||||
Step 8's required tests:
|
||||
- a frame `[AddItem, SetCell]` publishes one notice per committed command, numbered
|
||||
consecutively, and no client asks for a correction;
|
||||
- a dropped notice is detected;
|
||||
- a reconnect after a host restart asks for a correction;
|
||||
- a corrected state equals the target, `held + (target − held) = target`, and
|
||||
correcting a state that is already correct is the zero Z-set (property tests over
|
||||
random edit sequences and losses);
|
||||
- a refused command consumes no publication number, and the commands before it in its
|
||||
frame stay published;
|
||||
- a dropped connection or a failed request triggers a correction, and a later request
|
||||
with the same session resumes from the watermark.
|
||||
- a zero sum has the sign IEEE gives the same addends: `-0.0` only if every addend is
|
||||
`-0.0`, and `+0.0` for an exact cancellation (a property test against IEEE).
|
||||
|
||||
## Open questions
|
||||
|
||||
None.
|
||||
+48
-6
@@ -1792,9 +1792,51 @@ command name a key that does not exist once the two differ. That is the gap
|
||||
improvise-cq1q.11 names, and cq1q.7 later turns these key fields into ids. The
|
||||
active-view marker in both view panels still compares keys for the same reason.
|
||||
|
||||
**What this first slice covers:** the category panel's headers, the tile bar and
|
||||
the view list, in both front ends. Item rows, the grid frame, the page bar,
|
||||
records-mode labels, CSV export headers, status messages and the view title
|
||||
still show keys. They follow in later slices of this step, because the grid's
|
||||
label tuples are also cell coordinates (`cell_key`, `innermost_group`), so their
|
||||
display has to be added at projection time rather than in place.
|
||||
**What the first slice covers:** the category panel's headers, the tile bar and
|
||||
the view list, in both front ends.
|
||||
|
||||
**What the second slice covers (improvise-cq1q.12):** the grid frame, the page
|
||||
bar, records-mode headers and coordinate cells, the category panel's item rows,
|
||||
the view panel's axis line and CSV export. The frame's label tuples are also cell
|
||||
coordinates (`cell_key`, `innermost_group`, `run_start`), so the display is added
|
||||
beside the key and never in its place:
|
||||
|
||||
- `AxisEntry::DataItem` carries `display` parallel to `tuple`, and `GroupHeader`
|
||||
a `group_display`. They are built where the layout is, because a folded row's
|
||||
tuple holds a *group* name where an item name would be and only `expand_category`
|
||||
knows which. `Group` gained a private `display` for it.
|
||||
- `AxisFrame` carries `cat_displays`, `FrameEntry::Item` a `display` tuple and
|
||||
`FrameGroup` a `display`, beside `cats`, `tuple` and `name`. Deserialising
|
||||
refuses a display list that is not the length of its keys. `header_labels`
|
||||
decides *whether* a repeat blanks from the keys and draws the display, and
|
||||
`header_widths` measures what is drawn.
|
||||
- `PageAxis` carries `cat_display`, `selected_display` and `item_displays`
|
||||
beside `cat`, `selected` and `items`. A page command is composed from `items`
|
||||
at an index, so `item_displays` can never be read back as a key.
|
||||
- `CategoryRow.items` is `Vec<CategoryItem { name, display }>`, and
|
||||
`ViewSummary.axes` is `Vec<ViewAxis { category, display, axis }>`.
|
||||
- Records mode: a column header shows the category's or measure's display, and a
|
||||
coordinate cell's *text* is the item's display (`GridLayout::coordinate_display`).
|
||||
Its `edit_text` and `value` stay the key, because the editor seeds the rename
|
||||
from them.
|
||||
- CSV export writes displays in its corner cell, column headers, row labels and
|
||||
page note.
|
||||
- The browser's render model keeps its field names and carries the display in them
|
||||
(`RenderPageAxis.cat`, `RenderCatRow.label`), so `web/` is unchanged.
|
||||
|
||||
Still keys: `GridLayout::row_label`/`col_label` (commands and the status line
|
||||
match on them), `edit_text`, the active-view marker, and everything status
|
||||
messages and the view title say (improvise-cq1q.13). A display change alone does
|
||||
not move `layout_fingerprint`, so it resets no cursor; no production path changes
|
||||
one yet.
|
||||
|
||||
`AxisFrame::cat_displays` is projected and validated, but nothing paints it yet
|
||||
(the corner label has no consumer); that is a gap to close when one does, not a
|
||||
bug. The new wire fields carry no `serde(default)`, so a payload from before this
|
||||
change is refused: deliberate, since zero is a claim and not an absence (the same
|
||||
reason as `AxisFrame::first`), and bundles are stamped so a client and host
|
||||
cannot differ.
|
||||
|
||||
**`.improv` neither writes nor reads a display in this step.** A saved file still
|
||||
carries keys only, so a display set in memory is lost on save; display persists
|
||||
only with the new version literal (improvise-cq1q.5).
|
||||
|
||||
@@ -18,7 +18,7 @@ Published, in four sections:
|
||||
|
||||
| Section | Contents | Why |
|
||||
|---|---|---|
|
||||
| Start here | `docs/design-notes.md`, `context/design-principles.md`, `context/repo-map.md` | What the project is for, the doctrine, and the single best explanation of how it is built. |
|
||||
| Start here | `docs/design-notes.md`, `context/design-principles.md`, `context/repo-map.md`, `context/gotchas.md`, `context/file-inventory.md` | What the project is for, the doctrine, and the single best explanation of how it is built. The map is a digest; the last two are its long forms, published so a `gotcha N` citation and the per-file listing have somewhere to land. |
|
||||
| Decision records | `context/adr/*.md` | An ADR is written for an outside reader by construction — it states the options that were rejected, which is the part that is worth reading and the part that is nowhere else. |
|
||||
| The arguments, by area | `context/code/*.md` | This is the reasoning that used to live in code comments and was moved out under the two-line cap (design-principles §6). It is the most valuable prose in the repository and it is addressed to exactly the reader a documentation site has. |
|
||||
| Test evidence | `context/test-evidence/*.md` | One entry per test whose narrative is longer than its code. |
|
||||
@@ -43,7 +43,7 @@ Not published:
|
||||
will meet, and nobody visiting a website is about to read a PR thread.
|
||||
|
||||
None of the three is referenced by any published document except as a row in
|
||||
`repo-map.md`'s file inventory, which is inside a code fence, so leaving them
|
||||
the file inventory (`file-inventory.md`), which is inside a code fence, so leaving them
|
||||
out creates no dangling link. That was checked rather than assumed.
|
||||
|
||||
**And "not published" is now derived rather than assumed** (improvise-res9).
|
||||
@@ -233,11 +233,13 @@ recorded as a decision rather than left to be rediscovered as a bug.
|
||||
|
||||
## 6. Navigation
|
||||
|
||||
`repo-map.md` is one long document whose gotcha list is numbered 1 to 48, and
|
||||
splitting it per gotcha was rejected: the gotchas cross-reference each other
|
||||
constantly ("see gotcha 35", "for the reason gotcha 39 watches…"), and a reader
|
||||
following those wants a scroll, not 48 page loads. It stays one page, with an
|
||||
id on every list item and a compact strip of numbered links under the heading.
|
||||
`repo-map.md` carries a digest of the numbered gotchas and `gotchas.md` the long
|
||||
form; the map was condensed because it was too long to put in every new agent's
|
||||
context. Splitting per gotcha was rejected: the gotchas cross-reference each
|
||||
other constantly ("see gotcha 35", "for the reason gotcha 39 watches…"), and a
|
||||
reader following those wants a scroll, not one page load per number. Each of the
|
||||
two stays one page, with an id on every list item and a compact strip of
|
||||
numbered links under the heading.
|
||||
The ids come from the list item's **own number**, not from a counter, so an
|
||||
inserted gotcha renumbers the anchors exactly as it renumbers the prose that
|
||||
cites them.
|
||||
|
||||
@@ -24,7 +24,7 @@ over `unescape_pipe_body`.
|
||||
|
||||
Before improvise-i2m5 there were **three** copies and they had drifted, over
|
||||
the keyword rule below. **`improvise-io` no longer has one** — its
|
||||
`persistence/quoting.rs` is deleted. `context/repo-map.md`'s file inventory
|
||||
`persistence/quoting.rs` is deleted. `context/file-inventory.md`
|
||||
kept claiming io "still has its own unguarded copy" long after the deletion —
|
||||
it was corrected in wave 1, but any *other* doc or comment still saying so is
|
||||
wrong, and that is the shape this particular fact goes stale in.
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
# `numtower`: the reasoning
|
||||
|
||||
`crates/numtower` is ADR 005 migration step 1 (improvise-59nw.1): the numeric tower,
|
||||
first and alone. Nothing in improvise depends on it yet, so it changes no behaviour.
|
||||
The decisions are the ADR's (`context/adr/005-exact-decimal-arithmetic.md`). This file
|
||||
records how the crate reads them, the places the ADR left a choice open or said two
|
||||
things, and the measurements. The evidence for each test is in
|
||||
`context/test-evidence/numtower.md`.
|
||||
|
||||
## The shape
|
||||
|
||||
| type | what it is | ADR |
|
||||
|---|---|---|
|
||||
| `Num` | the stored value: an exact `Decimal`, an exact `Ratio<i128>`, or an `f64` | clauses 1, 2 |
|
||||
| `NumRef` | a borrowed view of a `Num`'s arm, for exhaustive matching | — |
|
||||
| `Interim` | a value inside one evaluation; exact intermediates widen to a bigint rational | question 13 (b) |
|
||||
| `NumSum` | a retractable, order-free `SUM` over any `Num` | clause 8, question 16 |
|
||||
| `ExactSum` | ADR 009's f64 superaccumulator | question 15 (a) |
|
||||
| `parse_number`, `is_number`, `Typing` | clause 3's number language, gate and parse | clause 3, question 12 |
|
||||
| `Display` / `Num::from_canonical` | the canonical text: `12.50`, `10/3`, `#i0.1`, `inf`, `-inf`, `nan` | clauses 10, 11, 14 |
|
||||
| `Num::round_to_places` | display rounding, the mode a parameter | clause 12 "Format" |
|
||||
|
||||
**`Num` is a struct with private arms, not the ADR's `pub enum`.** The ADR's sketch
|
||||
(clause 2) shows `pub enum Num { Exact(Decimal), Fraction(Box<Ratio<i128>>),
|
||||
Inexact(f64) }`. A public enum would let any caller build values the tower never
|
||||
produces: `Fraction(1/2)` (which must be the decimal `0.5`), an unreduced fraction, or an
|
||||
exact `-0`. Each would break exact equality or the fingerprint of clause 9. So the arms
|
||||
are private, every constructor normalizes, and callers match on `NumRef`. That is
|
||||
`context/design-principles.md` §3, smart constructors. It is also what clause 14's table
|
||||
asks for, since improvise calls "`Num`'s accessors for exactness, mantissa, scale and
|
||||
fraction parts". The `Box` lives in one constructor, as `AGENTS.md` asks. `Num` is 24
|
||||
bytes, as the ADR measured, and `a_num_is_24_bytes` pins it.
|
||||
|
||||
**The normal form.** An exact zero has no sign. A fraction is reduced and has a positive
|
||||
denominator. A value whose reduced denominator is `2^p·5^q`, with `k = max(p, q) <= 28`
|
||||
and a mantissa that fits 96 bits, is always the `Decimal`, never a `Fraction`. So
|
||||
`Fraction(n/1)` exists only when `n` does not fit 96 bits, as with `10^35/1`. Every `i128`
|
||||
is a valid numerator, `i128::MIN` included, because the arm is `Ratio<i128>`. That one
|
||||
value cannot be negated in `i128`, so `Operand::parts` keeps it out of the `i128` fast
|
||||
path and its arithmetic takes the bigint path (Copilot on PR #337).
|
||||
|
||||
## Arithmetic
|
||||
|
||||
### Why there are two types: `Num` and `Interim`
|
||||
|
||||
Question 13 (b) bounds only the **stored** result: "every intermediate inside one formula
|
||||
evaluation is unbounded up to the ceiling". The bounded `Num` cannot be such an
|
||||
intermediate, so `Interim` is the widened type. It is either a stored `Num` or an exact
|
||||
`BigQ` (a reduced `BigInt` fraction) that does not fit `i128`. `Interim::store` applies
|
||||
the stored bound. The operators on `&Num` are one-operation evaluations: each builds the
|
||||
`Interim`, applies the operation and stores the result. So `(a * a) / a` returns
|
||||
`a` exactly when computed through `Interim`, and is inexact when computed through `Num`
|
||||
(`an_intermediate_beyond_i128_comes_back_exact`). That difference is (b)'s whole point.
|
||||
Step 3's evaluators should evaluate through `Interim` and store once per cell.
|
||||
|
||||
**So `Interim` compares, and knows zero, without storing.** `IF(a*a/a = a, …)` and the
|
||||
division guard both run before the stored bound. If `=` had to store its operands first,
|
||||
its answer would depend on how the formula is spelled, which is what question 13 (b)
|
||||
rules out. `Interim::cmp_exact` is clause 6's exact order over every pairing: two stored
|
||||
values go through `Num::cmp_exact`, and anything wide compares as a bigint rational,
|
||||
against an f64 by its exact binary value. `Interim::is_zero` is the one zero predicate. A
|
||||
wide value is never zero, because zero always fits a stored arm. The comparison through
|
||||
storing is the rival (`an_interim_compares_exactly_without_storing_catches_storing_first`):
|
||||
`a` and `a ± 1/(3·10^30)` store to one f64 and compare equal. The review of `302594bf`
|
||||
asked for this or for a note naming it as a step-3 item; the crate does the first.
|
||||
|
||||
### Contagion, and how many roundings a demotion has
|
||||
|
||||
An inexact operand makes the result inexact. Each exact operand is converted by the
|
||||
correctly rounded conversion, and then the f64 operation runs. That is two roundings,
|
||||
which is clause 4's "computed in f64".
|
||||
|
||||
**An exact ∘ exact result that does not fit is rounded once.** It is the correctly rounded
|
||||
f64 of the exact value. Clause 4 still says a demoted sum or product is "rounded twice,
|
||||
once per operand and once in the operation". That text predates option (d). Option (d)'s
|
||||
"Operations" and question 13 (b) both say that a result failing the stored bound is "the
|
||||
correctly rounded f64 of the exact fraction", and under (b) the exact value is always in
|
||||
hand. The crate follows (d), because (d) is decided and later. Both texts cannot hold for
|
||||
one operation, so this is a reading, and it is flagged.
|
||||
|
||||
### Clause 4: the hint, the recompute, the stored scale
|
||||
|
||||
`Decimal + Decimal` and `Decimal × Decimal` first try `rust_decimal`'s checked operation.
|
||||
The result is trusted only when its scale equals the nominal one (`max(sa, sb)` for `+`
|
||||
and `−`, `sa + sb` for `×`). A lower scale is the **hint**, and so is a `None`. On the
|
||||
hint the exact integer result is recomputed in a 256-bit integer, and its trailing zeros
|
||||
are stripped. If the stripped mantissa fits 96 bits at a scale of at most 28, the result
|
||||
is a decimal at the **stored scale**, `min(nominal, 28)`, padded back with zeros while the
|
||||
mantissa fits. Otherwise the result goes to the fraction arm, then to a wide `Interim`.
|
||||
The ADR's own example holds: `1.2500000000000000 × 4.0000000000000000` is `5` at scale 28
|
||||
(`a_recomputed_result_keeps_the_stored_scale_clause_4_names`). A `None` counts as the
|
||||
hint, so that an overflow `rust_decimal` reports is never mistaken for an exact result.
|
||||
|
||||
### Division: the direct test, one implementation
|
||||
|
||||
Clause 5 is normative: write the quotient as a reduced `n / (2^p·5^q)`, then it is a
|
||||
decimal iff `k <= 28` and the padded mantissa fits. The crate does this the same way for
|
||||
every exact pairing, not only for decimals. It reduces the quotient (in `i128` when that
|
||||
cannot overflow, and in a bigint otherwise), then normalizes. `small_ratio` and
|
||||
`decimal_of` are the one normalization, so a decimal reached through any path has the
|
||||
same representation. Multiplying back is never used.
|
||||
|
||||
**A quotient carries its minimal scale** (`10.00 / 2` is `5`, `1 / 4` is `0.25`). The ADR
|
||||
does not say what scale a quotient has. A quotient's scale is not a function of its
|
||||
operands' scales (clause 5), and a result that passed through the fraction arm carries
|
||||
its minimal scale (option (d)), so the minimal scale is the consistent choice. It is a
|
||||
reading, and it is flagged.
|
||||
|
||||
**The ADR's argument against multiplying back is weaker than it says.** Clause 5 rejects
|
||||
it as incomplete, on `1e-28 / 0.5`: `q × 0.5` "needs scale 29, so the check flags it".
|
||||
That holds under revision 6's rule, which demotes on the hint
|
||||
(`division_decides_fit_directly_catches_multiplying_back` keeps that rival). But under
|
||||
revision 7's recompute the product `1e-28` is found exact after its zeros are stripped.
|
||||
So multiplying back by `Num`'s own `*` accepts the ADR's counterexample. In 20 runs of the
|
||||
division property (5,120 random cases plus the three seeds), it agreed with the direct
|
||||
test every time. That was an experiment and is not committed. The direct test stays the
|
||||
rule, because the ADR decided it and because it needs no second division.
|
||||
|
||||
### `POW`
|
||||
|
||||
- **The exponent.** It is exact when it is an exact **integer value**, so `2.0` counts.
|
||||
- **The size, decided before any multiplication.** The result's numerator has at least
|
||||
`|n|·(bits(num) − 1)` bits, and likewise its denominator. If either lower bound passes
|
||||
the ceiling, the result is `powf`, inexact, with nothing allocated. So
|
||||
`POW(1/3, 10^9)` costs nothing. Inside the bound the result is computed exactly, and it
|
||||
can still exceed the ceiling (`3^6000` is 9,510 bits). It then demotes, which is the
|
||||
ceiling's stated exception.
|
||||
- **The scale.** A positive power of a decimal takes clause 4's stored scale with nominal
|
||||
`s·n`, the scale repeated multiplication would give, so `1.50^2` is `2.2500`. A
|
||||
negative power is a division and has its minimal scale, **unit bases included**:
|
||||
`1.0^-2` is `1` and `(-1.0)^-3` is `-1`, as `2.0^-2` is `0.25`. Until the review of
|
||||
`302594bf` the unit-base arm padded every power to `s·|n|`, so `1.0^-2` read `1.00`
|
||||
(Copilot on PR #337). The power property now checks the scale of every decimal result
|
||||
against this rule, with unit bases among its seeds.
|
||||
- **The edge values.** `0^0` is `1`. `0^-n` is `div/0`. `±1^n` is `±1` for any `n`,
|
||||
including one too large to compute.
|
||||
- **A demoted power takes its sign from the exact exponent.** `powf` gets the base's
|
||||
magnitude, and the sign is set from `base < 0 && n odd`. An exponent above 2^53 rounds
|
||||
to an even f64, so handing `powf` the signed base made `(-2)^(2^53 + 1)` read `+inf`
|
||||
(Copilot on PR #337).
|
||||
|
||||
### The ceiling
|
||||
|
||||
`CEILING_BITS = 8,192`. It applies to the bit length of an intermediate's numerator *or*
|
||||
denominator. The ADR states the ceiling as a width, not as which of the two it measures.
|
||||
Past it, the intermediate becomes the correctly rounded f64 of its exact value.
|
||||
|
||||
## Comparison
|
||||
|
||||
- **Exact with exact.** Cross-multiplication in 256 bits. A decimal is `m / 10^s` with
|
||||
`10^28 < 2^94`, so every product fits.
|
||||
- **Exact with inexact.** The ADR's method: convert the exact operand, and the rounded
|
||||
value decides unless it lands on the f64 itself. A tie is resolved against the f64's
|
||||
exact binary value, in a bigint.
|
||||
- **That fast path is sound only because the conversion is correctly rounded.** Injecting
|
||||
`rust_decimal`'s `to_f64` failed `exact_mixed_comparison_is_transitive` as well as the
|
||||
conversion test. A future integer conversion (clause 4 allows one) must keep that.
|
||||
|
||||
`MIN` and `MAX` skip NaN. Equal values break their tie as follows. Only the first rule is
|
||||
the ADR's (question 11); the other two are this crate's, and they make the result
|
||||
independent of argument order:
|
||||
- the exact value is preferred to the inexact one (question 11);
|
||||
- of two equal decimals, the larger scale is kept, for the same reason: it keeps more of
|
||||
what was typed;
|
||||
- of `0.0` and `-0.0`, `MIN` takes `-0.0` and `MAX` takes `0.0`, as IEEE 754's
|
||||
`minimum` and `maximum` do.
|
||||
|
||||
## The number language
|
||||
|
||||
**The gate is literally `f64::from_str` reading a finite value.** The parse is the
|
||||
crate's own lexer, and it never goes through an f64 on an exact path. The property test
|
||||
checks that the two agree, and that the exact value read rounds to the f64
|
||||
`f64::from_str` reads.
|
||||
|
||||
- **A lexeme's scale.** It is the scale the lexeme implies (`1.50` → 2, `1.5e3` → 0). When
|
||||
the implied scale is above 28 but the value fits a decimal (`0.1` written with 29
|
||||
places), clause 4's stored-scale rule applies: strip the zeros, then pad toward 28.
|
||||
The ADR only says "with the scale the lexeme implies", which cannot exceed 28.
|
||||
- **Zero.** It is exact, unsigned, at the implied scale clamped to 0–28, so
|
||||
`0e99999999999999999999` is `0`.
|
||||
- **An exponent too long to read** saturates, because no exact arm can reach it. A
|
||||
significand of more than 180 digits, or a leading digit outside 10^-130 to 10^40, goes
|
||||
straight to f64. Those bounds are wider than the fraction arm can reach (a reduced
|
||||
denominator `10^k / 2^a` is at least `2^k`, so `k < 127`).
|
||||
- **The forced `n/d` arm.** Its value is a decimal, then a fraction, then **the nearest
|
||||
f64**. The ADR says `n/d` "is a fraction or, once normalized, a decimal", and does not
|
||||
say what a forced `n/d` too large for `i128` becomes. The crate applies clause 3's order
|
||||
to it, as it does to every other spelled number. This is a reading, and it is flagged.
|
||||
**Except that the nearest f64 must be finite.** A forced `n/d` whose value rounds to
|
||||
infinity (`n` of 400 digits, `/1`) is text, exactly as the gate makes `1e999` text.
|
||||
Clause 3: "a finite lexeme must never read as infinity". One that underflows is the
|
||||
inexact zero, as `1e-999` is. `is_number` and `parse_number` share the one helper,
|
||||
`forced_fraction`, so the gate and the parse cannot disagree on it.
|
||||
|
||||
**The canonical text is `Display`.** An inexact finite value is `#i` followed by Rust's
|
||||
`{:?}` spelling, which is the shortest that reads back (`#i1e300`, `#i0.1`).
|
||||
`from_canonical` reads all six spellings back, and reads an exact spelling only as an
|
||||
exact value. Clause 14's list puts "`#i…`" in the crate, while its table says "the `#i`
|
||||
prefix and the grammar are improvise's". Those two contradict each other. The crate
|
||||
holds the prefix, because the wire and the file both need the one spelling, and step 7
|
||||
may move the prefix out if the grammar wants it.
|
||||
|
||||
## Rounding to places
|
||||
|
||||
Every arm goes through one exact path: the value as a bigint fraction,
|
||||
`n·10^places / d`, then rounded by the caller's mode (`HalfAwayFromZero` or `HalfEven`).
|
||||
An inexact value is first read exactly from its shortest round-trip spelling, which is
|
||||
improvise-felo.9's fix, so a typed-then-computed `1.005` shows `1.01`. A result that
|
||||
rounds to zero has no sign (`-0.001` at two places is `0.00`). The ADR does not decide
|
||||
that, and today's f64 path would print `-0.00`.
|
||||
|
||||
## Sums
|
||||
|
||||
`NumSum` keeps five things, and every one is an abelian group, so the state is a function
|
||||
of the multiset of terms and not of their order.
|
||||
|
||||
| part | holds | width |
|
||||
|---|---|---|
|
||||
| the decimal partition | every decimal, and every fraction whose denominator is `2^p·5^q` with `k <= 28`, as an integer at scale 28 | 320 bits |
|
||||
| the groups | per other reduced denominator, the summed numerator | 256 bits each, in a `BTreeMap` |
|
||||
| `group_floats` | an `ExactSum` of each group's correctly rounded value | ADR 009's |
|
||||
| `inexact` | an `ExactSum` of the f64 terms | ADR 009's |
|
||||
| `inexact_terms` | the net weight per f64 bit pattern, entries at zero dropped | a `BTreeMap` |
|
||||
|
||||
**The read.** It is exact when no inexact term is present and at most K = 8 groups
|
||||
remain. The groups and the decimal partition are then combined **over the lcm of their
|
||||
denominators, and reduced once**, and the stored bound applies to the result. Past K the
|
||||
read is the f64 accumulators: `group_floats`, the decimal partition converted once, and
|
||||
the inexact terms. That reading rounds each group, then the total. With inexact terms and
|
||||
at most K groups, the exact total is converted once and added to the inexact terms. A
|
||||
group whose numerator returns to zero is dropped, so "how many groups" is a function of
|
||||
state.
|
||||
|
||||
**"Is an inexact term present" is asked per value, never of the net weight.** The first
|
||||
version kept one net count. Inserting `1.0` and retracting `2.0` leaves the count at zero
|
||||
while the accumulator holds −1.0, so the read took the exact branch and dropped it
|
||||
(ChatGPT Codex, P1 on PR #337). Presence is now a map from f64 bit pattern to net weight,
|
||||
dropped at zero, the same rule the groups follow. It costs O(distinct inexact values),
|
||||
the same order as the groups. The K cutoff overrides representability: nine denominators whose exact total fits
|
||||
still read inexact (`more_than_k_denominators_read_inexact_even_when_the_total_fits`).
|
||||
That is question 13's second exception, and the ADR's flagged reading.
|
||||
|
||||
**`ExactSum` is ADR 009's prototype**, from `crates/improvise-core/examples/retraction_drift.rs`
|
||||
on PR #309. It has three changes:
|
||||
- it splits a weight into base-2^30 digits, as ADR 009 says the `numtower` version must,
|
||||
instead of looping per chunk;
|
||||
- it holds 70 limbs, not 67, because a digit's shift adds up to 60 bits;
|
||||
- it reads through the crate's one rational-to-f64 rounding.
|
||||
|
||||
It counts NaN and the infinities, so they can be retracted. A count that is nonzero in
|
||||
either direction is treated as present.
|
||||
|
||||
**`ExactSum`'s `==` compares sums, not representations.** A derived equality compared the
|
||||
raw limbs and the `pending` carry counter, so `0.5 + 0.5 != 1.0`, an insert then retract
|
||||
was not equal to an empty sum, and the same weights added in another order compared
|
||||
unequal (Copilot on PR #337). For a type whose whole point is order-freedom, and which
|
||||
ADR 009's group newtype will wrap, that equality is a trap. It now compares the
|
||||
propagated limbs, which are canonical (every limb but the top in `[0, 2^32)`), plus the
|
||||
three special-value counts, and ignores `pending`. `NumSum` derives no equality: compare
|
||||
its `read()`.
|
||||
|
||||
**For ADR 009's seam crate.** `NumSum` and `ExactSum` both have the shape a Z-set group
|
||||
needs: `add(&Num, i64)` / `add(f64, i64)`, `insert`, `retract`, `read`/`value`, `Clone`.
|
||||
`ExactSum` also has `absorb`, to merge two accumulators. Neither implements a group trait
|
||||
yet, because `zset` does not exist yet. The newtype of question 15 is where that goes.
|
||||
|
||||
## The wide integers
|
||||
|
||||
`Fixed<N>` is a two's-complement integer of `N` 64-bit limbs that never allocates. It is
|
||||
used for the clause 4 recompute (256 bits), exact comparison (256 bits), the decimal
|
||||
partition (320 bits) and the group numerators (256 bits). Clause 14 also names "256-bit
|
||||
helpers for the division reduction". Division reduces in `i128` when the products cannot
|
||||
overflow, and otherwise in a bigint. It does not have a hand-rolled 256-bit gcd. That
|
||||
path is the slow path, and it is measured below.
|
||||
|
||||
## Dependencies
|
||||
|
||||
The normal edges are `rust_decimal` 1.43.0 (std only), `num-rational` 0.4.2 (no default
|
||||
features), `num-bigint` 0.4.8, `num-integer` 0.1.47 and `num-traits` 0.2.19. The last two
|
||||
are direct dependencies, used for `gcd`/`lcm` and the numeric traits. They were already
|
||||
in the tree through `num-rational` and `num-bigint`, so they add no crate. The ADR names
|
||||
only the first three. There is no serde, and `cargo tree --edges normal` shows no
|
||||
`borsh`. `BigRational` is a **dev**-dependency feature only, for the oracle, so the
|
||||
oracle and the implementation do not share a rational type.
|
||||
|
||||
## Measured
|
||||
|
||||
On an Apple M4 Max (64 GB), rustc 1.98.1, `cargo run --release -p numtower --example
|
||||
bench`. Each range is two runs.
|
||||
|
||||
| what | figure |
|
||||
|---|---|
|
||||
| SUM, 18k and 90k typed two-place values, ns per element | f64 0.53; `rust_decimal` fold 2.7–3.0; `Num` `+` fold 7.8–8.2; `NumSum` insert and read 7.9–8.1 |
|
||||
| per operation over 18,000 typed pairs, ns | `Num` add 3.1–3.3, mul 3.0–3.2, div 146–153; `rust_decimal` checked add 1.4–1.5, mul 1.2, div 24.7–25.3 |
|
||||
| `(Rev − Cost) / Rev`, ns per record | 151–153 |
|
||||
| `to_f64` of a decimal (the text path), ns | 36.7–37.1 |
|
||||
| `round_to_places(2)`, ns | 370–382 |
|
||||
| `NumSum` read, K distinct 126-bit denominators, numerators of about 190 bits | K = 2: 5.2–5.4 µs; K = 8: 41.4–42.0 µs |
|
||||
| `ExactSum`, 1M values | 1.78–1.81 ns per insert, 0.29 µs per read |
|
||||
|
||||
Here is how these sit against the ADR:
|
||||
- **The grouped read.** At K = 8 it is inside the ADR's 52–127 µs. The ADR measured
|
||||
250-bit numerators, and these are about 190 bits, because `NumSum` can only reach a
|
||||
group's numerator through its public `add`.
|
||||
- **`ExactSum`.** It agrees with ADR 009's 2.0 ns per update and 0.58 µs per read.
|
||||
- **Division.** At about 150 ns it is above the ADR's *estimate* of 60–110 ns. Most
|
||||
quotients of typed values do not terminate, so each one pays two `i128` gcds and the
|
||||
`Box` of a `Fraction`.
|
||||
- **The rest.** The ADR's whole-recompute measurement at 18k and 90k cells waits for
|
||||
step 3, as the ADR says.
|
||||
|
||||
**The wasm32 cost** comes from a probe outside the repository, built exactly as the
|
||||
ADR's appendix builds one: a `cdylib`, `opt-level = "s"`, LTO, `wasm-opt -Oz
|
||||
--enable-bulk-memory --enable-nontrapping-float-to-int`, `gzip -9`. The baseline already
|
||||
parses and prints an f64.
|
||||
|
||||
| probe | after `wasm-opt -Oz` | gzipped |
|
||||
|---|---|---|
|
||||
| f64 only | 48,331 B | 29,162 B |
|
||||
| the ADR's five operations (parse, `+`, `−`, `/`, print) through `numtower` | +89,375 B | **+36,714 B** |
|
||||
| plus `NumSum`, `cmp_exact` and `round_to_places` | +119,277 B | **+48,221 B** |
|
||||
|
||||
The narrow figure confirms the ADR's estimate of "up to about +36 KB together". The full
|
||||
surface is about +47 KB gzipped. That is what the bundle pays once step 4 sums through
|
||||
the crate and step 6 rounds through it. It is more than the +15.4 KB the user answered
|
||||
question 8 against, and the ADR says question 8 "reopens" if the figure changes the
|
||||
answer. Neither figure is the real bundle, which shares allocator and formatting code
|
||||
with everything else.
|
||||
@@ -902,7 +902,7 @@ The feature is on for **every workspace crate with a normal dependency on
|
||||
serde_json** (improvise-dz8c). Each crate's own manifest names it, because each
|
||||
build is made from one crate alone: the `wasm` CI job and `wasm-pack` invoke
|
||||
per crate, and feature unification cannot reach across separate invocations
|
||||
(`context/repo-map.md`, `## Wasm`). Enabling it in one crate and relying on the
|
||||
(`context/file-inventory.md`, appendix "Wasm"). Enabling it in one crate and relying on the
|
||||
others to inherit it works in `cargo test` and nowhere else.
|
||||
|
||||
**Why "can parse" and not "parses a frame".** The first version of the rule
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
# File Inventory (the long form)
|
||||
|
||||
The per-file listing that `context/repo-map.md` summarises. Moved here unchanged.
|
||||
|
||||
Line counts are a **dated snapshot**, not a maintained fact: measured 2026-09-24 at `c1772ca9`, and **70 of 105 had drifted past 10%** in the three days since the 2026-09-21 refresh — eleven had doubled or more (`client.rs` 255 → 943, `lockstep.rs` 1126 → 4064). Treat them as "is this file big", never as a figure to cite; re-derive with `wc -l`. The `ws-server/` and `wasm-client/` entries use a shorthand prefix, so a refresh must map it to `crates/improvise-<name>/{src,}/` by hand or they drift silently. Test counts are informational — run `cargo test --workspace` for live numbers. Files under 100 lines and render-only widgets omitted.
|
||||
|
||||
### `improvise-core` (`crates/improvise-core/src/`)
|
||||
```
|
||||
model/types.rs 3560 / 112t Model, formula eval (fixed-point), CRUD
|
||||
model/cell.rs 1358 / 47t CellKey (sorted), CellValue, DataStore (interned + index),
|
||||
live_records (insertion order — gotcha 18)
|
||||
model/record.rs 226 / 8t RecordHash / CellId surrogates: the opaque identities a
|
||||
thin client names a cell by (ADR 002)
|
||||
model/category.rs 310 / 9t Category, Item, Group, CategoryKind
|
||||
model/symbol.rs 89 / 3t Symbol interning
|
||||
view/layout.rs 2604 / 57t GridLayout, drill, records mode, records_columns; a private
|
||||
per-axis position index (`row_pos`/`col_pos`, rebuilt by `reindex`)
|
||||
so a data position resolves in O(1) (improvise-felo.13)
|
||||
view/types.rs 817 / 43t View config (axes, pages, hidden, collapsed, format)
|
||||
model/id.rs 290 slugify + IdAllocator: how a generated id is made (ADR 007
|
||||
clause 4, core.md §20). Nothing outside the file calls it yet
|
||||
view/axis.rs 190 / 5t Axis enum + label/next methods (no colour: that is a Face)
|
||||
workbook.rs 413 / 22t Workbook: Model + cross-view ops, item facade
|
||||
format.rs 519 / 40t format_f64, parse_number_format (display only)
|
||||
```
|
||||
|
||||
### `improvise-formula` (`crates/improvise-formula/src/`)
|
||||
```
|
||||
parser.rs 1394 / 79t pest grammar + tokenizer → Formula AST
|
||||
quote.rs 160 / 7t quote_name / is_bare_name / pipe_quote / unescape_pipe_body:
|
||||
the one home, both halves (improvise-i2m5). improvise-gp
|
||||
re-exports it; improvise-io calls it from
|
||||
persistence/{format,parse}.rs
|
||||
formula.pest 108 PEG grammar for formula text
|
||||
ast.rs 130 Expr, BinOp, AggFunc, Formula, Filter
|
||||
```
|
||||
|
||||
### `improvise-io` (`crates/improvise-io/src/`)
|
||||
```
|
||||
storage.rs 334 / 13t Storage trait (read/write/list/delete), PhysicalFs, MemoryFs,
|
||||
`impl Storage for Rc<T>` (improvise-hmws), and
|
||||
contract::storage_contract — public, not cfg(test), because
|
||||
a browser must run that sequence (harness/)
|
||||
opfs.rs 289 / 0t OpfsFs: Storage over the browser's Origin Private File
|
||||
System. wasm32 only. Run by harness/, not by cargo test
|
||||
persistence/improv.pest 179 PEG grammar — single source of truth
|
||||
persistence/mod.rs 441 / 11t document lifecycle: save/load/gzip/legacy-JSON over Storage;
|
||||
facade re-exporting format_md/parse_md/export_csv*
|
||||
persistence/format.rs 401 / 11t format_md: Workbook → .improv text
|
||||
persistence/parse.rs 1684 / 46t parse_md: pest tree-walk → Workbook (+ grammar generator)
|
||||
persistence/export.rs 243 / 5t export_csv / render_csv. Builds its layout with
|
||||
with_frozen_records, NOT new — `new` skips prune_empty
|
||||
(improvise-iot6)
|
||||
persistence/roundtrip_tests.rs
|
||||
1236 / 62t format_md ↔ parse_md characterization (tests only)
|
||||
import/wizard.rs 1967 / 62t ImportPipeline + ImportWizard. `Plan::place` is the one
|
||||
placement pass, shared by `collisions()` and the build so
|
||||
the two cannot disagree; `DuplicatePolicy` / `Collision`
|
||||
(improvise-cgqt)
|
||||
import/analyzer.rs 434 / 17t Field kind detection (Category/Measure/Time/Skip);
|
||||
analyze_records is first-seen order, not HashSet (gotcha 18)
|
||||
import/csv_parser.rs 590 / 26t CSV parsing, multi-file merge
|
||||
```
|
||||
|
||||
### `improvise-protocol` (`crates/improvise-protocol/src/`)
|
||||
```
|
||||
lib.rs 619 / 2t ViewState, ViewCursor, ViewFrame, AppMode (17), Panel,
|
||||
MinibufferConfig, MinibufColor, HELP_PAGE_COUNT
|
||||
reduce.rs 749 / 10t Command + ModelCommand (the wire format), HelpPage, reduce_view
|
||||
render.rs 2157 / 46t CellDisplay, WidgetCache trait + one cache per widget
|
||||
(GridCache/CategoryTreeCache/FormulaCache/ViewListCache/
|
||||
TileBarCache), AxisFrame/FrameEntry/FrameGroup and
|
||||
header_level_widths (the grid frame, gotcha 44),
|
||||
GridCache::seed (the bootstrap compaction), Projection,
|
||||
bootstrap-only Snapshot
|
||||
keymap.rs 975 / 0t KeyPattern, Binding, Keymap, ModeKey (17), TextEntry,
|
||||
PanelKeymap, Keymaps::default_keymaps/prefix_keymap — the
|
||||
shared table both front ends look up. Tests live with
|
||||
dispatch in improvise-command/src/command/keymap.rs
|
||||
viewmodel.rs 170 / 5t flatten_cat_tree — derivations between a cache and a
|
||||
renderer, called by both front ends (improvise-edp)
|
||||
face.rs 682 Face (a role: Axis, WizardField, Mode*, MinibufferOf…), Theme,
|
||||
AnsiSlot — appearance as data a front end applies (ADR 004,
|
||||
protocol.md §10); draw.rs::face_style is the TUI's one translation
|
||||
key.rs 96 / 3t KeyCode, KeyModifiers, KeyEvent (moved from command/)
|
||||
frame.rs 156 / 3t ClientFrame/ServerFrame — the ONE envelope pair, both
|
||||
deployments (improvise-xks6). Nothing else defines one.
|
||||
ClientFrame::Save is a request, not a Command (gotcha 45)
|
||||
basis.rs 257 / 6t Basis (the three tiers), Rejection, BasisCache — ADR 001
|
||||
layer 1. Basis has no Default and no public constructor
|
||||
help.rs 1241 / 14t HelpPage content and Hint { keys: Vec<(KeyCode, KeyModifiers)>,
|
||||
command, caption }. `compact` drops a chord by its MODIFIERS
|
||||
(anything beyond shift), never by parsing a label it just
|
||||
formatted (improvise-thpe) — the guard is the signature
|
||||
resolve.rs 4883 / 88t the browser's key resolvers: a BrowserKey to the Commands it
|
||||
composes, mirroring each Cmd line for line INCLUDING where
|
||||
siblings disagree (gotcha 47). The largest file in the crate
|
||||
client.rs 943 / 15t ClientState: ViewState + one cache per widget, and the fold;
|
||||
in_flight, `opened` (OpenedCell), notify, settle_status
|
||||
(gotcha 47). EditorSession is in lib.rs beside ViewState
|
||||
```
|
||||
|
||||
### `gp-tree` / `improvise-gp` (`crates/gp-tree/`, `crates/improvise-gp/`)
|
||||
```
|
||||
gp-tree/src/lib.rs 575 / 8t Tree, Spec, eval, simplify, render, breed, Population,
|
||||
SplitMix64 (vendored seeded Rng; seed() keeps the seed so
|
||||
a fit can be shown and recorded, pinned to the published
|
||||
reference vectors)
|
||||
gp-tree/src/data.rs 1000 / 21t raw string rows -> f32 feature vectors: per-column type
|
||||
inference, one-hot, mean-imputation + col.missing flag,
|
||||
ColumnSpec/encode_row, Dataset (incl. warnings),
|
||||
Model = trees + plan + seed + FitConfig
|
||||
gp-tree/src/fit.rs 961 / 32t the search: FitConfig (Default = the CLI's numbers), fit()
|
||||
-> simplified champion, cv_regress/cv_classify,
|
||||
permutation_importance, folds/standardize/mse.
|
||||
Returns data; never prints
|
||||
gp-tree/examples/fit_csv.rs 583 CLI over the above: flags, demo dataset, all printing
|
||||
gp-tree/tests/scoring_known_answers.rs
|
||||
250 / 6t f32 bit patterns + a next_u64 witness: the evidence any
|
||||
change to the scoring paths needs (gotcha 37c)
|
||||
improvise-gp/src/lib.rs 978 / 25t formula_text / fitted_formula: a Tree as IF(...) formula
|
||||
text, validated back through parse_formula. A threshold is
|
||||
the SHORTEST decimal that reads back as the same f64 (not
|
||||
the exact expansion), so a row on a split boundary routes
|
||||
the same in f64 as in f32 eval; non-finite values panic
|
||||
improvise-gp/src/dataset.rs 506 / 15t a View's records -> the feature matrix a fit consumes —
|
||||
the improvise-side counterpart of preprocess. Records mode
|
||||
is already a feature matrix (gotcha 1)
|
||||
improvise-gp/src/fit.rs 1778 / 35t one fit end to end: a View's records in, an improvise
|
||||
Formula and the evidence for believing it out. Not in a
|
||||
command, because none of it is terminal-specific
|
||||
```
|
||||
|
||||
### Command layer (`crates/improvise-command/src/command/`)
|
||||
```
|
||||
cmd/core.rs 475 / 2t Cmd trait, CmdContext, CmdRegistry, parse helpers
|
||||
cmd/registry.rs 903 / 13t default_registry() — all registrations
|
||||
cmd/navigation.rs 507 / 12t Move, EnterAdvance, Page*
|
||||
cmd/cell.rs 724 / 20t ClearCellCommand, YankCell, PasteCell, TransposeAxes, SaveCmd
|
||||
cmd/commit.rs 1304 / 27t CommitAndAdvance, CommitBuffer(CommitKind), CommitExport,
|
||||
CommitSave
|
||||
cmd/effect_cmds.rs 519 / 6t effect_cmd! macro, 25+ simple wrappers, `unless_unknown` (gotcha 51)
|
||||
cmd/fit.rs 293 / 8t fit-measure: evolve a tree over the active view and store it
|
||||
as an ordinary `## Formulas` line — nothing GP-specific is
|
||||
left in the document afterwards
|
||||
cmd/grid.rs 988 / 22t ToggleGroupAtCursor, ViewNavigate, DrillIntoCell, TogglePruneEmpty
|
||||
cmd/mode.rs 469 / 13t EnterMode, Quit, EditOrDrill, EnterTileSelect
|
||||
cmd/panel.rs 936 / 24t Panel toggle/cycle/cursor, formula/category/view panels
|
||||
cmd/search.rs 202 / 4t SearchNavigate, SearchOrCategoryAdd, ExitSearchMode
|
||||
cmd/text_buffer.rs 399 / 14t AppendChar, PopChar, CommandModeBackspace, ExecuteCommand
|
||||
cmd/tile.rs 170 / 6t MoveTileCursor, TileAxisOp
|
||||
cmd/wizard.rs 244 / 6t HandleWizardKey — per-step import-wizard key tables
|
||||
keymap.rs 1353 / 44t KeymapSet, Keymap::dispatch/dispatch_transient, effects_of,
|
||||
SetTransientPrefix — what a thin client cannot have. The
|
||||
table itself is improvise-protocol/src/keymap.rs
|
||||
parse.rs 350 / 23t Script/command-line parser (prefix syntax)
|
||||
```
|
||||
|
||||
### Semantic UI state (`crates/improvise-command/src/ui/`)
|
||||
```
|
||||
ui/effect.rs 4020 / 99t Effect trait. 63 effects: 53 `impl Effect for` blocks, 9
|
||||
simple_effect! expansions, and SetTransientPrefix in
|
||||
command/keymap.rs. The 10 outside the brace-match are all
|
||||
ViewOnly; 28 of the 53 are Model(_) (gotcha 38, h3y7)
|
||||
ui/app.rs 5558 / 134t App, ModelState, ViewState + dispatch methods, Panel, handle_key, dispatch_key, document_generation, autosave; apply_effects declares `dirty` for WholeWorkbook and observes it for CellData/LayoutGeometry (gotcha 45)
|
||||
ui/project.rs 6383 / 118t Dirty, Viewport, grid_of/category_rows/formula_texts/
|
||||
view_summaries/tile_entries, grid_updates (the diff),
|
||||
ClientSession + Sessions (connect/apply/rebase/project_all)
|
||||
ui/reduce.rs 1217 / 24t Checked / CheckedBatch / apply_command / model_effects /
|
||||
reduce_key — one door from a Command or a keypress to the
|
||||
authoritative App. `reduce_full` is GONE (improvise-h3y7)
|
||||
ui/host.rs 3410 / 67t Host: one App + one Sessions; handle, commands, project_all,
|
||||
rebase. Lives here, not in ws-server (gotcha 45)
|
||||
ui/cat_tree.rs 174 / 8t Category tree flattener for panel
|
||||
ui/viewport.rs 259 compute_col_widths / compute_row_header_width / visible_cols
|
||||
(pure geometry; the width rule itself is the protocol's);
|
||||
grid_width, SIDE_PANEL_WIDTH, MIN_GRID_WIDTH (improvise-gtn7.1);
|
||||
GridSummary / grid_summary / grid_window (improvise-felo.13)
|
||||
```
|
||||
|
||||
### TUI rendering, draw, main (`src/ui/`, `src/draw.rs`, `src/main.rs`)
|
||||
```
|
||||
ui/grid.rs 905 / 14t GridWidget (ratatui): no &Model, no &View — the frame comes
|
||||
off GridCache (gotcha 44). Its module doc is the best-written
|
||||
statement of that rule in the repo
|
||||
ui/help.rs 277 5-page help overlay (render only)
|
||||
ui/import_wizard_ui.rs 829 / 9t Import wizard rendering. `Panel` is the one writer, and
|
||||
every write is clipped to the panel's rows and width
|
||||
ui/text.rs 169 truncate and wrap, both measured in terminal cells, never bytes
|
||||
draw.rs 1122 / 20t TUI event loop, frame composition; `content_tests` draws a
|
||||
whole frame to check the selection beside a side panel
|
||||
main.rs 428 CLI entry (clap): open, import, cmd, script
|
||||
# other ui/*.rs are small panel renderers — skip unless changing layout/style
|
||||
tests/grid_frame_agreement.rs
|
||||
479 / 6t the two front ends draw the same grid frame (improvise-anr9)
|
||||
tests/records_wide_smoke.rs 67 / 1t
|
||||
tests/shipped_documents_load.rs 333 / 8t every TRACKED .improv/.improv.gz loads, and every record
|
||||
it projects is writable. "Shipped" is read off `git ls-files`,
|
||||
not a .gitignore reading (improvise-c0aj), and the walk's git
|
||||
is scrubbed of every hostile GIT_* setting the caller may carry
|
||||
tests/web_demo_model.rs 83 / 2t every visible cell in web/demo.improv is a Record
|
||||
```
|
||||
|
||||
### Browser host and client (`crates/improvise-ws-server/src/`, `crates/improvise-wasm-client/src/`, `web/`)
|
||||
```
|
||||
ws-server/registry.rs 616 / 14t Registry of Hosts keyed by DocumentId; ClientId {document,
|
||||
session} — a SessionId is unique only within its document.
|
||||
A document is never evicted while the server runs (ADR 001)
|
||||
ws-server/host.rs 4 / 0t a re-export shim only. The session loop is
|
||||
improvise-command/src/ui/host.rs (gotcha 45)
|
||||
ws-server/origin.rs 132 / 6t OriginPolicy: which origins may open a socket. Loopback by
|
||||
default, --allow-origin widens, absent allowed, two refused
|
||||
(improvise-kc7n). The only decision transport::admitted makes
|
||||
ws-server/transport.rs 330 / 4t tokio + tungstenite plumbing, plus the Origin check at the
|
||||
handshake — a refused upgrade, not an ignored socket
|
||||
ws-server/main.rs 190 / 5t CLI entry: serve a file on a port; --bind, --allow-origin
|
||||
ws-server/tests/socket.rs 389 / 6t the one test that uses a real socket: handshake, JSON
|
||||
framing, SessionId routing, which document a URL asked for
|
||||
wasm-client/lib.rs 455 / 8t Client: the wasm-bindgen surface (init, on_message, on_frame,
|
||||
viewport/set_viewport, render_model, set_cell_at, typed_cell)
|
||||
wasm-client/keys.rs 187 / 5t BrowserKey ONLY — a KeyboardEvent flattened to an improvise
|
||||
key. It does NOT resolve: `resolve` and KeyOutcome live in
|
||||
improvise-protocol/src/resolve.rs, shared by both front ends
|
||||
wasm-client/render.rs 1249 / 21t RenderModel: editable / selected / cat tree, decided here
|
||||
wasm-client/socket.rs 547 / 13t the fold (fold_frame/handle_frame), the two compositions
|
||||
and typed() text→CellValue. Defines no enums
|
||||
# state.rs is GONE — ClientState moved to improvise-protocol/src/client.rs
|
||||
wasm-client/tests/browser_round_trip.rs 2136 / 34t improvise-1ey's scope against a real Host
|
||||
wasm-client/tests/lockstep.rs 4064 / 78t host and client agree on a keypress
|
||||
web/app.js 816 string calls only, no protocol knowledge, no framework.
|
||||
Run by harness/tests/shell.spec.js (Chromium; the standalone half on Firefox too) — see
|
||||
gotcha 48 for what that does and does not reach
|
||||
web/worker.js 86 three string calls, no protocol knowledge. RUN by
|
||||
shell.spec.js's no-?ws path, with bindings.rs (gotcha 45)
|
||||
web/shell.js 399 everything app.js DECIDES that is not painting: no DOM,
|
||||
no browser API, one `node --test` away. Owns socketUrl,
|
||||
gridPlan, frameCostOf, documentTitle — so a chunk of the
|
||||
browser's render decisions is NOT in the file gotcha 42
|
||||
and gotcha 46 name. Reasoning: context/code/clients.md
|
||||
web/serial.js 19 the one rule worker.js exists to keep: one frame at a
|
||||
time, in arrival order (gotcha 48's async-listener race),
|
||||
extracted so it is testable without worker globals
|
||||
web/index.html, style.css the page; legibility only
|
||||
web/build.sh 38 wasm-pack build → web/pkg (gitignored). Needs
|
||||
wasm-opt = ["-O", "--enable-bulk-memory"] or the bundled
|
||||
wasm-opt rejects LLVM's memory.copy
|
||||
web/assemble.sh 112 assembles the deployable site (copies web/*.improv)
|
||||
web/demo.improv 65 the document the site ships and seeds from. Every cell a
|
||||
Record — asserted (gotcha 46)
|
||||
```
|
||||
|
||||
### Browser worker host (`crates/improvise-worker-server/`)
|
||||
```
|
||||
improvise-worker-server/src/worker.rs 765 / 17t Worker: one port, one client; handshake, Addressed
|
||||
filter, persist trigger (model_state.dirty). Every decision, every test
|
||||
improvise-worker-server/src/vfs.rs 348 / 8t documents/open: seeding is a Storage::write
|
||||
improvise-worker-server/src/bindings.rs 185 / 0t wasm32 shim: OpfsFs awaits + postMessage. No Rust test
|
||||
reaches it, but harness/tests/shell.spec.js DOES: the
|
||||
no-?ws path runs start()/OpfsFs::open under Chromium
|
||||
and Firefox.
|
||||
Called NEVER RUN here for months: see gotcha 45
|
||||
improvise-worker-server/src/lib.rs 51 / 1t the module doc that states exactly what is and is not
|
||||
covered — the one correct copy of that fact
|
||||
improvise-worker-server/tests/post_message.rs 407 / 8t main-thread traffic, strings only
|
||||
```
|
||||
|
||||
### Browser harness (`harness/`) — not a workspace member
|
||||
|
||||
```
|
||||
harness/playwright.config.js 50 projects: chromium (every spec), firefox (opfs, plus
|
||||
shell.spec's standalone half: grepInvert @mocked-peer),
|
||||
webkit (opfs only, which skips on a runtime probe: no
|
||||
OPFS). CI runs `--project=chromium --project=firefox` only:
|
||||
nixpkgs' WebKit aborts inside the runner VMs (ci.yml)
|
||||
harness/tests/shell.spec.js 345 the first execution of web/app.js. A change to web/*.js
|
||||
belongs here (gotcha 48). Playwright serves harness/serve/,
|
||||
which harness/build.sh ASSEMBLES: edit web/*.js, rebuild,
|
||||
or the specs run the old page
|
||||
harness/tests/opfs.spec.js 103 drives the OPFS contract page and reads back its result
|
||||
harness/opfs-contract/src/lib.rs 470 runs improvise_io::storage::contract::storage_contract
|
||||
against a real OpfsFs inside a real Web Worker
|
||||
harness/www/opfs/{index.html,worker.js} the page and worker that host it
|
||||
harness/build.sh 50 assembles harness/serve/ (web/assemble.sh, the fixtures,
|
||||
and the contract crate's wasm-pack build) — gitignored
|
||||
# gated by the `browser` job in .github/workflows/ci.yml, which can fail the build
|
||||
```
|
||||
|
||||
### Examples
|
||||
```
|
||||
examples/gen-grammar.rs Grammar-walking random .improv generator (pest_meta)
|
||||
examples/pretty-print.rs Parse stdin, print formatted .improv
|
||||
crates/improvise-core/examples/category_width_bench.rs
|
||||
The same records at 12 and at 24 regular categories
|
||||
(improvise-felo.1; context/code/core.md)
|
||||
```
|
||||
|
||||
### Context docs
|
||||
```
|
||||
.claude/skills/code-review/ Review skill: checks a change against the doctrine in these docs + kb.pl
|
||||
scripts/check-comment-runs.py The two-line comment cap AND --check-evidence. The flag is
|
||||
ADDITIVE since improvise-ijql — every invocation that can
|
||||
exit 0 answers the cap — guarded by a subprocess self-test,
|
||||
the only level a dispatch bug is visible at. Reasoning:
|
||||
context/code/tooling.md §2
|
||||
scripts/check-ci-graph.py pages-deploy must `needs` every job in ci.yml, and every
|
||||
suite `checkers.sh --list` knows must run in some CI job
|
||||
(improvise-dak5). It REFUSES what it cannot read rather than
|
||||
parsing YAML: the values it accepts are an allowlist
|
||||
(tooling.md). --self-test; run in the `test` job. README
|
||||
states the rule and lists no jobs (improvise-x7cq)
|
||||
scripts/checkers.sh The one entry point for every *-test.sh suite: scrubs the
|
||||
env to an allowlist, runs under the bash that runs it (no
|
||||
version floor since #297). `ci_suites` is the table of which
|
||||
suites each CI job runs (`--ci <job>`); ci.yml names none
|
||||
scripts/pr-watch Waits on a PR's checks (the pr-watch skill). Exit 5: the PR
|
||||
is already MERGED or CLOSED, so there is nothing to wait for
|
||||
scripts/assemble-stamp.sh The cache-bust stamp gate web/assemble.sh calls, extracted
|
||||
so its refusals can be exercised without a web/pkg
|
||||
scripts/assemble-stamp-test.sh Its check_the_checker, run first in the `build` CI job
|
||||
context/code/tooling.md The repository's own checkers: why an evidence heading's
|
||||
names come from cargo rather than from the source, and
|
||||
why a leaf name is not an identity
|
||||
context/test-evidence/tooling.md Evidence for the gates in scripts/ and for the tests that
|
||||
hold CI's shape
|
||||
scripts/nix-seed-flake-inputs.sh Seeds locked flake inputs over git so `nix develop` works in cloud sessions
|
||||
prolog-sessions/kb.pl Executable knowledge base: invariants, doctrine, decisions (CI-checked)
|
||||
context/adr/007-stable-identities.md Stable identities: ids unique across the document are the
|
||||
keys and names are display-only; gensym suffixes apply to
|
||||
generated ids only, and hand-written ids are canonical.
|
||||
Accepted 2026-09-25. (There is no ADR 005 or 006 file:
|
||||
improvise-l2b8 and improvise-ilvl claim those numbers.)
|
||||
context/adr/004-faces-and-themes.md Faces: appearance as UI-agnostic data a front end
|
||||
applies, emacs-style. Accepted 2026-09-22.
|
||||
context/adr/003-live-backed-models.md A model may be backed by a live query source —
|
||||
improvise as a DB client. Accepted, NOT a priority.
|
||||
context/adr/002-naming-a-cell.md How a client names a cell: an opaque surrogate on
|
||||
CellDisplay, whose absence means the cell is an
|
||||
aggregate. Records why _Index cannot serve.
|
||||
context/adr/001-concurrent-edits.md Concurrent edits between clients: record-level atomicity,
|
||||
optimistic basis check per batch, advisory reservations.
|
||||
Records why a causal clock was rejected.
|
||||
context/cloud-agents.md Cloud-session environment: tracker sync, bd pitfalls, what is blocked
|
||||
context/design-principles.md Architectural principles & testing doctrine
|
||||
context/plan.md Show HN launch plan
|
||||
context/repo-map.md The map: crates, how-to-find-things, the gotcha digest, a file map
|
||||
context/gotchas.md The long form of every numbered gotcha (the digest is in repo-map.md)
|
||||
context/file-inventory.md This file: the per-file listing the repo map summarises
|
||||
docs/design-notes.md Product vision & non-goals
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# Wasm, CLI, testing and dependencies (long form)
|
||||
|
||||
The three repo-map sections of these names, as they stood before the map was condensed.
|
||||
|
||||
## Wasm
|
||||
|
||||
The browser bundle is `improvise-core`, `improvise-formula`, `improvise-io`,
|
||||
`improvise-protocol`, `improvise-command`, `improvise-wasm-client` and
|
||||
`improvise-worker-server`. All seven
|
||||
`cargo check --target wasm32-unknown-unknown` clean with no feature flags
|
||||
(improvise-ywd), gated by the `wasm` job in `.github/workflows/ci.yml`. That
|
||||
job gates **eight** crates, not seven: `improvise-gp` was added (improvise-z5eg)
|
||||
explicitly *not* as a bundle member but as a candidate, because staying
|
||||
wasm-clean is free now and expensive to retrofit — so the bundle is seven and
|
||||
the gate is eight, and neither number is the other one's typo.
|
||||
**A feature that adds a dependency to a bundle crate must be checked with `cargo tree --edges normal`** — the default output includes **dev** edges, so a plain `cargo tree -p improvise-protocol --target wasm32-unknown-unknown` reports a dev-only `serde_json` as *present* and reads exactly like a leak. Check per-crate too, because the `wasm` CI job invokes each crate separately and feature unification cannot reach across that (improvise-26dm added a `test-support` feature on `improvise-protocol` on those terms; it is off in every shipped build).
|
||||
|
||||
`improvise-ws-server` is the host and is native only — tokio, tungstenite and
|
||||
`Box<dyn Storage>` — so `improvise-wasm-client` cannot depend on it in anger. It
|
||||
no longer needs to: both ends `pub use` `improvise_protocol::frame` and neither
|
||||
defines a pair (gotcha 42). The **dev**-dependency remains, for
|
||||
`tests/browser_round_trip.rs`, which hands the client's own serialized frame to
|
||||
a real `Host::handle`. The root `improvise` crate is
|
||||
the terminal binary and does **not** build for wasm — it depends on
|
||||
crossterm; that is correct, not a gap.
|
||||
|
||||
`cargo check` passing is not the same as running. Two known runtime hazards
|
||||
it cannot see: `Instant::now()` panics on wasm (hence `web-time` in
|
||||
`improvise-command`), and any `std::fs` call compiles but fails in a browser
|
||||
— improvise-fuj closed the effects that bypassed `Storage`; autosave and the headless final write still do (improvise-31oe.11).
|
||||
|
||||
## CLI
|
||||
|
||||
```
|
||||
improvise [model.improv] # open TUI (default)
|
||||
improvise import data.csv [--no-wizard] [-o out] # import CSV/JSON
|
||||
improvise cmd 'add-cat Region' -f model.improv # headless command(s)
|
||||
improvise script setup.txt -f model.improv # run script file
|
||||
```
|
||||
|
||||
Import flags: `--category`, `--measure`, `--time`, `--skip`, `--extract`, `--axis`, `--formula`, `--name`, `--split-date <field>` (a date column imports as ONE hierarchical category by default; this restores the flat split, and `--extract` still picks the components — improvise-8jk6.3), `--strict` (with `--no-wizard`, any skipped record is a refusal and nothing is written, where the default writes and warns with a count — improvise-8jk6.6), and `--on-duplicate refuse|sum|separate` (default `refuse`; applies with `--no-wizard`, where the wizard would otherwise ask — improvise-cgqt). A headless import also refuses a `--split-date` that names a non-date field and an `--axis` that names a category the import did not build (exit 1).
|
||||
|
||||
---
|
||||
|
||||
## Testing — the short version
|
||||
|
||||
Full guidance lives in `context/design-principles.md` §7. Quick reminders:
|
||||
|
||||
- **Two numbers, not one** — the ambiguity between them is what let the old
|
||||
"suite runs in <2s" drift: **no single test binary over 2 seconds**, and the
|
||||
workspace wall clock is the sum. Measured at `63a5c13`: **1464 tests over 35
|
||||
binaries** (1 ignored), longest single binary **0.6–1.0s across two runs on one machine** —
|
||||
the spread being its own argument for reading your own run. Excludes compilation,
|
||||
and excludes `harness/` (Playwright, minutes, its own CI job). *Re-measure* rather
|
||||
than re-assert — `cargo test --workspace` prints a `finished in` per binary, and
|
||||
**the portable signal is the ratio, not the seconds**. **Not the ratio to the
|
||||
median, though: it is degenerate.** Most binaries print `0.00s` at two decimals,
|
||||
so the median is zero and every ratio to it is undefined (improvise-i5z6, 39
|
||||
binaries). Compare the slowest binary to the next few slowest instead: an outlier
|
||||
is one that sits well clear of them *in every run*. That is how i5z6 was read —
|
||||
`improvise-formula`'s lib longest of 39 in all three runs, at 0.91–1.95s against a
|
||||
runner-up's 0.50–0.92s. Both outliers so far had one cause: a generator re-parsing
|
||||
its pest grammar per case. `improvise-io` (improvise-8shu, 1.42s → 0.21s) and
|
||||
`improvise-formula` (improvise-i5z6, 0.84–0.98s → 0.06–0.07s) each read it once through a
|
||||
`OnceLock` now. A new grammar-walking strategy should do the same from the start.
|
||||
The full argument is `context/design-principles.md` §7.
|
||||
- Commands: build a `CmdContext`, call `execute`, assert on returned effects. No terminal needed.
|
||||
- Property tests (`proptest`, default 256 cases) cover invariants: CellKey sort, axis consistency, save/load roundtrip, `parse(format(parse(generate())))` stability.
|
||||
- **Bug-fix workflow**: write a failing test *before* the fix (regression guard). Record the bug in `context/test-evidence/`, **keyed by test name** — *not* in the test's doc comment, which the two-line comment cap forbids and `scripts/check-comment-runs.py` enforces. A heading may be module-qualified and must be when its leaf is not unique.
|
||||
- Coverage target ~80% line/branch on logic code; skip ratatui render paths.
|
||||
|
||||
---
|
||||
|
||||
## Key dependencies
|
||||
|
||||
ratatui 0.30, crossterm 0.29, clap 4.6 (derive), serde/serde_json, indexmap 2, anyhow, chrono 0.4, pest + pest_derive, flate2 (gzip), csv, enum_dispatch. Dev: proptest, tempfile, pest_meta.
|
||||
File diff suppressed because one or more lines are too long
+93
-524
File diff suppressed because one or more lines are too long
@@ -2103,3 +2103,122 @@ fails on that rival (no `▶ Overview`), because the active view is a key
|
||||
|
||||
The browser half of the same three panels. It also asserts that the view row is
|
||||
still `active` when found by key.
|
||||
|
||||
## Displays in the grid frame, page bar, item rows and CSV (ADR 007 clause 2, improvise-cq1q.12)
|
||||
|
||||
The same test-only way to make a display differ from its key (`set_display`,
|
||||
`set_item_display`, `set_group_display`). Each rival below was injected, the
|
||||
workspace run, and the named tests failed; the revert was re-run green.
|
||||
|
||||
### view::layout::tests::a_layout_shows_displays_and_still_addresses_by_keys
|
||||
|
||||
Rivals: an item's display pushed as its key (`display.push(item_name)`); a page
|
||||
axis's `selected_display` set to `selected`. Both fail on the display asserts,
|
||||
while `cell_key`, `row_group_for` and `row_run_start` stay on keys.
|
||||
|
||||
### view::layout::tests::a_folded_group_is_shown_under_the_groups_display_and_keyed_by_its_name
|
||||
|
||||
A folded row's tuple holds the group name where an item name would be, so the
|
||||
display must come from the group. Not a distinct injection; it covers the arm
|
||||
that the item test cannot reach.
|
||||
|
||||
### view::layout::tests::a_records_grid_shows_displays_in_its_headers_and_coordinates
|
||||
|
||||
Rival: `coordinate_display` returns the item key. Fails here and in
|
||||
`ui::project::tests::a_records_coordinate_shows_its_display_and_edits_its_key`.
|
||||
|
||||
### ui::project::tests::the_frame_shows_displays_and_its_key_tuples_still_address_the_cells
|
||||
|
||||
The rival the ticket names: `axis_frame` writes `tuple: display.clone()`. The
|
||||
test rebuilds every cell's `CellKey` from the frame's key tuples, its `cats` and
|
||||
the page bar, and compares it to `layout.cell_key`, so it fails (and so does the
|
||||
records test, which looks the Region column up by its key tuple). Also fails when
|
||||
a group header's display is its key.
|
||||
|
||||
### ui::project::tests::each_view_summary_shows_its_own_display_and_its_categories_displays
|
||||
|
||||
Rival: every summary takes the ACTIVE view's display. The second view "Plan" has
|
||||
a display unlike both the active view's ("Overview") and its own key, so the
|
||||
fault fails here by name.
|
||||
|
||||
### ui::project::tests::an_item_row_shows_its_display_and_keeps_its_key
|
||||
|
||||
Rival: the item row's display is its key. The unrenamed item "West" must show its
|
||||
key, and `_Measure`'s "Revenue" shows "Sales".
|
||||
|
||||
### ui::project::tests::the_page_bar_carries_displays_beside_the_keys_a_page_command_names
|
||||
|
||||
Rival: `selected_display` is the key. Fails here, in the layout test, in the TUI
|
||||
grid test and in the CSV test.
|
||||
|
||||
### render::tests::a_header_draws_displays_and_blanks_a_repeat_by_key
|
||||
|
||||
Rival: suppression compares display tuples. West shares East's DISPLAY ("Same")
|
||||
and must still draw, so comparing displays blanks it.
|
||||
|
||||
### render::tests::a_header_is_as_wide_as_what_it_shows
|
||||
|
||||
Rival: `header_widths` measures the key tuples. Fails (key "E" against display
|
||||
"Eastern Seaboard"), as does the TUI grid test.
|
||||
|
||||
### render::tests::a_frame_with_ragged_displays_is_refused
|
||||
|
||||
Asserts the control parses, then that a `cat_displays` shorter than `cats` and a
|
||||
display tuple shorter than its key tuple are each refused with their own message.
|
||||
|
||||
### resolve::tests::hiding_a_displayed_row_item_names_its_key
|
||||
|
||||
Rival: the hide resolver reads the first DISPLAY of the row frame. It composes
|
||||
`HideItem { item: "Eastern Seaboard" }` and the test fails on `"East"`.
|
||||
|
||||
### resolve::tests::picking_a_displayed_page_item_still_names_its_key
|
||||
|
||||
Picks index 1 of a page axis whose displays are not its keys and requires the
|
||||
command to name `Q2`. The index is the only thing that travels from the menu.
|
||||
|
||||
### render::tests::the_grid_page_bar_and_item_rows_paint_displays
|
||||
|
||||
The browser half. Rival: the cycle's `items` relays the keys, which fails here.
|
||||
|
||||
### ui::grid::tests::the_frame_and_page_bar_draw_displays_not_keys
|
||||
|
||||
The TUI half. Rival: the group header draws `group.name`; fails on the missing
|
||||
"▼ Bay". The key assertions use strings that appear only as keys.
|
||||
|
||||
### ui::category_panel::tests::an_item_row_draws_its_display_not_its_key
|
||||
|
||||
The TUI item row. Not separately injected: its draw path is the one line the
|
||||
`CatTreeEntry::Item` pattern changed.
|
||||
|
||||
### persistence::export::tests::export_csv_writes_displays_in_every_header_and_label
|
||||
|
||||
Rival: the column header uses `col_label` (the key). The exact output pins the
|
||||
corner cell `Kind (Who=Alicia)`, the column header and the row label.
|
||||
|
||||
### view::layout::tests::a_page_axis_with_ragged_displays_is_refused
|
||||
|
||||
From Copilot's finding on #336: `item_displays` is a parallel list on a wire type
|
||||
that derives `Deserialize`. The control parses a well-formed axis first; then a
|
||||
shorter list and an absent one beside `items: Some(..)` are each refused with the
|
||||
"item displays" message. Without the `try_from` the same JSON parses.
|
||||
|
||||
### ui::viewport::tests::a_column_is_as_wide_as_the_display_its_header_draws
|
||||
|
||||
From the Opus review of #336: `header_level_seed` could measure keys and the
|
||||
suite stayed green. The fixture's column display ("Januaryyyyy", 11) is far longer
|
||||
than its key ("Jan") and the cell, so the 5-column minimum cannot hide it. Rival:
|
||||
the seed reads `tuple` instead of `display`; the width is 5, not 12, and this test
|
||||
fails.
|
||||
|
||||
### ui::viewport::tests::the_row_header_is_as_wide_as_the_display_its_rows_draw
|
||||
|
||||
Rival: `compute_row_header_width` reads `tuple`. That width drives `visible_cols`
|
||||
on the keypress path (the improvise-gtn7.1 shape: a scroll measured elsewhere than
|
||||
it is drawn), so a key measurement lets the cursor scroll to an undrawn column.
|
||||
The display "Eastern Seaboard" against the key "E" gives 17, not the 4 minimum.
|
||||
|
||||
### render::tests::a_column_group_header_is_painted_by_its_display
|
||||
|
||||
Rival: `col_groups` labels a run with `g.name`. The browser test for group displays
|
||||
covered row groups only; this one folds a column frame whose group "Q1" is shown as
|
||||
"First quarter" and fails on the key.
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
# Test evidence: `numtower`
|
||||
|
||||
What each test guards, which rival it was run against, and where that rival failed. The
|
||||
entries are keyed by exact test name, and a rename orphans its entry, which
|
||||
`scripts/check-comment-runs.py --check-evidence` catches. The reasoning these tests defend
|
||||
is in `context/code/numtower.md`. The decisions are ADR 005's.
|
||||
|
||||
**How the rivals are kept.** Every property in `crates/numtower/tests/properties.rs` is a
|
||||
checker that takes the implementation as a parameter. The property's own test runs it on
|
||||
the real code. A companion test, named `<property>_catches_<rival>`, runs the same checker
|
||||
on the rival, with a deterministic seed, and asserts two things:
|
||||
- the run fails;
|
||||
- the failure message carries **the property's own assertion text**, so a rival that
|
||||
fails in setup, panics, or fails some other assertion does not count (`assert_caught`
|
||||
in `tests/oracle/mod.rs`).
|
||||
|
||||
So the rival is watched failing on every run of the suite, not once by hand. Two rivals
|
||||
were also injected into the crate's source, and the run was recorded. Those runs are noted
|
||||
under their entries.
|
||||
|
||||
**The oracle** is `num-rational`'s `BigRational`, a dev-dependency feature. The crate
|
||||
itself never uses `BigRational`, so the oracle shares no rational type with the code under
|
||||
test. "Correctly rounded" is checked in the review's interval form (`is_nearest`). The
|
||||
exact value must lie inside the round-to-nearest-even interval of the returned f64, with
|
||||
the endpoints included only when the f64's mantissa is even. Comparing against
|
||||
`BigRational::to_f64` would have been circular in the way ADR 005's option (d) section
|
||||
describes.
|
||||
|
||||
---
|
||||
|
||||
## `crates/numtower/tests/properties.rs`
|
||||
|
||||
### an_exact_result_is_never_a_rounded_one
|
||||
|
||||
Every exact pairing of arms and every operation (`+ − × ÷`) is checked against the oracle.
|
||||
When the result claims to be exact, its value must be the exact value. The generator
|
||||
spans 96-bit mantissas, scales 0–28 and `i128` fractions. A fixed case pins ADR 005's own
|
||||
example, `1000 + 0.3333333333333333333333333333`. `rust_decimal` returns that at scale
|
||||
25, rounded, and the crate must not.
|
||||
|
||||
**Injected in the source.** In `arith.rs::decimal_sum`, the scale filter was deleted, so
|
||||
`checked_add`'s result was trusted. Three tests failed: this one,
|
||||
`contagion_is_total`, and `a_recomputed_result_keeps_the_stored_scale_clause_4_names`.
|
||||
The revert was followed by a full rerun.
|
||||
|
||||
### an_exact_result_is_never_a_rounded_one_catches_a_silently_rounding_add
|
||||
|
||||
The rival is `rust_decimal`'s own `checked_add`, trusted as exact. That is the one
|
||||
outcome ADR 005 clause 4 exists to prevent.
|
||||
|
||||
### contagion_is_total
|
||||
|
||||
Every pairing of the three arms, NaN and the infinities included, through `+ − × ÷`:
|
||||
- an inexact operand makes the result inexact;
|
||||
- an exact pair gives an exact result **iff** the oracle value fits a stored arm, and
|
||||
otherwise the correctly rounded f64 of that value;
|
||||
- division refuses iff the divisor is exactly zero, at either level.
|
||||
|
||||
"Total" means no pairing is missing. A pairing the code never handled would panic, and the
|
||||
runner reports a panic as a failure.
|
||||
|
||||
### contagion_is_total_catches_an_integral_inexact_kept_exact
|
||||
|
||||
The rival is a "harmless" promotion: an integral f64 is treated as exact, so `2 + 3.0` is
|
||||
exact. It is caught on the seed pair `(2, Inexact(3.0))`. That pair is in the generator on
|
||||
purpose. Integral f64s are rare among random floats, so without the seed the rival was
|
||||
caught only by luck.
|
||||
|
||||
### pow_is_exact_for_an_exact_base_and_integer_exponent_when_it_fits
|
||||
|
||||
Bases from every arm, exponents −6 to 6. An exact base gives an exact result iff the
|
||||
oracle's power fits, and otherwise the correctly rounded value. `0^-n` refuses.
|
||||
|
||||
### pow_decides_its_size_before_computing_and_honours_the_ceiling
|
||||
|
||||
- `POW(1/3, 10^9)` returns at once, inexact. Its lower bound is past the ceiling, so
|
||||
nothing is allocated. Without the size check the test does not finish.
|
||||
- `(1/3)^6000` is computed, but is 9,510 bits and demotes. That is the ceiling's
|
||||
exception.
|
||||
- `1.05^28` is exact and `1.05^29` is not, the option (d) table's k = 29.
|
||||
- Also pinned: `(-1.0)^(10^20)`, `0^0`, the scale of `1.50^2`, and that a non-integer
|
||||
exponent is `powf`.
|
||||
|
||||
### the_f64_conversion_agrees_with_parsing_the_decimal_text
|
||||
|
||||
The generator spans **96-bit mantissas and scales 0–28**, as ADR 005 requires. The oracle
|
||||
text is built digit by digit, not by `rust_decimal`'s `Display`, which is what the crate
|
||||
itself calls. So the test does not compare the crate's path with itself. The review's
|
||||
case `63294.607176721429540940386554` is pinned.
|
||||
|
||||
**Injected in the source.** `num.rs::decimal_to_f64` was replaced with `rust_decimal`'s
|
||||
`ToPrimitive::to_f64`, and three tests failed:
|
||||
- this one, minimal case `-0.10806611601966961` at scale 17;
|
||||
- `the_number_languages_gate_and_parse_agree`, on `92240.01133710762224`;
|
||||
- `exact_mixed_comparison_is_transitive`.
|
||||
|
||||
The third was not expected. The comparison's fast path decides by the rounded value, and
|
||||
that is sound only if the rounding is correct.
|
||||
|
||||
### the_f64_conversion_agrees_with_parsing_the_decimal_text_catches_rust_decimal_to_f64
|
||||
|
||||
The rival is `rust_decimal`'s `to_f64`, which is not correctly rounded (ADR 005, B2). The
|
||||
test also asserts that the rival misses on the review's case, so the pinned case is known
|
||||
to discriminate.
|
||||
|
||||
### rust_decimal_to_f64_agrees_on_fifteen_digit_mantissas_so_a_narrow_generator_is_vacuous
|
||||
|
||||
This test **passes the rival** on purpose. With mantissas of at most 15 digits,
|
||||
`rust_decimal`'s `to_f64` agrees with the text every time, at this test's 256 cases as
|
||||
in ADR 005's 200,000 of 200,000. So a test whose generator stays in that range proves
|
||||
nothing about the conversion, and this test is why the generator above spans 96 bits. It
|
||||
ran at 4,096 cases until Copilot pointed out, on PR #337, that the repository's budget is
|
||||
256 (`context/design-principles.md` §7). The 200,000 is the ADR's measurement, and this
|
||||
test only pins its direction.
|
||||
|
||||
If `rust_decimal` ever fixes its conversion, this test still passes. What fails then is
|
||||
`the_f64_conversion_agrees_with_parsing_the_decimal_text_catches_rust_decimal_to_f64`,
|
||||
whose rival would agree. That is the signal to re-read both. An earlier version of this
|
||||
entry named this test instead, which was wrong.
|
||||
|
||||
### a_fractions_conversion_lies_inside_the_round_to_nearest_even_interval
|
||||
|
||||
Reduced fractions with numerator and denominator widths from 20 to 126 bits, both signs:
|
||||
the review's independent form. It also checks the product of two such fractions, which
|
||||
reaches the bigint path and is stored. That covers `ratio_to_f64` on values wider than
|
||||
`i128`.
|
||||
|
||||
### a_fractions_conversion_lies_inside_the_round_to_nearest_even_interval_catches_an_f64_division
|
||||
|
||||
The rival is `n as f64 / d as f64`, which rounds three times.
|
||||
|
||||
### exact_mixed_comparison_is_transitive
|
||||
|
||||
The generator is a **crowd**: values crowded around one f64 `x`.
|
||||
- `x` itself, and its two neighbours;
|
||||
- the shortest decimal that spells `x`, and that decimal ± `k·10^-25`;
|
||||
- a third of the shortest decimal.
|
||||
|
||||
Every triple is checked for transitivity, and every pair for antisymmetry. Random values
|
||||
would almost never put two exacts and an inexact on one f64, which is exactly where the
|
||||
rival breaks.
|
||||
|
||||
### exact_mixed_comparison_is_transitive_catches_comparing_through_f64
|
||||
|
||||
The rival is revision 1's rule: convert the exact operand to f64, then compare. It fails
|
||||
as ADR 005 clause 6 says it does. `Exact(s) = Inexact(x)` and `Inexact(x) = Exact(s +
|
||||
10^-25)`, while the two exacts differ. The marker is `comparison: not transitive`, so it
|
||||
is the transitivity assertion that fails, not antisymmetry.
|
||||
|
||||
### comparison_agrees_with_the_oracle_at_every_pairing_of_arms
|
||||
|
||||
The differential half of comparison: the crowd, plus mixed random pools of decimals,
|
||||
fractions and finite f64s, against `BigRational`'s order. NaN is unordered, and an
|
||||
infinity orders past every exact value.
|
||||
|
||||
### min_and_max_prefer_the_exact_value_and_do_not_depend_on_order
|
||||
|
||||
Question 11's tie-break, plus the two the crate adds: the larger scale between equal
|
||||
decimals, and IEEE's `-0.0` for `MIN` and `0.0` for `MAX`. Each pair is asserted in both
|
||||
argument orders, the order-independence property the tie-breaks exist for.
|
||||
|
||||
### the_accumulator_is_independent_of_order
|
||||
|
||||
Random **weighted** term lists, from every arm, are summed in one order and in a shuffled
|
||||
order. The results must agree in **arm, value and scale**. The terms include fractions
|
||||
over a pool of fourteen non-decimal denominators, so groups receive several terms and
|
||||
lists often hold more than K of them, and arbitrary fractions too. ADR 005 clause 8's
|
||||
seed case, `[0.30000000000000004, 1e12, −1e12]`, is one of the generator's arms and is
|
||||
also pinned exactly. It caught the review's F12 (a group's old f64 not retracted), where
|
||||
the stale values make the past-K read depend on the order of updates.
|
||||
|
||||
**On weights** (the review of `302594bf`, B2). Until then every `NumSum` property called
|
||||
`insert` and `retract` only, so every weight was ±1. That was the cause the answer to
|
||||
Codex's P1 had itself named, and the generators had not changed. Two faults passed the
|
||||
whole suite: F7 (the decimal partition ignored the weight's magnitude) and F8 (a group
|
||||
multiplied by `weight.signum()`). Weights now come from `weight()`:
|
||||
- the units 1, −1 and 0;
|
||||
- −3 to 3;
|
||||
- magnitudes up to 2^40;
|
||||
- `±i64::MAX` and 2^30 − 1.
|
||||
|
||||
`i64::MIN` is left out so a weight always negates.
|
||||
|
||||
### the_accumulator_is_independent_of_order_catches_decimal_addition
|
||||
|
||||
The rival is clause 8's own rival, a fold with `Decimal`'s `+`, demoting to the tower
|
||||
where it overflows. On the seed case, one order rounds `0.30000000000000004 + 1e12` to
|
||||
scale 16 and the other does not.
|
||||
|
||||
### the_accumulator_retracts_exactly
|
||||
|
||||
Adding weighted `xs` and `ys`, then adding each `y` again with its weight negated in
|
||||
reverse order, must equal a fresh sum of `xs` in arm, value and scale. A group whose
|
||||
numerator returns to zero must be dropped, or a retracted denominator would still count
|
||||
toward K. With weights it catches F7 and F12 as well.
|
||||
|
||||
### the_accumulator_reads_the_exact_sum_or_its_stated_roundings
|
||||
|
||||
The differential test of the read against a `BigRational` oracle, over weighted terms. It
|
||||
states the value of all three branches:
|
||||
- **No inexact term, at most K live groups.** The read is exact iff the exact total fits,
|
||||
and is then that total. Otherwise it is the total's correctly rounded f64.
|
||||
- **Inexact terms present.** The exact total is rounded once, then the weighted f64s are
|
||||
added and the result is rounded once more.
|
||||
- **Past K groups.** The nearest f64 of Σ round(live group) + round(decimal partition) +
|
||||
Σ w·f.
|
||||
|
||||
**Until the review of `302594bf` (B1) the past-K branch asserted only that the read was
|
||||
inexact, and this entry claimed the test stated every branch. Both were wrong.** Three
|
||||
faults passed the whole suite:
|
||||
- F12: a group's old f64 was not retracted from `group_floats`;
|
||||
- F14: the past-K read dropped the decimal partition;
|
||||
- F15: the past-K read dropped every inexact term.
|
||||
|
||||
The test now has the reviewer's oracle, at the repository's 256-case budget. 34ab0fc3 ran
|
||||
it at 512, and Copilot pointed out the budget. At 256, every fault in the table below
|
||||
still failed by name, and F7, F8, F12, F14 and F15 failed in four of four passes. Half the lists come from the
|
||||
fourteen-denominator pool, up to 40 terms long, so groups receive several terms and K is
|
||||
passed often. **It also counts how many cases reach each branch and fails if any branch
|
||||
is reached fewer than 20 times**, so a generator change cannot silently starve one.
|
||||
|
||||
The oracle counts **live** groups, those whose summed numerator is nonzero. The first
|
||||
version counted denominators ever seen, and that disagrees with the state-based count
|
||||
whenever terms cancel. Inexact presence is likewise per bit pattern, by net weight.
|
||||
|
||||
**Every fault in the review's script, re-run after the fix.** Each was injected into the
|
||||
source and `cargo test -p numtower` was run on a confirmed rebuild. Each failed by name:
|
||||
|
||||
| fault | failed |
|
||||
|---|---|
|
||||
| F1 decimal sum's scale filter dropped | `a_recomputed_result_keeps_the_stored_scale_clause_4_names`, `an_exact_result_is_never_a_rounded_one`, `contagion_is_total` |
|
||||
| F2 `rust_decimal`'s `to_f64` | `exact_mixed_comparison_is_transitive`, `comparison_agrees_with_the_oracle_at_every_pairing_of_arms`, `the_f64_conversion_agrees_with_parsing_the_decimal_text`, `the_number_languages_gate_and_parse_agree` |
|
||||
| F3 mixed comparison through f64 | `exact_mixed_comparison_is_transitive`, `comparison_agrees_with_the_oracle_at_every_pairing_of_arms`, `an_interim_compares_exactly_without_storing` |
|
||||
| F4 one net inexact count | `the_accumulator_keeps_inexact_terms_whose_weights_cancel`, `the_accumulator_retracts_exactly`, this test |
|
||||
| F5 product, not lcm | `sum::tests::the_grouped_read_combines_over_the_lcm_and_reduces_once` |
|
||||
| F6 `ExactSum` rounded per limb | `exact_sum_is_the_exact_weighted_total_rounded_once`, `more_than_k_denominators_read_inexact_even_when_the_total_fits`, `the_accumulator_keeps_inexact_terms_whose_weights_cancel`, this test |
|
||||
| F7 decimal weight magnitude ignored | this test, `the_accumulator_retracts_exactly` |
|
||||
| F8 group weight as its sign | this test |
|
||||
| F9 decimal product's scale filter dropped | `a_typed_value_and_the_same_value_computed_are_equally_exact`, `an_exact_result_is_never_a_rounded_one`, `contagion_is_total` |
|
||||
| F10 `ExactSum`'s low weight digit only | `exact_sum_is_the_exact_weighted_total_rounded_once`, this test |
|
||||
| F11 `POW` sign lost | `a_huge_odd_exponent_keeps_the_sign_of_a_negative_base` |
|
||||
| F12 old group f64 kept | this test, `the_accumulator_is_independent_of_order`, `the_accumulator_retracts_exactly` |
|
||||
| F13 mixed tie not checked exactly | `comparison_agrees_with_the_oracle_at_every_pairing_of_arms`, `exact_mixed_comparison_is_transitive`, `an_interim_compares_exactly_without_storing` |
|
||||
| F14 past K, decimal partition dropped | this test |
|
||||
| F15 past K, inexact terms dropped | this test |
|
||||
|
||||
- **F13's text now occurs twice.** `Interim`'s comparison added a second copy of the
|
||||
replaced line, so the review's script refuses to inject it. It was injected at its
|
||||
original site, in `cmp.rs::mixed`.
|
||||
- **The five former survivors were run twice and failed both times.** F12's set varied
|
||||
between runs: `the_accumulator_retracts_exactly` failed in one run and not the other,
|
||||
and this test failed in both.
|
||||
|
||||
### more_than_k_denominators_read_inexact_even_when_the_total_fits
|
||||
|
||||
`1/3 + 1/7 + … + 1/31` is nine distinct denominators. The exact total fits an `i128`
|
||||
fraction, yet the read is inexact. That is question 13's second exception, the K cutoff
|
||||
overriding representability. Eight of them read as an exact `Fraction`. The read past K is
|
||||
asserted to be **each group rounded, then the total rounded once**. The first version
|
||||
asserted one rounding of the exact total, and it failed: clause 8's maintained f64
|
||||
accumulator of group values rounds twice by design.
|
||||
|
||||
### exact_sum_is_the_exact_weighted_total_rounded_once
|
||||
|
||||
ADR 009's `ExactSum` against the oracle. Weights are drawn from small values, from the
|
||||
whole `i64` range and up to 2^40. A whole-range weight exercises all three base-2^30
|
||||
digits, which ADR 009's chunk loop could not reach in reasonable time. Values include
|
||||
subnormals.
|
||||
|
||||
### division_decides_fit_directly
|
||||
|
||||
ADR 005 clause 5's three seed cases, as both property inputs and pinned assertions:
|
||||
- `1 / 2^40` and `1 / 5^30` are fractions;
|
||||
- `1e-28 / 0.5` is the exact decimal `2e-28`.
|
||||
|
||||
Random decimal pairs must also be an exact decimal iff the quotient fits one, an exact
|
||||
fraction iff it fits only that, and otherwise inexact. The test also pins `AVG`'s example
|
||||
(`70/3`), `1/3 × 3 = 1`, and division by an inexact `-0.0`.
|
||||
|
||||
### division_decides_fit_directly_catches_multiplying_back
|
||||
|
||||
The rival is multiplying back with **revision 6's** scale test (demote on the hint). It
|
||||
fails on `1e-28 / 0.5`, as ADR 005 says. An experiment, not committed, ran multiplying
|
||||
back with `Num`'s own `*`, which carries revision 7's recompute. It agreed with the direct
|
||||
test on all 5,120 random cases and the seeds. The ADR's argument that multiplying back is
|
||||
incomplete holds only under the earlier rule (`context/code/numtower.md`, "Division").
|
||||
|
||||
### the_number_languages_gate_and_parse_agree
|
||||
|
||||
The generated lexemes have four sources:
|
||||
- structured lexemes: an optional sign, up to 40 digits, an optional fraction, and an
|
||||
optional exponent of up to 4 digits, so the empty parts give `1.`, `.5` and `.`;
|
||||
- junk over the alphabet of digits, `eE+-._/aifnIN` and space;
|
||||
- `n/d` lexemes;
|
||||
- the named spellings: `inf`, `NaN`, `Infinity`, `1_000`, `+.5`, `e5`.
|
||||
|
||||
For each lexeme, the parse succeeds iff `f64::from_str` reads a finite value. The exact
|
||||
value parsed must round to the f64 `f64::from_str` reads. `is_number` and `parse_number`
|
||||
must agree in both typings.
|
||||
|
||||
### the_number_languages_gate_and_parse_agree_catches_from_str_exact
|
||||
|
||||
The rival is `rust_decimal`'s `from_str_exact`. ADR 005 clause 3 names it as the parse
|
||||
whose language differs from the gate's: it rejects `1.5e3` and accepts `1_000`.
|
||||
|
||||
### the_number_language_seed_cases
|
||||
|
||||
Every seed case ADR 005's migration step 1 lists:
|
||||
- `1.5e3` is the exact `1500`;
|
||||
- `1_000` is text;
|
||||
- `-0.0` is the exact `0.0`, with no sign;
|
||||
- `3/4` is text when the type is inferred, and `0.75` when it is forced;
|
||||
- `1e999`, `-1e999`, `nan`, `inf` and `Infinity` are text;
|
||||
- `1e-999` is an inexact zero;
|
||||
- `1e-30` is the exact `1/10^30`;
|
||||
- `0.12345678901234567890123456789` is an exact fraction, not a rounded decimal;
|
||||
- `2^120` is an exact `n/1`;
|
||||
- `1e-40` is inexact.
|
||||
|
||||
### a_typed_value_and_the_same_value_computed_are_equally_exact
|
||||
|
||||
For random decimal pairs (and the seed `1e-15 × 1e-15`), the exact product is spelled as
|
||||
`<integer>e-<k>` and parsed. The parse must have the arm and the value of the computed
|
||||
product. An earlier draft built the text only when the reduced denominator was a power of
|
||||
ten, and rejected most cases. A reduced decimal product's denominator is `2^a·5^b`, so the
|
||||
text now scales by the smallest power of ten that makes the product an integer.
|
||||
|
||||
### a_typed_value_and_the_same_value_computed_are_equally_exact_catches_demoting_on_the_hint
|
||||
|
||||
The rival is revision 6's multiplication, which demotes whenever the returned scale drops.
|
||||
`1e-15 × 1e-15` then becomes an f64, while the typed `1e-30` is the exact fraction. That
|
||||
is the asymmetry revision 6's N2 was about.
|
||||
|
||||
### a_recomputed_result_keeps_the_stored_scale_clause_4_names
|
||||
|
||||
ADR 005 clause 4's example: `1.2500000000000000 × 4.0000000000000000` is `5` at scale 28,
|
||||
not scale 0 and not 32. The test also pins that a common-path sum keeps its returned scale
|
||||
(`12.50 + 1.5 = 14.00`), that `1000 + 0.333…` goes to the fraction arm, and that an exact
|
||||
zero result has no sign.
|
||||
|
||||
### an_intermediate_beyond_i128_comes_back_exact
|
||||
|
||||
The point of question 13 (b). `a = 10^30/7`; `a × a` does not fit `i128`, yet `(a × a) /
|
||||
a` through `Interim` is `a`, exactly. The same product stored through `Num` is inexact
|
||||
and correctly rounded.
|
||||
|
||||
### the_canonical_text_reads_back_as_the_same_number
|
||||
|
||||
Every arm's `Display` text reads back through `from_canonical` as the same arm, value and
|
||||
scale. The test pins the four spellings ADR 005 clause 11 tabulates, and that canonical
|
||||
exact text never reads back as an inexact value.
|
||||
|
||||
### the_accumulator_keeps_inexact_terms_whose_weights_cancel
|
||||
|
||||
ChatGPT Codex, P1 on PR #337, against `edbb50c4`. `NumSum` decided "is an inexact term
|
||||
present" from the **net** inexact weight. Adding `1.0` with weight 1 and `2.0` with weight
|
||||
−1 nets to zero while the accumulator holds −1.0. So `read()` took the exact branch and
|
||||
returned the exact partition alone, dropping the inexact term silently.
|
||||
|
||||
This test **failed at `edbb50c4`**, on its first assertion: the read of `[1.0 ×1, 2.0 ×−1,
|
||||
0.5]` claimed to be exact. Two things made the bug invisible to the existing properties:
|
||||
- every generated term was inserted with weight +1;
|
||||
- the retraction test retracts the very values it inserted, so per-value presence and net
|
||||
presence agree there by construction.
|
||||
|
||||
The test also asserts that retracting both inexact terms returns the exact `0.5`, so the
|
||||
fix cannot simply make every sum that ever saw an f64 inexact.
|
||||
|
||||
### a_huge_odd_exponent_keeps_the_sign_of_a_negative_base
|
||||
|
||||
Copilot on PR #337, against `edbb50c4`. When the size check demotes a power to `powf`, the
|
||||
exponent becomes an f64. Above 2^53 it rounds to an even value, so `(-2)^(2^53 + 1)`
|
||||
returned `+inf` where the sign must be negative, and the negative exponent gave `+0.0`.
|
||||
This test **failed at `edbb50c4`** on its first assertion. It also checks a fraction base,
|
||||
and that an even exponent stays positive, so a fix that always negates cannot pass.
|
||||
|
||||
### i128_min_is_an_exact_fraction_numerator
|
||||
|
||||
Copilot on PR #337, against `edbb50c4`. `Num::fit` rejected `i128::MIN` as a numerator
|
||||
because `to_i128` filtered it for both halves, so `Num::ratio(i128::MIN, 1)` became inexact
|
||||
although `Ratio<i128>` holds it. This test **failed at `edbb50c4`** on its first
|
||||
assertion. The oracle's `fits` had the same symmetric-range assumption, so no property
|
||||
could see the bug. Both are fixed, and `fraction()` now generates `i128::MIN / d`.
|
||||
|
||||
Allowing the value exposes a second hazard: it cannot be negated in `i128`, and the old
|
||||
`negate` computed `-n`, which panics in a debug build. So the test also negates
|
||||
`i128::MIN / 3` (an inexact, correctly rounded result), takes it through `Interim` twice
|
||||
and back, divides it, compares it and reads its canonical text.
|
||||
|
||||
### a_negative_power_has_its_minimal_scale
|
||||
|
||||
Copilot on PR #337, and B3 of the review of `302594bf`. The unit-base arm of `POW`
|
||||
padded every result to `s·|n|`, so `1.0^-2` was `1.00` and `(-1.0)^-3` was `-1.000`,
|
||||
while `2.0^-2` was `0.25`. `context/code/numtower.md` already said "a negative power …
|
||||
has its minimal scale", so the code contradicted the crate's stated rule. This test
|
||||
**failed at `302594bf`** on its first assertion. It also pins that a positive power keeps
|
||||
`s·n` (`(-1.0)^3 = -1.000`), so a fix that stripped every scale cannot pass.
|
||||
|
||||
The broader guard is the scale assertion now in
|
||||
`pow_is_exact_for_an_exact_base_and_integer_exponent_when_it_fits`, which had checked
|
||||
value and exactness but never scale. Its oracle, `power_scale`, is the rule restated:
|
||||
- start from the minimal scale;
|
||||
- pad toward `min(s·k, 28)` for a decimal base with `k > 0` only, while the mantissa
|
||||
fits 96 bits.
|
||||
|
||||
Unit bases (`1.0`, `-1.0`, `1.000`, `-1`, `0.00`) are among its seeds. It **failed at
|
||||
`302594bf`** with the minimal case `1.0^-1 = 1.0`. The injection G1 (a negative power
|
||||
keeps the base's scale) fails both tests.
|
||||
|
||||
### exact_sums_are_equal_when_their_values_are
|
||||
|
||||
Copilot on PR #337, and B3 of the review. `ExactSum` derived `PartialEq` over its raw
|
||||
limbs and its `pending` carry counter. This test **failed at `302594bf`** on its first
|
||||
assertion, `0.5 + 0.5 == 1.0`. It also asserts:
|
||||
- insert then retract equals an empty sum;
|
||||
- weights `(2^30 − 1, 2)` added in either order are equal;
|
||||
- different values are unequal, and a counted NaN is part of the value, so an `==` that
|
||||
always answers true cannot pass.
|
||||
|
||||
Injection G2, equality back on the representation, fails it.
|
||||
|
||||
### a_forced_fraction_never_reads_as_infinity
|
||||
|
||||
A should-fix from the review of `302594bf`. A forced `n/d` with a 400-digit `n` read as
|
||||
`Inexact(inf)`, though clause 3 says a finite lexeme never reads as infinity. This test
|
||||
**failed at `302594bf`**. It also asserts:
|
||||
- `is_number` agrees with the parse;
|
||||
- a negative one is text too;
|
||||
- an underflowing `1/9…9` is still the inexact zero, so a fix that refused every inexact
|
||||
forced fraction cannot pass.
|
||||
|
||||
Injection G3 fails it.
|
||||
|
||||
### an_interim_compares_exactly_without_storing
|
||||
|
||||
New API, from a should-fix in the review: `Interim` had no comparison and no zero test
|
||||
while step 3 is told to evaluate through it. For two wide products `a`, it compares `a`
|
||||
against:
|
||||
- another wide product;
|
||||
- `a ± 1/(3·10^30)`;
|
||||
- `a` itself;
|
||||
- the f64 nearest `a`, and that f64's upper neighbour.
|
||||
|
||||
Each comparison is made in both directions, against the `BigRational` order. It also
|
||||
asserts `a − a` is zero and `a` is not. Injection G4, storing before comparing, fails it,
|
||||
and so do F3 and F13, because the stored arms delegate to `Num::cmp_exact`.
|
||||
|
||||
### an_interim_compares_exactly_without_storing_catches_storing_first
|
||||
|
||||
The rival is the comparison the review warned step 3 would otherwise have to make: store
|
||||
both operands, then compare. `a` and `a ± 1/(3·10^30)` store to one f64, so the rival
|
||||
calls them equal.
|
||||
|
||||
---
|
||||
|
||||
## `crates/numtower/src/sum.rs`
|
||||
|
||||
### sum::tests::the_grouped_read_combines_over_the_lcm_and_reduces_once
|
||||
|
||||
The combine must return the **lcm** as its denominator, and the reduced result must equal
|
||||
the `BigRational` sum. Group numerators are up to `i128 × i64`, and the denominators share
|
||||
factors (3, 77, 2^20, 3^40), so the lcm and the product differ.
|
||||
|
||||
### sum::tests::the_grouped_read_combines_over_the_lcm_and_reduces_once_catches_the_product
|
||||
|
||||
The rival combines over the product of the denominators. Its value is right, so only the
|
||||
lcm assertion can see it. That is why the property asserts the denominator before the
|
||||
value.
|
||||
@@ -777,6 +777,7 @@ impl Cmd for FilterToItem {
|
||||
Some(CatTreeEntry::Item {
|
||||
cat_name,
|
||||
item_name,
|
||||
..
|
||||
}) => {
|
||||
vec![
|
||||
Box::new(effect::SetAxis {
|
||||
@@ -821,6 +822,7 @@ impl Cmd for DeleteCategoryAtCursor {
|
||||
Some(CatTreeEntry::Item {
|
||||
cat_name,
|
||||
item_name,
|
||||
..
|
||||
}) => {
|
||||
let mut effects: Vec<Box<dyn Effect>> = vec![
|
||||
Box::new(effect::RemoveItem {
|
||||
|
||||
@@ -106,7 +106,9 @@ fn frame_of(layout: &crate::view::layout::GridLayout) -> u64 {
|
||||
group_name.hash(&mut h);
|
||||
collapsed.hash(&mut h);
|
||||
}
|
||||
crate::view::layout::AxisEntry::DataItem { tuple, collapsed } => {
|
||||
crate::view::layout::AxisEntry::DataItem {
|
||||
tuple, collapsed, ..
|
||||
} => {
|
||||
1u8.hash(&mut h);
|
||||
tuple.hash(&mut h);
|
||||
collapsed.hash(&mut h);
|
||||
|
||||
@@ -62,6 +62,7 @@ mod tests {
|
||||
CatTreeEntry::Item {
|
||||
cat_name,
|
||||
item_name,
|
||||
..
|
||||
} if cat_name == MEASURE_CATEGORY => Some(item_name),
|
||||
_ => None,
|
||||
})
|
||||
@@ -145,6 +146,7 @@ mod tests {
|
||||
let item = CatTreeEntry::Item {
|
||||
cat_name: "Region".into(),
|
||||
item_name: "North".into(),
|
||||
display: "North".into(),
|
||||
};
|
||||
assert_eq!(header.cat_name(), "Region");
|
||||
assert_eq!(item.cat_name(), "Region");
|
||||
|
||||
@@ -14,12 +14,12 @@ use improvise_core::workbook::Workbook;
|
||||
use improvise_io::import::analyzer::{DateComponent, DateShape, FieldKind};
|
||||
use improvise_io::import::wizard::{DuplicatePolicy, ImportWizard, WizardStep};
|
||||
use improvise_protocol::{
|
||||
AxisFrame, Basis, BasisCache, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay,
|
||||
CellId, Command, CreateKind, DocumentCache, FormulaCache, FormulaEntry, FrameEntry, FrameGroup,
|
||||
GridCache, GridExtent, GridUpdate, Projection, RecordCell, Rejection, Snapshot, TileBarCache,
|
||||
TileEntry, ViewListCache, ViewState, ViewSummary, WidgetCache, WizardCache, WizardChoice,
|
||||
WizardCollision, WizardDateField, WizardFieldKind, WizardProposal, WizardStepKind, basis,
|
||||
reduce_view,
|
||||
AxisFrame, Basis, BasisCache, CategoryItem, CategoryRow, CategoryTreeCache, CategoryTreeUpdate,
|
||||
CellDisplay, CellId, Command, CreateKind, DocumentCache, FormulaCache, FormulaEntry,
|
||||
FrameEntry, FrameGroup, GridCache, GridExtent, GridUpdate, Projection, RecordCell, Rejection,
|
||||
Snapshot, TileBarCache, TileEntry, ViewAxis, ViewListCache, ViewState, ViewSummary,
|
||||
WidgetCache, WizardCache, WizardChoice, WizardCollision, WizardDateField, WizardFieldKind,
|
||||
WizardProposal, WizardStepKind, basis, reduce_view,
|
||||
};
|
||||
|
||||
use crate::ui::app::App;
|
||||
@@ -157,8 +157,16 @@ pub fn grid_in(app: &App, view_name: &str, viewport: Viewport) -> GridCache {
|
||||
};
|
||||
|
||||
GridCache {
|
||||
row_frame: axis_frame(&layout.row_cats, &layout.row_items, rows.clone()),
|
||||
col_frame: axis_frame(&layout.col_cats, &layout.col_items, cols.clone()),
|
||||
row_frame: axis_frame(
|
||||
(&layout.row_cats, layout.row_cat_displays(model)),
|
||||
&layout.row_items,
|
||||
rows.clone(),
|
||||
),
|
||||
col_frame: axis_frame(
|
||||
(&layout.col_cats, layout.col_cat_displays(model)),
|
||||
&layout.col_items,
|
||||
cols.clone(),
|
||||
),
|
||||
col_widths: cols
|
||||
.clone()
|
||||
.map(|c| body.col_widths.get(c).copied().unwrap_or(0))
|
||||
@@ -222,7 +230,11 @@ pub(crate) fn rederived_for(
|
||||
|
||||
/// One axis's frame, windowed to the data indices `window` names. Three edge rules,
|
||||
/// all about group headers — see `context/code/command.md`.
|
||||
fn axis_frame(cats: &[String], items: &[AxisEntry], window: std::ops::Range<usize>) -> AxisFrame {
|
||||
fn axis_frame(
|
||||
(cats, cat_displays): (&[String], Vec<String>),
|
||||
items: &[AxisEntry],
|
||||
window: std::ops::Range<usize>,
|
||||
) -> AxisFrame {
|
||||
let total = items
|
||||
.iter()
|
||||
.filter(|e| matches!(e, AxisEntry::DataItem { .. }))
|
||||
@@ -242,6 +254,7 @@ fn axis_frame(cats: &[String], items: &[AxisEntry], window: std::ops::Range<usiz
|
||||
level,
|
||||
depth,
|
||||
group_name,
|
||||
group_display,
|
||||
collapsed,
|
||||
..
|
||||
} => {
|
||||
@@ -249,6 +262,7 @@ fn axis_frame(cats: &[String], items: &[AxisEntry], window: std::ops::Range<usiz
|
||||
level: *level,
|
||||
depth: *depth,
|
||||
name: group_name.clone(),
|
||||
display: group_display.clone(),
|
||||
collapsed: *collapsed,
|
||||
});
|
||||
match entries.is_empty() {
|
||||
@@ -266,7 +280,11 @@ fn axis_frame(cats: &[String], items: &[AxisEntry], window: std::ops::Range<usiz
|
||||
depth: *depth,
|
||||
}),
|
||||
},
|
||||
AxisEntry::DataItem { tuple, collapsed } => {
|
||||
AxisEntry::DataItem {
|
||||
tuple,
|
||||
display,
|
||||
collapsed,
|
||||
} => {
|
||||
let idx = data_idx;
|
||||
data_idx += 1;
|
||||
if window.contains(&idx) {
|
||||
@@ -276,6 +294,7 @@ fn axis_frame(cats: &[String], items: &[AxisEntry], window: std::ops::Range<usiz
|
||||
entries.append(&mut pending);
|
||||
entries.push(FrameEntry::Item {
|
||||
tuple: tuple.clone(),
|
||||
display: display.clone(),
|
||||
collapsed: *collapsed,
|
||||
});
|
||||
}
|
||||
@@ -290,6 +309,7 @@ fn axis_frame(cats: &[String], items: &[AxisEntry], window: std::ops::Range<usiz
|
||||
}
|
||||
AxisFrame {
|
||||
cats: cats.to_vec(),
|
||||
cat_displays,
|
||||
first: window.start,
|
||||
entries,
|
||||
}
|
||||
@@ -331,7 +351,9 @@ fn cell_display(
|
||||
if layout.is_records_mode() {
|
||||
// The value, not its spelling: this module used to infer numeric-ness
|
||||
// with `parse::<f64>()`, which called a coordinate `2024` a number.
|
||||
let text = value.as_ref().map(|v| v.to_string()).unwrap_or_default();
|
||||
let text = layout
|
||||
.coordinate_display(&app.model_state.workbook.model, row, col)
|
||||
.unwrap_or_else(|| value.as_ref().map(|v| v.to_string()).unwrap_or_default());
|
||||
return CellDisplay {
|
||||
edit_text: Some(value.as_ref().map(CellValue::edit_text).unwrap_or_default()),
|
||||
text,
|
||||
@@ -436,11 +458,6 @@ fn non_item(category: &str, item: &str) -> CellId {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a person sees for a category key; the key itself when no category holds it.
|
||||
fn category_display<'a>(model: &'a Model, name: &'a str) -> &'a str {
|
||||
model.category(name).map_or(name, |c| c.display())
|
||||
}
|
||||
|
||||
/// The categories the category panel draws: **every** category, virtual ones
|
||||
/// included, with their items and their axis (improvise-vjms, gotcha 7).
|
||||
pub fn category_rows(model: &Model, view: &View) -> Vec<CategoryRow> {
|
||||
@@ -449,8 +466,15 @@ pub fn category_rows(model: &Model, view: &View) -> Vec<CategoryRow> {
|
||||
.into_iter()
|
||||
.map(|name| CategoryRow {
|
||||
name: name.to_string(),
|
||||
display: category_display(model, name).to_string(),
|
||||
items: model.effective_item_names(name),
|
||||
display: model.category_display(name).to_string(),
|
||||
items: model
|
||||
.effective_item_names(name)
|
||||
.into_iter()
|
||||
.map(|item| CategoryItem {
|
||||
display: model.item_display(name, &item).to_string(),
|
||||
name: item,
|
||||
})
|
||||
.collect(),
|
||||
// `try_axis_of`, not `axis_of`: a view that has never seen a category
|
||||
// has no entry and `axis_of` panics. Unseen shows as `None`.
|
||||
axis: view.try_axis_of(name).unwrap_or(Axis::None),
|
||||
@@ -473,8 +497,8 @@ pub fn formula_entries(app: &App) -> Vec<FormulaEntry> {
|
||||
/// Every view and where each category sits in it. Which view is *active* is
|
||||
/// per-client and travels as `Command::SwitchView`, so it is not here.
|
||||
pub fn view_summaries(app: &App) -> Vec<ViewSummary> {
|
||||
app.model_state
|
||||
.workbook
|
||||
let workbook = &app.model_state.workbook;
|
||||
workbook
|
||||
.views
|
||||
.iter()
|
||||
.map(|(name, view)| ViewSummary {
|
||||
@@ -483,7 +507,11 @@ pub fn view_summaries(app: &App) -> Vec<ViewSummary> {
|
||||
axes: view
|
||||
.category_axes
|
||||
.iter()
|
||||
.map(|(cat, axis)| (cat.clone(), *axis))
|
||||
.map(|(cat, axis)| ViewAxis {
|
||||
category: cat.clone(),
|
||||
display: workbook.model.category_display(cat).to_string(),
|
||||
axis: *axis,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect()
|
||||
@@ -501,7 +529,7 @@ pub fn tiles_of(model: &Model, view: &View) -> Vec<TileEntry> {
|
||||
.into_iter()
|
||||
.map(|cat| TileEntry {
|
||||
category: cat.to_string(),
|
||||
display: category_display(model, cat).to_string(),
|
||||
display: model.category_display(cat).to_string(),
|
||||
axis: view.try_axis_of(cat).unwrap_or(Axis::None),
|
||||
kind: model
|
||||
.category(cat)
|
||||
@@ -1873,6 +1901,205 @@ mod tests {
|
||||
assert_ne!(summary.name, "Overview");
|
||||
}
|
||||
|
||||
/// A category, an item, a group, a page category, a measure and two views,
|
||||
/// each shown under a name that is not its key (improvise-cq1q.12).
|
||||
fn app_shown_otherwise() -> App {
|
||||
let mut wb = Workbook::new("Test");
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.add_category("Quarter").unwrap();
|
||||
wb.add_item_in_group("Region", "East", "Coastal").unwrap();
|
||||
wb.add_item("Region", "West").unwrap();
|
||||
wb.add_item("Quarter", "Q1").unwrap();
|
||||
wb.add_item("Quarter", "Q2").unwrap();
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.active_view_mut().set_axis("Region", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
wb.active_view_mut().set_axis("Quarter", Axis::Page);
|
||||
wb.active_view_mut().set_expand_depth("Region", 1);
|
||||
for region in ["East", "West"] {
|
||||
for quarter in ["Q1", "Q2"] {
|
||||
wb.model.set_cell(
|
||||
CellKey::new(vec![
|
||||
("Region".into(), region.into()),
|
||||
("Quarter".into(), quarter.into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
]),
|
||||
CellValue::Number(1.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
wb.create_view("Plan").set_axis("Region", Axis::Column);
|
||||
wb.active_view_mut().set_display("Overview");
|
||||
wb.views.get_mut("Plan").unwrap().set_display("Budget");
|
||||
let region = wb.model.category_mut("Region").unwrap();
|
||||
region.set_display("Sales Area");
|
||||
region.set_item_display("East", "Eastern Seaboard");
|
||||
region.set_group_display("Coastal", "Shoreline");
|
||||
let quarter = wb.model.category_mut("Quarter").unwrap();
|
||||
quarter.set_display("Period");
|
||||
quarter.set_item_display("Q1", "First");
|
||||
quarter.set_item_display("Q2", "Second");
|
||||
wb.model
|
||||
.category_mut("_Measure")
|
||||
.unwrap()
|
||||
.set_item_display("Revenue", "Sales");
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
app
|
||||
}
|
||||
|
||||
const ALL: Viewport = Viewport {
|
||||
row_offset: 0,
|
||||
col_offset: 0,
|
||||
rows: 20,
|
||||
cols: 20,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn the_frame_shows_displays_and_its_key_tuples_still_address_the_cells() {
|
||||
let app = app_shown_otherwise();
|
||||
let grid = grid_of(&app, ALL);
|
||||
assert_eq!(grid.row_frame.cat_displays, vec!["Sales Area"]);
|
||||
assert_eq!(
|
||||
grid.row_frame.cats,
|
||||
vec!["Region"],
|
||||
"the key stays beside it"
|
||||
);
|
||||
assert_eq!(
|
||||
grid.col_frame.header_labels(0),
|
||||
vec![vec!["Sales".to_string()]]
|
||||
);
|
||||
let header = grid
|
||||
.row_frame
|
||||
.entries
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
FrameEntry::Group(g) => Some(g),
|
||||
_ => None,
|
||||
})
|
||||
.expect("East sits under Coastal");
|
||||
assert_eq!(
|
||||
(header.name.as_str(), header.display.as_str()),
|
||||
("Coastal", "Shoreline")
|
||||
);
|
||||
assert_eq!(
|
||||
grid.row_frame.header_labels(0)[0],
|
||||
vec!["Eastern Seaboard".to_string()]
|
||||
);
|
||||
// The frame's KEY tuples, joined with the page bar, are the cell's address.
|
||||
let page: Vec<(String, String)> = grid
|
||||
.page_coords
|
||||
.iter()
|
||||
.map(|p| (p.cat.clone(), p.selected.clone()))
|
||||
.collect();
|
||||
for (r, row) in grid.row_frame.items().enumerate() {
|
||||
for (c, col) in grid.col_frame.items().enumerate() {
|
||||
let coords = grid
|
||||
.row_frame
|
||||
.cats
|
||||
.iter()
|
||||
.cloned()
|
||||
.zip(row.iter().cloned())
|
||||
.chain(grid.col_frame.cats.iter().cloned().zip(col.iter().cloned()))
|
||||
.chain(page.iter().cloned())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
Some(CellKey::new(coords)),
|
||||
app.layout.cell_key(r, c),
|
||||
"({r},{c}) must be addressed by keys"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_page_bar_carries_displays_beside_the_keys_a_page_command_names() {
|
||||
let app = app_shown_otherwise();
|
||||
let bar = grid_of(&app, ALL).page_coords;
|
||||
let quarter = &bar[0];
|
||||
assert_eq!(
|
||||
(quarter.cat.as_str(), quarter.selected.as_str()),
|
||||
("Quarter", "Q1")
|
||||
);
|
||||
assert_eq!(quarter.cat_display, "Period");
|
||||
assert_eq!(quarter.selected_display, "First");
|
||||
assert_eq!(
|
||||
quarter.items.as_deref(),
|
||||
Some(&["Q1".to_string(), "Q2".into()][..])
|
||||
);
|
||||
assert_eq!(
|
||||
quarter.item_displays.as_deref(),
|
||||
Some(&["First".to_string(), "Second".into()][..])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_row_shows_its_display_and_keeps_its_key() {
|
||||
let app = app_shown_otherwise();
|
||||
let rows = category_rows(&app.model_state.workbook.model, app.view());
|
||||
let region = rows.iter().find(|r| r.name == "Region").unwrap();
|
||||
let east = region.items.iter().find(|i| i.name == "East").unwrap();
|
||||
assert_eq!(east.display, "Eastern Seaboard");
|
||||
let west = region.items.iter().find(|i| i.name == "West").unwrap();
|
||||
assert_eq!(west.display, "West", "an unrenamed item shows its key");
|
||||
let measure = rows.iter().find(|r| r.name == "_Measure").unwrap();
|
||||
assert_eq!(measure.items[0].display, "Sales");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_view_summary_shows_its_own_display_and_its_categories_displays() {
|
||||
let app = app_shown_otherwise();
|
||||
let summaries = view_summaries(&app);
|
||||
let by_key = |key: &str| summaries.iter().find(|s| s.name == key).unwrap();
|
||||
assert_eq!(by_key("Default").display, "Overview");
|
||||
assert_eq!(by_key("Plan").display, "Budget", "not the active view's");
|
||||
let plan_region = by_key("Plan")
|
||||
.axes
|
||||
.iter()
|
||||
.find(|a| a.category == "Region")
|
||||
.unwrap();
|
||||
assert_eq!(plan_region.display, "Sales Area");
|
||||
assert_eq!(plan_region.axis, Axis::Column);
|
||||
let line = improvise_protocol::view_axis_summary(by_key("Plan"));
|
||||
assert_eq!(
|
||||
line, "C:Sales Area,Period",
|
||||
"the line draws displays, not keys"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_records_coordinate_shows_its_display_and_edits_its_key() {
|
||||
let mut app = app_shown_otherwise();
|
||||
let view = app.model_state.workbook.active_view_mut();
|
||||
view.set_axis("_Index", Axis::Row);
|
||||
view.set_axis("_Dim", Axis::Column);
|
||||
app.rebuild_layout();
|
||||
let grid = grid_of(&app, ALL);
|
||||
assert!(grid.records);
|
||||
let col = grid
|
||||
.col_frame
|
||||
.items()
|
||||
.position(|t| t == &vec!["Region".to_string()])
|
||||
.expect("a column per category, found by its key");
|
||||
assert_eq!(
|
||||
grid.col_frame.header_labels(0)[col],
|
||||
vec!["Sales Area".to_string()]
|
||||
);
|
||||
let shown: Vec<&str> = grid.cells.iter().map(|r| r[col].text.as_str()).collect();
|
||||
assert!(shown.contains(&"Eastern Seaboard"), "{shown:?}");
|
||||
let east = grid
|
||||
.cells
|
||||
.iter()
|
||||
.map(|r| &r[col])
|
||||
.find(|c| c.text == "Eastern Seaboard")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
east.edit_text.as_deref(),
|
||||
Some("East"),
|
||||
"the editor seeds the key"
|
||||
);
|
||||
}
|
||||
|
||||
/// A pivot-shaped default view plus a records-shaped second one, so a
|
||||
/// switch between them crosses the boundary `settled_mode` watches.
|
||||
fn app_with_a_records_shaped_second_view() -> App {
|
||||
@@ -2481,6 +2708,7 @@ mod tests {
|
||||
row_frame: AxisFrame {
|
||||
first: 0,
|
||||
cats: vec!["Region".into()],
|
||||
cat_displays: vec!["Region".into()],
|
||||
entries: vec![FrameEntry::item(vec!["North".into()])],
|
||||
},
|
||||
col_frame: AxisFrame::flat(["A", "B"]),
|
||||
@@ -2493,6 +2721,7 @@ mod tests {
|
||||
row_frame: AxisFrame {
|
||||
first: 0,
|
||||
cats: vec!["Department".into()],
|
||||
cat_displays: vec!["Department".into()],
|
||||
entries: mirror.row_frame.entries.clone(),
|
||||
},
|
||||
..mirror.clone()
|
||||
|
||||
@@ -73,7 +73,9 @@ pub fn grid_body(
|
||||
let (key, value) = cell_value(layout, &eval, records, ri, ci);
|
||||
let fmt = plan.at(key.as_ref());
|
||||
let text = match records {
|
||||
true => value.as_ref().map(|v| v.to_string()).unwrap_or_default(),
|
||||
true => layout
|
||||
.coordinate_display(model, ri, ci)
|
||||
.unwrap_or_else(|| value.as_ref().map(|v| v.to_string()).unwrap_or_default()),
|
||||
false => fmt.value(value.as_ref()),
|
||||
};
|
||||
widen(&mut widths, ci, text.width());
|
||||
@@ -168,7 +170,7 @@ fn cell_value(
|
||||
fn header_level_seed(layout: &GridLayout, n: usize) -> Vec<u16> {
|
||||
let mut widths = vec![0u16; n];
|
||||
let items = layout.col_items.iter().filter_map(|e| match e {
|
||||
AxisEntry::DataItem { tuple, .. } => Some(tuple),
|
||||
AxisEntry::DataItem { display, .. } => Some(display),
|
||||
AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. } => None,
|
||||
});
|
||||
for (ci, levels) in items.enumerate().take(n) {
|
||||
@@ -197,7 +199,7 @@ pub fn compute_col_widths(model: &Model, layout: &GridLayout, plan: &FormatPlan)
|
||||
/// keypress-path caller that borrows tuples — see `context/code/command.md`.
|
||||
pub fn compute_row_header_width(layout: &GridLayout) -> u16 {
|
||||
let items = layout.row_items.iter().filter_map(|e| match e {
|
||||
AxisEntry::DataItem { tuple, .. } => Some(tuple),
|
||||
AxisEntry::DataItem { display, .. } => Some(display),
|
||||
AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. } => None,
|
||||
});
|
||||
header_level_widths(items, layout.row_cats.len().max(1))
|
||||
@@ -251,6 +253,51 @@ mod tests {
|
||||
use crate::view::Axis;
|
||||
use crate::workbook::Workbook;
|
||||
|
||||
/// One row and one column, each item shown under a display far longer than its
|
||||
/// key and than the cell, so the 5-column minimum cannot hide a key measurement.
|
||||
fn long_displays() -> Workbook {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Type").unwrap();
|
||||
wb.add_category("Month").unwrap();
|
||||
wb.add_item("Type", "E").unwrap();
|
||||
wb.add_item("Month", "Jan").unwrap();
|
||||
wb.model.set_cell(
|
||||
CellKey::new(vec![
|
||||
("Type".into(), "E".into()),
|
||||
("Month".into(), "Jan".into()),
|
||||
]),
|
||||
CellValue::Number(1.0),
|
||||
);
|
||||
wb.model
|
||||
.category_mut("Type")
|
||||
.unwrap()
|
||||
.set_item_display("E", "Eastern Seaboard");
|
||||
wb.model
|
||||
.category_mut("Month")
|
||||
.unwrap()
|
||||
.set_item_display("Jan", "Januaryyyyy");
|
||||
wb
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_column_is_as_wide_as_the_display_its_header_draws() {
|
||||
let wb = long_displays();
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
let plan = FormatPlan::of(&wb.model, wb.active_view());
|
||||
let widths = compute_col_widths(&wb.model, &layout, &plan);
|
||||
assert_eq!(widths, vec!["Januaryyyyy".len() as u16 + 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_row_header_is_as_wide_as_the_display_its_rows_draw() {
|
||||
let wb = long_displays();
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
assert_eq!(
|
||||
compute_row_header_width(&layout),
|
||||
"Eastern Seaboard".len() as u16 + 1
|
||||
);
|
||||
}
|
||||
|
||||
/// Q1 = {Jan, Feb} collapsed, Q2 = {Apr}; F0 = Revenue and F(i) = F(i-1) twice.
|
||||
fn folded_chain() -> Workbook {
|
||||
let mut wb = Workbook::new("T");
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
//! How far a SUM maintained by insert/retract deltas drifts from the eager fold,
|
||||
//! in f64, and what an exact accumulator costs. ADR 009 (improvise-felo.4), clause 8.
|
||||
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
use improvise_core::format::format_f64;
|
||||
use improvise_core::model::cell::{CellKey, CellValue};
|
||||
use improvise_core::model::types::{Aggregation, Model};
|
||||
|
||||
const MEASURE: &str = "_Measure";
|
||||
|
||||
/// SplitMix64: seeded, so a run is reproducible and a second seed is a new sample.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
fn below(&mut self, n: u64) -> u64 {
|
||||
self.next() % n
|
||||
}
|
||||
}
|
||||
|
||||
/// A user-entered decimal as an exact count of 1e-5 units, its magnitude spread
|
||||
/// over `decades` decades. The f64 the store holds is `typed(units)`.
|
||||
fn decimal(rng: &mut Rng, decades: u32) -> i64 {
|
||||
let scale = 10i64.pow(rng.below(u64::from(decades)) as u32);
|
||||
let cents = rng.below(100_000) as i64;
|
||||
let sign = if rng.below(4) == 0 { -1 } else { 1 };
|
||||
sign * cents * scale
|
||||
}
|
||||
|
||||
fn typed(units: i64) -> f64 {
|
||||
units as f64 / 1e5
|
||||
}
|
||||
|
||||
/// The exact decimal sum, rounded to f64 once: what ADR 005's arithmetic would show.
|
||||
fn exact(units: &[i64]) -> f64 {
|
||||
units.iter().map(|&u| i128::from(u)).sum::<i128>() as f64 / 1e5
|
||||
}
|
||||
|
||||
fn key(row: usize) -> CellKey {
|
||||
CellKey::new(vec![
|
||||
("Row".to_string(), format!("r{row}")),
|
||||
(MEASURE.to_string(), "Amount".to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
/// `cells` rows of one measure; the eager SUM is the one the grid draws.
|
||||
fn document(rng: &mut Rng, cells: usize, decades: u32) -> (Model, Vec<i64>) {
|
||||
let mut m = Model::new("drift");
|
||||
m.add_category("Row").expect("under the limit");
|
||||
let values: Vec<i64> = (0..cells).map(|_| decimal(rng, decades)).collect();
|
||||
for (row, v) in values.iter().enumerate() {
|
||||
m.category_mut("Row")
|
||||
.expect("just added")
|
||||
.add_item(format!("r{row}"));
|
||||
m.set_cell(key(row), CellValue::Number(typed(*v)));
|
||||
}
|
||||
(m, values)
|
||||
}
|
||||
|
||||
fn eager(m: &Model) -> f64 {
|
||||
let total = CellKey::new(vec![(MEASURE.to_string(), "Amount".to_string())]);
|
||||
m.evaluate_aggregated_f64(&total, Aggregation::OverHidden)
|
||||
}
|
||||
|
||||
/// Neumaier's compensated running sum: the cheap mitigation, measured beside the naive one.
|
||||
#[derive(Default)]
|
||||
struct Compensated {
|
||||
sum: f64,
|
||||
carry: f64,
|
||||
}
|
||||
|
||||
impl Compensated {
|
||||
fn add(&mut self, x: f64) {
|
||||
let t = self.sum + x;
|
||||
self.carry += match self.sum.abs() >= x.abs() {
|
||||
true => (self.sum - t) + x,
|
||||
false => (x - t) + self.sum,
|
||||
};
|
||||
self.sum = t;
|
||||
}
|
||||
|
||||
fn value(&self) -> f64 {
|
||||
self.sum + self.carry
|
||||
}
|
||||
}
|
||||
|
||||
const LIMB_BITS: u32 = 32;
|
||||
/// Every finite f64 is `mant * 2^(shift - 1074)` with `shift <= 2045` and `mant < 2^53`.
|
||||
const LIMBS: usize = 67;
|
||||
/// The most weight one limb absorbs between carries: each add moves a limb by < 2^32.
|
||||
const CHUNK: i64 = 1 << 30;
|
||||
|
||||
/// An exact sum of f64s: a signed fixed-point integer in units of 2^-1074, wide enough
|
||||
/// for every finite f64, with non-finite values counted so they can be retracted too.
|
||||
#[derive(Clone)]
|
||||
struct ExactSum {
|
||||
limbs: [i64; LIMBS],
|
||||
pending: u64,
|
||||
nan: i64,
|
||||
pos_inf: i64,
|
||||
neg_inf: i64,
|
||||
}
|
||||
|
||||
impl ExactSum {
|
||||
fn new() -> Self {
|
||||
ExactSum {
|
||||
limbs: [0; LIMBS],
|
||||
pending: 0,
|
||||
nan: 0,
|
||||
pos_inf: 0,
|
||||
neg_inf: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add `x` with any `weight` (a consolidated Z-set weight). Exact: no rounding here.
|
||||
fn add(&mut self, x: f64, weight: i64) {
|
||||
match (x.is_nan(), x.is_infinite()) {
|
||||
(true, _) => self.nan += weight,
|
||||
(false, true) if x > 0.0 => self.pos_inf += weight,
|
||||
(false, true) => self.neg_inf += weight,
|
||||
(false, false) => {
|
||||
let mut left = weight;
|
||||
while left != 0 {
|
||||
let chunk = left.clamp(-CHUNK, CHUNK);
|
||||
self.add_finite(x, chunk);
|
||||
left -= chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded so each limb stays under 2^62: `|weight| <= CHUNK` and `pending`
|
||||
/// counts magnitude, not calls.
|
||||
fn add_finite(&mut self, x: f64, weight: i64) {
|
||||
if self.pending + weight.unsigned_abs() > CHUNK as u64 {
|
||||
propagate(&mut self.limbs);
|
||||
self.pending = 0;
|
||||
}
|
||||
let bits = x.to_bits();
|
||||
let biased = ((bits >> 52) & 0x7ff) as u32;
|
||||
let fraction = bits & ((1 << 52) - 1);
|
||||
let (mant, shift) = match biased {
|
||||
0 => (fraction, 0),
|
||||
e => (fraction | (1 << 52), e - 1),
|
||||
};
|
||||
let sign = if x.is_sign_negative() {
|
||||
-weight
|
||||
} else {
|
||||
weight
|
||||
};
|
||||
let wide = u128::from(mant) << (shift % LIMB_BITS);
|
||||
let k = (shift / LIMB_BITS) as usize;
|
||||
for i in 0..3 {
|
||||
let part = ((wide >> (LIMB_BITS * i as u32)) & 0xffff_ffff) as i64;
|
||||
self.limbs[k + i] += sign * part;
|
||||
}
|
||||
self.pending += weight.unsigned_abs();
|
||||
}
|
||||
|
||||
/// The exact sum rounded to the nearest f64, ties to even: the one rounding.
|
||||
fn value(&self) -> f64 {
|
||||
if self.nan > 0 || (self.pos_inf > 0 && self.neg_inf > 0) {
|
||||
return f64::NAN;
|
||||
}
|
||||
if self.pos_inf > 0 {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
if self.neg_inf > 0 {
|
||||
return f64::NEG_INFINITY;
|
||||
}
|
||||
let mut limbs = self.limbs;
|
||||
propagate(&mut limbs);
|
||||
let negative = limbs[LIMBS - 1] < 0;
|
||||
if negative {
|
||||
limbs.iter_mut().for_each(|l| *l = -*l);
|
||||
propagate(&mut limbs);
|
||||
}
|
||||
let magnitude = round_to_f64(&digits(&limbs));
|
||||
match negative {
|
||||
true => -magnitude,
|
||||
false => magnitude,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Carry each limb into the next, leaving every limb but the top in [0, 2^32).
|
||||
fn propagate(limbs: &mut [i64; LIMBS]) {
|
||||
for k in 0..LIMBS - 1 {
|
||||
let carry = limbs[k] >> LIMB_BITS;
|
||||
limbs[k] -= carry << LIMB_BITS;
|
||||
limbs[k + 1] += carry;
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-negative normalized accumulator as 32-bit digits, the top limb split out.
|
||||
fn digits(limbs: &[i64; LIMBS]) -> Vec<u64> {
|
||||
let mut out: Vec<u64> = limbs.iter().map(|&l| l as u64).collect();
|
||||
let mut top = out.pop().unwrap_or(0);
|
||||
loop {
|
||||
out.push(top & 0xffff_ffff);
|
||||
top >>= LIMB_BITS;
|
||||
if top == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn bit(d: &[u64], pos: usize) -> u64 {
|
||||
(d[pos / 32] >> (pos % 32)) & 1
|
||||
}
|
||||
|
||||
/// Round an exact multiple of 2^-1074 to the nearest f64, ties to even.
|
||||
fn round_to_f64(d: &[u64]) -> f64 {
|
||||
let Some(k) = d.iter().rposition(|&x| x != 0) else {
|
||||
return 0.0;
|
||||
};
|
||||
let msb = 32 * k + 63 - d[k].leading_zeros() as usize;
|
||||
if msb < 53 {
|
||||
let int = d[0] | (d[1] << 32);
|
||||
return int as f64 * f64::from_bits(1);
|
||||
}
|
||||
let mut mant = (0..53).fold(0u64, |m, i| m | (bit(d, msb - 52 + i) << i));
|
||||
let round = bit(d, msb - 53) == 1;
|
||||
let sticky = (0..msb - 53).any(|p| bit(d, p) == 1);
|
||||
let mut top = msb;
|
||||
if round && (sticky || mant & 1 == 1) {
|
||||
mant += 1;
|
||||
if mant == 1 << 53 {
|
||||
mant >>= 1;
|
||||
top += 1;
|
||||
}
|
||||
}
|
||||
let exp = top as i64 - 52 - 1074;
|
||||
if exp > 971 {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
let scale = match exp >= -1022 {
|
||||
true => f64::from_bits(((exp + 1023) as u64) << 52),
|
||||
false => f64::from_bits(1u64 << (exp + 1074)),
|
||||
};
|
||||
mant as f64 * scale
|
||||
}
|
||||
|
||||
fn exact_over(values: &[i64]) -> f64 {
|
||||
let mut acc = ExactSum::new();
|
||||
values.iter().for_each(|&v| acc.add(typed(v), 1));
|
||||
acc.value()
|
||||
}
|
||||
|
||||
fn ulps(a: f64, b: f64) -> u64 {
|
||||
(a.to_bits() as i64).abs_diff(b.to_bits() as i64)
|
||||
}
|
||||
|
||||
struct Sums {
|
||||
exact: f64,
|
||||
eager: f64,
|
||||
naive: f64,
|
||||
comp: f64,
|
||||
xs_maintained: f64,
|
||||
xs_fresh: f64,
|
||||
}
|
||||
|
||||
/// Each running sum against the eager fold (the differential oracle) and against the
|
||||
/// exact decimal sum. `flip`: the gap reaches `FLOAT_EQ_EPSILON`, so `=` can answer differently.
|
||||
fn report(label: &str, cycles: usize, s: &Sums) {
|
||||
let shown = |v: f64| format_f64(v, true, 2);
|
||||
println!(
|
||||
"{label:<22} {cycles:>8} {:>10.2e} {:>6} {:>10.2e} {:>6} {:>5} {:>10.2e} {:>10.2e} {:>10.2e} {:>7} {:>10.2e}",
|
||||
(s.naive - s.eager).abs(),
|
||||
ulps(s.naive, s.eager),
|
||||
(s.comp - s.eager).abs(),
|
||||
ulps(s.comp, s.eager),
|
||||
(s.naive - s.eager).abs() >= 1e-10,
|
||||
(s.eager - s.exact).abs(),
|
||||
(s.naive - s.exact).abs(),
|
||||
(s.comp - s.exact).abs(),
|
||||
s.xs_maintained.to_bits() == s.xs_fresh.to_bits(),
|
||||
(s.xs_maintained - s.exact).abs(),
|
||||
);
|
||||
if shown(s.naive) != shown(s.eager) || shown(s.eager) != shown(s.exact) {
|
||||
println!(
|
||||
" shown at ,.2: naive {} eager {} exact {} maintained-exact-sum {}",
|
||||
shown(s.naive),
|
||||
shown(s.eager),
|
||||
shown(s.exact),
|
||||
shown(s.xs_maintained)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each cycle retracts one live value and inserts a fresh one at the same key.
|
||||
fn random_updates(seed: u64, cells: usize, decades: u32, checkpoints: &[usize]) {
|
||||
let mut rng = Rng(seed);
|
||||
let (mut m, mut values) = document(&mut rng, cells, decades);
|
||||
let mut naive = eager(&m);
|
||||
let mut comp = Compensated::default();
|
||||
comp.add(naive);
|
||||
let mut xs = ExactSum::new();
|
||||
values.iter().for_each(|&v| xs.add(typed(v), 1));
|
||||
let mut done = 0;
|
||||
for &at in checkpoints {
|
||||
while done < at {
|
||||
let row = rng.below(cells as u64) as usize;
|
||||
let new = decimal(&mut rng, decades);
|
||||
naive = naive - typed(values[row]) + typed(new);
|
||||
comp.add(-typed(values[row]));
|
||||
comp.add(typed(new));
|
||||
xs.add(typed(values[row]), -1);
|
||||
xs.add(typed(new), 1);
|
||||
values[row] = new;
|
||||
m.set_cell(key(row), CellValue::Number(typed(new)));
|
||||
done += 1;
|
||||
}
|
||||
let sums = Sums {
|
||||
exact: exact(&values),
|
||||
eager: eager(&m),
|
||||
naive,
|
||||
comp: comp.value(),
|
||||
xs_maintained: xs.value(),
|
||||
xs_fresh: exact_over(&values),
|
||||
};
|
||||
report(&format!("{cells} cells, {decades} dec"), at, &sums);
|
||||
}
|
||||
}
|
||||
|
||||
/// A large value arrives, small ones accumulate under it, then it is retracted.
|
||||
fn absorbed_by_a_large_value() {
|
||||
let mut m = Model::new("absorb");
|
||||
m.add_category("Row").expect("under the limit");
|
||||
m.category_mut("Row").expect("just added").add_item("r0");
|
||||
let mut naive = 0.0;
|
||||
let mut comp = Compensated::default();
|
||||
let mut xs = ExactSum::new();
|
||||
m.set_cell(key(0), CellValue::Number(1e17));
|
||||
naive += 1e17;
|
||||
comp.add(1e17);
|
||||
xs.add(1e17, 1);
|
||||
for row in 1..=1000 {
|
||||
m.category_mut("Row")
|
||||
.expect("just added")
|
||||
.add_item(format!("r{row}"));
|
||||
m.set_cell(key(row), CellValue::Number(0.01));
|
||||
naive += 0.01;
|
||||
comp.add(0.01);
|
||||
xs.add(0.01, 1);
|
||||
}
|
||||
m.clear_cell(&key(0));
|
||||
naive -= 1e17;
|
||||
comp.add(-1e17);
|
||||
xs.add(1e17, -1);
|
||||
let mut fresh = ExactSum::new();
|
||||
(0..1000).for_each(|_| fresh.add(0.01, 1));
|
||||
let sums = Sums {
|
||||
exact: 10.0,
|
||||
eager: eager(&m),
|
||||
naive,
|
||||
comp: comp.value(),
|
||||
xs_maintained: xs.value(),
|
||||
xs_fresh: fresh.value(),
|
||||
};
|
||||
report("1e17, 1000 x 0.01, -1e17", 1001, &sums);
|
||||
}
|
||||
|
||||
/// Nanoseconds per operation over `n` values, best of three runs.
|
||||
fn per_op(n: usize, mut op: impl FnMut() -> f64) -> f64 {
|
||||
(0..3)
|
||||
.map(|_| {
|
||||
let t = Instant::now();
|
||||
black_box(op());
|
||||
t.elapsed().as_secs_f64() * 1e9 / n as f64
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
fn cost() {
|
||||
let n = 10_000_000;
|
||||
let mut rng = Rng(7);
|
||||
let values: Vec<f64> = (0..n).map(|_| typed(decimal(&mut rng, 9))).collect();
|
||||
let updates = 3 * n;
|
||||
let plain = per_op(updates, || {
|
||||
values
|
||||
.iter()
|
||||
.fold(0.0, |s, &v| black_box(s + v) - black_box(v) + v)
|
||||
});
|
||||
let exact_add = per_op(updates, || {
|
||||
let mut xs = ExactSum::new();
|
||||
values.iter().for_each(|&v| {
|
||||
xs.add(black_box(v), 1);
|
||||
xs.add(black_box(v), -1);
|
||||
xs.add(v, 1);
|
||||
});
|
||||
xs.limbs[0] as f64
|
||||
});
|
||||
let mut xs = ExactSum::new();
|
||||
values.iter().for_each(|&v| xs.add(v, 1));
|
||||
let read = per_op(1, || xs.value());
|
||||
println!();
|
||||
println!("cost, {n} values x (insert, retract, insert), best of 3, per single update:");
|
||||
println!(" plain f64 add/sub {plain:>8.2} ns per update");
|
||||
println!(" exact accumulator {exact_add:>8.2} ns per update");
|
||||
println!(" exact read (round) {read:>8.0} ns per read");
|
||||
println!(
|
||||
" state per group {} bytes, against 8 for an f64",
|
||||
std::mem::size_of::<ExactSum>()
|
||||
);
|
||||
}
|
||||
|
||||
/// A consolidated Z-set weight can be large; the accumulator must stay exact at 2^33.
|
||||
fn large_weight() {
|
||||
let x = f64::from_bits((1u64 << 32) - 1);
|
||||
let mut xs = ExactSum::new();
|
||||
xs.add(x, 1 << 33);
|
||||
let want = ((1u64 << 32) - 1) as f64 * 2f64.powi(33) * f64::from_bits(1);
|
||||
println!();
|
||||
println!(
|
||||
"large weight: (2^32-1) x 2^-1074 at weight 2^33 reads {:e}, exact {:e}, equal {}",
|
||||
xs.value(),
|
||||
want,
|
||||
xs.value().to_bits() == want.to_bits()
|
||||
);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!(
|
||||
"{:<22} {:>8} {:>10} {:>6} {:>10} {:>6} {:>5} {:>10} {:>10} {:>10} {:>7} {:>10}",
|
||||
"case",
|
||||
"cycles",
|
||||
"naive-eag",
|
||||
"ulp",
|
||||
"comp-eag",
|
||||
"ulp",
|
||||
"flip",
|
||||
"eag-exact",
|
||||
"nai-exact",
|
||||
"cmp-exact",
|
||||
"xs=fresh",
|
||||
"xs-exact"
|
||||
);
|
||||
let checkpoints = [1_000, 10_000, 100_000, 1_000_000];
|
||||
for seed in [1, 2] {
|
||||
random_updates(seed, 1_000, 3, &checkpoints);
|
||||
random_updates(seed, 18_000, 3, &checkpoints);
|
||||
random_updates(seed, 18_000, 9, &checkpoints);
|
||||
}
|
||||
absorbed_by_a_large_value();
|
||||
large_weight();
|
||||
cost();
|
||||
}
|
||||
@@ -38,18 +38,26 @@ impl Item {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Group {
|
||||
pub name: String,
|
||||
/// What a person sees; the key until a rename (ADR 007 clause 2).
|
||||
display: String,
|
||||
/// Parent group name for nested hierarchies
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
impl Group {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
let name = name.into();
|
||||
Self {
|
||||
name: name.into(),
|
||||
display: name.clone(),
|
||||
name,
|
||||
parent: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display(&self) -> &str {
|
||||
&self.display
|
||||
}
|
||||
|
||||
pub fn with_parent(mut self, parent: impl Into<String>) -> Self {
|
||||
self.parent = Some(parent.into());
|
||||
self
|
||||
@@ -180,6 +188,34 @@ impl Category {
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// What a person sees for an item: its display, or the key where the
|
||||
/// category holds no such item (a `_Measure` formula target).
|
||||
pub fn item_display<'a>(&'a self, item: &'a str) -> &'a str {
|
||||
self.items.get(item).map_or(item, Item::display)
|
||||
}
|
||||
|
||||
/// What a person sees for a group; the key where no `Group` was declared
|
||||
/// (a group may exist only as an item's `group`).
|
||||
pub fn group_display<'a>(&'a self, group: &'a str) -> &'a str {
|
||||
self.groups
|
||||
.iter()
|
||||
.find(|g| g.name == group)
|
||||
.map_or(group, Group::display)
|
||||
}
|
||||
|
||||
/// Changes a group's display and nothing else. A group may exist only as an
|
||||
/// item's `group`, so one is declared (as a root) when none is yet.
|
||||
pub fn set_group_display(&mut self, group: &str, display: impl Into<String>) {
|
||||
match self.groups.iter_mut().find(|g| g.name == group) {
|
||||
Some(g) => g.display = display.into(),
|
||||
None => {
|
||||
let mut declared = Group::new(group);
|
||||
declared.display = display.into();
|
||||
self.groups.push(declared);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_kind(mut self, kind: CategoryKind) -> Self {
|
||||
self.kind = kind;
|
||||
self
|
||||
@@ -307,6 +343,21 @@ mod tests {
|
||||
assert!(!c.set_item_display("West", "Western"), "no such item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_item_and_missing_key_fall_back_to_the_key_they_were_asked() {
|
||||
let mut c = cat();
|
||||
c.add_item_in_group("East", "Coastal");
|
||||
assert_eq!(c.group_display("Coastal"), "Coastal", "no group declared");
|
||||
c.set_group_display("Coastal", "Shoreline");
|
||||
assert_eq!(c.group_display("Coastal"), "Shoreline");
|
||||
assert_eq!(
|
||||
c.group_chain("Coastal"),
|
||||
vec!["Coastal"],
|
||||
"the key is unmoved"
|
||||
);
|
||||
assert_eq!(c.item_display("Nowhere"), "Nowhere", "a formula target");
|
||||
}
|
||||
|
||||
/// improvise-2lh: call sites ask `of_name` rather than compare literals,
|
||||
/// so both directions of the mapping must agree.
|
||||
#[test]
|
||||
|
||||
@@ -525,6 +525,17 @@ impl Model {
|
||||
self.categories.get(name)
|
||||
}
|
||||
|
||||
/// What a person sees for a category key; the key itself when none holds it.
|
||||
pub fn category_display<'a>(&'a self, name: &'a str) -> &'a str {
|
||||
self.category(name).map_or(name, |c| c.display())
|
||||
}
|
||||
|
||||
/// What a person sees for an item of `cat`; the key where `cat` has no such
|
||||
/// item, as for a formula target in `_Measure` (core.md §21).
|
||||
pub fn item_display<'a>(&'a self, cat: &str, item: &'a str) -> &'a str {
|
||||
self.category(cat).map_or(item, |c| c.item_display(item))
|
||||
}
|
||||
|
||||
/// `group` and its ancestors in `cat`, root first and owned: the chain a
|
||||
/// `View` folds by (core.md §19). Just `[group]` where `cat` is unknown.
|
||||
pub fn group_chain(&self, cat: &str, group: &str) -> Vec<String> {
|
||||
|
||||
@@ -212,6 +212,8 @@ pub enum AxisEntry {
|
||||
depth: usize,
|
||||
cat_name: String,
|
||||
group_name: String,
|
||||
/// What the group is called on screen; `group_name` stays the key (core.md §21).
|
||||
group_display: String,
|
||||
/// The view's answer for this group, so no reader re-derives it.
|
||||
collapsed: bool,
|
||||
},
|
||||
@@ -220,17 +222,32 @@ pub enum AxisEntry {
|
||||
GroupEnd { level: usize, depth: usize },
|
||||
/// `collapsed` is not derivable from `tuple`: a group and an item may share a
|
||||
/// name. Set for a folded group and for everything expanded beneath one.
|
||||
DataItem { tuple: Vec<String>, collapsed: bool },
|
||||
DataItem {
|
||||
tuple: Vec<String>,
|
||||
/// Parallel to `tuple`; the tuple is never replaced (core.md §21).
|
||||
display: Vec<String>,
|
||||
collapsed: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl AxisEntry {
|
||||
/// An ordinary, uncollapsed data item.
|
||||
/// An ordinary, uncollapsed data item shown under its own keys.
|
||||
pub fn item(tuple: Vec<String>) -> Self {
|
||||
AxisEntry::DataItem {
|
||||
display: tuple.clone(),
|
||||
tuple,
|
||||
collapsed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// An ordinary, uncollapsed data item whose levels show other names.
|
||||
pub fn item_shown(tuple: Vec<String>, display: Vec<String>) -> Self {
|
||||
AxisEntry::DataItem {
|
||||
tuple,
|
||||
display,
|
||||
collapsed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The data-item tuples on an axis, skipping group headers. Every public
|
||||
@@ -270,6 +287,14 @@ fn data_item<'a>(items: &'a [AxisEntry], pos: &[usize], idx: usize) -> Option<&'
|
||||
}
|
||||
}
|
||||
|
||||
/// The names the data item at `idx` is shown under, one per level.
|
||||
fn data_display<'a>(items: &'a [AxisEntry], pos: &[usize], idx: usize) -> Option<&'a Vec<String>> {
|
||||
match items.get(*pos.get(idx)?)? {
|
||||
AxisEntry::DataItem { display, .. } => Some(display),
|
||||
AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the data item at `idx` folds a collapsed group.
|
||||
fn folds(items: &[AxisEntry], pos: &[usize], idx: usize) -> bool {
|
||||
match pos.get(idx).and_then(|at| items.get(*at)) {
|
||||
@@ -317,6 +342,19 @@ fn axis_label(items: &[AxisEntry], pos: &[usize], idx: usize) -> String {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn cat_displays(model: &Model, cats: &[String]) -> Vec<String> {
|
||||
cats.iter()
|
||||
.map(|c| model.category_display(c).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`axis_label`] as a person reads it: the displays, not the keys.
|
||||
fn axis_display_label(items: &[AxisEntry], pos: &[usize], idx: usize) -> String {
|
||||
data_display(items, pos, idx)
|
||||
.map(|v| v.join("/"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Visual index of the nth data entry (skipping group headers).
|
||||
fn data_index_to_visual(pos: &[usize], data_idx: usize) -> Option<usize> {
|
||||
pos.get(data_idx).copied()
|
||||
@@ -452,12 +490,55 @@ fn innermost_group(items: &[AxisEntry], idx: usize) -> Option<(String, String)>
|
||||
/// One category constraining which records are visible: what it is fixed to,
|
||||
/// and — when the user may page it — what `[`/`]` cycle. core.md §7.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(try_from = "UncheckedPageAxis")]
|
||||
pub struct PageAxis {
|
||||
pub cat: String,
|
||||
/// What the bar shows for `cat`; the key stays what a command names (core.md §21).
|
||||
pub cat_display: String,
|
||||
pub selected: String,
|
||||
/// What the bar shows for `selected`.
|
||||
pub selected_display: String,
|
||||
/// `Some` exactly for `Axis::Page`. A `Filter` axis constrains without
|
||||
/// being navigable, so a client can compose no cycle for one (gotcha 27).
|
||||
pub items: Option<Vec<String>>,
|
||||
/// Parallel to `items`: what the menu shows for each. `[`/`]` and a click
|
||||
/// compose a key from `items` and never from here.
|
||||
pub item_displays: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// A [`PageAxis`] as the wire delivers it, before `item_displays` is checked
|
||||
/// against `items`: a parallel list a client must not see ragged.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct UncheckedPageAxis {
|
||||
cat: String,
|
||||
cat_display: String,
|
||||
selected: String,
|
||||
selected_display: String,
|
||||
items: Option<Vec<String>>,
|
||||
item_displays: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl TryFrom<UncheckedPageAxis> for PageAxis {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(raw: UncheckedPageAxis) -> Result<Self, String> {
|
||||
let keys = raw.items.as_ref().map(Vec::len);
|
||||
let shown = raw.item_displays.as_ref().map(Vec::len);
|
||||
match keys == shown {
|
||||
true => Ok(Self {
|
||||
cat: raw.cat,
|
||||
cat_display: raw.cat_display,
|
||||
selected: raw.selected,
|
||||
selected_display: raw.selected_display,
|
||||
items: raw.items,
|
||||
item_displays: raw.item_displays,
|
||||
}),
|
||||
false => Err(format!(
|
||||
"page axis {}: {keys:?} items but {shown:?} item displays",
|
||||
raw.cat
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The page axes as the plain coordinate pairs `matching_cells` and
|
||||
@@ -469,6 +550,18 @@ pub fn page_constraints(axes: &[PageAxis]) -> Vec<(String, String)> {
|
||||
}
|
||||
|
||||
impl PageAxis {
|
||||
/// An axis shown under its own keys, as it is wherever nothing is renamed.
|
||||
pub fn plain(cat: &str, selected: &str, items: Option<Vec<String>>) -> Self {
|
||||
Self {
|
||||
cat: cat.to_string(),
|
||||
cat_display: cat.to_string(),
|
||||
selected: selected.to_string(),
|
||||
selected_display: selected.to_string(),
|
||||
item_displays: items.clone(),
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
/// The items to cycle and the index `selected` sits at, for the two
|
||||
/// callers that page. `None` where the axis is not navigable.
|
||||
pub fn cycle(&self) -> Option<(&[String], usize)> {
|
||||
@@ -560,9 +653,13 @@ impl GridLayout {
|
||||
.map(String::from)
|
||||
.or_else(|| items.first().cloned())?;
|
||||
let navigable = view.try_axis_of(cat) == Some(Axis::Page);
|
||||
let shown = |item: &str| model.item_display(cat, item).to_string();
|
||||
Some(PageAxis {
|
||||
cat: cat.clone(),
|
||||
cat_display: model.category_display(cat).to_string(),
|
||||
selected_display: shown(&selected),
|
||||
selected,
|
||||
item_displays: navigable.then(|| items.iter().map(|i| shown(i)).collect()),
|
||||
items: navigable.then_some(items),
|
||||
})
|
||||
})
|
||||
@@ -608,7 +705,12 @@ impl GridLayout {
|
||||
let record_cols = Self::records_columns(model);
|
||||
let col_items: Vec<AxisEntry> = record_cols
|
||||
.iter()
|
||||
.map(|c| AxisEntry::item(vec![Self::record_col_label(c)]))
|
||||
.map(|c| {
|
||||
AxisEntry::item_shown(
|
||||
vec![Self::record_col_label(c)],
|
||||
vec![Self::record_col_display(model, c)],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
@@ -671,6 +773,25 @@ impl GridLayout {
|
||||
}
|
||||
}
|
||||
|
||||
/// The same header as a person reads it: a category's or a measure's display.
|
||||
fn record_col_display(model: &Model, col: &RecordCol) -> String {
|
||||
match col {
|
||||
RecordCol::Category(name) => model.category_display(name).to_string(),
|
||||
RecordCol::Measure(name) => model.item_display(MEASURE_CATEGORY, name).to_string(),
|
||||
RecordCol::NewMeasure | RecordCol::NewCategory => Self::record_col_label(col),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text a records-mode coordinate cell shows: the item's display, where
|
||||
/// `records_display` is the item's key. `None` for every other column.
|
||||
pub fn coordinate_display(&self, model: &Model, row: usize, col: usize) -> Option<String> {
|
||||
let RecordCol::Category(category) = self.record_col(col)? else {
|
||||
return None;
|
||||
};
|
||||
let item = self.record_row(row)?.coords.get(category)?;
|
||||
Some(model.item_display(category, item).to_string())
|
||||
}
|
||||
|
||||
/// What the records-mode column at `col` holds; `None` outside records
|
||||
/// mode or past the last column.
|
||||
pub fn record_col(&self, col: usize) -> Option<&RecordCol> {
|
||||
@@ -787,6 +908,24 @@ impl GridLayout {
|
||||
axis_label(&self.col_items, &self.col_pos, col)
|
||||
}
|
||||
|
||||
/// [`GridLayout::row_label`] as a person reads it (ADR 007): the displays.
|
||||
pub fn row_display_label(&self, row: usize) -> String {
|
||||
axis_display_label(&self.row_items, &self.row_pos, row)
|
||||
}
|
||||
|
||||
pub fn col_display_label(&self, col: usize) -> String {
|
||||
axis_display_label(&self.col_items, &self.col_pos, col)
|
||||
}
|
||||
|
||||
/// What each row-axis category is called on screen, outermost first.
|
||||
pub fn row_cat_displays(&self, model: &Model) -> Vec<String> {
|
||||
cat_displays(model, &self.row_cats)
|
||||
}
|
||||
|
||||
pub fn col_cat_displays(&self, model: &Model) -> Vec<String> {
|
||||
cat_displays(model, &self.col_cats)
|
||||
}
|
||||
|
||||
/// The value of a pivot key as THIS grid reads it: aggregated over its None
|
||||
/// axis, and a fold over the members it draws (core.md §14). One evaluation.
|
||||
pub fn evaluate(&self, model: &Model, key: &CellKey) -> Option<CellValue> {
|
||||
@@ -815,7 +954,9 @@ impl GridLayout {
|
||||
plan: &crate::format::FormatPlan,
|
||||
) -> String {
|
||||
if self.is_records_mode() {
|
||||
self.records_display(row, col).unwrap_or_default()
|
||||
self.coordinate_display(model, row, col)
|
||||
.or_else(|| self.records_display(row, col))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
self.cell_key(row, col)
|
||||
.and_then(|key| {
|
||||
@@ -922,7 +1063,7 @@ fn expand_category(
|
||||
model: &Model,
|
||||
view: &View,
|
||||
cat_name: &str,
|
||||
prefix: Vec<String>,
|
||||
(prefix, shown): (Vec<String>, Vec<String>),
|
||||
prefix_collapsed: bool,
|
||||
) -> Vec<AxisEntry> {
|
||||
let Some(cat) = model.category(cat_name) else {
|
||||
@@ -930,6 +1071,7 @@ fn expand_category(
|
||||
};
|
||||
// The prefix holds one item per outer category, so its length is this level.
|
||||
let mut runs = CategoryRuns {
|
||||
model,
|
||||
level: prefix.len(),
|
||||
cat_name,
|
||||
view,
|
||||
@@ -956,15 +1098,21 @@ fn expand_category(
|
||||
runs.enter(&chain[..fold_depth.map_or(chain.len(), |d| d + 1)]);
|
||||
|
||||
let mut tuple = prefix.clone();
|
||||
let mut display = shown.clone();
|
||||
match fold {
|
||||
Some(g) => {
|
||||
folded.push(g);
|
||||
tuple.push(g.to_string());
|
||||
display.push(cat.group_display(g).to_string());
|
||||
}
|
||||
None => {
|
||||
tuple.push(item_name.to_string());
|
||||
display.push(cat.item_display(item_name).to_string());
|
||||
}
|
||||
None => tuple.push(item_name.to_string()),
|
||||
}
|
||||
runs.out.push(AxisEntry::DataItem {
|
||||
tuple,
|
||||
display,
|
||||
collapsed: prefix_collapsed || fold.is_some(),
|
||||
});
|
||||
}
|
||||
@@ -977,6 +1125,7 @@ fn expand_category(
|
||||
/// One category's group runs as `expand_category` emits them: the chain of groups
|
||||
/// open, root first, and the markers that move it from item to item.
|
||||
struct CategoryRuns<'a> {
|
||||
model: &'a Model,
|
||||
level: usize,
|
||||
cat_name: &'a str,
|
||||
view: &'a View,
|
||||
@@ -995,6 +1144,7 @@ impl<'a> CategoryRuns<'a> {
|
||||
.take_while(|(open, want)| open == want)
|
||||
.count();
|
||||
let level = self.level;
|
||||
let category = self.model.category(self.cat_name);
|
||||
for depth in (shared + 1..self.open.len()).rev() {
|
||||
self.out.push(AxisEntry::GroupEnd { level, depth });
|
||||
}
|
||||
@@ -1011,6 +1161,9 @@ impl<'a> CategoryRuns<'a> {
|
||||
depth,
|
||||
cat_name: self.cat_name.to_string(),
|
||||
group_name: group.to_string(),
|
||||
group_display: category
|
||||
.map_or(*group, |c| c.group_display(group))
|
||||
.to_string(),
|
||||
collapsed: self
|
||||
.view
|
||||
.is_group_collapsed(self.cat_name, &chain[..=depth]),
|
||||
@@ -1031,9 +1184,11 @@ fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry>
|
||||
result = result
|
||||
.into_iter()
|
||||
.flat_map(|entry| match entry {
|
||||
AxisEntry::DataItem { tuple, collapsed } => {
|
||||
expand_category(model, view, cat_name, tuple, collapsed)
|
||||
}
|
||||
AxisEntry::DataItem {
|
||||
tuple,
|
||||
display,
|
||||
collapsed,
|
||||
} => expand_category(model, view, cat_name, (tuple, display), collapsed),
|
||||
marker @ (AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. }) => {
|
||||
vec![marker]
|
||||
}
|
||||
@@ -1073,7 +1228,7 @@ impl GridEval<'_> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
AxisEntry, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, RecordCol, data_items,
|
||||
AxisEntry, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, PageAxis, RecordCol, data_items,
|
||||
prune_axis_items, synthetic_record_info,
|
||||
};
|
||||
use crate::model::Aggregation;
|
||||
@@ -1119,6 +1274,7 @@ mod tests {
|
||||
depth: 0,
|
||||
cat_name: "C".into(),
|
||||
group_name: "G".into(),
|
||||
group_display: "G".into(),
|
||||
collapsed: false,
|
||||
},
|
||||
AxisEntry::item(vec!["B".into()]),
|
||||
@@ -1146,6 +1302,7 @@ mod tests {
|
||||
depth: 0,
|
||||
cat_name: "C".into(),
|
||||
group_name: "G".into(),
|
||||
group_display: "G".into(),
|
||||
collapsed: false,
|
||||
},
|
||||
AxisEntry::item(vec!["A".into()]),
|
||||
@@ -1542,6 +1699,145 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A category, two items, a group, a page category and a measure, each shown
|
||||
/// under a name that is not its key. The test-only way to make them differ.
|
||||
fn shown_otherwise() -> Workbook {
|
||||
let mut wb = Workbook::new("T");
|
||||
for cat in ["Month", "Type", "Region", "_Measure"] {
|
||||
let _ = wb.add_category(cat);
|
||||
}
|
||||
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
|
||||
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
wb.add_item("Region", "East").unwrap();
|
||||
wb.add_item("Region", "West").unwrap();
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.active_view_mut().set_axis("Type", Axis::Column);
|
||||
wb.active_view_mut().set_axis("Region", Axis::Page);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::None);
|
||||
let month = wb.model.category_mut("Month").unwrap();
|
||||
month.set_display("Period");
|
||||
month.set_item_display("Jan", "January");
|
||||
month.set_group_display("Q1", "First quarter");
|
||||
let region = wb.model.category_mut("Region").unwrap();
|
||||
region.set_display("Sales Area");
|
||||
region.set_item_display("East", "Eastern Seaboard");
|
||||
region.set_item_display("West", "Pacific");
|
||||
wb.model
|
||||
.category_mut("Type")
|
||||
.unwrap()
|
||||
.set_item_display("Food", "Groceries");
|
||||
wb
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layout_shows_displays_and_still_addresses_by_keys() {
|
||||
let wb = shown_otherwise();
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
assert_eq!(layout.row_label(0), "Jan", "the key");
|
||||
assert_eq!(layout.row_display_label(0), "January");
|
||||
assert_eq!(layout.col_display_label(0), "Groceries");
|
||||
assert_eq!(layout.row_cat_displays(&wb.model), vec!["Period"]);
|
||||
assert!(matches!(
|
||||
&layout.row_items[0],
|
||||
AxisEntry::GroupHeader { group_name, group_display, .. }
|
||||
if group_name == "Q1" && group_display == "First quarter"
|
||||
));
|
||||
// Addressing is untouched: the cell, its group and its run start are keys.
|
||||
assert_eq!(
|
||||
layout.cell_key(0, 0),
|
||||
Some(CellKey::new(vec![
|
||||
("Month".into(), "Jan".into()),
|
||||
("Region".into(), "East".into()),
|
||||
("Type".into(), "Food".into()),
|
||||
]))
|
||||
);
|
||||
assert_eq!(layout.row_group_for(0), Some(("Month".into(), "Q1".into())));
|
||||
assert_eq!(layout.row_run_start("Month", "Q1", &[]), Some(0));
|
||||
let page = &layout.page_coords[0];
|
||||
assert_eq!(
|
||||
(page.cat.as_str(), page.selected.as_str()),
|
||||
("Region", "East")
|
||||
);
|
||||
assert_eq!(page.cat_display, "Sales Area");
|
||||
assert_eq!(page.selected_display, "Eastern Seaboard");
|
||||
assert_eq!(
|
||||
page.items.as_deref(),
|
||||
Some(&["East".to_string(), "West".into()][..])
|
||||
);
|
||||
assert_eq!(
|
||||
page.item_displays.as_deref(),
|
||||
Some(&["Eastern Seaboard".to_string(), "Pacific".into()][..])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_axis_with_ragged_displays_is_refused() {
|
||||
let good = PageAxis::plain("Q", "Q1", Some(vec!["Q1".into(), "Q2".into()]));
|
||||
let wire = serde_json::to_value(&good).expect("a page axis serializes");
|
||||
assert_eq!(
|
||||
serde_json::from_value::<PageAxis>(wire.clone()).unwrap(),
|
||||
good
|
||||
);
|
||||
for bad in [serde_json::json!(["Only one"]), serde_json::json!(null)] {
|
||||
let mut ragged = wire.clone();
|
||||
ragged["item_displays"] = bad;
|
||||
let err = serde_json::from_value::<PageAxis>(ragged).unwrap_err();
|
||||
assert!(err.to_string().contains("item displays"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folded_group_is_shown_under_the_groups_display_and_keyed_by_its_name() {
|
||||
let mut wb = shown_otherwise();
|
||||
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
assert_eq!(layout.row_label(0), "Q1");
|
||||
assert_eq!(layout.row_display_label(0), "First quarter");
|
||||
assert!(layout.row_folds_a_group(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_records_grid_shows_displays_in_its_headers_and_coordinates() {
|
||||
let mut wb = shown_otherwise();
|
||||
wb.model.set_cell(
|
||||
CellKey::new(vec![
|
||||
("Month".into(), "Jan".into()),
|
||||
("Region".into(), "East".into()),
|
||||
("Type".into(), "Food".into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
]),
|
||||
CellValue::Number(1.0),
|
||||
);
|
||||
wb.model
|
||||
.category_mut("_Measure")
|
||||
.unwrap()
|
||||
.set_item_display("Revenue", "Sales");
|
||||
let view = wb.active_view_mut();
|
||||
view.set_axis("_Index", Axis::Row);
|
||||
view.set_axis("_Dim", Axis::Column);
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
assert!(layout.is_records_mode());
|
||||
let col_of = |label: &str| (0..layout.col_count()).find(|&c| layout.col_label(c) == label);
|
||||
let month = col_of("Month").expect("a column per category");
|
||||
assert_eq!(layout.col_display_label(month), "Period");
|
||||
assert_eq!(
|
||||
layout.col_display_label(col_of("Revenue").unwrap()),
|
||||
"Sales"
|
||||
);
|
||||
assert_eq!(layout.records_display(0, month).as_deref(), Some("Jan"));
|
||||
assert_eq!(
|
||||
layout.coordinate_display(&wb.model, 0, month).as_deref(),
|
||||
Some("January")
|
||||
);
|
||||
assert_eq!(
|
||||
layout.coordinate_display(&wb.model, 0, col_of("Revenue").unwrap()),
|
||||
None,
|
||||
"a measure column holds a value, not a coordinate"
|
||||
);
|
||||
}
|
||||
|
||||
/// The inversion of `collapsed_group_has_header_but_no_data_items`: a
|
||||
/// collapsed group is ONE row. Evidence: context/test-evidence/core.md.
|
||||
#[test]
|
||||
@@ -1874,10 +2170,12 @@ mod tests {
|
||||
depth: 0,
|
||||
cat_name: "Month".into(),
|
||||
group_name: "Q1".into(),
|
||||
group_display: "Q1".into(),
|
||||
collapsed: true,
|
||||
},
|
||||
AxisEntry::DataItem {
|
||||
tuple: vec!["Q1".into()],
|
||||
display: vec!["Q1".into()],
|
||||
collapsed: true
|
||||
},
|
||||
AxisEntry::GroupEnd { level: 0, depth: 0 },
|
||||
@@ -2719,6 +3017,7 @@ mod group_membership {
|
||||
depth: 0,
|
||||
cat_name: "Region".into(),
|
||||
group_name: "Upper".into(),
|
||||
group_display: "Upper".into(),
|
||||
collapsed: false,
|
||||
},
|
||||
AxisEntry::item(vec!["North".into()]),
|
||||
@@ -2727,6 +3026,7 @@ mod group_membership {
|
||||
depth: 0,
|
||||
cat_name: "Nowhere".into(),
|
||||
group_name: "Unreachable".into(),
|
||||
group_display: "Unreachable".into(),
|
||||
collapsed: false,
|
||||
},
|
||||
AxisEntry::item(vec!["South".into()]),
|
||||
@@ -2746,6 +3046,7 @@ mod group_membership {
|
||||
depth: 0,
|
||||
cat_name: "Month".into(),
|
||||
group_name: group.into(),
|
||||
group_display: group.into(),
|
||||
collapsed: false,
|
||||
};
|
||||
let items = vec![
|
||||
@@ -2878,6 +3179,7 @@ mod hierarchy {
|
||||
depth,
|
||||
cat_name: "Date".into(),
|
||||
group_name: group.into(),
|
||||
group_display: group.into(),
|
||||
collapsed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,11 +41,11 @@ pub fn render_csv(workbook: &Workbook, view_name: &str) -> Result<Vec<u8>> {
|
||||
.from_writer(Vec::new());
|
||||
|
||||
// Header row
|
||||
let row_header = layout.row_cats.join("/");
|
||||
let row_header = layout.row_cat_displays(model).join("/");
|
||||
let page_label: Vec<String> = layout
|
||||
.page_coords
|
||||
.iter()
|
||||
.map(|p| format!("{}={}", p.cat, p.selected))
|
||||
.map(|p| format!("{}={}", p.cat_display, p.selected_display))
|
||||
.collect();
|
||||
let header_prefix = if page_label.is_empty() {
|
||||
row_header
|
||||
@@ -56,13 +56,13 @@ pub fn render_csv(workbook: &Workbook, view_name: &str) -> Result<Vec<u8>> {
|
||||
if !header_prefix.is_empty() {
|
||||
header.push(header_prefix);
|
||||
}
|
||||
header.extend((0..layout.col_count()).map(|ci| layout.col_label(ci)));
|
||||
header.extend((0..layout.col_count()).map(|ci| layout.col_display_label(ci)));
|
||||
wtr.write_record(&header)?;
|
||||
|
||||
// Data rows
|
||||
for ri in 0..layout.row_count() {
|
||||
let mut record: Vec<String> = Vec::new();
|
||||
let row_label = layout.row_label(ri);
|
||||
let row_label = layout.row_display_label(ri);
|
||||
if !row_label.is_empty() {
|
||||
record.push(row_label);
|
||||
}
|
||||
@@ -106,6 +106,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_csv_writes_displays_in_every_header_and_label() {
|
||||
let mut m = Workbook::new("T");
|
||||
for cat in ["Type", "Month", "Payer"] {
|
||||
m.add_category(cat).unwrap();
|
||||
}
|
||||
m.add_item("Type", "Food").unwrap();
|
||||
m.add_item("Month", "Jan").unwrap();
|
||||
m.add_item("Payer", "Alice").unwrap();
|
||||
m.model.set_cell(
|
||||
coord(&[("Type", "Food"), ("Month", "Jan"), ("Payer", "Alice")]),
|
||||
CellValue::Number(1.0),
|
||||
);
|
||||
let ty = m.model.category_mut("Type").unwrap();
|
||||
ty.set_display("Kind");
|
||||
ty.set_item_display("Food", "Groceries");
|
||||
m.model
|
||||
.category_mut("Month")
|
||||
.unwrap()
|
||||
.set_item_display("Jan", "January");
|
||||
let payer = m.model.category_mut("Payer").unwrap();
|
||||
payer.set_display("Who");
|
||||
payer.set_item_display("Alice", "Alicia");
|
||||
let bytes = super::render_csv(&m, "Default").unwrap();
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
assert_eq!(text, "Kind (Who=Alicia),January\nGroceries,1.00\n");
|
||||
}
|
||||
|
||||
/// Bug improvise-1cz: bare-comma joining and an ignored number format
|
||||
/// corrupted every row. Evidence: `context/test-evidence/formula-and-io.md`.
|
||||
#[test]
|
||||
|
||||
@@ -531,7 +531,7 @@ mod tests {
|
||||
live.fold(&Command::Project(Projection::ViewList(vec![ViewSummary {
|
||||
name: "Default".into(),
|
||||
display: "Default".into(),
|
||||
axes: vec![("Region".into(), Axis::Row)],
|
||||
axes: vec![crate::ViewAxis::plain("Region", Axis::Row)],
|
||||
}])));
|
||||
live.fold(&Command::SetSelected { row: 1, col: 1 });
|
||||
|
||||
|
||||
@@ -34,12 +34,12 @@ pub use reduce::{
|
||||
records_named, reduce_view,
|
||||
};
|
||||
pub use render::{
|
||||
AxisFrame, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay, CellId, CreateKind,
|
||||
DocumentCache, FormulaCache, FormulaEntry, FrameEntry, FrameGroup, GridCache, GridExtent,
|
||||
GridUpdate, MAX_ROW_HEADER_W, MIN_ROW_HEADER_W, Projection, RecordCell, Snapshot, TileBarCache,
|
||||
TileEntry, ViewListCache, ViewSummary, WidgetCache, WizardCache, WizardChoice, WizardCollision,
|
||||
WizardDateField, WizardFieldKind, WizardProposal, WizardStepKind, header_level_widths,
|
||||
non_item_refusal, opening_tile,
|
||||
AxisFrame, CategoryItem, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay,
|
||||
CellId, CreateKind, DocumentCache, FormulaCache, FormulaEntry, FrameEntry, FrameGroup,
|
||||
GridCache, GridExtent, GridUpdate, MAX_ROW_HEADER_W, MIN_ROW_HEADER_W, Projection, RecordCell,
|
||||
Snapshot, TileBarCache, TileEntry, ViewAxis, ViewListCache, ViewSummary, WidgetCache,
|
||||
WizardCache, WizardChoice, WizardCollision, WizardDateField, WizardFieldKind, WizardProposal,
|
||||
WizardStepKind, header_level_widths, non_item_refusal, opening_tile,
|
||||
};
|
||||
pub use resolve::{
|
||||
Click, KeyOutcome, OpenedEditor, Request, click, edited, read_buffer, resolve, typed,
|
||||
|
||||
@@ -557,7 +557,7 @@ mod tests {
|
||||
Command::Project(Projection::ViewList(vec![ViewSummary {
|
||||
name: "Default".into(),
|
||||
display: "Default".into(),
|
||||
axes: vec![("Region".into(), Axis::Row)],
|
||||
axes: vec![crate::ViewAxis::plain("Region", Axis::Row)],
|
||||
}])),
|
||||
Command::Project(Projection::TileBar(vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
|
||||
@@ -217,7 +217,10 @@ pub struct FrameGroup {
|
||||
/// The group's depth in its category's hierarchy, 0 for a root. Runs nest
|
||||
/// by `(level, depth)`: `context/code/protocol.md` §3a (improvise-felo.6).
|
||||
pub depth: usize,
|
||||
/// The key; what `toggle-group` and a run lookup name.
|
||||
pub name: String,
|
||||
/// What the header shows (ADR 007); never an address.
|
||||
pub display: String,
|
||||
pub collapsed: bool,
|
||||
}
|
||||
|
||||
@@ -235,16 +238,21 @@ pub enum FrameEntry {
|
||||
/// One label per header level, outermost first. Data indices — the
|
||||
/// `row`/`col` of every other field on [`GridCache`] — count only these.
|
||||
Item {
|
||||
/// The keys: the coordinate this position names, never replaced by `display`.
|
||||
tuple: Vec<String>,
|
||||
/// Parallel to `tuple`, one name per level: what the header shows (ADR 007).
|
||||
display: Vec<String>,
|
||||
/// The layout's own fold flag: `context/code/protocol.md` (improvise-4k1j).
|
||||
collapsed: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl FrameEntry {
|
||||
/// An ordinary, unfolded item, as `AxisEntry::item` is on the layout side.
|
||||
/// An ordinary, unfolded item shown under its own keys, as
|
||||
/// `AxisEntry::item` is on the layout side.
|
||||
pub fn item(tuple: Vec<String>) -> Self {
|
||||
FrameEntry::Item {
|
||||
display: tuple.clone(),
|
||||
tuple,
|
||||
collapsed: false,
|
||||
}
|
||||
@@ -259,6 +267,8 @@ pub struct AxisFrame {
|
||||
/// The categories on this axis, outermost first. Empty is a real layout
|
||||
/// (one nameless row or column), not a gap — hence [`AxisFrame::levels`].
|
||||
pub cats: Vec<String>,
|
||||
/// Parallel to `cats`: what each level's category is called on screen.
|
||||
pub cat_displays: Vec<String>,
|
||||
/// The **absolute** data index of this frame's first item. Zero is a claim
|
||||
/// and not an absence, so it is NOT `serde(default)` — §3a, improvise-1wl0.
|
||||
pub first: usize,
|
||||
@@ -270,6 +280,7 @@ pub struct AxisFrame {
|
||||
#[derive(Deserialize)]
|
||||
struct UncheckedAxisFrame {
|
||||
cats: Vec<String>,
|
||||
cat_displays: Vec<String>,
|
||||
first: usize,
|
||||
entries: Vec<FrameEntry>,
|
||||
}
|
||||
@@ -278,6 +289,22 @@ impl TryFrom<UncheckedAxisFrame> for AxisFrame {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(raw: UncheckedAxisFrame) -> Result<Self, String> {
|
||||
if raw.cat_displays.len() != raw.cats.len() {
|
||||
return Err(format!(
|
||||
"{} category displays for {} categories",
|
||||
raw.cat_displays.len(),
|
||||
raw.cats.len()
|
||||
));
|
||||
}
|
||||
let ragged = raw.entries.iter().find_map(|e| match e {
|
||||
FrameEntry::Item { tuple, display, .. } if tuple.len() != display.len() => {
|
||||
Some((tuple.len(), display.len()))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if let Some((keys, shown)) = ragged {
|
||||
return Err(format!("{shown} displays for a tuple of {keys} keys"));
|
||||
}
|
||||
let beyond = raw
|
||||
.entries
|
||||
.iter()
|
||||
@@ -294,6 +321,7 @@ impl TryFrom<UncheckedAxisFrame> for AxisFrame {
|
||||
)),
|
||||
None => Ok(Self {
|
||||
cats: raw.cats,
|
||||
cat_displays: raw.cat_displays,
|
||||
first: raw.first,
|
||||
entries: raw.entries,
|
||||
}),
|
||||
@@ -307,6 +335,7 @@ impl AxisFrame {
|
||||
pub fn flat<S: Into<String>>(labels: impl IntoIterator<Item = S>) -> Self {
|
||||
Self {
|
||||
cats: Vec::new(),
|
||||
cat_displays: Vec::new(),
|
||||
first: 0,
|
||||
entries: labels
|
||||
.into_iter()
|
||||
@@ -324,6 +353,14 @@ impl AxisFrame {
|
||||
})
|
||||
}
|
||||
|
||||
/// The display tuples, parallel to [`AxisFrame::items`]: what is drawn.
|
||||
pub fn displays(&self) -> impl Iterator<Item = &Vec<String>> {
|
||||
self.entries.iter().filter_map(|e| match e {
|
||||
FrameEntry::Item { display, .. } => Some(display),
|
||||
FrameEntry::Group(_) | FrameEntry::GroupEnd { .. } => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The innermost label of each data item. **Not "the labels"** — a draw
|
||||
/// loop wants [`AxisFrame::header_labels`] and would draw these wrong.
|
||||
pub fn leaf_labels(&self) -> Vec<String> {
|
||||
@@ -391,12 +428,13 @@ impl AxisFrame {
|
||||
/// **One pass, and that is the point** — what a draw loop wants (§3).
|
||||
pub fn header_labels(&self, first_rendered: usize) -> Vec<Vec<String>> {
|
||||
let items: Vec<&Vec<String>> = self.items().collect();
|
||||
let shown: Vec<&Vec<String>> = self.displays().collect();
|
||||
let levels = self.levels();
|
||||
(0..items.len())
|
||||
.map(|idx| {
|
||||
(0..levels)
|
||||
.map(|level| match shows(&items, idx, first_rendered, level) {
|
||||
true => items[idx].get(level).cloned().unwrap_or_default(),
|
||||
true => shown[idx].get(level).cloned().unwrap_or_default(),
|
||||
false => String::new(),
|
||||
})
|
||||
.collect()
|
||||
@@ -499,7 +537,7 @@ impl AxisFrame {
|
||||
/// The width of each header level, in display cells. Derived rather than
|
||||
/// projected, because the frame is the whole input (§3, improvise-1125).
|
||||
pub fn header_widths(&self) -> Vec<u16> {
|
||||
header_level_widths(self.items(), self.levels())
|
||||
header_level_widths(self.displays(), self.levels())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,7 +655,7 @@ impl GridCache {
|
||||
if self.col_widths.len() < cols {
|
||||
// Materialized once: `AxisFrame::item` scans, so asking it per
|
||||
// column would make a re-fit quadratic in the column count.
|
||||
let items: Vec<&Vec<String>> = self.col_frame.items().collect();
|
||||
let items: Vec<&Vec<String>> = self.col_frame.displays().collect();
|
||||
for levels in &items[self.col_widths.len()..cols] {
|
||||
// Display cells, the unit `header_widths` and the
|
||||
// authoritative `ColWidths` use — `chars().count()` is short.
|
||||
@@ -761,10 +799,34 @@ pub struct CategoryRow {
|
||||
pub name: String,
|
||||
/// What the panel shows; `name` stays what a command names (ADR 007).
|
||||
pub display: String,
|
||||
pub items: Vec<String>,
|
||||
pub items: Vec<CategoryItem>,
|
||||
pub axis: Axis,
|
||||
}
|
||||
|
||||
/// One item row: the key a command names, and what the panel shows (ADR 007).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CategoryItem {
|
||||
pub name: String,
|
||||
pub display: String,
|
||||
}
|
||||
|
||||
impl CategoryItem {
|
||||
/// An item shown under its own key.
|
||||
pub fn plain(name: impl Into<String>) -> Self {
|
||||
let name = name.into();
|
||||
Self {
|
||||
display: name.clone(),
|
||||
name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for CategoryItem {
|
||||
fn from(name: &str) -> Self {
|
||||
Self::plain(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// The category panel's categories, items and axes (improvise-pca) —
|
||||
/// **every** category, and `_Measure`'s items are derived. See §3.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -786,7 +848,7 @@ pub enum CategoryTreeUpdate {
|
||||
Categories(Vec<CategoryRow>),
|
||||
Items {
|
||||
category: String,
|
||||
items: Vec<String>,
|
||||
items: Vec<CategoryItem>,
|
||||
},
|
||||
/// Every category whose axis moved, in one update.
|
||||
Axes(Vec<(String, Axis)>),
|
||||
@@ -861,8 +923,29 @@ pub struct ViewSummary {
|
||||
pub name: String,
|
||||
/// What the view panel shows; `name` stays what a command names.
|
||||
pub display: String,
|
||||
/// `(category, axis)` for the categories this view displays.
|
||||
pub axes: Vec<(String, Axis)>,
|
||||
/// Where each category this view displays sits.
|
||||
pub axes: Vec<ViewAxis>,
|
||||
}
|
||||
|
||||
/// One category's place in a view: the key a command names, what the panel
|
||||
/// shows for it, and the axis.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ViewAxis {
|
||||
pub category: String,
|
||||
pub display: String,
|
||||
pub axis: Axis,
|
||||
}
|
||||
|
||||
impl ViewAxis {
|
||||
/// A category shown under its own key.
|
||||
pub fn plain(category: impl Into<String>, axis: Axis) -> Self {
|
||||
let category = category.into();
|
||||
Self {
|
||||
display: category.clone(),
|
||||
category,
|
||||
axis,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The view panel's copy of the view list (improvise-pca). Which view is
|
||||
@@ -1167,7 +1250,7 @@ mod tests {
|
||||
CategoryRow {
|
||||
name: name.into(),
|
||||
display: name.into(),
|
||||
items: items.iter().map(|s| (*s).to_string()).collect(),
|
||||
items: items.iter().map(|s| CategoryItem::plain(*s)).collect(),
|
||||
axis,
|
||||
}
|
||||
}
|
||||
@@ -1328,6 +1411,7 @@ mod tests {
|
||||
grid.apply(&GridUpdate::RowFrame(AxisFrame {
|
||||
first: 0,
|
||||
cats: vec!["Zone".into()],
|
||||
cat_displays: vec!["Zone".into()],
|
||||
entries: grid.row_frame.entries.clone(),
|
||||
}));
|
||||
assert!(grid.totals.iter().all(|t| t.text.is_empty()));
|
||||
@@ -1343,11 +1427,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_page_bar_outlives_a_reshape() {
|
||||
let mut grid = framed_grid();
|
||||
let payer = PageAxis {
|
||||
cat: "Payer".into(),
|
||||
selected: "Alice".into(),
|
||||
items: Some(vec!["Alice".into(), "Bob".into()]),
|
||||
};
|
||||
let payer = PageAxis::plain("Payer", "Alice", Some(vec!["Alice".into(), "Bob".into()]));
|
||||
grid.apply(&GridUpdate::PageCoords(vec![payer.clone()]));
|
||||
grid.apply(&GridUpdate::RowFrame(AxisFrame::default()));
|
||||
grid.apply(&GridUpdate::ColFrame(AxisFrame::default()));
|
||||
@@ -1361,6 +1441,7 @@ mod tests {
|
||||
AxisFrame {
|
||||
first: 0,
|
||||
cats: vec!["Type".into(), "Who".into()],
|
||||
cat_displays: vec!["Type".into(), "Who".into()],
|
||||
entries: vec![
|
||||
FrameEntry::item(vec!["A".into(), "x".into()]),
|
||||
FrameEntry::item(vec!["A".into(), "y".into()]),
|
||||
@@ -1368,6 +1449,7 @@ mod tests {
|
||||
level: 0,
|
||||
depth: 0,
|
||||
name: "Late".into(),
|
||||
display: "Late".into(),
|
||||
collapsed: true,
|
||||
}),
|
||||
FrameEntry::item(vec!["B".into(), "z".into()]),
|
||||
@@ -1375,6 +1457,75 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Two keys per level shown under displays: the outer pair share a display,
|
||||
/// the inner pair share a KEY. Suppression must follow the keys.
|
||||
fn shown_frame() -> AxisFrame {
|
||||
let item = |tuple: [&str; 2], display: [&str; 2]| FrameEntry::Item {
|
||||
tuple: tuple.map(String::from).to_vec(),
|
||||
display: display.map(String::from).to_vec(),
|
||||
collapsed: false,
|
||||
};
|
||||
AxisFrame {
|
||||
cats: vec!["Region".into(), "Month".into()],
|
||||
cat_displays: vec!["Sales Area".into(), "Period".into()],
|
||||
first: 0,
|
||||
entries: vec![
|
||||
item(["East", "Jan"], ["Same", "January"]),
|
||||
item(["East", "Feb"], ["Same", "February"]),
|
||||
item(["West", "Jan"], ["Same", "January"]),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_draws_displays_and_blanks_a_repeat_by_key() {
|
||||
let frame = shown_frame();
|
||||
assert_eq!(
|
||||
frame.header_labels(0),
|
||||
vec![
|
||||
vec!["Same".to_string(), "January".into()],
|
||||
vec![String::new(), "February".into()],
|
||||
vec!["Same".to_string(), "January".into()],
|
||||
],
|
||||
"East repeats and blanks; West shares East's DISPLAY and still draws"
|
||||
);
|
||||
let keys: Vec<&Vec<String>> = frame.items().collect();
|
||||
assert_eq!(keys[2], &vec!["West".to_string(), "Jan".into()]);
|
||||
assert_eq!(frame.label(0, 0), "East", "the lookup is still the key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_is_as_wide_as_what_it_shows() {
|
||||
let frame = AxisFrame {
|
||||
cats: vec!["R".into()],
|
||||
cat_displays: vec!["Region".into()],
|
||||
first: 0,
|
||||
entries: vec![FrameEntry::Item {
|
||||
tuple: vec!["E".into()],
|
||||
display: vec!["Eastern Seaboard".into()],
|
||||
collapsed: false,
|
||||
}],
|
||||
};
|
||||
assert_eq!(
|
||||
frame.header_widths(),
|
||||
vec!["Eastern Seaboard".len() as u16 + 1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_with_ragged_displays_is_refused() {
|
||||
let good = serde_json::to_value(shown_frame()).expect("a frame serializes");
|
||||
assert!(serde_json::from_value::<AxisFrame>(good.clone()).is_ok());
|
||||
let mut short_cats = good.clone();
|
||||
short_cats["cat_displays"] = serde_json::json!(["Only one"]);
|
||||
let err = serde_json::from_value::<AxisFrame>(short_cats).unwrap_err();
|
||||
assert!(err.to_string().contains("category displays"), "{err}");
|
||||
let mut short_tuple = good;
|
||||
short_tuple["entries"][0]["Item"]["display"] = serde_json::json!(["Same"]);
|
||||
let err = serde_json::from_value::<AxisFrame>(short_tuple).unwrap_err();
|
||||
assert!(err.to_string().contains("displays for a tuple"), "{err}");
|
||||
}
|
||||
|
||||
/// Two runs of one group name under two different outer items, with nothing
|
||||
/// ungrouped between them — improvise-h0iv's reachable shape.
|
||||
fn repeated_group_frame() -> AxisFrame {
|
||||
@@ -1382,11 +1533,13 @@ mod tests {
|
||||
level: 1,
|
||||
depth: 0,
|
||||
name: "Q1".into(),
|
||||
display: "Q1".into(),
|
||||
collapsed: false,
|
||||
};
|
||||
AxisFrame {
|
||||
first: 0,
|
||||
cats: vec!["Region".into(), "Month".into()],
|
||||
cat_displays: vec!["Region".into(), "Month".into()],
|
||||
entries: vec![
|
||||
FrameEntry::Group(q1()),
|
||||
FrameEntry::item(vec!["North".into(), "Jul".into()]),
|
||||
@@ -1404,11 +1557,13 @@ mod tests {
|
||||
AxisFrame {
|
||||
first: 0,
|
||||
cats: vec!["Month".into()],
|
||||
cat_displays: vec!["Month".into()],
|
||||
entries: vec![
|
||||
FrameEntry::Group(FrameGroup {
|
||||
level: 0,
|
||||
depth: 0,
|
||||
name: "Q1".into(),
|
||||
display: "Q1".into(),
|
||||
collapsed: false,
|
||||
}),
|
||||
FrameEntry::item(vec!["Jan".into()]),
|
||||
@@ -1428,11 +1583,13 @@ mod tests {
|
||||
level,
|
||||
depth: 0,
|
||||
name: name.into(),
|
||||
display: name.into(),
|
||||
collapsed: true,
|
||||
})
|
||||
};
|
||||
let frame = AxisFrame {
|
||||
cats: vec!["Region".into(), "Month".into()],
|
||||
cat_displays: vec!["Region".into(), "Month".into()],
|
||||
first: 0,
|
||||
entries: vec![
|
||||
group(0, "Upper"),
|
||||
@@ -1473,11 +1630,13 @@ mod tests {
|
||||
level: 0,
|
||||
depth,
|
||||
name: name.into(),
|
||||
display: name.into(),
|
||||
collapsed: false,
|
||||
})
|
||||
};
|
||||
let frame = AxisFrame {
|
||||
cats: vec!["Date".into()],
|
||||
cat_displays: vec!["Date".into()],
|
||||
first: 0,
|
||||
entries: vec![
|
||||
group(0, "2024"),
|
||||
@@ -1527,11 +1686,13 @@ mod tests {
|
||||
let frame = AxisFrame {
|
||||
first: 0,
|
||||
cats: vec!["Month".into()],
|
||||
cat_displays: vec!["Month".into()],
|
||||
entries: vec![
|
||||
FrameEntry::Group(FrameGroup {
|
||||
level: 0,
|
||||
depth: 0,
|
||||
name: "Q1".into(),
|
||||
display: "Q1".into(),
|
||||
collapsed: true,
|
||||
}),
|
||||
FrameEntry::GroupEnd { level: 0, depth: 0 },
|
||||
@@ -1698,6 +1859,7 @@ mod tests {
|
||||
let frame = AxisFrame {
|
||||
first: 0,
|
||||
cats: vec!["A".into(), "B".into()],
|
||||
cat_displays: vec!["A".into(), "B".into()],
|
||||
entries: vec![FrameEntry::item(vec!["Clothing".into(), "日本".into()])],
|
||||
};
|
||||
assert_eq!(
|
||||
@@ -1736,6 +1898,7 @@ mod tests {
|
||||
let frame = AxisFrame {
|
||||
first: 0,
|
||||
cats: Vec::new(),
|
||||
cat_displays: Vec::new(),
|
||||
entries: vec![FrameEntry::item(Vec::new())],
|
||||
};
|
||||
assert_eq!(frame.levels(), 1);
|
||||
@@ -1756,7 +1919,10 @@ mod tests {
|
||||
},
|
||||
]);
|
||||
assert_eq!(tree.category("Region").unwrap().items.len(), 2);
|
||||
assert_eq!(tree.category("Product").unwrap().items, vec!["Shirts"]);
|
||||
assert_eq!(
|
||||
tree.category("Product").unwrap().items,
|
||||
vec![CategoryItem::plain("Shirts")]
|
||||
);
|
||||
|
||||
// An update for a category the cache has never heard of is dropped.
|
||||
tree.apply(&CategoryTreeUpdate::Items {
|
||||
@@ -1784,7 +1950,7 @@ mod tests {
|
||||
assert_eq!(tree.category("Product").unwrap().axis, Axis::Row);
|
||||
assert_eq!(
|
||||
tree.category("Region").unwrap().items,
|
||||
vec!["North"],
|
||||
vec![CategoryItem::plain("North")],
|
||||
"moving an axis does not disturb membership"
|
||||
);
|
||||
}
|
||||
@@ -1813,7 +1979,7 @@ mod tests {
|
||||
let views = ViewListCache::folded(&[vec![ViewSummary {
|
||||
name: "Default".into(),
|
||||
display: "Default".into(),
|
||||
axes: vec![("Region".into(), Axis::Row)],
|
||||
axes: vec![ViewAxis::plain("Region", Axis::Row)],
|
||||
}]]);
|
||||
assert_eq!(views.views[0].name, "Default");
|
||||
|
||||
@@ -1869,6 +2035,7 @@ mod tests {
|
||||
let axis = |cat: &str, labels: [&str; 2]| AxisFrame {
|
||||
first: 0,
|
||||
cats: vec![cat.into()],
|
||||
cat_displays: vec![cat.into()],
|
||||
entries: labels
|
||||
.iter()
|
||||
.map(|l| FrameEntry::item(vec![(*l).into()]))
|
||||
@@ -1895,6 +2062,7 @@ mod tests {
|
||||
let renamed = AxisFrame {
|
||||
first: 0,
|
||||
cats: vec!["Department".into()],
|
||||
cat_displays: vec!["Department".into()],
|
||||
entries: grid.row_frame.entries.clone(),
|
||||
};
|
||||
assert_eq!(
|
||||
@@ -1935,6 +2103,7 @@ mod tests {
|
||||
let renamed = AxisFrame {
|
||||
first: 0,
|
||||
cats: vec!["Metric".into()],
|
||||
cat_displays: vec!["Metric".into()],
|
||||
entries: grid.col_frame.entries.clone(),
|
||||
};
|
||||
grid.apply(&GridUpdate::ColFrame(renamed));
|
||||
@@ -2215,7 +2384,7 @@ mod tests {
|
||||
Projection::ViewList(vec![ViewSummary {
|
||||
name: "Default".into(),
|
||||
display: "Default".into(),
|
||||
axes: vec![("Region".into(), Axis::None)],
|
||||
axes: vec![ViewAxis::plain("Region", Axis::None)],
|
||||
}]),
|
||||
Projection::TileBar(vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
|
||||
@@ -1613,6 +1613,7 @@ fn filter_to_item_at(state: &ClientState, index: usize) -> Option<Vec<Command>>
|
||||
Some(CatTreeEntry::Item {
|
||||
cat_name,
|
||||
item_name,
|
||||
..
|
||||
}) => Some(vec![
|
||||
model(
|
||||
state,
|
||||
@@ -1648,6 +1649,7 @@ fn delete_at_cat_cursor(state: &ClientState) -> Option<Vec<Command>> {
|
||||
Some(CatTreeEntry::Item {
|
||||
cat_name,
|
||||
item_name,
|
||||
..
|
||||
}) => {
|
||||
let mut commands = vec![
|
||||
model(
|
||||
@@ -2204,6 +2206,7 @@ mod tests {
|
||||
Some(CatTreeEntry::Item {
|
||||
cat_name: "Product".into(),
|
||||
item_name: "Shirts".into(),
|
||||
display: "Shirts".into(),
|
||||
}),
|
||||
"row 4 is Product's ONLY item — no sibling above it and none below"
|
||||
);
|
||||
@@ -2628,10 +2631,12 @@ mod tests {
|
||||
// Named axes, as a host's always are: `H` hides from `cats[0]`.
|
||||
Projection::Grid(GridUpdate::RowFrame(AxisFrame {
|
||||
cats: vec!["Region".into()],
|
||||
cat_displays: vec!["Region".into()],
|
||||
..AxisFrame::flat(["North", "South", "East"])
|
||||
})),
|
||||
Projection::Grid(GridUpdate::ColFrame(AxisFrame {
|
||||
cats: vec!["_Measure".into()],
|
||||
cat_displays: vec!["_Measure".into()],
|
||||
..AxisFrame::flat(["Revenue", "Cost", "Profit"])
|
||||
})),
|
||||
Projection::Grid(GridUpdate::Cells(cells)),
|
||||
@@ -2665,11 +2670,11 @@ mod tests {
|
||||
// produce, so the fixture projects both (improvise-e5pf).
|
||||
crate::WidgetCache::apply(&mut state.grid, &GridUpdate::Records(mode.is_records()));
|
||||
state.fold(&Command::ChangeMode(mode));
|
||||
state.grid.page_coords = vec![crate::PageAxis {
|
||||
cat: "Quarter".into(),
|
||||
selected: "Q2".into(),
|
||||
items: Some(vec!["Q1".into(), "Q2".into(), "Q3".into()]),
|
||||
}];
|
||||
state.grid.page_coords = vec![crate::PageAxis::plain(
|
||||
"Quarter",
|
||||
"Q2",
|
||||
Some(vec!["Q1".into(), "Q2".into(), "Q3".into()]),
|
||||
)];
|
||||
// THREE of everything, every panel cursor seated in the MIDDLE: an edge
|
||||
// makes `move-panel-cursor` answer `Ignored` for the wrong reason.
|
||||
crate::WidgetCache::apply(
|
||||
@@ -3989,16 +3994,12 @@ mod tests {
|
||||
let mut state = in_mode(AppMode::Normal);
|
||||
crate::WidgetCache::apply(&mut state.basis, &published());
|
||||
state.grid.page_coords = vec![
|
||||
crate::PageAxis {
|
||||
cat: "Year".into(),
|
||||
selected: "2024".into(),
|
||||
items: Some(vec!["2024".into(), "2025".into()]),
|
||||
},
|
||||
crate::PageAxis {
|
||||
cat: "Quarter".into(),
|
||||
selected: "Q4".into(),
|
||||
items: Some(vec!["Q1".into(), "Q2".into(), "Q3".into(), "Q4".into()]),
|
||||
},
|
||||
crate::PageAxis::plain("Year", "2024", Some(vec!["2024".into(), "2025".into()])),
|
||||
crate::PageAxis::plain(
|
||||
"Quarter",
|
||||
"Q4",
|
||||
Some(vec!["Q1".into(), "Q2".into(), "Q3".into(), "Q4".into()]),
|
||||
),
|
||||
];
|
||||
state
|
||||
}
|
||||
@@ -4047,11 +4048,10 @@ mod tests {
|
||||
#[test]
|
||||
fn a_filter_axis_is_projected_but_not_cycled() {
|
||||
let mut state = paged();
|
||||
state.grid.page_coords.push(crate::PageAxis {
|
||||
cat: "Region".into(),
|
||||
selected: "East".into(),
|
||||
items: None,
|
||||
});
|
||||
state
|
||||
.grid
|
||||
.page_coords
|
||||
.push(crate::PageAxis::plain("Region", "East", None));
|
||||
let moved = selections(resolve(&state, ev(KeyCode::Char(']'))));
|
||||
assert!(
|
||||
!moved.iter().any(|(cat, _)| cat == "Region"),
|
||||
@@ -4082,16 +4082,68 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// ADR 007: the bar shows displays and a pick composes the KEY at that index.
|
||||
#[test]
|
||||
fn picking_a_displayed_page_item_still_names_its_key() {
|
||||
let mut state = paged();
|
||||
state.grid.page_coords[1].cat_display = "Period".into();
|
||||
state.grid.page_coords[1].item_displays = Some(
|
||||
["First", "Second", "Third", "Fourth"]
|
||||
.map(String::from)
|
||||
.to_vec(),
|
||||
);
|
||||
let commands = click(&state, Click::PageItem { axis: 1, item: 1 });
|
||||
let [
|
||||
Command::Model {
|
||||
cmd: ModelCommand::SetPageSelection { category, item, .. },
|
||||
..
|
||||
},
|
||||
] = commands.as_slice()
|
||||
else {
|
||||
panic!("expected one page selection, got {commands:?}");
|
||||
};
|
||||
assert_eq!((category.as_str(), item.as_str()), ("Quarter", "Q2"));
|
||||
}
|
||||
|
||||
/// The frame shows an item under its display; hiding it names the key.
|
||||
#[test]
|
||||
fn hiding_a_displayed_row_item_names_its_key() {
|
||||
let mut state = in_mode(AppMode::Normal);
|
||||
crate::WidgetCache::apply(&mut state.basis, &published());
|
||||
state.grid.row_frame = crate::AxisFrame {
|
||||
cats: vec!["Region".into()],
|
||||
cat_displays: vec!["Sales Area".into()],
|
||||
first: 0,
|
||||
entries: vec![crate::FrameEntry::Item {
|
||||
tuple: vec!["East".into()],
|
||||
display: vec!["Eastern Seaboard".into()],
|
||||
collapsed: false,
|
||||
}],
|
||||
};
|
||||
let KeyOutcome::Local(commands) = resolve(&state, ev(KeyCode::Char('H'))) else {
|
||||
panic!("hiding resolves here");
|
||||
};
|
||||
let [
|
||||
Command::Model {
|
||||
cmd: ModelCommand::HideItem { category, item, .. },
|
||||
..
|
||||
},
|
||||
] = commands.as_slice()
|
||||
else {
|
||||
panic!("expected one hide, got {commands:?}");
|
||||
};
|
||||
assert_eq!((category.as_str(), item.as_str()), ("Region", "East"));
|
||||
}
|
||||
|
||||
/// A Filter axis is on the bar and offers nothing to pick (gotcha 27).
|
||||
/// Offering a menu that cannot act is worse than offering none.
|
||||
#[test]
|
||||
fn a_filter_axis_offers_no_item_to_pick() {
|
||||
let mut state = paged();
|
||||
state.grid.page_coords.push(crate::PageAxis {
|
||||
cat: "Region".into(),
|
||||
selected: "East".into(),
|
||||
items: None,
|
||||
});
|
||||
state
|
||||
.grid
|
||||
.page_coords
|
||||
.push(crate::PageAxis::plain("Region", "East", None));
|
||||
assert!(
|
||||
click(&state, Click::PageItem { axis: 2, item: 0 }).is_empty(),
|
||||
"a Filter axis has no item list, so there is nothing a click could mean"
|
||||
|
||||
@@ -19,8 +19,12 @@ pub enum CatTreeEntry {
|
||||
expanded: bool,
|
||||
axis: Axis,
|
||||
},
|
||||
/// Item row under a category.
|
||||
Item { cat_name: String, item_name: String },
|
||||
/// Item row under a category: the keys a command names, and what is drawn.
|
||||
Item {
|
||||
cat_name: String,
|
||||
item_name: String,
|
||||
display: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl CatTreeEntry {
|
||||
@@ -56,10 +60,11 @@ pub fn flatten_cat_tree(
|
||||
axis: row.axis,
|
||||
});
|
||||
if is_expanded {
|
||||
for item_name in &row.items {
|
||||
for item in &row.items {
|
||||
entries.push(CatTreeEntry::Item {
|
||||
cat_name: row.name.clone(),
|
||||
item_name: item_name.clone(),
|
||||
item_name: item.name.clone(),
|
||||
display: item.display.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -76,8 +81,8 @@ pub fn view_axis_summary(summary: &ViewSummary) -> String {
|
||||
let cats: Vec<&str> = summary
|
||||
.axes
|
||||
.iter()
|
||||
.filter(|(cat, a)| *a == axis && !cat.starts_with('_'))
|
||||
.map(|(cat, _)| cat.as_str())
|
||||
.filter(|a| a.axis == axis && !a.category.starts_with('_'))
|
||||
.map(|a| a.display.as_str())
|
||||
.collect();
|
||||
(!cats.is_empty()).then(|| format!("{}:{}", axis.initial(), cats.join(",")))
|
||||
})
|
||||
@@ -97,7 +102,7 @@ mod tests {
|
||||
.map(|(name, items)| CategoryRow {
|
||||
name: (*name).to_string(),
|
||||
display: (*name).to_string(),
|
||||
items: items.iter().map(|i| i.to_string()).collect(),
|
||||
items: items.iter().map(|i| (*i).into()).collect(),
|
||||
axis: Axis::None,
|
||||
})
|
||||
.collect(),
|
||||
@@ -136,6 +141,7 @@ mod tests {
|
||||
CatTreeEntry::Item {
|
||||
cat_name: "Region".into(),
|
||||
item_name: "South".into(),
|
||||
display: "South".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@ pub fn render_model(state: &ClientState) -> RenderModel {
|
||||
let row = match entry {
|
||||
FrameEntry::GroupEnd { .. } => return Some(None),
|
||||
FrameEntry::Group(group) => RenderRow::Group {
|
||||
label: group.name.clone(),
|
||||
label: group.display.clone(),
|
||||
collapsed: group.collapsed,
|
||||
},
|
||||
FrameEntry::Item { .. } => {
|
||||
@@ -340,12 +340,14 @@ fn page_axes(state: &ClientState) -> Vec<RenderPageAxis> {
|
||||
.page_coords
|
||||
.iter()
|
||||
.map(|axis| RenderPageAxis {
|
||||
cat: axis.cat.clone(),
|
||||
selected: axis.selected.clone(),
|
||||
cat: axis.cat_display.clone(),
|
||||
selected: axis.selected_display.clone(),
|
||||
cycle: axis.cycle().map(|(items, at)| RenderPageCycle {
|
||||
at,
|
||||
count: items.len(),
|
||||
items: items.to_vec(),
|
||||
// The menu shows displays; a pick travels as the index `at` and
|
||||
// `items` order name, so no key is read back from here.
|
||||
items: axis.item_displays.clone().unwrap_or_default(),
|
||||
}),
|
||||
})
|
||||
.collect()
|
||||
@@ -481,8 +483,8 @@ impl RenderCatRow {
|
||||
expanded: Some(*expanded),
|
||||
selected,
|
||||
},
|
||||
CatTreeEntry::Item { item_name, .. } => Self {
|
||||
label: item_name.clone(),
|
||||
CatTreeEntry::Item { display, .. } => Self {
|
||||
label: display.clone(),
|
||||
is_category: false,
|
||||
axis: String::new(),
|
||||
item_count: None,
|
||||
@@ -544,7 +546,7 @@ fn col_groups(cols: &AxisFrame) -> Vec<RenderColGroup> {
|
||||
true => runs
|
||||
.into_iter()
|
||||
.map(|(group, span)| RenderColGroup {
|
||||
label: group.map(|g| g.name.clone()),
|
||||
label: group.map(|g| g.display.clone()),
|
||||
collapsed: group.is_some_and(|g| g.collapsed),
|
||||
span,
|
||||
})
|
||||
@@ -594,6 +596,7 @@ mod tests {
|
||||
level: 0,
|
||||
depth: 0,
|
||||
name: name.into(),
|
||||
display: name.into(),
|
||||
collapsed,
|
||||
})
|
||||
}
|
||||
@@ -654,6 +657,7 @@ mod tests {
|
||||
let frame = AxisFrame {
|
||||
first: 0,
|
||||
cats: vec!["Month".into()],
|
||||
cat_displays: vec!["Month".into()],
|
||||
entries: vec![
|
||||
group("Q1", false),
|
||||
item("Jan"),
|
||||
@@ -879,6 +883,109 @@ mod tests {
|
||||
assert!(views[0].active, "the active view is still found by its key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grid_page_bar_and_item_rows_paint_displays() {
|
||||
use improvise_protocol::{AxisFrame, CategoryItem, FrameGroup};
|
||||
let mut client = init();
|
||||
let state = client.state_mut();
|
||||
let shown = |key: &str, display: &str| FrameEntry::Item {
|
||||
tuple: vec![key.into()],
|
||||
display: vec![display.into()],
|
||||
collapsed: false,
|
||||
};
|
||||
state.fold(&Command::Project(Projection::Grid(GridUpdate::RowFrame(
|
||||
AxisFrame {
|
||||
cats: vec!["Region".into()],
|
||||
cat_displays: vec!["Sales Area".into()],
|
||||
first: 0,
|
||||
entries: vec![
|
||||
FrameEntry::Group(FrameGroup {
|
||||
level: 0,
|
||||
depth: 0,
|
||||
name: "Coastal".into(),
|
||||
display: "Shoreline".into(),
|
||||
collapsed: false,
|
||||
}),
|
||||
shown("East", "Eastern Seaboard"),
|
||||
],
|
||||
},
|
||||
))));
|
||||
state.fold(&Command::Project(Projection::Grid(GridUpdate::ColFrame(
|
||||
AxisFrame {
|
||||
cats: vec!["_Measure".into()],
|
||||
cat_displays: vec!["_Measure".into()],
|
||||
first: 0,
|
||||
entries: vec![shown("Revenue", "Sales")],
|
||||
},
|
||||
))));
|
||||
state.fold(&Command::Project(Projection::Grid(GridUpdate::PageCoords(
|
||||
vec![PageAxis {
|
||||
cat: "Quarter".into(),
|
||||
cat_display: "Period".into(),
|
||||
selected: "Q1".into(),
|
||||
selected_display: "First".into(),
|
||||
items: Some(vec!["Q1".into(), "Q2".into()]),
|
||||
item_displays: Some(vec!["First".into(), "Second".into()]),
|
||||
}],
|
||||
))));
|
||||
state.fold(&Command::Project(Projection::CategoryTree(
|
||||
CategoryTreeUpdate::Categories(vec![CategoryRow {
|
||||
name: "Region".into(),
|
||||
display: "Sales Area".into(),
|
||||
items: vec![CategoryItem {
|
||||
name: "East".into(),
|
||||
display: "Eastern Seaboard".into(),
|
||||
}],
|
||||
axis: Axis::Row,
|
||||
}]),
|
||||
)));
|
||||
state.view.expanded_cats.insert("Region".into());
|
||||
let model = client.model();
|
||||
assert!(matches!(
|
||||
&model.rows[0],
|
||||
RenderRow::Group { label, .. } if label == "Shoreline"
|
||||
));
|
||||
assert!(matches!(
|
||||
&model.rows[1],
|
||||
RenderRow::Data { labels, .. } if labels == &vec!["Eastern Seaboard".to_string()]
|
||||
));
|
||||
assert_eq!(model.col_levels, vec![vec!["Sales".to_string()]]);
|
||||
let bar = &model.page_coords[0];
|
||||
assert_eq!(
|
||||
(bar.cat.as_str(), bar.selected.as_str()),
|
||||
("Period", "First")
|
||||
);
|
||||
assert_eq!(bar.cycle.as_ref().unwrap().items, vec!["First", "Second"]);
|
||||
assert_eq!(model.categories[1].label, "Eastern Seaboard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_column_group_header_is_painted_by_its_display() {
|
||||
use improvise_protocol::{AxisFrame, FrameGroup};
|
||||
let mut client = init();
|
||||
let state = client.state_mut();
|
||||
state.fold(&Command::Project(Projection::Grid(GridUpdate::ColFrame(
|
||||
AxisFrame {
|
||||
cats: vec!["Month".into()],
|
||||
cat_displays: vec!["Period".into()],
|
||||
first: 0,
|
||||
entries: vec![
|
||||
FrameEntry::Group(FrameGroup {
|
||||
level: 0,
|
||||
depth: 0,
|
||||
name: "Q1".into(),
|
||||
display: "First quarter".into(),
|
||||
collapsed: false,
|
||||
}),
|
||||
item("Jan"),
|
||||
],
|
||||
},
|
||||
))));
|
||||
let groups = client.model().col_groups;
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].label.as_deref(), Some("First quarter"));
|
||||
}
|
||||
|
||||
fn client_with_panels() -> Client {
|
||||
let mut client = init();
|
||||
client
|
||||
@@ -887,7 +994,10 @@ mod tests {
|
||||
ViewSummary {
|
||||
name: "Default".into(),
|
||||
display: "Default".into(),
|
||||
axes: vec![("Region".into(), Axis::Row), ("_Dim".into(), Axis::Column)],
|
||||
axes: vec![
|
||||
improvise_protocol::ViewAxis::plain("Region", Axis::Row),
|
||||
improvise_protocol::ViewAxis::plain("_Dim", Axis::Column),
|
||||
],
|
||||
},
|
||||
ViewSummary {
|
||||
name: "Plan".into(),
|
||||
@@ -1135,11 +1245,11 @@ mod tests {
|
||||
}
|
||||
|
||||
fn page_axis(cat: &str, selected: &str, items: Option<Vec<&str>>) -> PageAxis {
|
||||
PageAxis {
|
||||
cat: cat.into(),
|
||||
selected: selected.into(),
|
||||
items: items.map(|i| i.into_iter().map(String::from).collect()),
|
||||
}
|
||||
PageAxis::plain(
|
||||
cat,
|
||||
selected,
|
||||
items.map(|i| i.into_iter().map(String::from).collect()),
|
||||
)
|
||||
}
|
||||
|
||||
/// improvise-p7lh: the page bar reached the client's cache and stopped
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "numtower"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
description = "A three-level numeric tower: exact decimals, bounded exact fractions and inexact f64, with Scheme's contagion rules and exact, order-free sums."
|
||||
keywords = ["decimal", "rational", "exact", "numeric-tower"]
|
||||
categories = ["mathematics"]
|
||||
|
||||
# ADR 005 clause 14: rust_decimal (std only), num-rational without its default
|
||||
# features (for Ratio<i128>), and num-bigint always. No serde.
|
||||
[dependencies]
|
||||
rust_decimal = { version = "1.43", default-features = false, features = ["std"] }
|
||||
num-rational = { version = "0.4.2", default-features = false }
|
||||
num-bigint = { version = "0.4", default-features = false, features = ["std"] }
|
||||
num-integer = { version = "0.1", default-features = false }
|
||||
num-traits = { version = "0.2", default-features = false, features = ["std"] }
|
||||
|
||||
[dev-dependencies]
|
||||
num-rational = { version = "0.4.2", features = ["num-bigint-std"] }
|
||||
proptest = "1"
|
||||
|
||||
[[example]]
|
||||
name = "bench"
|
||||
test = false
|
||||
@@ -0,0 +1,187 @@
|
||||
//! ADR 005 migration step 1's benchmark: `cargo run --release -p numtower --example bench`.
|
||||
//! A microbenchmark, not the evaluator; what it does and does not show is in `context/code/numtower.md`.
|
||||
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
use numtower::{Decimal, ExactSum, Num, NumSum, Rounding, Typing, parse_number};
|
||||
|
||||
struct SplitMix64(u64);
|
||||
|
||||
impl SplitMix64 {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed two-place values from 0.01 to 999,999.99, as the ADR's appendix draws them.
|
||||
fn typed_texts(seed: u64, n: usize) -> Vec<String> {
|
||||
let mut rng = SplitMix64(seed);
|
||||
(0..n)
|
||||
.map(|_| {
|
||||
let cents = 1 + rng.next() % 99_999_999;
|
||||
format!("{}.{:02}", cents / 100, cents % 100)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn nums(texts: &[String]) -> Vec<Num> {
|
||||
texts
|
||||
.iter()
|
||||
.map(|t| parse_number(t, Typing::Inferred).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The best of `runs`, in nanoseconds per item.
|
||||
fn best(runs: usize, items: usize, mut f: impl FnMut()) -> f64 {
|
||||
(0..runs)
|
||||
.map(|_| {
|
||||
let t = Instant::now();
|
||||
f();
|
||||
t.elapsed().as_nanos() as f64 / items as f64
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
fn sums() {
|
||||
println!("SUM over typed two-place values, ns per element, best of 20");
|
||||
for n in [18_000, 90_000] {
|
||||
let texts = typed_texts(1, n);
|
||||
let floats: Vec<f64> = texts.iter().map(|t| t.parse().unwrap()).collect();
|
||||
let decimals: Vec<Decimal> = texts.iter().map(|t| t.parse().unwrap()).collect();
|
||||
let values = nums(&texts);
|
||||
let f64_fold = best(20, n, || {
|
||||
black_box(black_box(&floats).iter().sum::<f64>());
|
||||
});
|
||||
let decimal_fold = best(20, n, || {
|
||||
let s = black_box(&decimals)
|
||||
.iter()
|
||||
.fold(Decimal::ZERO, |a, b| a.checked_add(*b).unwrap());
|
||||
black_box(s);
|
||||
});
|
||||
let num_fold = best(20, n, || {
|
||||
let zero = Num::exact(Decimal::ZERO);
|
||||
black_box(black_box(&values).iter().fold(zero, |a, b| &a + b));
|
||||
});
|
||||
let accumulator = best(20, n, || {
|
||||
let mut s = NumSum::new();
|
||||
black_box(&values).iter().for_each(|v| s.insert(v));
|
||||
black_box(s.read());
|
||||
});
|
||||
println!(
|
||||
" {n:>6} cells: f64 {f64_fold:.2}, rust_decimal {decimal_fold:.2}, Num + fold {num_fold:.2}, NumSum {accumulator:.2}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn operations() {
|
||||
println!("Per operation over 18,000 typed pairs, ns, best of 20");
|
||||
let (a, b) = (
|
||||
nums(&typed_texts(21, 18_000)),
|
||||
nums(&typed_texts(22, 18_000)),
|
||||
);
|
||||
let decimal =
|
||||
|v: &[Num]| -> Vec<Decimal> { v.iter().map(|n| n.to_string().parse().unwrap()).collect() };
|
||||
let (da, db) = (decimal(&a), decimal(&b));
|
||||
let pairs = a.len();
|
||||
let rd_add = best(20, pairs, || {
|
||||
da.iter().zip(&db).for_each(|(x, y)| {
|
||||
black_box(x.checked_add(*y));
|
||||
})
|
||||
});
|
||||
let rd_mul = best(20, pairs, || {
|
||||
da.iter().zip(&db).for_each(|(x, y)| {
|
||||
black_box(x.checked_mul(*y));
|
||||
})
|
||||
});
|
||||
let rd_div = best(20, pairs, || {
|
||||
da.iter().zip(&db).for_each(|(x, y)| {
|
||||
black_box(x.checked_div(*y));
|
||||
})
|
||||
});
|
||||
let add = best(20, pairs, || {
|
||||
a.iter().zip(&b).for_each(|(x, y)| drop(black_box(x + y)))
|
||||
});
|
||||
let mul = best(20, pairs, || {
|
||||
a.iter().zip(&b).for_each(|(x, y)| drop(black_box(x * y)))
|
||||
});
|
||||
let div = best(20, pairs, || {
|
||||
a.iter()
|
||||
.zip(&b)
|
||||
.for_each(|(x, y)| drop(black_box(x.checked_div(y))))
|
||||
});
|
||||
let margin = best(20, pairs, || {
|
||||
a.iter()
|
||||
.zip(&b)
|
||||
.for_each(|(rev, cost)| drop(black_box((rev - cost).checked_div(rev))));
|
||||
});
|
||||
let to_f64 = best(20, pairs, || {
|
||||
a.iter().for_each(|x| {
|
||||
black_box(x.to_f64());
|
||||
})
|
||||
});
|
||||
let places = best(20, pairs, || {
|
||||
a.iter()
|
||||
.for_each(|x| drop(black_box(x.round_to_places(2, Rounding::HalfAwayFromZero))));
|
||||
});
|
||||
println!(" rust_decimal checked: add {rd_add:.1}, mul {rd_mul:.1}, div {rd_div:.1}");
|
||||
println!(" Num: add {add:.1}, mul {mul:.1}, div {div:.1}");
|
||||
println!(
|
||||
" Num (Rev - Cost) / Rev {margin:.1}, to_f64 {to_f64:.1}, round_to_places(2) {places:.1}"
|
||||
);
|
||||
}
|
||||
|
||||
/// K groups of near-`i128` denominators: the ADR's worst case for the exact read.
|
||||
fn grouped_read() {
|
||||
println!("NumSum read over K distinct 126-bit denominators, us per read, best of 200");
|
||||
for k in [2usize, 8] {
|
||||
let mut rng = SplitMix64(0x5EED);
|
||||
let mut sum = NumSum::new();
|
||||
for _ in 0..k {
|
||||
let den = ((rng.next() as i128) << 62 | rng.next() as i128 >> 2) | 1 | (1 << 125);
|
||||
let num = (rng.next() as i128) << 60 ^ rng.next() as i128;
|
||||
let term = Num::ratio(num, den).unwrap();
|
||||
sum.add(&term, i64::MAX);
|
||||
sum.add(&term, i64::MAX);
|
||||
}
|
||||
assert_eq!(sum.groups(), k);
|
||||
let read = best(200, 1, || drop(black_box(sum.read())));
|
||||
println!(
|
||||
" K = {k}: {:.1} us (exact: {})",
|
||||
read / 1000.0,
|
||||
sum.read().is_exact()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_sum() {
|
||||
println!("ExactSum (f64 superaccumulator), best of 20");
|
||||
let floats: Vec<f64> = typed_texts(1, 1_000_000)
|
||||
.iter()
|
||||
.map(|t| t.parse().unwrap())
|
||||
.collect();
|
||||
let mut acc = ExactSum::new();
|
||||
let insert = best(20, floats.len(), || {
|
||||
acc = ExactSum::new();
|
||||
floats.iter().for_each(|x| acc.add(*x, 1));
|
||||
});
|
||||
let read = best(20, 1, || {
|
||||
black_box(acc.value());
|
||||
});
|
||||
println!(
|
||||
" {insert:.2} ns per insert, {:.2} us per read",
|
||||
read / 1000.0
|
||||
);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!("numtower bench (release); name the machine when you quote these");
|
||||
sums();
|
||||
operations();
|
||||
grouped_read();
|
||||
exact_sum();
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
//! Arithmetic and contagion (ADR 005 clauses 4 and 5, question 13 (b)).
|
||||
//! The reasoning for each rule is in `context/code/numtower.md`, "Arithmetic".
|
||||
|
||||
use std::ops::{Add, Mul, Neg, Sub};
|
||||
|
||||
use num_bigint::{BigInt, BigUint};
|
||||
use num_integer::Integer;
|
||||
use num_traits::{One, Signed, ToPrimitive, Zero};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
use crate::fixed::I256;
|
||||
use crate::num::{MANTISSA_LIMIT, MAX_SCALE, Num, NumRef, decimal, pow10, small_ratio};
|
||||
use crate::ratio::BigQ;
|
||||
|
||||
/// The widest an exact intermediate may grow, in bits of numerator or denominator.
|
||||
pub const CEILING_BITS: u64 = 8192;
|
||||
|
||||
/// A value inside one evaluation: exact intermediates are unbounded up to the ceiling.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Interim(Inner);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Inner {
|
||||
Stored(Num),
|
||||
Wide(Box<BigQ>),
|
||||
}
|
||||
|
||||
/// The divisor was exactly zero, exact or inexact (clause 5).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct DivisionByZero;
|
||||
|
||||
impl From<Num> for Interim {
|
||||
fn from(n: Num) -> Interim {
|
||||
Interim(Inner::Stored(n))
|
||||
}
|
||||
}
|
||||
|
||||
impl Interim {
|
||||
fn wide(q: BigQ) -> Interim {
|
||||
Interim(Inner::Wide(Box::new(q)))
|
||||
}
|
||||
|
||||
/// The stored bound of question 13 (b): a wide value is the nearest f64.
|
||||
pub fn store(self) -> Num {
|
||||
match self.0 {
|
||||
Inner::Stored(n) => n,
|
||||
Inner::Wide(q) => Num::inexact(q.to_f64()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_exact(&self) -> bool {
|
||||
match &self.0 {
|
||||
Inner::Stored(n) => n.is_exact(),
|
||||
Inner::Wide(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn operand(&self) -> Operand<'_> {
|
||||
match &self.0 {
|
||||
Inner::Stored(n) => Operand::of(n),
|
||||
Inner::Wide(q) => Operand::Big(q),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn checked_div(&self, o: &Interim) -> Result<Interim, DivisionByZero> {
|
||||
divide(self.operand(), o.operand())
|
||||
}
|
||||
|
||||
pub fn pow(&self, o: &Interim) -> Result<Interim, DivisionByZero> {
|
||||
power(self.operand(), o.operand())
|
||||
}
|
||||
|
||||
/// Exactly zero; a wide value never is, because zero fits a stored arm.
|
||||
pub fn is_zero(&self) -> bool {
|
||||
match &self.0 {
|
||||
Inner::Stored(n) => n.is_zero(),
|
||||
Inner::Wide(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clause 6's exact order, before the stored bound; `None` only when either is NaN.
|
||||
pub fn cmp_exact(&self, o: &Interim) -> Option<std::cmp::Ordering> {
|
||||
match (&self.0, &o.0) {
|
||||
(Inner::Stored(a), Inner::Stored(b)) => a.cmp_exact(b),
|
||||
_ => match (self.operand().big(), o.operand().big()) {
|
||||
(Some(a), Some(b)) => Some(a.cmp_value(&b)),
|
||||
(Some(a), None) => crate::cmp::exact_against_f64(&a, o.operand().float()),
|
||||
(None, Some(b)) => {
|
||||
crate::cmp::exact_against_f64(&b, self.operand().float()).map(|c| c.reverse())
|
||||
}
|
||||
(None, None) => self.operand().float().partial_cmp(&o.operand().float()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Num {
|
||||
pub fn checked_div(&self, o: &Num) -> Result<Num, DivisionByZero> {
|
||||
divide(Operand::of(self), Operand::of(o)).map(Interim::store)
|
||||
}
|
||||
|
||||
pub fn pow(&self, o: &Num) -> Result<Num, DivisionByZero> {
|
||||
power(Operand::of(self), Operand::of(o)).map(Interim::store)
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! binary_ops {
|
||||
($($trait:ident $method:ident $f:ident;)*) => {$(
|
||||
impl $trait<&Interim> for &Interim {
|
||||
type Output = Interim;
|
||||
fn $method(self, o: &Interim) -> Interim {
|
||||
$f(self.operand(), o.operand())
|
||||
}
|
||||
}
|
||||
impl $trait<&Num> for &Num {
|
||||
type Output = Num;
|
||||
fn $method(self, o: &Num) -> Num {
|
||||
$f(Operand::of(self), Operand::of(o)).store()
|
||||
}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
binary_ops! {
|
||||
Add add plus;
|
||||
Sub sub minus;
|
||||
Mul mul times;
|
||||
}
|
||||
|
||||
impl Neg for &Interim {
|
||||
type Output = Interim;
|
||||
fn neg(self) -> Interim {
|
||||
negate(self.operand())
|
||||
}
|
||||
}
|
||||
|
||||
impl Neg for &Num {
|
||||
type Output = Num;
|
||||
fn neg(self) -> Num {
|
||||
negate(Operand::of(self)).store()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Operand<'a> {
|
||||
Dec(&'a Decimal),
|
||||
Small(i128, i128),
|
||||
Big(&'a BigQ),
|
||||
Float(f64),
|
||||
}
|
||||
|
||||
impl<'a> Operand<'a> {
|
||||
fn of(n: &'a Num) -> Operand<'a> {
|
||||
match n.kind() {
|
||||
NumRef::Exact(d) => Operand::Dec(d),
|
||||
NumRef::Fraction(r) => Operand::Small(*r.numer(), *r.denom()),
|
||||
NumRef::Inexact(x) => Operand::Float(x),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `i128` fast path's operands; `i128::MIN` cannot be negated, so it takes the bigint path.
|
||||
fn parts(self) -> Option<(i128, i128)> {
|
||||
match self {
|
||||
Operand::Dec(d) => Some((d.mantissa(), pow10(d.scale()))),
|
||||
Operand::Small(n, d) => (n != i128::MIN).then_some((n, d)),
|
||||
Operand::Big(_) | Operand::Float(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn big(self) -> Option<BigQ> {
|
||||
match self {
|
||||
Operand::Dec(d) => Some(BigQ::from_i128(d.mantissa(), pow10(d.scale()))),
|
||||
Operand::Small(n, d) => Some(BigQ::from_i128(n, d)),
|
||||
Operand::Big(q) => Some(q.clone()),
|
||||
Operand::Float(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn float(self) -> f64 {
|
||||
match self {
|
||||
Operand::Dec(d) => crate::num::decimal_to_f64(d),
|
||||
Operand::Small(n, d) => crate::ratio::ratio_to_f64(&BigInt::from(n), &BigInt::from(d)),
|
||||
Operand::Big(q) => q.to_f64(),
|
||||
Operand::Float(x) => x,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_float(self) -> bool {
|
||||
matches!(self, Operand::Float(_))
|
||||
}
|
||||
|
||||
fn is_zero(self) -> bool {
|
||||
match self {
|
||||
Operand::Dec(d) => d.is_zero(),
|
||||
Operand::Small(..) | Operand::Big(_) => false,
|
||||
Operand::Float(x) => x == 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inexact(x: f64) -> Interim {
|
||||
Interim::from(Num::inexact(x))
|
||||
}
|
||||
|
||||
/// An exact result in the first arm it fits; wide while under the ceiling, else demoted.
|
||||
fn settle_wide(q: BigQ) -> Interim {
|
||||
match Num::fit(&q) {
|
||||
Some(n) => Interim::from(n),
|
||||
None if q.bits() > CEILING_BITS => inexact(q.to_f64()),
|
||||
None => Interim::wide(q),
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_or_wide(
|
||||
a: Operand,
|
||||
b: Operand,
|
||||
small: impl Fn(i128, i128, i128, i128) -> Option<(i128, i128)>,
|
||||
big: impl Fn(&BigQ, &BigQ) -> BigQ,
|
||||
) -> Interim {
|
||||
let fast = a
|
||||
.parts()
|
||||
.zip(b.parts())
|
||||
.and_then(|((n1, d1), (n2, d2))| small(n1, d1, n2, d2));
|
||||
fast.and_then(|(n, d)| small_ratio(n, d))
|
||||
.map(Interim::from)
|
||||
.unwrap_or_else(|| match a.big().zip(b.big()) {
|
||||
Some((x, y)) => settle_wide(big(&x, &y)),
|
||||
None => unreachable!("both operands are exact here"),
|
||||
})
|
||||
}
|
||||
|
||||
fn plus(a: Operand, b: Operand) -> Interim {
|
||||
match (a, b) {
|
||||
(x, y) if x.is_float() || y.is_float() => inexact(x.float() + y.float()),
|
||||
(Operand::Dec(x), Operand::Dec(y)) => decimal_sum(x, y),
|
||||
_ => exact_or_wide(
|
||||
a,
|
||||
b,
|
||||
|n1, d1, n2, d2| {
|
||||
let n = n1.checked_mul(d2)?.checked_add(n2.checked_mul(d1)?)?;
|
||||
Some((n, d1.checked_mul(d2)?))
|
||||
},
|
||||
BigQ::add,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn minus(a: Operand, b: Operand) -> Interim {
|
||||
plus(a, negate(b).operand())
|
||||
}
|
||||
|
||||
fn times(a: Operand, b: Operand) -> Interim {
|
||||
match (a, b) {
|
||||
(x, y) if x.is_float() || y.is_float() => inexact(x.float() * y.float()),
|
||||
(Operand::Dec(x), Operand::Dec(y)) => decimal_product(x, y),
|
||||
_ => exact_or_wide(
|
||||
a,
|
||||
b,
|
||||
|n1, d1, n2, d2| {
|
||||
let (g1, g2) = (n1.gcd(&d2).max(1), n2.gcd(&d1).max(1));
|
||||
Some((
|
||||
(n1 / g1).checked_mul(n2 / g2)?,
|
||||
(d1 / g2).checked_mul(d2 / g1)?,
|
||||
))
|
||||
},
|
||||
BigQ::mul,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn negate(a: Operand) -> Interim {
|
||||
match a {
|
||||
Operand::Dec(d) => Interim::from(Num::exact(-*d)),
|
||||
Operand::Float(x) => inexact(-x),
|
||||
Operand::Small(n, d) => n
|
||||
.checked_neg()
|
||||
.and_then(|m| small_ratio(m, d))
|
||||
.map(Interim::from)
|
||||
.unwrap_or_else(|| settle_wide(BigQ::from_i128(n, d).neg())),
|
||||
Operand::Big(q) => settle_wide(q.neg()),
|
||||
}
|
||||
}
|
||||
|
||||
fn divide(a: Operand, b: Operand) -> Result<Interim, DivisionByZero> {
|
||||
match (a, b) {
|
||||
(_, y) if y.is_zero() => Err(DivisionByZero),
|
||||
(x, y) if x.is_float() || y.is_float() => Ok(inexact(x.float() / y.float())),
|
||||
_ => Ok(exact_or_wide(
|
||||
a,
|
||||
b,
|
||||
|n1, d1, n2, d2| {
|
||||
let (g1, g2) = (n1.gcd(&n2).max(1), d1.gcd(&d2).max(1));
|
||||
Some((
|
||||
(n1 / g1).checked_mul(d2 / g2)?,
|
||||
(d1 / g2).checked_mul(n2 / g1)?,
|
||||
))
|
||||
},
|
||||
|x, y| {
|
||||
x.div(y)
|
||||
.unwrap_or_else(|| unreachable!("the divisor is not zero"))
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clause 4's hint: a returned scale below the nominal one may mean rounding.
|
||||
fn decimal_sum(x: &Decimal, y: &Decimal) -> Interim {
|
||||
let nominal = x.scale().max(y.scale());
|
||||
match x.checked_add(*y).filter(|r| r.scale() == nominal) {
|
||||
Some(r) => Interim::from(Num::exact(r)),
|
||||
None => {
|
||||
let align = |d: &Decimal| {
|
||||
I256::from_i128(d.mantissa())
|
||||
.checked_mul_u128(pow10(nominal - d.scale()) as u128)
|
||||
.expect("an aligned mantissa is below 2^190")
|
||||
};
|
||||
let sum = align(x).checked_add(&align(y)).expect("below 2^191");
|
||||
settle_decimal_integer(sum, nominal, nominal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decimal_product(x: &Decimal, y: &Decimal) -> Interim {
|
||||
let nominal = x.scale() + y.scale();
|
||||
match x.checked_mul(*y).filter(|r| r.scale() == nominal) {
|
||||
Some(r) => Interim::from(Num::exact(r)),
|
||||
None => {
|
||||
settle_decimal_integer(I256::mul_i128(x.mantissa(), y.mantissa()), nominal, nominal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The recompute of clause 4: strip trailing zeros, fit 96 bits at scale <= 28, else a fraction.
|
||||
fn settle_decimal_integer(m: I256, scale: u32, nominal: u32) -> Interim {
|
||||
let (mut m, mut s) = (m, scale);
|
||||
while s > 0 {
|
||||
let (q, r) = m.div_rem_u64(10);
|
||||
if r != 0 {
|
||||
break;
|
||||
}
|
||||
m = q;
|
||||
s -= 1;
|
||||
}
|
||||
let fits = m
|
||||
.to_i128()
|
||||
.filter(|v| v.unsigned_abs() < MANTISSA_LIMIT as u128);
|
||||
match fits {
|
||||
Some(v) if s <= MAX_SCALE => Interim::from(padded(v, s, nominal.min(MAX_SCALE))),
|
||||
_ => settle_wide(BigQ::new(m.to_bigint(), BigInt::from(10).pow(s))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored scale of clause 4: pad toward `target` while the mantissa fits 96 bits.
|
||||
pub(crate) fn padded(mantissa: i128, scale: u32, target: u32) -> Num {
|
||||
let (mut m, mut s) = (mantissa, scale);
|
||||
while s < target && (m * 10).unsigned_abs() < MANTISSA_LIMIT as u128 {
|
||||
m *= 10;
|
||||
s += 1;
|
||||
}
|
||||
decimal(m, s).expect("a padded mantissa still fits")
|
||||
}
|
||||
|
||||
/// The exponent of an exact integer-valued operand.
|
||||
fn integer_value(b: Operand) -> Option<BigInt> {
|
||||
match b {
|
||||
Operand::Dec(d) if d.fract().is_zero() => {
|
||||
Some(BigInt::from(d.mantissa() / pow10(d.scale())))
|
||||
}
|
||||
Operand::Small(n, 1) => Some(BigInt::from(n)),
|
||||
Operand::Big(q) if q.den.is_one() => Some(q.num.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn power(a: Operand, b: Operand) -> Result<Interim, DivisionByZero> {
|
||||
match (a.is_float(), integer_value(b)) {
|
||||
(false, Some(n)) => exact_power(a, &n),
|
||||
_ => Ok(inexact(a.float().powf(b.float()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clause 5's `POW`: the size is checked before anything is computed.
|
||||
fn exact_power(a: Operand, n: &BigInt) -> Result<Interim, DivisionByZero> {
|
||||
let q = a.big().expect("an exact base");
|
||||
let base_scale = match a {
|
||||
Operand::Dec(d) => d.scale(),
|
||||
_ => 0,
|
||||
};
|
||||
let k = n.magnitude();
|
||||
let target = match n.is_negative() {
|
||||
true => 0,
|
||||
false => k
|
||||
.to_u32()
|
||||
.and_then(|k| k.checked_mul(base_scale))
|
||||
.map_or(MAX_SCALE, |s| s.min(MAX_SCALE)),
|
||||
};
|
||||
let unit = q.den.is_one() && q.num.magnitude().is_one();
|
||||
match (q.num.is_zero(), n.is_negative(), unit) {
|
||||
(_, _, _) if n.is_zero() => Ok(Interim::from(padded(1, 0, 0))),
|
||||
(true, true, _) => Err(DivisionByZero),
|
||||
(true, false, _) => Ok(Interim::from(padded(0, 0, target))),
|
||||
(false, _, true) => {
|
||||
let negative = q.num.is_negative() && k.is_odd();
|
||||
Ok(Interim::from(padded(
|
||||
if negative { -1 } else { 1 },
|
||||
0,
|
||||
target,
|
||||
)))
|
||||
}
|
||||
(false, negative, false) => {
|
||||
let lower = |x: &BigInt| BigUint::from(x.bits().saturating_sub(1)) * k;
|
||||
let too_big = lower(&q.num).max(lower(&q.den)) > BigUint::from(CEILING_BITS);
|
||||
let odd_negative = q.num.is_negative() && k.is_odd();
|
||||
match (too_big, k.to_u32()) {
|
||||
(false, Some(k)) => Ok(exact_integer_power(&q, k, negative, target)),
|
||||
_ => Ok(inexact(signed_powf(a.float(), n, odd_negative))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `powf` on the magnitude, the sign taken from the exact exponent's parity.
|
||||
fn signed_powf(base: f64, n: &BigInt, odd_negative: bool) -> f64 {
|
||||
let magnitude = base.abs().powf(n.to_f64().unwrap_or(f64::INFINITY));
|
||||
match odd_negative {
|
||||
true => -magnitude,
|
||||
false => magnitude,
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_integer_power(q: &BigQ, k: u32, negative: bool, target: u32) -> Interim {
|
||||
let (num, den) = (q.num.pow(k), q.den.pow(k));
|
||||
let value = match negative {
|
||||
true => BigQ::new(den, num),
|
||||
false => BigQ { num, den },
|
||||
};
|
||||
let settled = settle_wide(value);
|
||||
match (&settled.0, negative) {
|
||||
(Inner::Stored(n), false) => match n.kind() {
|
||||
NumRef::Exact(d) => Interim::from(padded(d.mantissa(), d.scale(), target)),
|
||||
_ => settled,
|
||||
},
|
||||
_ => settled,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Exact comparison at every pairing of arms (clause 6), and the `MIN`/`MAX`
|
||||
//! tie-breaks (clause 8). Why: `context/code/numtower.md`, "Comparison".
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use crate::fixed::I256;
|
||||
use crate::num::{Num, NumRef};
|
||||
use crate::ratio::BigQ;
|
||||
|
||||
impl Num {
|
||||
/// The order of the two values, exactly; `None` only when either is NaN.
|
||||
pub fn cmp_exact(&self, o: &Num) -> Option<Ordering> {
|
||||
match (self.exact_parts(), o.exact_parts()) {
|
||||
(Some(a), Some(b)) => Some(cross(a, b)),
|
||||
(Some(_), None) => mixed(self, o.to_f64()),
|
||||
(None, Some(_)) => mixed(o, self.to_f64()).map(Ordering::reverse),
|
||||
(None, None) => self.to_f64().partial_cmp(&o.to_f64()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The smaller, NaN skipped; ties keep the most information (clause 8, question 11).
|
||||
pub fn min_of<'a>(&'a self, o: &'a Num) -> &'a Num {
|
||||
pick(self, o, Ordering::Less)
|
||||
}
|
||||
|
||||
pub fn max_of<'a>(&'a self, o: &'a Num) -> &'a Num {
|
||||
pick(self, o, Ordering::Greater)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Num {
|
||||
fn eq(&self, o: &Num) -> bool {
|
||||
self.cmp_exact(o) == Some(Ordering::Equal)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Num {
|
||||
fn partial_cmp(&self, o: &Num) -> Option<Ordering> {
|
||||
self.cmp_exact(o)
|
||||
}
|
||||
}
|
||||
|
||||
/// `a.0/a.1` against `b.0/b.1`, denominators positive, in 256 bits.
|
||||
fn cross(a: (i128, i128), b: (i128, i128)) -> Ordering {
|
||||
I256::mul_i128(a.0, b.1).cmp(&I256::mul_i128(b.0, a.1))
|
||||
}
|
||||
|
||||
/// An exact value against an f64: the rounded value decides unless it ties.
|
||||
fn mixed(exact: &Num, x: f64) -> Option<Ordering> {
|
||||
match (x.is_nan(), x.is_infinite()) {
|
||||
(true, _) => None,
|
||||
(false, true) => Some(if x > 0.0 {
|
||||
Ordering::Less
|
||||
} else {
|
||||
Ordering::Greater
|
||||
}),
|
||||
(false, false) => match exact.to_f64().partial_cmp(&x)? {
|
||||
Ordering::Equal => {
|
||||
let q = exact.exact_q().expect("an exact operand");
|
||||
Some(q.cmp_value(&BigQ::from_f64(x)))
|
||||
}
|
||||
decided => Some(decided),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A wide exact value against an f64, with no fast path: the slow, total form of `mixed`.
|
||||
pub(crate) fn exact_against_f64(q: &BigQ, x: f64) -> Option<Ordering> {
|
||||
match (x.is_nan(), x.is_infinite(), x > 0.0) {
|
||||
(true, _, _) => None,
|
||||
(false, true, true) => Some(Ordering::Less),
|
||||
(false, true, false) => Some(Ordering::Greater),
|
||||
(false, false, _) => Some(q.cmp_value(&BigQ::from_f64(x))),
|
||||
}
|
||||
}
|
||||
|
||||
fn pick<'a>(a: &'a Num, b: &'a Num, want: Ordering) -> &'a Num {
|
||||
match (a.cmp_exact(b), is_nan(a), is_nan(b)) {
|
||||
(_, true, _) => b,
|
||||
(_, false, true) => a,
|
||||
(Some(o), _, _) if o == want => a,
|
||||
(Some(Ordering::Equal), _, _) => tie(a, b, want),
|
||||
_ => b,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_nan(n: &Num) -> bool {
|
||||
matches!(n.kind(), NumRef::Inexact(x) if x.is_nan())
|
||||
}
|
||||
|
||||
/// Equal values: exact over inexact, the larger scale, and IEEE's signed zero.
|
||||
fn tie<'a>(a: &'a Num, b: &'a Num, want: Ordering) -> &'a Num {
|
||||
match (a.kind(), b.kind()) {
|
||||
(NumRef::Inexact(_), NumRef::Exact(_) | NumRef::Fraction(_)) => b,
|
||||
(NumRef::Exact(_) | NumRef::Fraction(_), NumRef::Inexact(_)) => a,
|
||||
(NumRef::Exact(x), NumRef::Exact(y)) if y.scale() > x.scale() => b,
|
||||
(NumRef::Inexact(x), NumRef::Inexact(y)) => {
|
||||
let negative_wanted = want == Ordering::Less;
|
||||
match y.is_sign_negative() == negative_wanted && x.is_sign_negative() != negative_wanted
|
||||
{
|
||||
true => b,
|
||||
false => a,
|
||||
}
|
||||
}
|
||||
_ => a,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Fixed-width two's-complement integers for the hot paths: no allocation.
|
||||
//! Why they are hand-rolled: `context/code/numtower.md`, "The wide integers".
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use num_bigint::{BigInt, Sign};
|
||||
|
||||
/// A signed integer of `64 * N` bits, little-endian limbs, two's complement.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct Fixed<const N: usize> {
|
||||
limbs: [u64; N],
|
||||
}
|
||||
|
||||
/// The 256-bit integer clauses 4–6 use, and the 320-bit decimal partition of clause 8.
|
||||
pub(crate) type I256 = Fixed<4>;
|
||||
pub(crate) type I320 = Fixed<5>;
|
||||
|
||||
impl<const N: usize> Fixed<N> {
|
||||
pub(crate) const ZERO: Self = Fixed { limbs: [0; N] };
|
||||
|
||||
pub(crate) fn from_i128(x: i128) -> Self {
|
||||
let fill = if x < 0 { u64::MAX } else { 0 };
|
||||
let mut limbs = [fill; N];
|
||||
limbs[0] = x as u64;
|
||||
limbs[1] = (x >> 64) as u64;
|
||||
Fixed { limbs }
|
||||
}
|
||||
|
||||
pub(crate) fn is_negative(&self) -> bool {
|
||||
self.limbs[N - 1] >> 63 == 1
|
||||
}
|
||||
|
||||
pub(crate) fn is_zero(&self) -> bool {
|
||||
self.limbs.iter().all(|&l| l == 0)
|
||||
}
|
||||
|
||||
fn wrapping_neg(&self) -> Self {
|
||||
let mut out = [0u64; N];
|
||||
let mut carry = 1u64;
|
||||
for (o, &l) in out.iter_mut().zip(&self.limbs) {
|
||||
let (v, c) = (!l).overflowing_add(carry);
|
||||
*o = v;
|
||||
carry = c as u64;
|
||||
}
|
||||
Fixed { limbs: out }
|
||||
}
|
||||
|
||||
fn wrapping_add(&self, other: &Self) -> Self {
|
||||
let mut out = [0u64; N];
|
||||
let mut carry = false;
|
||||
for (o, (a, b)) in out.iter_mut().zip(self.limbs.iter().zip(&other.limbs)) {
|
||||
let (s, c1) = a.overflowing_add(*b);
|
||||
let (s, c2) = s.overflowing_add(carry as u64);
|
||||
*o = s;
|
||||
carry = c1 || c2;
|
||||
}
|
||||
Fixed { limbs: out }
|
||||
}
|
||||
|
||||
pub(crate) fn checked_add(&self, other: &Self) -> Option<Self> {
|
||||
let sum = self.wrapping_add(other);
|
||||
let same = self.is_negative() == other.is_negative();
|
||||
(!same || sum.is_negative() == self.is_negative()).then_some(sum)
|
||||
}
|
||||
|
||||
pub(crate) fn checked_neg(&self) -> Option<Self> {
|
||||
let neg = self.wrapping_neg();
|
||||
(self.is_zero() || neg.is_negative() != self.is_negative()).then_some(neg)
|
||||
}
|
||||
|
||||
/// The magnitude as unsigned limbs, with the sign split out.
|
||||
fn magnitude(&self) -> (bool, [u64; N]) {
|
||||
match self.is_negative() {
|
||||
true => (true, self.wrapping_neg().limbs),
|
||||
false => (false, self.limbs),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_magnitude(negative: bool, limbs: [u64; N]) -> Option<Self> {
|
||||
let unsigned = Fixed { limbs };
|
||||
match (negative, unsigned.is_negative()) {
|
||||
(false, false) => Some(unsigned),
|
||||
(true, _) => {
|
||||
let neg = unsigned.wrapping_neg();
|
||||
(neg.is_negative() || neg.is_zero()).then_some(neg)
|
||||
}
|
||||
(false, true) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn checked_mul_u128(&self, k: u128) -> Option<Self> {
|
||||
let (negative, mag) = self.magnitude();
|
||||
let mut out = [0u64; N];
|
||||
for (j, kk) in [k as u64, (k >> 64) as u64].into_iter().enumerate() {
|
||||
if kk == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut carry = 0u128;
|
||||
for (i, &limb) in mag.iter().enumerate() {
|
||||
let idx = i + j;
|
||||
let prod = limb as u128 * kk as u128 + carry;
|
||||
match idx < N {
|
||||
true => {
|
||||
let s = out[idx] as u128 + (prod as u64) as u128;
|
||||
out[idx] = s as u64;
|
||||
carry = (prod >> 64) + (s >> 64);
|
||||
}
|
||||
false if prod != 0 => return None,
|
||||
false => carry = 0,
|
||||
}
|
||||
}
|
||||
if carry != 0 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Self::from_magnitude(negative, out)
|
||||
}
|
||||
|
||||
/// Divides the magnitude by `k`, returning the quotient (sign kept) and the remainder.
|
||||
pub(crate) fn div_rem_u64(&self, k: u64) -> (Self, u64) {
|
||||
let (negative, mag) = self.magnitude();
|
||||
let mut out = [0u64; N];
|
||||
let mut rem = 0u128;
|
||||
for i in (0..N).rev() {
|
||||
let cur = (rem << 64) | mag[i] as u128;
|
||||
out[i] = (cur / k as u128) as u64;
|
||||
rem = cur % k as u128;
|
||||
}
|
||||
let q = Self::from_magnitude(negative, out).unwrap_or(Self::ZERO);
|
||||
(q, rem as u64)
|
||||
}
|
||||
|
||||
pub(crate) fn to_i128(self) -> Option<i128> {
|
||||
let fill = if self.is_negative() { u64::MAX } else { 0 };
|
||||
let hi_ok = self.limbs[2..].iter().all(|&l| l == fill);
|
||||
let v = (self.limbs[0] as u128 | ((self.limbs[1] as u128) << 64)) as i128;
|
||||
(hi_ok && (v < 0) == self.is_negative()).then_some(v)
|
||||
}
|
||||
|
||||
pub(crate) fn to_bigint(self) -> BigInt {
|
||||
let (negative, mag) = self.magnitude();
|
||||
let digits: Vec<u32> = mag
|
||||
.iter()
|
||||
.flat_map(|&l| [l as u32, (l >> 32) as u32])
|
||||
.collect();
|
||||
let sign = if negative { Sign::Minus } else { Sign::Plus };
|
||||
BigInt::from_slice(sign, &digits)
|
||||
}
|
||||
|
||||
/// The product of two `i128`s; needs `N >= 4`.
|
||||
pub(crate) fn mul_i128(a: i128, b: i128) -> Self {
|
||||
let negative = (a < 0) != (b < 0);
|
||||
let (x, y) = (a.unsigned_abs(), b.unsigned_abs());
|
||||
let (x0, x1) = (x as u64 as u128, x >> 64);
|
||||
let (y0, y1) = (y as u64 as u128, y >> 64);
|
||||
let p00 = x0 * y0;
|
||||
let p01 = x0 * y1;
|
||||
let p10 = x1 * y0;
|
||||
let p11 = x1 * y1;
|
||||
let mid = (p00 >> 64) + (p01 as u64 as u128) + (p10 as u64 as u128);
|
||||
let mut limbs = [0u64; N];
|
||||
limbs[0] = p00 as u64;
|
||||
limbs[1] = mid as u64;
|
||||
let hi = (mid >> 64) + (p01 >> 64) + (p10 >> 64) + p11;
|
||||
limbs[2] = hi as u64;
|
||||
limbs[3] = (hi >> 64) as u64;
|
||||
let unsigned = Fixed { limbs };
|
||||
match negative {
|
||||
true => unsigned.wrapping_neg(),
|
||||
false => unsigned,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize> Default for Fixed<N> {
|
||||
fn default() -> Self {
|
||||
Self::ZERO
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize> Ord for Fixed<N> {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
match (self.is_negative(), other.is_negative()) {
|
||||
(true, false) => Ordering::Less,
|
||||
(false, true) => Ordering::Greater,
|
||||
_ => self.limbs.iter().rev().cmp(other.limbs.iter().rev()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize> PartialOrd for Fixed<N> {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_256_bit_product_of_two_i128s_matches_bigint() {
|
||||
let cases = [
|
||||
(i128::MAX, i128::MAX),
|
||||
(i128::MAX, -i128::MAX),
|
||||
(-1, 1),
|
||||
(0, i128::MIN),
|
||||
(123_456_789_012_345_678_901_234, -98_765_432_109_876_543_210),
|
||||
];
|
||||
for (a, b) in cases {
|
||||
let want = BigInt::from(a) * BigInt::from(b);
|
||||
assert_eq!(I256::mul_i128(a, b).to_bigint(), want, "{a} * {b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_arithmetic_refuses_what_overflows_and_keeps_what_fits() {
|
||||
let max = I256::from_i128(i128::MAX)
|
||||
.checked_mul_u128(u128::MAX)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
max.to_bigint(),
|
||||
BigInt::from(i128::MAX) * BigInt::from(u128::MAX)
|
||||
);
|
||||
assert!(max.checked_mul_u128(u128::MAX).is_none());
|
||||
let neg = max.checked_neg().unwrap();
|
||||
assert_eq!(neg.to_bigint(), -max.to_bigint());
|
||||
let (q, r) = neg.div_rem_u64(10);
|
||||
assert_eq!(q.to_bigint() * 10 - BigInt::from(r), neg.to_bigint());
|
||||
assert_eq!(I256::from_i128(-5).to_i128(), Some(-5));
|
||||
assert_eq!(max.to_i128(), None);
|
||||
assert!(I256::from_i128(-1) < I256::ZERO);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Clause 3's one number language: the gate, the exact parse, the forced `n/d` arm,
|
||||
//! and the canonical text. Why each rule: `context/code/numtower.md`, "The number language".
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use num_bigint::BigInt;
|
||||
use num_traits::{One, Zero};
|
||||
|
||||
use crate::arith::padded;
|
||||
use crate::num::{MAX_SCALE, Num, NumRef};
|
||||
use crate::ratio::BigQ;
|
||||
|
||||
/// Whether the type of the text is being guessed or was forced (question 12).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Typing {
|
||||
Inferred,
|
||||
Forced,
|
||||
}
|
||||
|
||||
/// The gate: the strings `f64::from_str` accepts and reads as a finite value.
|
||||
pub fn is_number(text: &str, typing: Typing) -> bool {
|
||||
gate(text) || (typing == Typing::Forced && forced_fraction(text).is_some())
|
||||
}
|
||||
|
||||
/// A number if the text is one, read exactly; `None` means the text is text.
|
||||
pub fn parse_number(text: &str, typing: Typing) -> Option<Num> {
|
||||
match (gate(text), typing) {
|
||||
(true, _) => Lexeme::read(text).map(|lx| lx.value(text)),
|
||||
(false, Typing::Forced) => forced_fraction(text),
|
||||
(false, Typing::Inferred) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A forced `n/d`; one that rounds to infinity is text, as a finite lexeme never reads as one.
|
||||
fn forced_fraction(text: &str) -> Option<Num> {
|
||||
let n = Num::settle(&fraction_lexeme(text)?);
|
||||
n.to_f64().is_finite().then_some(n)
|
||||
}
|
||||
|
||||
fn gate(text: &str) -> bool {
|
||||
text.parse::<f64>().is_ok_and(f64::is_finite)
|
||||
}
|
||||
|
||||
/// `[+-]digits/digits` with a nonzero denominator, as an exact value.
|
||||
fn fraction_lexeme(text: &str) -> Option<BigQ> {
|
||||
let (n, d) = text.split_once('/')?;
|
||||
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
|
||||
let unsigned = n.strip_prefix(['+', '-']).unwrap_or(n);
|
||||
let ok = digits(unsigned) && digits(d);
|
||||
let num: BigInt = ok.then(|| n.trim_start_matches('+').parse().ok())??;
|
||||
let den: BigInt = d.parse().ok()?;
|
||||
(!den.is_zero()).then(|| BigQ::new(num, den))
|
||||
}
|
||||
|
||||
/// A lexeme of the gate's language: sign, significant digits, and the power of ten.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Lexeme {
|
||||
pub(crate) negative: bool,
|
||||
pub(crate) digits: String,
|
||||
pub(crate) exp10: i64,
|
||||
}
|
||||
|
||||
/// Exponents beyond this cannot reach either exact arm, and saturate.
|
||||
const EXP_SATURATE: i64 = 1 << 40;
|
||||
|
||||
impl Lexeme {
|
||||
/// `[+-]? (d+ (. d*)? | . d+) ([eE] [+-]? d+)?`, and nothing else.
|
||||
pub(crate) fn read(text: &str) -> Option<Lexeme> {
|
||||
let (negative, rest) = match text.as_bytes().first()? {
|
||||
b'-' => (true, &text[1..]),
|
||||
b'+' => (false, &text[1..]),
|
||||
_ => (false, text),
|
||||
};
|
||||
let (mantissa, exponent) = match rest.find(['e', 'E']) {
|
||||
Some(i) => (&rest[..i], Some(&rest[i + 1..])),
|
||||
None => (rest, None),
|
||||
};
|
||||
let (int, frac) = mantissa.split_once('.').unwrap_or((mantissa, ""));
|
||||
let all_digits = |s: &str| s.bytes().all(|b| b.is_ascii_digit());
|
||||
let mantissa_ok =
|
||||
all_digits(int) && all_digits(frac) && !(int.is_empty() && frac.is_empty());
|
||||
let exp = match exponent {
|
||||
Some(e) => read_exponent(e)?,
|
||||
None => 0,
|
||||
};
|
||||
let digits = format!("{int}{frac}");
|
||||
mantissa_ok.then(|| Lexeme {
|
||||
negative,
|
||||
digits,
|
||||
exp10: exp.saturating_sub(frac.len() as i64),
|
||||
})
|
||||
}
|
||||
|
||||
/// Clause 3's order: a decimal at the lexeme's scale, then a fraction, then the nearest f64.
|
||||
fn value(&self, text: &str) -> Num {
|
||||
let significant = self.digits.trim_start_matches('0');
|
||||
let stripped = significant.trim_end_matches('0');
|
||||
let implied_scale = (-self.exp10).clamp(0, MAX_SCALE as i64) as u32;
|
||||
match stripped.is_empty() {
|
||||
true => padded(0, 0, implied_scale),
|
||||
false => {
|
||||
let shift = self
|
||||
.exp10
|
||||
.saturating_add((significant.len() - stripped.len()) as i64);
|
||||
self.exact(stripped, shift, implied_scale)
|
||||
.unwrap_or_else(|| Num::inexact(text.parse().unwrap_or(f64::NAN)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn exact(&self, stripped: &str, shift: i64, implied_scale: u32) -> Option<Num> {
|
||||
let leading = stripped.len() as i64 + shift;
|
||||
let reachable = stripped.len() <= 180 && (-130..=40).contains(&leading) && shift >= -400;
|
||||
let q = reachable.then(|| self.rational(stripped, shift))?;
|
||||
Num::fit(&q).map(|n| match n.kind() {
|
||||
NumRef::Exact(d) => padded(d.mantissa(), d.scale(), implied_scale),
|
||||
_ => n,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn rational(&self, stripped: &str, shift: i64) -> BigQ {
|
||||
let m: BigInt = stripped.parse().unwrap_or_default();
|
||||
let m = if self.negative { -m } else { m };
|
||||
let ten = BigInt::from(10);
|
||||
match shift >= 0 {
|
||||
true => BigQ::new(m * ten.pow(shift as u32), BigInt::one()),
|
||||
false => BigQ::new(m, ten.pow((-shift) as u32)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The exact value, whatever its size: for display rounding.
|
||||
pub(crate) fn exact_value(&self) -> BigQ {
|
||||
let stripped = self.digits.trim_start_matches('0');
|
||||
match stripped.is_empty() {
|
||||
true => BigQ::new(BigInt::zero(), BigInt::one()),
|
||||
false => self.rational(stripped, self.exp10),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_exponent(e: &str) -> Option<i64> {
|
||||
let (negative, digits) = match e.as_bytes().first()? {
|
||||
b'-' => (true, &e[1..]),
|
||||
b'+' => (false, &e[1..]),
|
||||
_ => (false, e),
|
||||
};
|
||||
let ok = !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit());
|
||||
let significant = digits.trim_start_matches('0');
|
||||
let magnitude = match significant.len() > 12 {
|
||||
true => EXP_SATURATE,
|
||||
false => significant.parse::<i64>().unwrap_or(0).min(EXP_SATURATE),
|
||||
};
|
||||
ok.then_some(if negative { -magnitude } else { magnitude })
|
||||
}
|
||||
|
||||
/// The shortest text that reads back as `x`; `None` for a non-finite value.
|
||||
pub fn shortest_round_trip(x: f64) -> Option<String> {
|
||||
x.is_finite().then(|| format!("{x:?}"))
|
||||
}
|
||||
|
||||
const INEXACT_MARK: &str = "#i";
|
||||
|
||||
impl Num {
|
||||
/// Reads every spelling `Display` writes: a decimal, `n/d`, `#i…`, `inf`, `-inf`, `nan`.
|
||||
pub fn from_canonical(text: &str) -> Option<Num> {
|
||||
match text {
|
||||
"inf" => Some(Num::inexact(f64::INFINITY)),
|
||||
"-inf" => Some(Num::inexact(f64::NEG_INFINITY)),
|
||||
"nan" => Some(Num::inexact(f64::NAN)),
|
||||
_ => match text.strip_prefix(INEXACT_MARK) {
|
||||
Some(rest) if gate(rest) => rest.parse().ok().map(Num::inexact),
|
||||
Some(_) => None,
|
||||
None => parse_number(text, Typing::Forced).filter(Num::is_exact),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical text: a decimal with its scale, `n/d`, `#i` and the shortest spelling, or a token.
|
||||
impl fmt::Display for Num {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self.kind() {
|
||||
NumRef::Exact(d) => write!(f, "{d}"),
|
||||
NumRef::Fraction(r) => write!(f, "{}/{}", r.numer(), r.denom()),
|
||||
NumRef::Inexact(x) if x.is_nan() => f.write_str("nan"),
|
||||
NumRef::Inexact(x) if x.is_infinite() => {
|
||||
f.write_str(if x > 0.0 { "inf" } else { "-inf" })
|
||||
}
|
||||
NumRef::Inexact(x) => write!(f, "{INEXACT_MARK}{x:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! A three-level numeric tower: exact decimal, exact bounded fraction, inexact f64.
|
||||
//! The decisions are ADR 005; the reasoning is `context/code/numtower.md`.
|
||||
|
||||
mod arith;
|
||||
mod cmp;
|
||||
mod fixed;
|
||||
mod lang;
|
||||
mod num;
|
||||
mod ratio;
|
||||
mod round;
|
||||
mod sum;
|
||||
|
||||
pub use arith::{CEILING_BITS, DivisionByZero, Interim};
|
||||
pub use lang::{Typing, is_number, parse_number, shortest_round_trip};
|
||||
pub use num::{Num, NumRef};
|
||||
pub use round::Rounding;
|
||||
pub use sum::{ExactSum, K, NumSum};
|
||||
|
||||
pub use num_rational::Ratio;
|
||||
pub use rust_decimal::Decimal;
|
||||
@@ -0,0 +1,198 @@
|
||||
//! The stored number: an exact decimal, an exact bounded fraction, or an f64.
|
||||
//! The arms are private so a value is always normalized: `context/code/numtower.md`.
|
||||
|
||||
use num_bigint::BigInt;
|
||||
use num_integer::Integer;
|
||||
use num_rational::Ratio;
|
||||
use num_traits::{One, ToPrimitive};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
use crate::ratio::{BigQ, ratio_to_f64};
|
||||
|
||||
/// The most significant digits a `Decimal` holds, and its mantissa bound.
|
||||
pub(crate) const MAX_SCALE: u32 = 28;
|
||||
pub(crate) const MANTISSA_LIMIT: i128 = 1 << 96;
|
||||
|
||||
/// A number of the tower (ADR 005 clause 2), always in its one normal form.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Num(Repr);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Repr {
|
||||
Exact(Decimal),
|
||||
Fraction(Box<Ratio<i128>>),
|
||||
Inexact(f64),
|
||||
}
|
||||
|
||||
/// A borrowed view of a `Num`'s arm, for exhaustive matching by callers.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum NumRef<'a> {
|
||||
Exact(&'a Decimal),
|
||||
Fraction(&'a Ratio<i128>),
|
||||
Inexact(f64),
|
||||
}
|
||||
|
||||
impl Num {
|
||||
/// An exact decimal. Zero loses its sign: the sign of zero is an f64 artefact.
|
||||
pub fn exact(d: Decimal) -> Num {
|
||||
let mut d = d;
|
||||
if d.is_zero() {
|
||||
d.set_sign_positive(true);
|
||||
}
|
||||
Num(Repr::Exact(d))
|
||||
}
|
||||
|
||||
pub fn inexact(x: f64) -> Num {
|
||||
Num(Repr::Inexact(x))
|
||||
}
|
||||
|
||||
/// The exact value `num / den`, normalized; `None` when `den` is zero.
|
||||
pub fn ratio(num: i128, den: i128) -> Option<Num> {
|
||||
(den != 0).then(|| Num::settle(&BigQ::from_i128(num, den)))
|
||||
}
|
||||
|
||||
fn fraction(num: i128, den: i128) -> Num {
|
||||
Num(Repr::Fraction(Box::new(Ratio::new_raw(num, den))))
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> NumRef<'_> {
|
||||
match &self.0 {
|
||||
Repr::Exact(d) => NumRef::Exact(d),
|
||||
Repr::Fraction(r) => NumRef::Fraction(r),
|
||||
Repr::Inexact(x) => NumRef::Inexact(*x),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_exact(&self) -> bool {
|
||||
!matches!(self.0, Repr::Inexact(_))
|
||||
}
|
||||
|
||||
/// Exactly zero, at either level; the one zero predicate of clause 6.
|
||||
pub fn is_zero(&self) -> bool {
|
||||
match &self.0 {
|
||||
Repr::Exact(d) => d.is_zero(),
|
||||
Repr::Fraction(_) => false,
|
||||
Repr::Inexact(x) => *x == 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The correctly rounded f64 (clause 4): a decimal through its text, a fraction exactly.
|
||||
pub fn to_f64(&self) -> f64 {
|
||||
match &self.0 {
|
||||
Repr::Exact(d) => decimal_to_f64(d),
|
||||
Repr::Fraction(r) => ratio_to_f64(&BigInt::from(*r.numer()), &BigInt::from(*r.denom())),
|
||||
Repr::Inexact(x) => *x,
|
||||
}
|
||||
}
|
||||
|
||||
/// The exact value as a rational; `None` for an inexact number.
|
||||
pub(crate) fn exact_parts(&self) -> Option<(i128, i128)> {
|
||||
match &self.0 {
|
||||
Repr::Exact(d) => Some((d.mantissa(), pow10(d.scale()))),
|
||||
Repr::Fraction(r) => Some((*r.numer(), *r.denom())),
|
||||
Repr::Inexact(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn exact_q(&self) -> Option<BigQ> {
|
||||
self.exact_parts().map(|(n, d)| BigQ::from_i128(n, d))
|
||||
}
|
||||
|
||||
/// The stored bound: a decimal if it fits, else an `i128` fraction, else the nearest f64.
|
||||
pub(crate) fn settle(q: &BigQ) -> Num {
|
||||
Num::fit(q).unwrap_or_else(|| Num::inexact(q.to_f64()))
|
||||
}
|
||||
|
||||
/// `q` in an exact arm, at its minimal scale, if it fits one.
|
||||
pub(crate) fn fit(q: &BigQ) -> Option<Num> {
|
||||
decimal_of(q).or_else(|| {
|
||||
let num = q.num.to_i128()?;
|
||||
let den = q.den.to_i128()?;
|
||||
Some(Num::fraction(num, den))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `num / den` normalized without allocating; `None` hands the case to the bigint path.
|
||||
pub(crate) fn small_ratio(num: i128, den: i128) -> Option<Num> {
|
||||
let representable = num != i128::MIN && den != i128::MIN && den != 0;
|
||||
representable.then(|| {
|
||||
let g = num.gcd(&den);
|
||||
let (n, d) = match den < 0 {
|
||||
true => (-num / g, -den / g),
|
||||
false => (num / g, den / g),
|
||||
};
|
||||
let p = d.trailing_zeros();
|
||||
let mut rest = d >> p;
|
||||
let mut q5 = 0;
|
||||
while rest % 5 == 0 {
|
||||
rest /= 5;
|
||||
q5 += 1;
|
||||
}
|
||||
let k = p.max(q5);
|
||||
let as_decimal = (rest == 1 && k <= MAX_SCALE)
|
||||
.then(|| 2i128.pow(k - p) * 5i128.pow(k - q5))
|
||||
.and_then(|pad| n.checked_mul(pad))
|
||||
.and_then(|m| decimal(m, k));
|
||||
as_decimal.unwrap_or_else(|| Num::fraction(n, d))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn pow10(k: u32) -> i128 {
|
||||
10i128.pow(k)
|
||||
}
|
||||
|
||||
pub(crate) fn decimal_to_f64(d: &Decimal) -> f64 {
|
||||
d.to_string().parse().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
/// A decimal from a mantissa and scale, if the mantissa fits 96 bits.
|
||||
pub(crate) fn decimal(mantissa: i128, scale: u32) -> Option<Num> {
|
||||
let fits = mantissa.unsigned_abs() < MANTISSA_LIMIT as u128 && scale <= MAX_SCALE;
|
||||
fits.then(|| Num::exact(Decimal::from_i128_with_scale(mantissa, scale)))
|
||||
}
|
||||
|
||||
/// The direct test of clause 5: a reduced `n / (2^p 5^q)` is a decimal iff it fits.
|
||||
fn decimal_of(q: &BigQ) -> Option<Num> {
|
||||
let p = q.den.trailing_zeros().unwrap_or(0);
|
||||
let mut rest = &q.den >> p as usize;
|
||||
let five = BigInt::from(5);
|
||||
let mut q5 = 0u64;
|
||||
while rest.is_multiple_of(&five) && q5 <= MAX_SCALE as u64 {
|
||||
rest /= &five;
|
||||
q5 += 1;
|
||||
}
|
||||
let k = p.max(q5);
|
||||
if !rest.is_one() || k > MAX_SCALE as u64 {
|
||||
return None;
|
||||
}
|
||||
let pad = (BigInt::one() << (k - p) as usize) * five.pow((k - q5) as u32);
|
||||
let mantissa = (&q.num * pad).to_i128()?;
|
||||
decimal(mantissa, k as u32)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_num_is_24_bytes() {
|
||||
assert_eq!(std::mem::size_of::<Num>(), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fraction_that_is_a_fitting_decimal_is_stored_as_the_decimal() {
|
||||
let half = Num::ratio(1, 2).unwrap();
|
||||
assert_eq!(half.kind(), NumRef::Exact(&Decimal::new(5, 1)));
|
||||
let third = Num::ratio(-10, 3).unwrap();
|
||||
assert_eq!(third.kind(), NumRef::Fraction(&Ratio::new_raw(-10, 3)));
|
||||
let tiny = Num::ratio(1, 1 << 40).unwrap();
|
||||
assert!(matches!(tiny.kind(), NumRef::Fraction(_)), "{tiny:?}");
|
||||
let mut minus_zero = Decimal::new(0, 2);
|
||||
minus_zero.set_sign_negative(true);
|
||||
assert!(minus_zero.is_sign_negative());
|
||||
let zero = Num::exact(minus_zero);
|
||||
assert!(!zero.to_f64().is_sign_negative());
|
||||
assert!(Num::ratio(1, 0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Exact rationals in bigints: the widened intermediate of question 13 (b), and
|
||||
//! the one correctly rounded conversion of a rational to f64 (clauses 4 and 5).
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use num_bigint::{BigInt, BigUint, Sign};
|
||||
use num_integer::Integer;
|
||||
use num_traits::{One, Signed, ToPrimitive, Zero};
|
||||
|
||||
/// A reduced fraction with a positive denominator.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct BigQ {
|
||||
pub(crate) num: BigInt,
|
||||
pub(crate) den: BigInt,
|
||||
}
|
||||
|
||||
impl BigQ {
|
||||
pub(crate) fn new(num: BigInt, den: BigInt) -> BigQ {
|
||||
let (num, den) = match den.is_negative() {
|
||||
true => (-num, -den),
|
||||
false => (num, den),
|
||||
};
|
||||
let g = num.gcd(&den);
|
||||
match g.is_zero() || g.is_one() {
|
||||
true => BigQ { num, den },
|
||||
false => BigQ {
|
||||
num: num / &g,
|
||||
den: den / &g,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_i128(num: i128, den: i128) -> BigQ {
|
||||
BigQ::new(BigInt::from(num), BigInt::from(den))
|
||||
}
|
||||
|
||||
pub(crate) fn add(&self, o: &BigQ) -> BigQ {
|
||||
BigQ::new(&self.num * &o.den + &o.num * &self.den, &self.den * &o.den)
|
||||
}
|
||||
|
||||
pub(crate) fn neg(&self) -> BigQ {
|
||||
BigQ {
|
||||
num: -&self.num,
|
||||
den: self.den.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mul(&self, o: &BigQ) -> BigQ {
|
||||
BigQ::new(&self.num * &o.num, &self.den * &o.den)
|
||||
}
|
||||
|
||||
/// `None` when `o` is zero.
|
||||
pub(crate) fn div(&self, o: &BigQ) -> Option<BigQ> {
|
||||
(!o.num.is_zero()).then(|| BigQ::new(&self.num * &o.den, &self.den * &o.num))
|
||||
}
|
||||
|
||||
pub(crate) fn cmp_value(&self, o: &BigQ) -> Ordering {
|
||||
(&self.num * &o.den).cmp(&(&o.num * &self.den))
|
||||
}
|
||||
|
||||
pub(crate) fn bits(&self) -> u64 {
|
||||
self.num.bits().max(self.den.bits())
|
||||
}
|
||||
|
||||
pub(crate) fn to_f64(&self) -> f64 {
|
||||
ratio_to_f64(&self.num, &self.den)
|
||||
}
|
||||
|
||||
/// The exact value of a finite f64.
|
||||
pub(crate) fn from_f64(x: f64) -> BigQ {
|
||||
let bits = x.to_bits();
|
||||
let biased = ((bits >> 52) & 0x7ff) as i64;
|
||||
let fraction = bits & ((1u64 << 52) - 1);
|
||||
let (mant, exp) = match biased {
|
||||
0 => (fraction, -1074),
|
||||
e => (fraction | (1 << 52), e - 1075),
|
||||
};
|
||||
let signed = match x.is_sign_negative() {
|
||||
true => -BigInt::from(mant),
|
||||
false => BigInt::from(mant),
|
||||
};
|
||||
match exp >= 0 {
|
||||
true => BigQ::new(signed << exp as usize, BigInt::one()),
|
||||
false => BigQ::new(signed, BigInt::one() << (-exp) as usize),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `num / den` rounded to the nearest f64, ties to even, subnormals and overflow included.
|
||||
pub(crate) fn ratio_to_f64(num: &BigInt, den: &BigInt) -> f64 {
|
||||
let negative = (num.sign() == Sign::Minus) != (den.sign() == Sign::Minus);
|
||||
let a = num.magnitude();
|
||||
let b = den.magnitude();
|
||||
let magnitude = match a.is_zero() {
|
||||
true => 0.0,
|
||||
false => magnitude_to_f64(a, b),
|
||||
};
|
||||
match negative {
|
||||
true => -magnitude,
|
||||
false => magnitude,
|
||||
}
|
||||
}
|
||||
|
||||
fn magnitude_to_f64(a: &BigUint, b: &BigUint) -> f64 {
|
||||
let shift = 54 - (a.bits() as i64 - b.bits() as i64);
|
||||
let (n, d) = match shift >= 0 {
|
||||
true => (a << shift as usize, b.clone()),
|
||||
false => (a.clone(), b << (-shift) as usize),
|
||||
};
|
||||
let (q, r) = n.div_rem(&d);
|
||||
let qbits = q.bits() as i64;
|
||||
let drop = (qbits - 53).max(shift - 1074);
|
||||
if drop > qbits + 1 {
|
||||
return 0.0;
|
||||
}
|
||||
let kept = (&q >> drop as usize).to_u64().unwrap_or(0);
|
||||
let round = q.bit(drop as u64 - 1);
|
||||
let sticky = !r.is_zero() || q.trailing_zeros().is_some_and(|t| (t as i64) < drop - 1);
|
||||
let up = round && (sticky || kept & 1 == 1);
|
||||
ldexp(kept + up as u64, drop - shift)
|
||||
}
|
||||
|
||||
/// `m * 2^e` for `m <= 2^53`, exact unless it overflows to infinity.
|
||||
fn ldexp(m: u64, e: i64) -> f64 {
|
||||
let top = e + 63 - m.leading_zeros() as i64;
|
||||
match (m == 0, top > 1023) {
|
||||
(true, _) => 0.0,
|
||||
(false, true) => f64::INFINITY,
|
||||
(false, false) => {
|
||||
let half = e / 2;
|
||||
m as f64 * pow2(half) * pow2(e - half)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pow2(e: i64) -> f64 {
|
||||
match e {
|
||||
e if e >= -1022 => f64::from_bits(((e + 1023) as u64) << 52),
|
||||
e => f64::from_bits(1u64 << (e + 1074)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rounding_to_f64_reaches_subnormals_ties_and_overflow() {
|
||||
let one = BigInt::one();
|
||||
let min_sub = f64::from_bits(1);
|
||||
assert_eq!(ratio_to_f64(&one, &(BigInt::one() << 1074)), min_sub);
|
||||
assert_eq!(ratio_to_f64(&one, &(BigInt::one() << 1075)), 0.0);
|
||||
let three = BigInt::from(3);
|
||||
assert_eq!(ratio_to_f64(&three, &(BigInt::one() << 1076)), min_sub);
|
||||
assert_eq!(ratio_to_f64(&one, &BigInt::from(3)), 1.0 / 3.0);
|
||||
let tie = (BigInt::one() << 53) + 1;
|
||||
assert_eq!(ratio_to_f64(&tie, &one), 9007199254740992.0);
|
||||
let big = BigInt::one() << 1024;
|
||||
assert_eq!(ratio_to_f64(&big, &one), f64::INFINITY);
|
||||
assert_eq!(ratio_to_f64(&-big, &one), f64::NEG_INFINITY);
|
||||
let max = BigQ::from_f64(f64::MAX);
|
||||
assert_eq!(max.to_f64(), f64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_f64_converts_to_its_exact_value_and_back() {
|
||||
for x in [0.1, -2.5e-310, 1e300, f64::MIN_POSITIVE, 123456.789, -7.0] {
|
||||
assert_eq!(BigQ::from_f64(x).to_f64().to_bits(), x.to_bits(), "{x}");
|
||||
}
|
||||
assert!(BigQ::from_f64(-0.0).num.is_zero());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Rounding any value to `places` for display, exactly (clause 12, "Format").
|
||||
//! An inexact value is rounded over its shortest round-trip spelling (improvise-felo.9).
|
||||
|
||||
use num_bigint::BigInt;
|
||||
use num_integer::Integer;
|
||||
use num_traits::Signed;
|
||||
|
||||
use crate::lang::{Lexeme, shortest_round_trip};
|
||||
use crate::num::Num;
|
||||
use crate::ratio::BigQ;
|
||||
|
||||
/// The rounding mode is the caller's policy, not the crate's.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Rounding {
|
||||
HalfAwayFromZero,
|
||||
HalfEven,
|
||||
}
|
||||
|
||||
impl Num {
|
||||
/// Plain decimal text with exactly `places` fractional digits; `None` for a non-finite value.
|
||||
pub fn round_to_places(&self, places: u8, mode: Rounding) -> Option<String> {
|
||||
let q = match self.exact_q() {
|
||||
Some(q) => q,
|
||||
None => Lexeme::read(&shortest_round_trip(self.to_f64())?)?.exact_value(),
|
||||
};
|
||||
Some(render(round(&q, places, mode), places))
|
||||
}
|
||||
}
|
||||
|
||||
/// `q * 10^places`, rounded to an integer by `mode`.
|
||||
fn round(q: &BigQ, places: u8, mode: Rounding) -> BigInt {
|
||||
let scaled = q.num.abs() * BigInt::from(10).pow(places as u32);
|
||||
let (whole, rest) = scaled.div_rem(&q.den);
|
||||
let twice: BigInt = rest * 2;
|
||||
let up = match twice.cmp(&q.den) {
|
||||
std::cmp::Ordering::Greater => true,
|
||||
std::cmp::Ordering::Equal => mode == Rounding::HalfAwayFromZero || whole.is_odd(),
|
||||
std::cmp::Ordering::Less => false,
|
||||
};
|
||||
let magnitude = whole + BigInt::from(up as u8);
|
||||
match q.num.is_negative() {
|
||||
true => -magnitude,
|
||||
false => magnitude,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(units: BigInt, places: u8) -> String {
|
||||
let digits = units.abs().to_string();
|
||||
let width = places as usize + 1;
|
||||
let padded = format!("{digits:0>width$}");
|
||||
let (int, frac) = padded.split_at(padded.len() - places as usize);
|
||||
let sign = if units.is_negative() { "-" } else { "" };
|
||||
match places {
|
||||
0 => format!("{sign}{int}"),
|
||||
_ => format!("{sign}{int}.{frac}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lang::{Typing, parse_number};
|
||||
|
||||
fn typed(s: &str) -> Num {
|
||||
parse_number(s, Typing::Inferred).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_typed_value_rounds_as_it_was_typed() {
|
||||
let at2 = |n: &Num| n.round_to_places(2, Rounding::HalfAwayFromZero).unwrap();
|
||||
for (text, want) in [
|
||||
("1.005", "1.01"),
|
||||
("1.015", "1.02"),
|
||||
("0.285", "0.29"),
|
||||
("-2.675", "-2.68"),
|
||||
] {
|
||||
assert_eq!(at2(&typed(text)), want, "{text}");
|
||||
assert_eq!(
|
||||
at2(&Num::inexact(text.parse().unwrap())),
|
||||
want,
|
||||
"inexact {text}"
|
||||
);
|
||||
}
|
||||
assert_eq!(at2(&Num::ratio(10, 3).unwrap()), "3.33");
|
||||
assert_eq!(at2(&Num::ratio(2, 3).unwrap()), "0.67");
|
||||
assert_eq!(at2(&typed("-0.001")), "0.00");
|
||||
let even = typed("0.125")
|
||||
.round_to_places(2, Rounding::HalfEven)
|
||||
.unwrap();
|
||||
assert_eq!(even, "0.12");
|
||||
assert_eq!(
|
||||
typed("12.5")
|
||||
.round_to_places(0, Rounding::HalfEven)
|
||||
.unwrap(),
|
||||
"12"
|
||||
);
|
||||
assert_eq!(
|
||||
Num::inexact(f64::NAN).round_to_places(2, Rounding::HalfEven),
|
||||
None
|
||||
);
|
||||
let wide = Num::ratio(1, 3)
|
||||
.unwrap()
|
||||
.round_to_places(40, Rounding::HalfEven)
|
||||
.unwrap();
|
||||
assert_eq!(wide, format!("0.{}", "3".repeat(40)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
//! Order-free, retractable sums (clause 8): ADR 009's f64 `ExactSum`, and `NumSum`,
|
||||
//! which adds the exact partitions. The design: `context/code/numtower.md`, "Sums".
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use num_bigint::{BigInt, BigUint};
|
||||
use num_integer::Integer;
|
||||
use num_traits::{One, Zero};
|
||||
|
||||
use crate::fixed::{I256, I320};
|
||||
use crate::num::{MAX_SCALE, Num, NumRef, pow10};
|
||||
use crate::ratio::{BigQ, ratio_to_f64};
|
||||
|
||||
/// The most non-decimal denominator groups a sum reads exactly (question 16: K = 8).
|
||||
pub const K: usize = 8;
|
||||
|
||||
const LIMB_BITS: u32 = 32;
|
||||
/// A finite f64 is `mant * 2^(shift - 1074)`, `shift <= 2045`; weights add 60 bits.
|
||||
const LIMBS: usize = 70;
|
||||
const CHUNK: u64 = 1 << 30;
|
||||
|
||||
/// An exact sum of weighted f64s, rounded once when read (ADR 009).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExactSum {
|
||||
limbs: [i64; LIMBS],
|
||||
pending: u64,
|
||||
nan: i128,
|
||||
pos_inf: i128,
|
||||
neg_inf: i128,
|
||||
}
|
||||
|
||||
/// Equal sums, not equal representations: carries propagated, `pending` ignored.
|
||||
impl PartialEq for ExactSum {
|
||||
fn eq(&self, o: &ExactSum) -> bool {
|
||||
let (mut mine, mut theirs) = (self.limbs, o.limbs);
|
||||
propagate(&mut mine);
|
||||
propagate(&mut theirs);
|
||||
let counts = |s: &ExactSum| (s.nan, s.pos_inf, s.neg_inf);
|
||||
mine == theirs && counts(self) == counts(o)
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for ExactSum {}
|
||||
|
||||
impl Default for ExactSum {
|
||||
fn default() -> Self {
|
||||
ExactSum {
|
||||
limbs: [0; LIMBS],
|
||||
pending: 0,
|
||||
nan: 0,
|
||||
pos_inf: 0,
|
||||
neg_inf: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExactSum {
|
||||
pub fn new() -> ExactSum {
|
||||
ExactSum::default()
|
||||
}
|
||||
|
||||
/// Adds `x` with a Z-set `weight`; the weight is split into base-2^30 digits.
|
||||
pub fn add(&mut self, x: f64, weight: i64) {
|
||||
match (x.is_nan(), x.is_infinite(), x > 0.0) {
|
||||
(true, _, _) => self.nan += weight as i128,
|
||||
(false, true, true) => self.pos_inf += weight as i128,
|
||||
(false, true, false) => self.neg_inf += weight as i128,
|
||||
(false, false, _) => {
|
||||
let magnitude = weight.unsigned_abs();
|
||||
for j in 0..3 {
|
||||
let digit = (magnitude >> (30 * j)) & (CHUNK - 1);
|
||||
if digit != 0 {
|
||||
let signed = if weight < 0 {
|
||||
-(digit as i64)
|
||||
} else {
|
||||
digit as i64
|
||||
};
|
||||
self.add_digit(x, signed, 30 * j);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_digit(&mut self, x: f64, weight: i64, extra: u32) {
|
||||
if self.pending + weight.unsigned_abs() > CHUNK {
|
||||
propagate(&mut self.limbs);
|
||||
self.pending = 0;
|
||||
}
|
||||
let bits = x.to_bits();
|
||||
let biased = ((bits >> 52) & 0x7ff) as u32;
|
||||
let fraction = bits & ((1 << 52) - 1);
|
||||
let (mant, shift) = match biased {
|
||||
0 => (fraction, 0),
|
||||
e => (fraction | (1 << 52), e - 1),
|
||||
};
|
||||
let sign = if x.is_sign_negative() {
|
||||
-weight
|
||||
} else {
|
||||
weight
|
||||
};
|
||||
let at = shift + extra;
|
||||
let wide = u128::from(mant) << (at % LIMB_BITS);
|
||||
let k = (at / LIMB_BITS) as usize;
|
||||
for i in 0..3 {
|
||||
let part = ((wide >> (LIMB_BITS * i as u32)) & 0xffff_ffff) as i64;
|
||||
self.limbs[k + i] += sign * part;
|
||||
}
|
||||
self.pending += weight.unsigned_abs();
|
||||
}
|
||||
|
||||
/// Adds another accumulator's contents.
|
||||
pub fn absorb(&mut self, o: &ExactSum) {
|
||||
let mut theirs = o.limbs;
|
||||
propagate(&mut theirs);
|
||||
propagate(&mut self.limbs);
|
||||
for (mine, t) in self.limbs.iter_mut().zip(theirs) {
|
||||
*mine += t;
|
||||
}
|
||||
propagate(&mut self.limbs);
|
||||
self.pending = 0;
|
||||
self.nan += o.nan;
|
||||
self.pos_inf += o.pos_inf;
|
||||
self.neg_inf += o.neg_inf;
|
||||
}
|
||||
|
||||
/// The exact sum rounded to the nearest f64, ties to even.
|
||||
pub fn value(&self) -> f64 {
|
||||
match (self.nan != 0, self.pos_inf != 0, self.neg_inf != 0) {
|
||||
(true, _, _) | (false, true, true) => f64::NAN,
|
||||
(false, true, false) => f64::INFINITY,
|
||||
(false, false, true) => f64::NEG_INFINITY,
|
||||
(false, false, false) => ratio_to_f64(&self.total(), &(BigInt::one() << 1074)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The finite part as an integer in units of 2^-1074.
|
||||
fn total(&self) -> BigInt {
|
||||
let mut limbs = self.limbs;
|
||||
propagate(&mut limbs);
|
||||
let digits: Vec<u32> = limbs[..LIMBS - 1].iter().map(|&l| l as u32).collect();
|
||||
let low = BigInt::from(BigUint::from_slice(&digits));
|
||||
(BigInt::from(limbs[LIMBS - 1]) << (LIMB_BITS as usize * (LIMBS - 1))) + low
|
||||
}
|
||||
}
|
||||
|
||||
/// Carry each limb into the next, leaving every limb but the top in [0, 2^32).
|
||||
fn propagate(limbs: &mut [i64; LIMBS]) {
|
||||
for k in 0..LIMBS - 1 {
|
||||
let carry = limbs[k] >> LIMB_BITS;
|
||||
limbs[k] -= carry << LIMB_BITS;
|
||||
limbs[k + 1] += carry;
|
||||
}
|
||||
}
|
||||
|
||||
/// A `SUM` over any `Num`s: exact when every term is exact and at most K groups remain.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct NumSum {
|
||||
decimals: I320,
|
||||
groups: BTreeMap<i128, I256>,
|
||||
group_floats: ExactSum,
|
||||
inexact: ExactSum,
|
||||
/// Net weight per f64 bit pattern: a read is inexact while any term is present.
|
||||
inexact_terms: BTreeMap<u64, i128>,
|
||||
}
|
||||
|
||||
impl NumSum {
|
||||
pub fn new() -> NumSum {
|
||||
NumSum::default()
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, x: &Num) {
|
||||
self.add(x, 1);
|
||||
}
|
||||
|
||||
pub fn retract(&mut self, x: &Num) {
|
||||
self.add(x, -1);
|
||||
}
|
||||
|
||||
/// Adds `x` with a Z-set `weight`. Exact and associative: the order cannot matter.
|
||||
pub fn add(&mut self, x: &Num, weight: i64) {
|
||||
match x.kind() {
|
||||
NumRef::Inexact(f) => {
|
||||
self.inexact.add(f, weight);
|
||||
let net = self.inexact_terms.entry(f.to_bits()).or_insert(0);
|
||||
*net += weight as i128;
|
||||
if *net == 0 {
|
||||
self.inexact_terms.remove(&f.to_bits());
|
||||
}
|
||||
}
|
||||
NumRef::Exact(d) => {
|
||||
let unit = pow10(MAX_SCALE - d.scale()) as u128;
|
||||
self.add_scaled(d.mantissa(), unit, weight);
|
||||
}
|
||||
NumRef::Fraction(r) => match at_scale_28(*r.denom()) {
|
||||
Some(unit) => self.add_scaled(*r.numer(), unit, weight),
|
||||
None => self.add_to_group(*r.numer(), *r.denom(), weight),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn add_scaled(&mut self, n: i128, unit: u128, weight: i64) {
|
||||
let magnitude = weight.unsigned_abs() as u128;
|
||||
let scaled = match unit.checked_mul(magnitude) {
|
||||
Some(both) => I320::from_i128(n).checked_mul_u128(both),
|
||||
None => I320::from_i128(n)
|
||||
.checked_mul_u128(unit)
|
||||
.and_then(|t| t.checked_mul_u128(magnitude)),
|
||||
};
|
||||
let term = scaled
|
||||
.and_then(|t| if weight < 0 { t.checked_neg() } else { Some(t) })
|
||||
.expect("a weighted term is below 2^284");
|
||||
self.decimals = self
|
||||
.decimals
|
||||
.checked_add(&term)
|
||||
.expect("320 bits holds 2^36 terms");
|
||||
}
|
||||
|
||||
fn add_to_group(&mut self, n: i128, d: i128, weight: i64) {
|
||||
let old = self.groups.get(&d).copied().unwrap_or(I256::ZERO);
|
||||
let new = old
|
||||
.checked_add(&I256::mul_i128(n, weight as i128))
|
||||
.expect("256 bits holds 2^64 terms");
|
||||
let value = |num: I256| ratio_to_f64(&num.to_bigint(), &BigInt::from(d));
|
||||
if !old.is_zero() {
|
||||
self.group_floats.add(value(old), -1);
|
||||
}
|
||||
match new.is_zero() {
|
||||
true => {
|
||||
self.groups.remove(&d);
|
||||
}
|
||||
false => {
|
||||
self.group_floats.add(value(new), 1);
|
||||
self.groups.insert(d, new);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The current non-decimal denominator groups.
|
||||
pub fn groups(&self) -> usize {
|
||||
self.groups.len()
|
||||
}
|
||||
|
||||
/// One read: exact over at most K groups, else the f64 accumulators (clause 8).
|
||||
pub fn read(&self) -> Num {
|
||||
match (!self.inexact_terms.is_empty(), self.groups.len() > K) {
|
||||
(false, false) => Num::settle(&self.exact_total()),
|
||||
(_, true) => {
|
||||
let mut acc = self.inexact.clone();
|
||||
acc.absorb(&self.group_floats);
|
||||
acc.add(self.decimal_total().to_f64(), 1);
|
||||
Num::inexact(acc.value())
|
||||
}
|
||||
(true, false) => {
|
||||
let mut acc = self.inexact.clone();
|
||||
acc.add(self.exact_total().to_f64(), 1);
|
||||
Num::inexact(acc.value())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decimal_total(&self) -> BigQ {
|
||||
BigQ::new(self.decimals.to_bigint(), BigInt::from(pow10(MAX_SCALE)))
|
||||
}
|
||||
|
||||
fn exact_total(&self) -> BigQ {
|
||||
let decimal = (!self.decimals.is_zero())
|
||||
.then(|| (self.decimals.to_bigint(), BigInt::from(pow10(MAX_SCALE))));
|
||||
let terms: Vec<(BigInt, BigInt)> = decimal
|
||||
.into_iter()
|
||||
.chain(
|
||||
self.groups
|
||||
.iter()
|
||||
.map(|(d, n)| (n.to_bigint(), BigInt::from(*d))),
|
||||
)
|
||||
.collect();
|
||||
let (num, den) = combine_over_lcm(&terms);
|
||||
BigQ::new(num, den)
|
||||
}
|
||||
}
|
||||
|
||||
/// `Σ n/d` over the lcm of the denominators, not yet reduced: the read reduces once.
|
||||
pub(crate) fn combine_over_lcm(terms: &[(BigInt, BigInt)]) -> (BigInt, BigInt) {
|
||||
let lcm = terms.iter().fold(BigInt::one(), |l, (_, d)| l.lcm(d));
|
||||
let num = terms
|
||||
.iter()
|
||||
.fold(BigInt::zero(), |s, (n, d)| s + n * (&lcm / d));
|
||||
(num, lcm)
|
||||
}
|
||||
|
||||
/// `10^28 / d` when `d = 2^p 5^q` with `max(p, q) <= 28`: the term joins the decimal partition.
|
||||
fn at_scale_28(d: i128) -> Option<u128> {
|
||||
let p = d.trailing_zeros();
|
||||
let mut rest = d >> p;
|
||||
let mut q = 0;
|
||||
while rest % 5 == 0 {
|
||||
rest /= 5;
|
||||
q += 1;
|
||||
}
|
||||
(rest == 1 && p.max(q) <= MAX_SCALE).then(|| (pow10(MAX_SCALE) / d) as u128)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use num_rational::BigRational;
|
||||
use proptest::prelude::*;
|
||||
use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner};
|
||||
|
||||
type Combine = fn(&[(BigInt, BigInt)]) -> (BigInt, BigInt);
|
||||
|
||||
/// Up to K groups of wide numerators whose denominators share factors.
|
||||
fn groups() -> impl Strategy<Value = Vec<(BigInt, BigInt)>> {
|
||||
let shared = prop::sample::select(vec![1i128, 3, 7 * 11, 1 << 20, 3i128.pow(40)]);
|
||||
let group =
|
||||
(any::<i128>(), any::<i64>(), shared, 1i128..(1 << 60)).prop_map(|(n, w, s, d)| {
|
||||
(
|
||||
BigInt::from(n) * BigInt::from(w),
|
||||
BigInt::from(s) * BigInt::from(d),
|
||||
)
|
||||
});
|
||||
prop::collection::vec(group, 1..=K)
|
||||
}
|
||||
|
||||
fn check_grouped_read(combine: Combine, deterministic: bool) -> Result<(), String> {
|
||||
let config = Config {
|
||||
cases: 256,
|
||||
failure_persistence: None,
|
||||
..Config::default()
|
||||
};
|
||||
let mut runner = match deterministic {
|
||||
true => {
|
||||
TestRunner::new_with_rng(config, TestRng::deterministic_rng(RngAlgorithm::ChaCha))
|
||||
}
|
||||
false => TestRunner::new(config),
|
||||
};
|
||||
let result = runner.run(&groups(), |terms| {
|
||||
let (num, den) = combine(&terms);
|
||||
let lcm = terms.iter().fold(BigInt::one(), |l, (_, d)| l.lcm(d));
|
||||
prop_assert_eq!(&den, &lcm, "the read did not combine over the lcm");
|
||||
let oracle: BigRational = terms
|
||||
.iter()
|
||||
.map(|(n, d)| BigRational::new(n.clone(), d.clone()))
|
||||
.sum();
|
||||
let read = BigQ::new(num, den);
|
||||
let got = BigRational::new(read.num.clone(), read.den.clone());
|
||||
prop_assert_eq!(got, oracle, "the read is not the sum");
|
||||
prop_assert!(read.num.gcd(&read.den).is_one(), "the read is not reduced");
|
||||
Ok(())
|
||||
});
|
||||
result.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grouped_read_combines_over_the_lcm_and_reduces_once() {
|
||||
check_grouped_read(combine_over_lcm, false).unwrap();
|
||||
}
|
||||
|
||||
fn over_the_product(terms: &[(BigInt, BigInt)]) -> (BigInt, BigInt) {
|
||||
let den = terms.iter().fold(BigInt::one(), |p, (_, d)| p * d);
|
||||
let num = terms
|
||||
.iter()
|
||||
.fold(BigInt::zero(), |s, (n, d)| s + n * (&den / d));
|
||||
(num, den)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grouped_read_combines_over_the_lcm_and_reduces_once_catches_the_product() {
|
||||
let err = check_grouped_read(over_the_product, true).expect_err("the rival passed");
|
||||
assert!(
|
||||
err.contains("the read did not combine over the lcm"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fraction_with_a_decimal_denominator_joins_the_decimal_partition() {
|
||||
let mut s = NumSum::new();
|
||||
s.insert(&Num::ratio(10i128.pow(35), 1).unwrap());
|
||||
s.insert(&Num::ratio(3, 1 << 20).unwrap());
|
||||
assert_eq!(s.groups(), 0);
|
||||
s.insert(&Num::ratio(1, 1i128 << 40).unwrap());
|
||||
s.insert(&Num::ratio(1, 3).unwrap());
|
||||
assert_eq!(
|
||||
s.groups(),
|
||||
2,
|
||||
"2^40 has scale 40, past 28: a group of its own"
|
||||
);
|
||||
s.retract(&Num::ratio(1, 3).unwrap());
|
||||
assert_eq!(
|
||||
s.groups(),
|
||||
1,
|
||||
"a group whose numerator returns to zero is dropped"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! The `BigRational` oracle and the generators the property tests share.
|
||||
//! Each property is a checker taking the implementation, so a rival can be run through it.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use num_bigint::BigInt;
|
||||
use num_rational::BigRational;
|
||||
use num_traits::{One, Signed, ToPrimitive, Zero};
|
||||
use numtower::{Decimal, Num, NumRef};
|
||||
use proptest::prelude::*;
|
||||
use proptest::test_runner::{Config, RngAlgorithm, TestCaseError, TestRng, TestRunner};
|
||||
|
||||
pub fn q(n: &Num) -> Option<BigRational> {
|
||||
match n.kind() {
|
||||
NumRef::Exact(d) => Some(BigRational::new(
|
||||
d.mantissa().into(),
|
||||
BigInt::from(10).pow(d.scale()),
|
||||
)),
|
||||
NumRef::Fraction(r) => Some(BigRational::new((*r.numer()).into(), (*r.denom()).into())),
|
||||
NumRef::Inexact(x) => BigRational::from_float(x),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `v` fits a stored exact arm: a decimal at scale <= 28, or an `i128` fraction.
|
||||
pub fn fits(v: &BigRational) -> bool {
|
||||
let limit = BigInt::one() << 96;
|
||||
let decimal = (0..=28u32).any(|s| {
|
||||
let scaled = v * BigRational::from_integer(BigInt::from(10).pow(s));
|
||||
scaled.is_integer() && scaled.to_integer().abs() < limit
|
||||
});
|
||||
let (min, max) = (BigInt::from(i128::MIN), BigInt::from(i128::MAX));
|
||||
decimal || (&min <= v.numer() && v.numer() <= &max && v.denom() <= &max)
|
||||
}
|
||||
|
||||
/// Whether `f` is the round-to-nearest-even f64 of `v`: the review's interval form.
|
||||
pub fn is_nearest(f: f64, v: &BigRational) -> bool {
|
||||
let exact = |x: f64| BigRational::from_float(x).expect("finite");
|
||||
let two = BigRational::from_integer(BigInt::from(2));
|
||||
let half_ulp_past = |x: f64, up: bool| {
|
||||
let (next, inward) = match up {
|
||||
true => (x.next_up(), x.next_down()),
|
||||
false => (x.next_down(), x.next_up()),
|
||||
};
|
||||
match next.is_finite() {
|
||||
true => (exact(x) + exact(next)) / &two,
|
||||
false => exact(x) + (exact(x) - exact(inward)) / &two,
|
||||
}
|
||||
};
|
||||
match f.is_finite() {
|
||||
false => {
|
||||
let edge = half_ulp_past(f64::MAX.copysign(f), f > 0.0);
|
||||
(f > 0.0 && v >= &edge) || (f < 0.0 && v <= &edge)
|
||||
}
|
||||
true => {
|
||||
let lo = half_ulp_past(f, false);
|
||||
let hi = half_ulp_past(f, true);
|
||||
let even = f.to_bits() & 1 == 0;
|
||||
match even {
|
||||
true => &lo <= v && v <= &hi,
|
||||
false => &lo < v && v < &hi,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A decimal's text built digit by digit, without `rust_decimal`'s `Display`.
|
||||
pub fn decimal_text(mantissa: i128, scale: u32) -> String {
|
||||
let digits = format!(
|
||||
"{:0>width$}",
|
||||
mantissa.unsigned_abs(),
|
||||
width = scale as usize + 1
|
||||
);
|
||||
let (int, frac) = digits.split_at(digits.len() - scale as usize);
|
||||
let sign = if mantissa < 0 { "-" } else { "" };
|
||||
format!("{sign}{int}.{frac}0")
|
||||
}
|
||||
|
||||
/// The correctly rounded f64 of an exact value, independent of `numtower`'s own.
|
||||
pub fn independent_f64(n: &Num) -> f64 {
|
||||
match n.kind() {
|
||||
NumRef::Exact(d) => decimal_text(d.mantissa(), d.scale()).parse().unwrap(),
|
||||
NumRef::Fraction(_) => q(n).unwrap().to_f64().unwrap(),
|
||||
NumRef::Inexact(x) => x,
|
||||
}
|
||||
}
|
||||
|
||||
/// Same arm, same value and same scale: what a fingerprint would see.
|
||||
pub fn same(a: &Num, b: &Num) -> bool {
|
||||
match (a.kind(), b.kind()) {
|
||||
(NumRef::Exact(x), NumRef::Exact(y)) => {
|
||||
x.mantissa() == y.mantissa() && x.scale() == y.scale()
|
||||
}
|
||||
(NumRef::Fraction(x), NumRef::Fraction(y)) => x == y,
|
||||
(NumRef::Inexact(x), NumRef::Inexact(y)) => x.to_bits() == y.to_bits(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn arm(n: &Num) -> &'static str {
|
||||
match n.kind() {
|
||||
NumRef::Exact(_) => "exact",
|
||||
NumRef::Fraction(_) => "fraction",
|
||||
NumRef::Inexact(_) => "inexact",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mantissa96() -> impl Strategy<Value = i128> {
|
||||
let max = (1i128 << 96) - 1;
|
||||
prop_oneof![
|
||||
-max..=max,
|
||||
-1_000_000i128..1_000_000,
|
||||
-(10i128.pow(15))..10i128.pow(15),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn decimal() -> impl Strategy<Value = Num> {
|
||||
(mantissa96(), 0u32..=28).prop_map(|(m, s)| Num::exact(Decimal::from_i128_with_scale(m, s)))
|
||||
}
|
||||
|
||||
fn of_width(bits: u32) -> impl Strategy<Value = i128> {
|
||||
(any::<u128>(), any::<bool>()).prop_map(move |(r, neg)| {
|
||||
let v = ((r >> (128 - bits)) | (1u128 << (bits - 1))) as i128;
|
||||
if neg { -v } else { v }
|
||||
})
|
||||
}
|
||||
|
||||
pub fn fraction() -> impl Strategy<Value = Num> {
|
||||
let widths = (1u32..=126, 1u32..=126)
|
||||
.prop_flat_map(|(a, b)| (of_width(a), of_width(b)))
|
||||
.prop_map(|(n, d)| Num::ratio(n, d).unwrap());
|
||||
let edge = (1i128..i128::MAX).prop_map(|d| Num::ratio(i128::MIN, d | 1).unwrap());
|
||||
prop_oneof![8 => widths, 1 => edge]
|
||||
}
|
||||
|
||||
pub fn finite_f64() -> impl Strategy<Value = f64> {
|
||||
use proptest::num::f64::{NEGATIVE, NORMAL, POSITIVE, SUBNORMAL, ZERO};
|
||||
prop_oneof![
|
||||
(POSITIVE | NEGATIVE | NORMAL | SUBNORMAL | ZERO),
|
||||
-1e6f64..1e6,
|
||||
(-1000i64..1000).prop_map(|k| k as f64 / 8.0),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn exact() -> impl Strategy<Value = Num> {
|
||||
prop_oneof![decimal(), fraction()]
|
||||
}
|
||||
|
||||
pub fn any_num() -> impl Strategy<Value = Num> {
|
||||
prop_oneof![
|
||||
3 => exact(),
|
||||
1 => finite_f64().prop_map(Num::inexact),
|
||||
1 => prop_oneof![Just(f64::NAN), Just(f64::INFINITY), Just(f64::NEG_INFINITY)].prop_map(Num::inexact),
|
||||
]
|
||||
}
|
||||
|
||||
/// Runs a property; `deterministic` for a rival, so its failure is not luck.
|
||||
pub fn run<S: Strategy>(
|
||||
strategy: S,
|
||||
cases: u32,
|
||||
deterministic: bool,
|
||||
test: impl Fn(S::Value) -> Result<(), TestCaseError>,
|
||||
) -> Result<(), String> {
|
||||
let config = Config {
|
||||
cases,
|
||||
failure_persistence: None,
|
||||
..Config::default()
|
||||
};
|
||||
let mut runner = match deterministic {
|
||||
true => TestRunner::new_with_rng(config, TestRng::deterministic_rng(RngAlgorithm::ChaCha)),
|
||||
false => TestRunner::new(config),
|
||||
};
|
||||
runner.run(&strategy, test).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// A rival must fail on the property's own assertion, never on something else.
|
||||
pub fn assert_caught(result: Result<(), String>, marker: &str) {
|
||||
let err = result.expect_err("the rival passed: the property cannot see it");
|
||||
assert!(err.contains(marker), "failed for another reason: {err}");
|
||||
}
|
||||
|
||||
pub fn oracle_cmp(a: &Num, b: &Num) -> Option<Ordering> {
|
||||
Some(q(a)?.cmp(&q(b)?))
|
||||
}
|
||||
|
||||
pub fn integer(v: &BigRational) -> bool {
|
||||
v.is_integer() && !v.numer().is_zero()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,8 @@
|
||||
"improvise-wasm-client"
|
||||
"improvise-worker-server"
|
||||
"improvise-gp"
|
||||
# ADR 005 clause 14: nothing depends on it yet, and improvise-core will.
|
||||
"numtower"
|
||||
];
|
||||
pFlags = crates: lib.concatMapStringsSep " " (c: "-p ${c}") crates;
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ const SECTIONS = [
|
||||
["docs/design-notes.md", "design-notes", "Design notes"],
|
||||
["context/design-principles.md", "design-principles", "Design principles"],
|
||||
["context/repo-map.md", "repo-map", "Repository map"],
|
||||
["context/gotchas.md", "gotchas", "Gotchas (long form)"],
|
||||
["context/file-inventory.md", "file-inventory", "File inventory (long form)"],
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -48,6 +50,7 @@ const SECTIONS = [
|
||||
["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"],
|
||||
["context/adr/007-stable-identities-amendment-1.md", "adr/007-stable-identities-amendment-1", "ADR 007, amendment 1"],
|
||||
["context/adr/009-incremental-evaluation.md", "adr/009-incremental-evaluation", "ADR 009 — Incremental evaluation (proposed)"],
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -64,6 +67,7 @@ const SECTIONS = [
|
||||
["context/code/tui.md", "code/tui", "The TUI"],
|
||||
["context/code/cli.md", "code/cli", "The headless CLI"],
|
||||
["context/code/gp.md", "code/gp", "gp-tree and improvise-gp"],
|
||||
["context/code/numtower.md", "code/numtower", "numtower, the numeric tower"],
|
||||
["context/code/kb-tool.md", "code/kb-tool", "The kb tool"],
|
||||
["context/code/tooling.md", "code/tooling", "The repository's own checkers"],
|
||||
],
|
||||
@@ -82,6 +86,7 @@ const SECTIONS = [
|
||||
["context/test-evidence/tui.md", "test-evidence/tui", "The TUI"],
|
||||
["context/test-evidence/cli.md", "test-evidence/cli", "The headless CLI"],
|
||||
["context/test-evidence/gp.md", "test-evidence/gp", "gp-tree and improvise-gp"],
|
||||
["context/test-evidence/numtower.md", "test-evidence/numtower", "numtower"],
|
||||
["context/test-evidence/tooling.md", "test-evidence/tooling", "The repository's own gates"],
|
||||
["context/test-evidence/layout-cost.md", "test-evidence/layout-cost", "Layout and keypress cost"],
|
||||
["context/test-evidence/scale-bisect.md", "test-evidence/scale-bisect", "Scale bisect"],
|
||||
@@ -611,7 +616,7 @@ for (const section of SECTIONS) {
|
||||
for (const [src, slug, title] of section.docs) {
|
||||
const depth = slug.split("/").length - 1;
|
||||
const ctx = { depth, cited, unresolved, anchors: new Set() };
|
||||
if (slug === "repo-map") ctx.listItemId = (n) => (n >= 1 && n <= 200 ? `gotcha-${n}` : null);
|
||||
if (slug === "repo-map" || slug === "gotchas") ctx.listItemId = (n) => (n >= 1 && n <= 200 ? `gotcha-${n}` : null);
|
||||
const md = readFileSync(join(root, src), "utf8");
|
||||
const { html, toc } = renderMarkdown(md, ctx);
|
||||
|
||||
|
||||
+6
-9
@@ -189,12 +189,9 @@ def read_rows(path):
|
||||
|
||||
|
||||
def gotchas(text):
|
||||
"""(number, lines, chars) for each numbered item of repo-map's Gotchas section."""
|
||||
out, cur, inside = [], None, False
|
||||
"""(number, lines, chars) for each numbered item of context/gotchas.md."""
|
||||
out, cur, inside = [], None, True
|
||||
for line in text.split("\n"):
|
||||
if line.startswith("## "):
|
||||
inside = line.startswith("## Gotchas")
|
||||
continue
|
||||
if inside and line.startswith("---"):
|
||||
inside = False
|
||||
if not inside:
|
||||
@@ -214,13 +211,13 @@ def doc_sizes(repo, sha):
|
||||
"context").split("\n") if p.endswith(".md")]
|
||||
show = lambda p: git(repo, "show", f"{sha}:{p}")
|
||||
lines = {p: len(show(p).splitlines()) for p in sorted(files)}
|
||||
gs = gotchas(show("context/repo-map.md")) if "context/repo-map.md" in files else []
|
||||
gs = gotchas(show("context/gotchas.md")) if "context/gotchas.md" in files else []
|
||||
longest = max(gs, key=lambda g: g[2], default=None)
|
||||
evidence = {p: sum(1 for l in show(p).split("\n") if re.match(r"^#{3,4} ", l))
|
||||
for p in sorted(files) if p.startswith("context/test-evidence/")}
|
||||
return {
|
||||
"context_lines": lines,
|
||||
"repo_map": {"gotchas": len(gs), "longest": longest and
|
||||
"gotchas": {"count": len(gs), "longest": longest and
|
||||
{"number": longest[0], "lines": longest[1], "chars": longest[2]}},
|
||||
"test_evidence": {"entries": sum(evidence.values()), "per_file": evidence},
|
||||
}
|
||||
@@ -270,9 +267,9 @@ def text(r, top):
|
||||
out += ["", TITLES[t]] + table(r["tables"][t])
|
||||
out += ["", "Doc sizes (lines)"]
|
||||
out += [f" {n:>6} {path}" for path, n in r["docs"]["context_lines"].items()]
|
||||
rm, te = r["docs"]["repo_map"], r["docs"]["test_evidence"]
|
||||
rm, te = r["docs"]["gotchas"], r["docs"]["test_evidence"]
|
||||
lg = rm["longest"]
|
||||
out.append(f" repo-map gotchas: {rm['gotchas']}" + (
|
||||
out.append(f" gotchas: {rm['count']}" + (
|
||||
f"; longest is {lg['number']} ({lg['lines']} lines, {lg['chars']} chars)"
|
||||
if lg else ""))
|
||||
out.append(f" test-evidence entries: {te['entries']}")
|
||||
|
||||
+10
-11
@@ -76,10 +76,9 @@ put context/code/x.md 8 x3
|
||||
mkdir -p "$repo/context/test-evidence"
|
||||
printf '### t_one\ntext\n#### t_two\n## not an entry\n### t_three\n' \
|
||||
> "$repo/context/test-evidence/y.md"
|
||||
cat > "$repo/context/repo-map.md" <<'EOF'
|
||||
# Map
|
||||
|
||||
## Gotchas (read before editing)
|
||||
printf '# Map\n' > "$repo/context/repo-map.md"
|
||||
cat > "$repo/context/gotchas.md" <<'EOF'
|
||||
# Gotchas
|
||||
|
||||
1. **One.** short.
|
||||
2. **Two.** a much longer gotcha, with plenty of words in it to be the longest one
|
||||
@@ -136,25 +135,25 @@ if run "$work/stats" text; then
|
||||
row "docs/*.md is other docs" by-area "other docs" '["2","0","0.000"]'
|
||||
row "context/code" by-area "context/code" '["8","0","0.000"]'
|
||||
row "context/test-evidence" by-area "context/test-evidence" '["5","0","0.000"]'
|
||||
row "repo-map is its own area" by-area "repo-map" '["13","0","0.000"]'
|
||||
row "other context" by-area "other context" '["2","0","0.000"]'
|
||||
row "repo-map is its own area" by-area "repo-map" '["1","0","0.000"]'
|
||||
row "other context" by-area "other context" '["13","0","0.000"]'
|
||||
row "kb.pl" by-area "kb.pl" '["5","0","0.000"]'
|
||||
row "scripts/CI takes .github, and a rename is a delete and an add" \
|
||||
by-area "scripts/CI" '["15","7","0.467"]'
|
||||
row "web and harness, and its deletion ratio" by-area "web and harness" '["14","9","0.643"]'
|
||||
row "a lock file, .beads/ and site/ are generated" by-area "generated" '["14","0","0.000"]'
|
||||
row "the pre-window README counts nowhere" by-area "other" '["4","0","0.000"]'
|
||||
row "prose is the context areas, other docs and agent docs" by-kind prose '["43","6","0.140"]'
|
||||
row "prose is the context areas, other docs and agent docs" by-kind prose '["42","6","0.143"]'
|
||||
row "code is rust, scripts/CI and web" by-kind code '["82","45","0.549"]'
|
||||
row "prose to code is prose over code" by-kind ProseToCode '["0.524","0.133",""]'
|
||||
row "prose to code is prose over code" by-kind ProseToCode '["0.512","0.133",""]'
|
||||
is "weeks are columns, first-seen first" '.tables["area-by-week"][0][1:]' '["2026-W24","2026-W23"]'
|
||||
row "an area is split by ISO week" area-by-week "web and harness" '["3","11"]'
|
||||
is "every day's churn.csv total equals code-maat's abs-churn" \
|
||||
'[.tables["by-day"][1:][]|.[1]==.[2]]|all' 'true'
|
||||
row "a day's total is every file changed that day" by-day 2026-06-04 '["72","72"]'
|
||||
is "gotchas end with their section" '.docs.repo_map.gotchas' '3'
|
||||
row "a day's total is every file changed that day" by-day 2026-06-04 '["71","71"]'
|
||||
is "gotchas end with their section" '.docs.gotchas.count' '3'
|
||||
is "the longest gotcha is measured over its lines" \
|
||||
'.docs.repo_map.longest|[.number,.lines]' '[2,2]'
|
||||
'.docs.gotchas.longest|[.number,.lines]' '[2,2]'
|
||||
is "test-evidence entries are ### and #### headings" '.docs.test_evidence.entries' '3'
|
||||
is "coupling finds the 15 pairs of six files, and web/app.js with scripts/foo.sh" \
|
||||
'.coupling|length' '16'
|
||||
|
||||
@@ -94,8 +94,8 @@ impl PanelContent for CategoryContent {
|
||||
);
|
||||
}
|
||||
}
|
||||
CatTreeEntry::Item { item_name, .. } => {
|
||||
let label = format!(" · {item_name}");
|
||||
CatTreeEntry::Item { display, .. } => {
|
||||
let label = format!(" · {display}");
|
||||
buf.set_string(inner.x, y, &label, base_style);
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,7 @@ impl PanelContent for CategoryContent {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::protocol::CategoryRow;
|
||||
use crate::protocol::{CategoryItem, CategoryRow};
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::ui::panel::Panel;
|
||||
use ratatui::{layout::Rect, widgets::Widget};
|
||||
@@ -117,7 +117,7 @@ mod tests {
|
||||
name: name.to_string(),
|
||||
display: name.to_string(),
|
||||
axis: Axis::Row,
|
||||
items: vec!["x".to_string()],
|
||||
items: vec![CategoryItem::plain("x")],
|
||||
}],
|
||||
};
|
||||
let area = Rect::new(0, 0, 30, 4);
|
||||
@@ -138,7 +138,7 @@ mod tests {
|
||||
name: "Region".to_string(),
|
||||
display: "Sales Area".to_string(),
|
||||
axis: Axis::Row,
|
||||
items: vec!["x".to_string()],
|
||||
items: vec![CategoryItem::plain("x")],
|
||||
}],
|
||||
};
|
||||
let area = Rect::new(0, 0, 30, 4);
|
||||
@@ -154,6 +154,29 @@ mod tests {
|
||||
assert!(!row.contains("Region"), "{row}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_row_draws_its_display_not_its_key() {
|
||||
let cache = CategoryTreeCache {
|
||||
categories: vec![CategoryRow {
|
||||
name: "Region".to_string(),
|
||||
display: "Region".to_string(),
|
||||
axis: Axis::Row,
|
||||
items: vec![CategoryItem {
|
||||
name: "East".to_string(),
|
||||
display: "Eastern Seaboard".to_string(),
|
||||
}],
|
||||
}],
|
||||
};
|
||||
let expanded = std::collections::HashSet::from(["Region".to_string()]);
|
||||
let area = Rect::new(0, 0, 30, 5);
|
||||
let mut buf = Buffer::empty(area);
|
||||
Panel::new(CategoryContent::new(&cache, &expanded), &AppMode::Normal, 0)
|
||||
.render(area, &mut buf);
|
||||
let text: String = buf.content().iter().map(|c| c.symbol()).collect();
|
||||
assert!(text.contains("Eastern Seaboard"), "{text}");
|
||||
assert!(!text.contains("East "), "the key is not drawn: {text}");
|
||||
}
|
||||
|
||||
/// improvise-8l0c — `context/test-evidence/tui.md`.
|
||||
#[test]
|
||||
fn the_axis_tag_sits_beside_a_multibyte_category_name() {
|
||||
|
||||
+24
-3
@@ -154,7 +154,7 @@ impl<'a> GridWidget<'a> {
|
||||
false => run_heads.get(ci).copied().flatten(),
|
||||
};
|
||||
let label = match head {
|
||||
Some(g) => format!("{} {}", indicator(g.collapsed), g.name),
|
||||
Some(g) => format!("{} {}", indicator(g.collapsed), g.display),
|
||||
None => String::new(),
|
||||
};
|
||||
buf.set_string(
|
||||
@@ -238,7 +238,7 @@ impl<'a> GridWidget<'a> {
|
||||
// consumed (improvise-gd83).
|
||||
FrameEntry::GroupEnd { .. } => continue,
|
||||
FrameEntry::Group(group) => {
|
||||
let label = format!("{} {}", indicator(group.collapsed), group.name);
|
||||
let label = format!("{} {}", indicator(group.collapsed), group.display);
|
||||
buf.set_string(
|
||||
area.x,
|
||||
y,
|
||||
@@ -454,7 +454,7 @@ impl<'a> Widget for GridWidget<'a> {
|
||||
.cache
|
||||
.page_coords
|
||||
.iter()
|
||||
.map(|p| format!("{} = {}", p.cat, p.selected))
|
||||
.map(|p| format!("{} = {}", p.cat_display, p.selected_display))
|
||||
.collect();
|
||||
let page_str = format!(" [{}] ", page_info.join(" | "));
|
||||
buf.set_string(
|
||||
@@ -884,6 +884,27 @@ mod tests {
|
||||
assert_eq!(text, GROUPED_FRAME, "\n--- got ---\n{text}\n--- end ---");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_frame_and_page_bar_draw_displays_not_keys() {
|
||||
let mut m = grouped_model();
|
||||
let region = m.model.category_mut("Region").unwrap();
|
||||
region.set_item_display("East", "Eastward");
|
||||
region.set_group_display("Coastal", "Bay");
|
||||
let month = m.model.category_mut("Month").unwrap();
|
||||
month.set_item_display("Jan", "Janv");
|
||||
month.set_group_display("Q1", "Qa");
|
||||
let payer = m.model.category_mut("Payer").unwrap();
|
||||
payer.set_display("Who");
|
||||
payer.set_item_display("Alice", "Alicia");
|
||||
let text = buf_text(&render(&mut m, 60, 16));
|
||||
for shown in ["Who = Alicia", "Eastward", "▼ Bay", "Janv", "▼ Qa"] {
|
||||
assert!(text.contains(shown), "{shown:?} missing:\n{text}");
|
||||
}
|
||||
for key in ["Payer", "Alice]", "East ", "Coastal", "Jan ", "Q1"] {
|
||||
assert!(!text.contains(key), "the key {key:?} was drawn:\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-pinned for improvise-0o6x: the leading `▼ Co…` is the ONE line that
|
||||
/// moved, and its absence was the bug. context/test-evidence/tui.md.
|
||||
const GROUPED_FRAME: &str = concat!(
|
||||
|
||||
@@ -99,7 +99,7 @@ mod tests {
|
||||
views: vec![ViewSummary {
|
||||
name: view_name.to_string(),
|
||||
display: view_name.to_string(),
|
||||
axes: vec![("Region".to_string(), Axis::Row)],
|
||||
axes: vec![crate::protocol::ViewAxis::plain("Region", Axis::Row)],
|
||||
}],
|
||||
};
|
||||
let area = Rect::new(0, 0, 30, 4);
|
||||
|
||||
Reference in New Issue
Block a user