docs(adr): ADR 009 follows the document per model effect; answer the joint review (improvise-felo.4)

- clause 7: the operator shape and the formula-set fingerprint are re-read after each
  model effect inside the loop. Formula edits are an explicit input, and a document
  swap rebuilds everything. Stated in the same words as ADR 008 revision 3.
- clause 3: a delta key's identity is bit identity, matching hash_value. The dirty
  flag keeps a formula fingerprint, so formula edits still persist.
- clause 5: the stem rule for a formula with a top-level WHERE.
- clause 11 and question 9: rollback is a snapshot, with its second O(cells) clone
  stated. The inverse delta is not a rewind.
- migration: formulas become step 5, and Sum/Avg and column totals step 6.
- the drift example's values are five-place, not two-place. Clause 7 and question 7
  are worded as recommendations.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Claude (agent-felo4)
2026-09-24 20:45:59 -07:00
committed by Edward Langley
parent 9be37faf79
commit c456be2d54
+166 -55
View File
@@ -15,7 +15,7 @@ no open PR claimed it when this was written.
**Citations.** Every `file:line` below is at `gh/main` `57d385e1`. Line numbers are
dated, so re-derive them before citing this document elsewhere. "ADR 008 clause N" means
revision 2 of `context/adr/008-virtualized-grid.md` on PR #308.
revision 3 of `context/adr/008-virtualized-grid.md` on PR #308.
## The decision this ADR writes down
@@ -64,10 +64,15 @@ plugs in, and what it may not change.
(`types.rs:905-916`) calls `Expansion::members` (`:201-213`), which materializes every
combination of the spanned categories' items. ADR 008 clause 5 names this as the one
O(grid) path left in totals.
- **Two of the record tiers are observed by scanning the store.** `record_fingerprints`
(`app.rs:254-264`) hashes every cell before and after a cell-writing batch, and
`document_content` (`app.rs:165-178`) does the same for the dirty flag. Both are
O(cells) per batch, whatever the batch wrote.
- **Two observations scan the store.** `record_fingerprints` (`app.rs:254-264`) hashes
every cell before and after a cell-writing batch. `document_content`
(`app.rs:165-180`) hashes every cell and every formula for the dirty flag, and it is
re-read after **each** `CellData` effect (`app.rs:1039-1045`). So the first is O(cells)
per batch and the second O(cells) per effect, whatever was written.
- **Every mutating frame from a client already clones the document.**
`Host::mutating_frame` takes `App::snapshot` before applying anything (`host.rs:162`,
`app.rs:627-629`), for ADR 001 clause 4. That is O(cells) per frame on the network
path, whatever this ADR does.
### What an incremental evaluator must not change
@@ -158,8 +163,8 @@ name is `Derived`. It replaces the two places where eager evaluation folds the s
- the raw fold of the cells matching a partial key under an aggregation, which is
`aggregate_cell_values(matching_values(..))` today (`types.rs:844`, `:1191`).
It has one mutation, `apply(&mut self, delta: &CellDelta)`, plus a rebuild for the
changes that are not cell deltas (clause 7).
It takes two inputs, both applied after each model effect (clause 7): the cell delta,
and the formula delta. It also has a rebuild, for an operator shape that moved.
**The formula evaluator stays one.** `eval_expr` and `eval_expr_cached` keep their code
and read through the trait. The circuit decides *which* formula cells to re-evaluate
@@ -198,6 +203,25 @@ An overwrite with an equal value consolidates to the empty Z-set. The circuit st
per model effect, as ADR 008 clause 3 requires: the frame observation reads occupancy
inside the effect loop (`app.rs:1073-1084`).
**A delta key's identity is bit identity, the same identity the record tier uses
today.** Two `(CellKey, CellValue)` pairs are one element exactly when their keys are
equal and their values are equal *by bits*: `Number` by `f64::to_bits`, `Text` and
`Error` by their strings. That is `hash_value` (`app.rs:233-250`), which
`record_fingerprints` already hashes. So the cell delta reproduces today's tier
exactly:
- overwriting `0.0` with `-0.0` is a non-empty delta and bumps the record, as it does
today;
- overwriting a NaN with a NaN of the same bits is empty and bumps nothing, as today.
It is **not** `CellValue`'s derived `PartialEq`, under which `-0.0 == 0.0` and
`NaN != NaN`. `recompute_formulas` uses that one for its "did this cell move" test
(`types.rs:1010`), and it stays there. The ordering behind a multiset (clause 4) is
`f64::total_cmp`, whose equality *is* bit equality for every finite and non-finite
value. So the delta identity and the multiset ordering agree, and neither is
`PartialEq`. Step 3's tests pin both cases: signed zero, and a repeated same-bits
NaN.
**This lines up with ADR 001's record tier with no extra rule.** The records a batch
moved are the `record_of` (`crates/improvise-core/src/model/record.rs:14`) of the keys
in the batch's consolidated delta. So:
@@ -208,9 +232,19 @@ in the batch's consolidated delta. So:
`bump_changed_records` can then read the delta instead of diffing two
`record_fingerprints` scans (`app.rs:254-264`). The tier is still *observed*: the delta
comes from the mutation sites, not from an `EffectKind`. The same holds for the content
half of the dirty flag, where a non-empty delta means dirty. `layout_version` is
unchanged, because it observes `view_shape` and not cells.
comes from the mutation sites, not from an `EffectKind`. `layout_version` is unchanged,
because it observes `view_shape` and not cells.
**The dirty flag's content half does not move onto the cell delta alone.**
`document_content` (`app.rs:165-180`) hashes the formulas as well as the cells, and it
is re-read after every `CellData` effect (`app.rs:1039-1045`). `AddFormula`,
`RemoveFormula` and `ReplaceFormula` are `CellData` effects (`effect.rs:230`, `:247`,
`:268`) that emit no cell delta. The worker persists on this flag (gotcha 45). So "a
non-empty cell delta means dirty" would never save a formula edit in the browser. The
content half becomes: **a non-empty cell delta, or a moved formula-set fingerprint**.
The fingerprint is the one clause 7 already reads after each model effect, so nothing
new is observed. Its cost is O(formulas), where `document_content` is O(cells) per
`CellData` effect.
**`CellKey` gains `Ord`.** It is a sorted `Vec<(String, String)>`
(`cell.rs:10-11`), so deriving `Ord` is sound. `CellValue` holds an `f64` and is only
@@ -252,11 +286,13 @@ key's size gives a few megabytes at the 190k cells ADR 008 measured. Only the me
whose `measure_agg` is `Min`, `Max`, or that hold text, need the multisets. A
`Sum`-only document keeps one number per group.
**`total_cmp` is not `==`, and the differential test must say so.** `NaN != NaN`, so
today two NaNs in one group read as `mixed`, and `f64::min` skips a NaN. A multiset keyed
by `total_cmp` would call two NaNs one value and would order NaN at an end. The test
generates non-finite values (core.md §16), and the aggregator matches eager
behaviour explicitly rather than inheriting `total_cmp`'s.
**The multiset's identity is clause 3's bit identity, and eager's *reading* is not.** A
multiset keyed by `total_cmp` holds the values the store holds, by bits, and that is
correct for the state. But eager *reads* a group through `PartialEq` and `f64::min`:
`NaN != NaN`, so two NaNs in one group read as `mixed` today, `-0.0` and `0.0` read as
one value, and `f64::min` skips a NaN. So the aggregator's *read* reproduces eager's
rules explicitly and does not inherit `total_cmp`'s. The differential test generates
signed zeros and non-finite values (core.md §16) to pin it.
### 5. Formulas: the circuit finds what moved, and the evaluator recomputes it
@@ -265,6 +301,21 @@ with the target category and each `none_cats` entry removed. That is
`distinct(map(project))` over the cell delta. A stem enters when its first cell arrives
and leaves when its last cell goes.
**The stem rule: a formula cell exists at a stem only where its value would be cached
today.** `recompute_formulas` caches a formula cell only when the cached evaluator
returns a value (`types.rs:1010-1014`). For a formula with a top-level `WHERE`, a stem
whose coordinate fails the filter falls back to `aggregate_raw` (`types.rs:1047-1055`),
which is absent when no raw data lies at the target key. So:
- **a formula counts at a stem only where its top-level `WHERE` (if any) matches the
stem's coordinate, or where raw data exists at stem + target.**
This is a coordinate predicate plus one count lookup, never an evaluation. It is the
rule ADR 008 clause 3's occupancy uses for formula cells, and it keeps today's pruning.
The review of ADR 008 found the case that needs it: `Double = Revenue * 2 WHERE
Region = East` with data only at West is pruned today, and a rule that counted every
stem would have kept the column.
**A formula cell is re-evaluated when one of its inputs moved.** The inputs of formula
`T` at stem `s` are:
@@ -326,26 +377,51 @@ So the crate has no recursion operator, and circularity is decided *outside* it:
like with like. Changing the rule would be its own decision, for both backends at
once.
### 7. What is not a cell delta rebuilds, once
### 7. The circuit follows the document per model effect
Some changes alter the circuit's *shape*, not its input:
Some changes alter what the circuit computes, not only its input. They are observed
**inside** `apply_effects`, after **every** `EffectKind::Model(_)` effect: the same
position as the frame and dirty observations (`app.rs:1036-1084`). They are not the
post-loop `view_shape` diff that bumps `layout_version` (`app.rs:1102`, `:1116-1124`).
That diff comes too late, and it cannot see most of these changes. ADR 008 states this
clause in the same words.
- the formula set: add, remove or replace;
- `measure_agg`;
- category and item structure: add or remove a category, remove an item, and group
membership;
- a view's axes, hidden items and collapsed groups, which change `view_shape`;
- a document swap, `document_generation`.
- **Operator shape, per view.** The operator shape is `view_shape` (`app.rs:40-72`),
plus the group membership of every drawn item, collapsed or not, plus
`measure_agg`. `view_shape` itself is not widened, so what bumps `layout_version` is
unchanged. A view whose operator shape moved has its operators rebuilt from the
integrated base Z-set **before the next effect's cell delta is applied**.
- **The formula set is an input, not a rebuild.** After each model effect the host
compares a fingerprint of the formula set, over (`raw`, `target`,
`target_category`), with its previous read. If it moved, the formula delta (−the old
formulas, +the new ones) is fed to the circuit as an explicit input. The formula
layer then re-derives the formula graph (clause 6) and re-evaluates the affected
targets at their stems. This is observed, not declared by effect type, as the tiers
are. It is what catches a `ReplaceFormula` that keeps its target, which moves no
`view_shape`.
- **A document swap rebuilds every circuit** from the new store. `install_workbook`
bumps `document_generation`, and the same after-each-effect read sees it.
Each rebuilds only the affected operators, from the integrated base Z-set. That costs
O(cells), once per change, and is never repeated per keypress. **The trigger is the same
observation the tiers use**: a `view_shape` diff (`app.rs:40-72`) rebuilds that view's
operators, the same diff that bumps `layout_version`. So there is one signal and not two.
Re-reading the operator shape costs O(items) per view per model effect, and the formula
fingerprint O(formulas). Neither grows with the cells or the grid. A rebuild costs
O(cells), once per actual reshape. The same `view_shape` function is therefore read at
two moments: by the circuit after each effect, and by `layout_version` after the loop.
They answer different questions, and neither replaces the other.
**A page selection is not a rebuild.** The group key includes the page categories, so
`[` and `]` read another slice of state that already exists. The held-key path stays
O(visible) (improvise-748f's constraint). The slices cost nothing extra, because the
state is still bounded by the stored cells, however many pages there are (question 7).
**The worked case is gotcha 43's frame, `[AddItem(East), SetCell(East)]`.** `AddItem`
moves the operator shape, so the view is rebuilt with East in its sequences. Only then
does `SetCell`'s delta arrive, at operators that already know East. So the in-loop
`CellData` frame observation reads occupancy for the new shape. The `LayoutGeometry`
arm (`app.rs:1062-1071`) reads the frame immediately after each geometry effect, and it
reads a rebuilt occupancy for the same reason.
**Recommended: a page selection is not a rebuild (question 7).** Under the recommended
answer, the group key includes the page categories, so `[` and `]` read another slice
of state that already exists. The held-key path stays O(visible) (improvise-748f's
constraint). The slices cost nothing extra, because the state is still bounded by the
stored cells, however many pages there are. Filter coordinates still filter; only page
coordinates key the slice. If the user answers the other way, a page change is an
operator-shape change like any other.
### 8. Floating point: retraction drifts, and exactness is ADR 005's
@@ -356,8 +432,10 @@ the eager fold computes. **Measured**, not asserted:
an Apple M4 Max, with seeds 1 and 2. The example keeps a running sum through N
retract-and-insert cycles. It compares the sum against the eager fold,
`evaluate_aggregated_f64`, and against the exact decimal sum, taken as an integer count
of 10⁻⁵ units rounded to f64 once. Values are signed two-place decimals spread over 3 or
9 decades.
of 10⁻⁵ units rounded to f64 once. Each value is a signed whole number of 10⁻⁵ units:
up to 99,999 units, times a power of ten spread over 3 or 9 decades. So the smallest
values carry five decimal places, not the two a user usually types. Two-place input
would absorb differently, and it was **not** measured.
| store | magnitudes | after 1M cycles: naive vs eager | ≥ `FLOAT_EQ_EPSILON`? | eager vs exact |
|---|---|---|---|---|
@@ -403,12 +481,15 @@ The measurement above is the evidence for and against that fallback.
### 9. What ADR 008 gets from it
ADR 008 (revision 2, PR #308) assumes these, under the shared names below.
ADR 008 (revision 3, PR #308) assumes these, under the shared names below.
- **Occupancy** (ADR 008 clause 3). For each view, there is a count per row tuple and
per column tuple of cells that hold a value. Stored cells pass the view's filter, with
hidden items dropped, and a collapsed group's members map to the group's tuple.
Formula cells count at their stems, which is today's `formula_cache` stem set. A tuple
Formula cells count by clause 5's stem rule, which is when today's `formula_cache`
holds a value: a top-level `WHERE` must match, or raw data must exist at the target.
The operators are rebuilt per clause 7, so a batch that reshapes and then writes feeds
its write to the new shape. Page coordinates key a slice, subject to question 7. A tuple
is on the axis iff its count is above zero. **This ADR does not settle ADR 008
question 1 (both cases: fold rows with a formula column, and `Exact`).** Today those
cells are values with no stored cell under them. The stem definition drops them, and
@@ -463,9 +544,20 @@ read-only live source also sidesteps ADR 001's record tier, as ADR 003 already n
compares them. One circuit compared with itself proves nothing (improvise-hzjo's
vacuity).
- **ADR 001 clause 4, rollback.** A refused frame is put back by `App::restore`
(`app.rs:647`). The backend's state is part of the snapshot. Undoing by applying the
inverse delta is exact for integer weights and order-free aggregators, but not for an
f64 `Sum`, so the snapshot is kept until clause 8's prerequisite lands (question 9).
(`app.rs:647`). **Recommended (question 9): the backend's state joins the snapshot.**
Applying the inverse delta is **not** a rollback, even with exact aggregators. It
restores integrated values, but `step(d)` then `step(−d)` still advances the circuit
and changes the history that `delay` and `differentiate` hold. So the next step after
a "rewind" can differ from the next step after nothing happened. A real rewind would
have to restore every stateful operator, with a test of observational equivalence
across all of them. This ADR does not propose one.
**The snapshot costs a second O(cells) clone per mutating frame.**
`Host::mutating_frame` already clones the workbook on every mutating frame
(`host.rs:162`, `app.rs:627-629`), and the integrated state is of the same order. So
on the network path a write is not O(delta): it is O(cells) for the snapshot today,
and roughly twice that with the circuit in it. The keyboard path (`apply_effects`
without a `Host`) takes no snapshot and keeps the O(delta) cost.
- **ADR 006, replay (improvise-ilvl).** A circuit is a deterministic function of the
delta sequence, so replaying commands reproduces its state.
@@ -517,16 +609,23 @@ displayed path until the differential test holds.
test. Nothing in improvise uses it yet.
2. **The `Derived` trait, with `Eager` behind it.** No behaviour change, and the
existing suite is the evidence. `CellKey` gains `Ord`.
3. **The cell delta from `DataStore`.** `bump_changed_records` and the content half of
the dirty flag move onto it. That removes two O(cells) scans per cell-writing batch.
The existing tier tests are the evidence.
4. **`Incremental`, for the order-free parts**: stems, `Count`, `Min`, `Max`,
unanimity, occupancy and the occupancy delta, under the differential test. **ADR 008
clauses 3 and 8 (pruning and the frame observation) depend on this step.**
5. **`Sum` and `Avg`, and column totals, after ADR 005** (or on question 3's fallback).
**ADR 008 clause 5 depends on this step.**
6. **Formulas**: the formula graph, topological re-evaluation, the component fixed
point, and the aggregate-over-expression map that replaces `Expansion::members`.
3. **The cell delta from `DataStore`**, with clause 3's bit identity and its signed-zero
and NaN tests. `bump_changed_records` moves onto it. The dirty flag's content half
becomes "a non-empty cell delta, or a moved formula-set fingerprint" (clause 3), with
a test that a formula edit alone still marks the document dirty. That removes the
O(cells) scans per cell write. The existing tier tests are the evidence.
4. **`Incremental`, for the order-free parts**, under the differential test: stems with
clause 5's stem rule, `Count`, `Min`, `Max`, unanimity, occupancy and the occupancy
delta, all following clause 7's per-effect operator shape. **ADR 008 clauses 3 and 8
(pruning and the frame observation) depend on this step.**
5. **Formulas**: the formula graph and the formula delta (clause 7), topological
re-evaluation, the component fixed point (clause 6), and the aggregate-over-expression
map that replaces `Expansion::members` (clause 5). Formula values that depend on a
`Sum` or `Avg` read it from the eager fold until step 6. **ADR 008's retirement of
`Expansion::members` depends on this step.**
6. **`Sum` and `Avg`, and column totals, after ADR 005** (or on question 3's
fallback). A column total is a formula over the pooled fold, so it needs step 5's
re-evaluation. **ADR 008 clause 5 (column totals) depends on this step.**
7. **Live sources as delta streams**, when ADR 003's source trait exists.
**Tickets.** improvise-felo.4 is this ADR. The steps above are not yet filed, which is
@@ -535,7 +634,9 @@ improvise-l2b8 (ADR 005) gains a dependant, and a measured argument, from clause
**Good.**
- A write costs O(delta) plus the formula cells it reaches, not O(cells).
- On the keyboard path, a write's *evaluation* costs O(delta) plus the formula cells it
reaches, not O(cells). On the network path, the ADR 001 snapshot still costs O(cells)
per mutating frame (see "Bad").
- The store scans behind the record tier and the dirty flag disappear.
- Per-view model clones for a different None axis disappear.
- ADR 008 gets occupancy, the occupancy delta and column totals without a second
@@ -551,6 +652,11 @@ improvise-l2b8 (ADR 005) gains a dependant, and a measured argument, from clause
the measurement says so.
- Circularity is decided in two layers: the formula graph's components and the
per-cell bound.
- A mutating frame from a client already clones the workbook for rollback
(`host.rs:162`). Snapshotting the circuit too (question 9) adds a second O(cells)
clone per frame. Incremental evaluation does not make the network path O(delta).
- The operator shape is re-read after every model effect: O(items) per view, a cost the
post-loop `view_shape` diff paid once per batch.
- The browser bundle grows by two small crates.
## Open questions
@@ -594,7 +700,8 @@ the user decided", as ADRs 004 and 007 did.
switching page reads existing state, rather than rebuilding the view's operators per
page change?
*Recommendation:* the slice. The state stays bounded by the stored cells, and the
held-key path (`[`/`]`) stays O(visible). ADR 008 revision 2 assumes this.
held-key path (`[`/`]`) stays O(visible). Clause 7 is written to this
recommendation, and ADR 008 clause 3 says it is "subject to ADR 009 question 7".
8. **Live sources.** Should `Incremental` serve only sources that can emit change deltas,
with query-only sources kept on `Eager` and ADR 003's pushdown?
@@ -602,13 +709,17 @@ the user decided", as ADRs 004 and 007 did.
source, which contradicts ADR 003's larger-than-memory goal for exactly the sources
that cannot report their changes anyway.
9. **Rollback.** For ADR 001 clause 4, should the backend's state be snapshotted with
the `App`, or undone by applying the inverse delta?
*Recommendation:* snapshot until ADR 005 lands, then the inverse delta. The inverse
is exact only when every aggregator is.
9. **Rollback.** For ADR 001 clause 4, should the backend's state join the `App`
snapshot that `Host::mutating_frame` already takes?
*Recommendation:* yes, snapshot, with no later switch to the inverse delta. Applying
`−d` restores integrated values but not the history `delay` and `differentiate`
hold, so it is not a rewind even with exact aggregators (clause 11). The cost is a
second O(cells) clone per mutating frame, beside the workbook clone that exists
today. A true rewind would need its own design and an observational-equivalence test
over every stateful operator.
10. **Ticket structure.** Should improvise-felo.4 become the epic for clause-order steps
1–7, with one child bead per step, and ADR 008's dependent steps linked as blocked by
steps 4 and 5?
steps 4, 5 and 6?
*Recommendation:* yes. The user's decision is recorded on felo.4, and the steps are
how it is met.