|
| 1 | +# Grouping-Apply Insert Phase (#500 cycle 2) — Design |
| 2 | + |
| 3 | +**Status:** approved direction, specced in full. |
| 4 | +**Parent:** `2026-08-29-grouping-apply-cooperative-cost.md` (cycle 1: seal |
| 5 | +units + clock — merged as PR #518). Cycle 1 fixed the seal phase; this fixes |
| 6 | +the insert phase, which measurement showed dominates: **~2.9s of the ~3.2s** |
| 7 | +at 50k rows × 10 `avg` columns. |
| 8 | + |
| 9 | +## The cost, measured (investigation 2026-08-29; scripts in session scratchpad) |
| 10 | + |
| 11 | +Per row, per grouping level, per aggregated column, the build inserts into |
| 12 | +TWO ordered AVL aggregate trees (`all` + `filtered` roots): ~5.8µs and |
| 13 | +~1.4KB of heap per row·column — perfectly linear at ~289ms/72MB per column on |
| 14 | +a 314ms zero-aggregate base. Three structural facts make this waste, not |
| 15 | +work: |
| 16 | + |
| 17 | +1. **The comparator never reads values.** `compareAggregateLeaves` orders by |
| 18 | + row sort keys only, so a group's 20 aggregate trees are isomorphic — the |
| 19 | + engine builds 20 identical AVL shapes per group and shares nothing. |
| 20 | +2. **Scalar kinds don't need order.** `sum`/`avg`/`count` are commutative |
| 21 | + with **exact inverses** (bigint superaccumulator subtraction; count |
| 22 | + decrement; ±Infinity as counters), and streaming updates already arrive |
| 23 | + as remove-then-insert with the originally-inserted value on the removal |
| 24 | + record. Only `min`/`max` (no inverse) and **custom aggregator specs** |
| 25 | + (associativity is the only validated law — the fold order is observable) |
| 26 | + need a removal structure. The real builtin `avg` measures ~0.11µs per |
| 27 | + scalar accumulate — ~50× under the tree path. |
| 28 | +3. **One population root is write-only.** `aggregateFilteredRows` is a |
| 29 | + model-lifetime option; `aggregateRecord` finalizes only the selected |
| 30 | + root. The other root is built and never read (its `firstId` serves only a |
| 31 | + vestigial error-context path). |
| 32 | + |
| 33 | +## Decisions |
| 34 | + |
| 35 | +1. **(A) Kind-aware aggregation.** `sum`/`avg`/`count` columns get a scalar |
| 36 | + accumulator cell behind the same root interface |
| 37 | + (`insertOrReplace`/`remove`/`finalize`/`firstId`): insert = accumulate, |
| 38 | + remove = exact inverse (bigint units subtraction, count decrement, |
| 39 | + ±Infinity flags become counters so `Inf + (−Inf) → NaN` finalize |
| 40 | + semantics survive; NaN admission applied symmetrically on both sides). |
| 41 | + `min`/`max` and custom specs keep the ordered tree unchanged. Scalar |
| 42 | + columns charge **no seal units** (accumulate is O(1) inline at insert); |
| 43 | + the cooperative resumable-unit contract is untouched for the kinds that |
| 44 | + still seal. `firstId` on a builtin-only scalar group may return |
| 45 | + undefined — its only consumer is custom-finalizer error context. |
| 46 | +2. **(C1) Build only the selected population root.** The unselected root |
| 47 | + (`filtered` when `aggregateFilteredRows` is on — the option selects the |
| 48 | + `all` population — and `all` otherwise) is |
| 49 | + write-only today; stop building it. Halves the remaining tree cost for |
| 50 | + min/max/custom AND the scalar-cell writes. `representativeRowId` falls |
| 51 | + back to the root that exists. The option is model-lifetime, so nothing |
| 52 | + can request the dropped root later. |
| 53 | +3. **(micro) `normalizedLeaf` stops copying frozen leaves** — it currently |
| 54 | + allocates a fresh frozen copy of an already-frozen leaf on every insert |
| 55 | + (~157ms + GC at 50k×10). Return the input when it is already canonical. |
| 56 | +4. **Rejected: bulk tree construction at seal (shape B)** — the O(n) |
| 57 | + `createOrderStatisticTreeFromSortedEntries` primitive exists, but B keeps |
| 58 | + the full allocation/GC bill, needs non-resumable per-group sorts inside |
| 59 | + the cooperative seal, and under A+C1 the residual tree count is too small |
| 60 | + to repay that; **rejected: multi-measure shared trees (C2)** — mostly |
| 61 | + redundant once A lands, large typing/instrumentation churn. |
| 62 | +5. **Streaming stays the CONTROL and should strictly improve** — scalar |
| 63 | + cells make per-touch group updates O(1) where trees were O(log n). The |
| 64 | + S5 group-updates 20k p95 (10.0ms) must not regress; any improvement is |
| 65 | + welcome but not claimed in advance. |
| 66 | + |
| 67 | +## Invariants that must be test-pinned (the risk register, made checkable) |
| 68 | + |
| 69 | +- **Inverse exactness**: insert/remove fuzz (randomized adds/removes incl. |
| 70 | + NaN, ±Infinity, duplicates) comparing the scalar cell against a tree |
| 71 | + oracle for sum/avg/count — final values identical, intermediate finalize |
| 72 | + identical at every step. |
| 73 | +- **Remove-then-insert invariant**: streaming updates must keep arriving as |
| 74 | + remove-then-insert (scalar cells cannot detect replace); pin it with a |
| 75 | + test that fails if an update path skips the remove. |
| 76 | +- **±Infinity finalize semantics**: `Inf`, `−Inf`, and mixed populations |
| 77 | + finalize identically to the tree path, including after removals that |
| 78 | + clear one flag. |
| 79 | +- **Custom specs untouched**: an order-sensitive custom aggregator (one the |
| 80 | + associativity law permits but commutativity would break) produces the |
| 81 | + SAME fold as before — proving customs still ride the ordered tree. |
| 82 | +- **C1 twins**: both `aggregateFilteredRows` configs run the full aggregate |
| 83 | + fixture set; a filter making the two populations differ proves the |
| 84 | + SELECTED root is the one surviving. |
| 85 | +- **Cycle-1 pins hold**: unit-accounting and slice bounds re-derived for |
| 86 | + scalar columns charging zero seal units (the existing pins' fixtures use |
| 87 | + aggregated columns — update the derivations honestly, don't loosen). |
| 88 | + |
| 89 | +## Verification |
| 90 | + |
| 91 | +- The pins above; the full row-model + react suites untouched-green. |
| 92 | +- Node repro (50k×10avg): time-to-grouped from ~3.2s to **≤ 600ms** (target |
| 93 | + ~400-450ms; the gate at 600 leaves loaded-box headroom), heap Δ from |
| 94 | + ~780MB to ≤ 200MB. |
| 95 | +- Bench, same-window controls: S2 `group`/`group-expand` at target scale |
| 96 | + return **`completed`** (the #500 headline); settle reported against the |
| 97 | + ~400ms pre-#321 number; S5 `group-updates` 20k p95 ≤ 10.0ms (control); |
| 98 | + 3k/750 settles reported. Isolated port, full output to files. |
| 99 | +- Changeset: `@pretable-internal/row-model` patch. |
| 100 | +- #500 closes only if S2 target completes; else it stays open with the |
| 101 | + residual named. |
| 102 | + |
| 103 | +## Out of scope |
| 104 | + |
| 105 | +The renderer-dom height-index cost (filed, task chip); shape B / C2 |
| 106 | +(rejected above, revisit only with new evidence); any change to budget or |
| 107 | +slicing constants; the jsdom setDerivations stall. |
0 commit comments