From 22db55b0f0556175bd61045d5137c3d61425b951 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 16 Aug 2026 20:22:44 -0700 Subject: [PATCH 1/4] docs(plan): spacer accuracy, and the claims that rest on it The spacer is rowCount x defaultRowHeight and never reads a retained height, so wrapped grids understate the extent by ~2x. The test that appeared to prove otherwise feeds planViewport a number the controller never computes. --- .../plans/2026-08-17-spacer-accuracy.md | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-17-spacer-accuracy.md diff --git a/docs/superpowers/plans/2026-08-17-spacer-accuracy.md b/docs/superpowers/plans/2026-08-17-spacer-accuracy.md new file mode 100644 index 00000000..2bad5cc5 --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-spacer-accuracy.md @@ -0,0 +1,135 @@ +# Spacer accuracy, and the claims that rest on it + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. + +**Goal:** Make the windowed spacer reflect what rows actually measure, and make every published claim about it true. + +**Context:** An audit of the eviction project found the spacer never consults a retained height, a published exactness claim the code cannot honour, and the test that appeared to prove otherwise feeding the planner a number production never computes. + +--- + +## The defect + +`packages/renderer-dom/src/row-layout-controller.ts` (~line 716): + +```ts +const leadingHeight = Math.max(0, (spacers?.leadingRows ?? 0) * defaultRowHeight); +const trailingHeight = Math.max(0, (spacers?.trailingRows ?? 0) * defaultRowHeight); +``` + +The comment states it plainly — *"Row counts, not pixel heights."* Three consequences: + +1. **The retained-measurement cache is never read for spacer geometry.** It is keyed by row identity (`row-height-index.ts:1146` `retainMeasurement`); `getWindowSpacers` (`renderer-dom/src/types.ts:386`) supplies only `leadingRows`/`trailingRows` counts. The two systems cannot meet. +2. **`estimate()` floors at `defaultRowHeight`**, so every evicted row is understated whenever rows wrap. A 10,000-row grid averaging 96px against a 48px default publishes an extent about **half** the truth, and it moves every time the window moves. This is the wrapped-text case — the feature's entire differentiator. +3. **Spec §4's cost model is inverted.** It claims evicting *measured* rows is free and only unmeasured rows cost an anchor correction. In practice every eviction costs one. + +### The published claim is false + +`apps/website/content/docs/server-data/eviction.mdx`: + +> *"sized from the population rather than from what is loaded. Where the retained heights are exact the spacer reproduces the region's height precisely, so the scroll extent is the same number after the eviction as before it and nothing shifts at all."* + +It reproduces the region's height precisely only when every evicted row measured exactly `defaultRowHeight`. + +### And the test cannot see it + +`packages/layout-core/src/__tests__/eviction-anchor.test.ts:99-105` calls `planViewport({ leadingHeight: sumHeights(0, EVICT_BEFORE) })` — the exact sum of the evicted rows' measured heights. `planViewport` is pure and uses what it is handed. **The controller never computes that number.** The assertion is real; the quantity is not the one the product produces. Same shape as the row-height-error proxy this repo already fixed. + +--- + +### Task 1: A test that drives the real spacer + +**Do this before changing any production code.** + +The existing anchor test exercises `planViewport` directly, so it can never see this bug. Add coverage in `packages/renderer-dom` that drives `createRowLayoutController` with `getWindowSpacers` returning nonzero `leadingRows`, **varied row heights that are not the default**, and measurements retained for the evicted rows. + +Assert the published `totalHeight` against the truth — the sum of what those rows actually measured. + +- [ ] **Step 1: Write it and watch it fail.** Expected: the extent is short by `(measured − default) × leadingRows`. Report the actual numbers, not just red/green. +- [ ] **Step 2: If it passes, STOP and report.** The diagnosis is wrong and the rest of this plan is void. + +Fixture requirements, because this repo has shipped four vacuous tests in a week: +- Heights must **differ from `defaultRowHeight`**, or the bug is invisible by construction. +- Heights must **vary between rows** (`30 + ((i * 7) % 23)` is the established idiom), or arithmetic errors land on multiples of the row height and look right. +- The spacer must be **nonzero**, or every conversion is an identity. + +### Task 2: Decide how the spacer learns heights + +**This is the plan's one real design decision. Resolve it before implementing.** + +**Option A — calibrated mean (recommended).** Give `RowHeightIndex` a running sum and count of retained measurements, exposed as a mean. The controller multiplies the spacer's row count by that instead of `defaultRowHeight`, falling back to `defaultRowHeight` when nothing has been measured. + +- No consumer API change; no new information required from anyone. +- Turns a systematic understatement into an unbiased estimate: a 96px-average grid gets a ~96px-per-row spacer instead of 48. +- Still an **estimate**. Rows are not uniform, so the extent will not be exact — and anchoring is what absorbs the residual, which is precisely what `eviction-anchor.test.ts` was written to prove and would now be proving about a real quantity. +- The index already tracks `measurementCacheCount` (`row-height-index.ts:134`), so the shape exists. + +**Option B — exact per-region.** `getWindowSpacers` carries heights, or row keys, rather than counts. Exact when the consumer knows what it evicted — but it is a public API change, it pushes bookkeeping onto every consumer, and a consumer that windows without having ever rendered a row has no heights to give. + +**Option C — neither; correct the claim only.** Cheapest, and leaves the differentiator understated by 2× on wrapped grids. + +**Recommendation: A, and C regardless** — A does not make the spacer exact, so the exactness claim has to go either way. + +- [ ] Decide, and record the reasoning in the commit message. + +### Task 3: Implement + +- [ ] Implement the chosen option. +- [ ] **Mutate:** revert the calibration and confirm Task 1's test reddens with the specific pixel gap. Report both directions verbatim. +- [ ] Confirm a grid with **no** retained measurements is byte-for-byte unchanged — that is the local-mode and cold-start regression guard. + +### Task 4: Make the published claims true + +- [ ] `eviction.mdx` — replace the exactness claim with what the code does: the spacer is estimated from what rows have actually measured, and the anchor absorbs the residual. Say the extent is an estimate that improves as more rows are measured. +- [ ] `docs/superpowers/specs/2026-08-14-eviction-design.md` §4 — the cost model is inverted; correct it. +- [ ] Check for other places asserting spacer exactness (`grep -rn "precisely\|exact" apps/website/content/docs/server-data/`). + +### Task 5: Verify + +Baselines must be **measured on `origin/main` first** — numbers in this document may be stale. + +```bash +npx vitest run --root packages/renderer-dom +npx vitest run --root packages/layout-core +npx vitest run --root packages/grid-core +pnpm --filter @pretable/react test +pnpm --filter @pretable/app-bench test +./node_modules/.bin/playwright test +pnpm --filter @pretable/app-website test +``` + +Then `pnpm build && pnpm api && pnpm api:check`, in that order. + +Changeset: **minor** for affected public packages (pre-1.0; breaking ships as minor, never major). + +--- + +## What this does NOT fix + +Stated so the next reader does not assume otherwise: + +- **Memory is still unmeasured.** See the separate plan item below. The spacer is about *geometry*, not about bytes. +- **No evictor ships.** Spec §3 remains absent; consumers do the releasing. +- The spacer remains an **estimate** under Option A. Exactness needs Option B and consumer cooperation. + +--- + +## Next, after the spacer + +Ranked. Each is independent. + +1. **Measure memory, or stop claiming it.** `resident-cap-memory.spec.ts` runs an *append* script through an adapter that passes no `resultMeta`, so eviction is structurally unreachable; doubling resident rows moved the heap by −0.28 MB. Either instrument the windowed harness — which does evict — and assert that heap falls when the window shrinks, or withdraw the bounded-memory claim until something proves it. **This is the feature's central premise and nothing tests it.** +2. **`PretableCellRangeFor` is missing `datasetRowSpan`** (`react/src/surface-types.ts`). It is the type the docs tell controlled consumers to use, and the value crosses via an `as unknown as` launder. A consumer following the documented recipe who rebuilds range objects loses the span with no type error, and it presents as "eviction doesn't work". Replace the six structural re-declarations with one shared interface. +3. **The Tab branch still has the `-1` sentinel bug** that #453 removed from the page keys (`pretable-surface.tsx:7473`). Latent — needs `tabBehavior="wrap-rows"` — but it is the identical defect. +4. **`verified` reaches no UI.** A public field with no consumer and no documentation; the live-region announcement states an unverified count as fact. Either wire it into the announcement or stop paying for it. +5. **Delete dead weight.** `getScrollTopForIndexedFocus` has zero callers, is exported, and its signature invites the coordinate-space bug the `ScrollRequest` seam exists to prevent. `sameDatasetRowSpan` duplicates `sameSpan` byte-for-byte. +6. **Bench specs are neither typechecked nor linted** — `apps/bench/tsconfig.json` includes only `["src", "vite.config.ts"]`, and lint is `eslint src`. CI now runs those 9 specs; nothing checks them. + +**Not on this list, and deliberately:** #452, #457 and #458 are open perf issues filed against the comparative bench. #457 — *S2 sort at 50k rows never settles* — reads as more serious than anything above. They are a different thread and want their own triage. + +## Two decisions owed by a human + +Carried from the P0 fixes, unchanged: + +1. Whether to add a **consumer-supplied population token**, the only thing that closes the equal-insert-and-delete gap a size comparison cannot see. +2. Whether `indexedRangeContainsCell` becomes **tri-state**, so an unconfirmed span paints distinctly rather than as ordinary selection. UI and aria consequences beyond the engine. From d86b2815c01cf358afb54f6d38844cf6ba7d3ab4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 16 Aug 2026 20:54:02 -0700 Subject: [PATCH 2/4] feat(layout-core): expose the mean of the measurement cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A windowed grid's spacer arrives as a row COUNT — `getWindowSpacers` reports how many rows sit outside the loaded window and never which — so the only calibration available to whoever draws it is a per-row average. The index is the one thing that holds real measurements, so the mean belongs here. `getMeasuredHeightMean()` returns the mean of the whole measurement cache: rows currently visible and measured, plus the retained heights of rows that have left the view. Estimates are excluded, because `apply` already drops a row's cached entry when it re-estimates. `undefined` when nothing has been measured, so a caller falls back to its own default rather than to a mean of no samples. Aggregated STRUCTURALLY. Every hash node now carries `sum` beside the `count` it already carried, derived at construction from the same children. The alternative — a running total threaded through `measure`, `apply`, the retention eviction loop and the cooperative replacement builder — has five independent chances to go stale on a copy-on-write index, and the aggregate has none: a node that exists has the right sum, and a rebuilt node recomputes it. Non-numeric values weigh nothing, so the visible-key map sums to zero; only the measurement map's sum is ever read. The 500-step measurement-cache oracle now checks the mean against the same Map it already checks the cache count against, so re-measures, re-estimates, tombstone eviction past the bound and full replacement rebuilds are all covered. Dropping the entry's own weight from a collision node, and zeroing a leaf's, each redden it. Co-Authored-By: Claude Opus 5 --- .../src/__tests__/row-height-index.test.ts | 109 ++++++++++++++++++ packages/layout-core/src/row-height-index.ts | 44 ++++++- packages/layout-core/src/types.ts | 14 +++ 3 files changed, 165 insertions(+), 2 deletions(-) diff --git a/packages/layout-core/src/__tests__/row-height-index.test.ts b/packages/layout-core/src/__tests__/row-height-index.test.ts index 0fb80020..0b788b0e 100644 --- a/packages/layout-core/src/__tests__/row-height-index.test.ts +++ b/packages/layout-core/src/__tests__/row-height-index.test.ts @@ -537,6 +537,102 @@ describe("persistent row-height index", () => { }); }); + test("means the measurement cache, and nothing that is not in it", () => { + // `getMeasuredHeightMean` is what a windowed grid sizes its spacer from: + // it knows how many rows are out there and not which, so the mean of what + // rows have actually measured is the only calibration available to it. + // Every clause below is a way that mean could be wrong while the row + // heights it is derived from stay right. + const a = data("a"); + const b = data("b"); + const c = data("c"); + + // Estimates are not measurements. A grid that has rendered nothing has no + // opinion, and says so rather than returning a mean of no samples. + let index = createIndex([entry(a, 10), entry(b, 20), entry(c, 30)], 30, 2); + expect(index.getMeasuredHeightMean()).toBeUndefined(); + + index = index.measure(0, a, 40).measure(1, b, 60); + expect(index.getMeasuredHeightMean()).toBe(50); + + // Re-measuring REPLACES. A running total incremented at each `measure` + // would read 140/2 = 70 here; the structural aggregate reads what the + // cache holds. + index = index.measure(0, a, 80); + expect(index.getMeasuredHeightMean()).toBe(70); + + // Re-estimating a row drops its measurement, and the mean with it. + index = index.apply([ + { kind: "update", ref: a, index: 0, estimatedHeight: 11 }, + ]); + expect(index.hasMeasurement(a)).toBe(false); + expect(index.getMeasuredHeightMean()).toBe(60); + + // The case the spacer exists for: an EVICTED row is no longer drawn, but + // its height is retained, and it still counts. Anything else and a fully + // evicted region would fall back to the default height it was measured + // away from. + index = index.apply([{ kind: "remove", ref: b, previousIndex: 1 }]); + expect(index.rowCount).toBe(2); + expect(index.hasMeasurement(b)).toBe(true); + expect(index.getMeasuredHeightMean()).toBe(60); + + // Retention is bounded at two here. Tombstoning a third measurement + // evicts the oldest FROM THE CACHE, so it has to leave the mean too. + // 30 and 100 average 65; leaving b's 60 in would read 63.33, so this + // number can tell the two apart. + index = index + .measure(0, a, 30) + .measure(1, c, 100) + .apply([{ kind: "remove", ref: a, previousIndex: 0 }]) + .apply([{ kind: "remove", ref: c, previousIndex: 0 }]); + expect(index.hasMeasurement(b)).toBe(false); + expect(getRowHeightIndexDiagnosticsForTesting(index)).toMatchObject({ + measurementCacheCount: 2, + }); + expect(index.getMeasuredHeightMean()).toBe(65); + + // Retaining nothing at all: the cache empties, and the caller is back to + // its default height rather than to a stale mean. + const unretained = createIndex([entry(a, 10)], 30, 0) + .measure(0, a, 44) + .apply([{ kind: "remove", ref: a, previousIndex: 0 }]); + expect(unretained.getMeasuredHeightMean()).toBeUndefined(); + }); + + test("means measurements that share a hash, and survives a rebuild", () => { + // The collision tree is a second aggregation path, reached only by keys + // whose identities hash alike — these two do, under the index's FNV-1a. + const first = data("k-ielz1d-1wwy"); + const second = data("k-1i39yng-2umb"); + let index = createRowHeightIndex({ + defaultHeight: 30, + getKey: (key: Key) => key.id, + rows: [entry(first, 20), entry(second, 40)], + maxRetainedMeasurements: 2, + }); + expect( + getRowHeightIndexDiagnosticsForTesting(index).identityComparisons, + ).toBeGreaterThan(0); + + index = index.measure(0, first, 51).measure(1, second, 61); + expect(index.getMeasuredHeightMean()).toBe(56); + index = index.apply([{ kind: "remove", ref: first, previousIndex: 0 }]); + expect(index.getMeasuredHeightMean()).toBe(56); + + // A cooperative replacement rebuilds every root from scratch. The mean is + // recomputed with them, not carried over from the index it replaced. + const builder = index.beginReplacement({ + rowCount: 1, + entryAt: () => entry(data("k-1i39yng-2umb"), 40), + }); + while (!builder.done) builder.advance({ maxUnits: 256, now: () => 0 }); + const rebuilt = builder.finish(); + expect(rebuilt.rowCount).toBe(1); + expect(rebuilt.getHeight(0)).toBe(61); + expect(rebuilt.getMeasuredHeightMean()).toBe(56); + }); + test("replaces 100k rows with explicitly linear identity and measurement work", () => { const count = 100_000; const rows = Array.from({ length: count }, (_, index) => @@ -1209,6 +1305,19 @@ describe("persistent row-height index", () => { expect(diagnostics.visibleMeasurementCount).toBe( rows.filter((row) => measurements.has(row.id)).length, ); + // The mean rides the same oracle. It is aggregated structurally rather + // than threaded as a running total precisely so that inserts, removes, + // re-measures, re-estimates, tombstone eviction past the limit and full + // replacement rebuilds cannot each drift it — this is where that gets + // checked, 500 steps of them, against a Map that knows the answer. + const mean = index.getMeasuredHeightMean(); + if (measurements.size === 0) { + expect(mean).toBeUndefined(); + } else { + let expectedSum = 0; + for (const height of measurements.values()) expectedSum += height; + expect(mean! * measurements.size).toBeCloseTo(expectedSum, 6); + } } }, 30_000); }); diff --git a/packages/layout-core/src/row-height-index.ts b/packages/layout-core/src/row-height-index.ts index b3cb2d28..bcac386a 100644 --- a/packages/layout-core/src/row-height-index.ts +++ b/packages/layout-core/src/row-height-index.ts @@ -62,11 +62,28 @@ interface HashEntry { readonly value: TValue; } +/** + * Every hash node carries `sum` — the total of its numeric values — beside the + * `count` it already carried, so a mean over the whole map is two O(1) root + * reads rather than a traversal. + * + * Derived at construction from the same children `count` is derived from, + * which is the point: this index is persistent and copy-on-write, and a total + * threaded separately through `measure`, `apply`, the retention eviction loop + * and the cooperative replacement builder would have five chances to go stale. + * A structural aggregate has none — a node that exists has the right sum, and + * a node that is rebuilt recomputes it. + * + * Non-numeric values weigh nothing, so the visible-key map (`HashNode`) + * sums to zero. The tombstone map's values are retention tickets, whose sum is + * meaningless; only the measurement map's sum is ever read. + */ interface HashLeaf { readonly kind: "leaf"; readonly hash: number; readonly entry: HashEntry; readonly count: 1; + readonly sum: number; } interface CollisionNode { @@ -75,6 +92,7 @@ interface CollisionNode { readonly right: CollisionNode | null; readonly height: number; readonly count: number; + readonly sum: number; } interface HashCollision { @@ -82,6 +100,7 @@ interface HashCollision { readonly hash: number; readonly root: CollisionNode; readonly count: number; + readonly sum: number; } interface HashBranch { @@ -89,6 +108,7 @@ interface HashBranch { readonly bitmap: number; readonly children: readonly HashNode[]; readonly count: number; + readonly sum: number; } type HashTerminal = HashLeaf | HashCollision; @@ -406,6 +426,15 @@ function hashCount(root: HashNode | null): number { return root?.count ?? 0; } +/** Total of a map's numeric values; see {@link HashLeaf}. */ +function hashSum(root: HashNode | null): number { + return root?.sum ?? 0; +} + +function entryWeight(value: TValue): number { + return typeof value === "number" ? value : 0; +} + function popCount(value: number): number { let remaining = value >>> 0; remaining -= (remaining >>> 1) & 0x55555555; @@ -431,13 +460,17 @@ function hashLeaf( work: Work, ): HashLeaf { work.nodesCreated += 1; - return { kind: "leaf", hash, entry, count: 1 }; + return { kind: "leaf", hash, entry, count: 1, sum: entryWeight(entry.value) }; } function collisionCount(root: CollisionNode | null): number { return root?.count ?? 0; } +function collisionSum(root: CollisionNode | null): number { + return root?.sum ?? 0; +} + function collisionNode( entry: HashEntry, left: CollisionNode | null, @@ -451,6 +484,7 @@ function collisionNode( right, height: 1 + Math.max(nodeHeight(left), nodeHeight(right)), count: collisionCount(left) + 1 + collisionCount(right), + sum: collisionSum(left) + entryWeight(entry.value) + collisionSum(right), }; } @@ -605,7 +639,7 @@ function hashCollision( work: Work, ): HashCollision { work.nodesCreated += 1; - return { kind: "collision", hash, root, count: root.count }; + return { kind: "collision", hash, root, count: root.count, sum: root.sum }; } function hashBranch( @@ -619,6 +653,7 @@ function hashBranch( bitmap: bitmap >>> 0, children, count: children.reduce((count, child) => count + child.count, 0), + sum: children.reduce((sum, child) => sum + child.sum, 0), }; } @@ -1112,6 +1147,11 @@ class PersistentRowHeightIndex implements RowHeightIndex { return hashGet(this.#measurements, this.#identity(ref)) !== undefined; } + getMeasuredHeightMean(): number | undefined { + const count = hashCount(this.#measurements); + return count === 0 ? undefined : hashSum(this.#measurements) / count; + } + measure(index: number, ref: TKey, height: number): RowHeightIndex { assertExistingIndex(index, this.rowCount, "Row measurement index"); const normalized = normalizeHeight(height, "Measured row height"); diff --git a/packages/layout-core/src/types.ts b/packages/layout-core/src/types.ts index 784c576c..f4e59ed6 100644 --- a/packages/layout-core/src/types.ts +++ b/packages/layout-core/src/types.ts @@ -148,6 +148,20 @@ export interface RowHeightReplacementBuilder { export interface RowHeightIndex extends RowMetricsReader { keyAt(index: number): TKey | undefined; hasMeasurement(ref: TKey): boolean; + /** + * Mean of every height in the measurement cache — rows currently visible and + * measured, plus the retained measurements of rows that have left the view. + * `undefined` when nothing has been measured, which is the caller's cue to + * fall back to its default height rather than to a mean of no samples. + * + * Estimates are deliberately excluded: `apply` drops a row's cached entry + * when it re-estimates, so this is a mean over numbers the DOM reported, not + * over the arithmetic that stands in for them. + * + * It is an ESTIMATOR, not a total. Rows are not uniform, so multiplying it + * by a row count gives a region's approximate height, never its exact one. + */ + getMeasuredHeightMean(): number | undefined; measure(index: number, ref: TKey, height: number): RowHeightIndex; /** Retains a bounded measured height for a stable key absent from the view. */ retainMeasurement(ref: TKey, height: number): RowHeightIndex; From a249aa1baa18ea736ad2a2a51bfdfaa0300244fe Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 16 Aug 2026 20:54:15 -0700 Subject: [PATCH 3/4] fix(renderer-dom): size a window spacer from what rows measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prepareWindow` turned a spacer's row count into pixels by multiplying by `defaultRowHeight`. Its own comment said so — "Row counts, not pixel heights." That is the region's real height only on a grid whose rows are all the default height; on a grid whose rows wrap it understates the entire scroll extent by the ratio between a wrapped row and the unwrapped default, which is the case this feature exists for. The retained-measurement cache knew exactly what those rows were worth and was never consulted for geometry, because it is keyed by row identity while the spacer arrives as a count. The spacer's rows are now priced at `getMeasuredHeightMean()`, falling back to `defaultRowHeight` until something has been measured — so a cold grid, and every grid with no window, is byte-for-byte unchanged. Read once from the incoming root, like `spacers` itself: the estimate passes below add estimates, which never enter the measurement cache. Option A of the plan, deliberately: exactness would need `getWindowSpacers` to carry heights or row keys rather than counts, which is a public API change that pushes bookkeeping onto every consumer and that a consumer windowing over rows it has never rendered cannot satisfy. This stays an estimate, and anchoring is what absorbs the residual. The new test drives `createRowLayoutController`, not `planViewport`, because `planViewport` is handed the leading height it would be asked to check — which is why `eviction-anchor.test.ts` looked like proof of this property for the whole eviction project and was not. Reverting the calibration alone reddens it by 54,800px on the leading spacer and 109,052px on the extent. One existing assertion moved: the first measurement on a windowed grid is also the first sample the spacer is calibrated from, so the scroll offset now follows the row's growth AND the spacer's. The row's on-screen position, which is the claim that test exists for, is unchanged. Co-Authored-By: Claude Opus 5 --- .../window-spacer-coordinates.test.ts | 18 +- .../__tests__/window-spacer-height.test.ts | 246 ++++++++++++++++++ .../renderer-dom/src/row-layout-controller.ts | 28 +- packages/renderer-dom/src/types.ts | 10 +- 4 files changed, 291 insertions(+), 11 deletions(-) create mode 100644 packages/renderer-dom/src/__tests__/window-spacer-height.test.ts diff --git a/packages/renderer-dom/src/__tests__/window-spacer-coordinates.test.ts b/packages/renderer-dom/src/__tests__/window-spacer-coordinates.test.ts index 52f1fef9..7f5d39e5 100644 --- a/packages/renderer-dom/src/__tests__/window-spacer-coordinates.test.ts +++ b/packages/renderer-dom/src/__tests__/window-spacer-coordinates.test.ts @@ -243,9 +243,23 @@ describe("windowed scroll coordinates", () => { expect .soft(screenYOf(controller, 20), "row 20 has not moved on screen") .toBe(screenYBefore); + // The offset follows the growth AND the spacer above it. + // + // This measurement is the grid's first, so it is also the first sample the + // leading spacer is calibrated from: 5,000 rows go from the 30px default + // to the one 82px height anybody has reported, and the spacer above the + // window grows by 260,000px in the same commit. Both terms are required — + // dropping the spacer term is how this assertion read before spacers were + // sized from measurements, and dropping the `+ 40` would stop it saying + // anything about row 5 at all. expect - .soft(after.scrollTop, "the scroll offset followed the growth") - .toBe(before.scrollTop + 40); + .soft(after.leadingHeight, "the spacer recalibrated on the first sample") + .toBe(LEADING_ROWS * (heightAt(5) + 40)); + expect + .soft(after.scrollTop, "the scroll offset followed both") + .toBe( + before.scrollTop + 40 + (after.leadingHeight - before.leadingHeight), + ); }); test("anchoring survives a rebuild that changes the row set", () => { diff --git a/packages/renderer-dom/src/__tests__/window-spacer-height.test.ts b/packages/renderer-dom/src/__tests__/window-spacer-height.test.ts new file mode 100644 index 00000000..6318200b --- /dev/null +++ b/packages/renderer-dom/src/__tests__/window-spacer-height.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, test } from "vitest"; + +import { + createColumnHelper, + createLocalRowModel, + type PretableRowModel, + type PretableVisibleRowRef, +} from "@pretable-internal/row-model"; + +import { + createRowLayoutController, + type RowLayoutScheduler, +} from "../row-layout-controller"; +import type { RowLayoutController } from "../types"; + +/** + * What PIXEL height does a window spacer actually get? + * + * `eviction-anchor.test.ts` in layout-core looks like it settles this. It does + * not: it calls `planViewport({ leadingHeight: sumHeights(0, EVICT_BEFORE) })` + * — the exact sum of the evicted rows' measured heights, computed by the test + * itself. `planViewport` is pure and spends whatever number it is handed, so + * the assertion is real but the quantity is not one production ever computes. + * + * These tests drive `createRowLayoutController`, which is the thing that has + * to decide the spacer's height from the row COUNTS `getWindowSpacers` + * returns. Three properties of the fixture are load-bearing: + * + * - **Measured heights differ from `defaultRowHeight`.** Sizing a spacer at + * `rows × defaultRowHeight` is invisible on a grid whose rows are the + * default height, which is every browser fixture in this repo. + * - **They VARY between rows.** A uniform non-default height would let an + * arithmetic error land on a multiple of the row height and look right. + * - **The spacer is nonzero.** With no window every conversion below is the + * identity, and the whole file is vacuous. + */ + +type Row = { id: number; score: number; label: string }; + +const helper = createColumnHelper(); +const modelColumns = [ + helper.accessor("score", { type: "number" }), + helper.accessor("label", { type: "text" }), +] as const; +const renderColumns = [{ id: "label", header: "Label", widthPx: 90 }] as const; + +const data = (rowId: number): PretableVisibleRowRef => ({ + kind: "data", + rowId, +}); + +/** Rows 0..49 of a 10,000-row dataset: the window starts at 5,000. */ +const LOADED_ROWS = 50; +const LEADING_ROWS = 5_000; +const TRAILING_ROWS = 4_950; +const DEFAULT_ROW_HEIGHT = 30; +const VIEWPORT = 400; + +/** 30..52, never a multiple of anything the arithmetic could land on by luck. */ +const heightAt = (index: number) => DEFAULT_ROW_HEIGHT + ((index * 7) % 23); + +const sumHeights = (from: number, to: number): number => { + let total = 0; + for (let index = from; index < to; index += 1) total += heightAt(index); + return total; +}; + +/** 2,048px over 50 rows — a 40.96px mean against a 30px default. */ +const MEASURED_MEAN = sumHeights(0, LOADED_ROWS) / LOADED_ROWS; + +class ImmediateScheduler implements RowLayoutScheduler { + readonly tasks: Array<{ task: () => void; cancelled: boolean }> = []; + + schedule(task: () => void): () => void { + const entry = { task, cancelled: false }; + this.tasks.push(entry); + return () => { + entry.cancelled = true; + }; + } + + flushAll(limit = 100_000): void { + let count = 0; + for (;;) { + const entry = this.tasks.shift(); + if (entry === undefined) return; + if (!entry.cancelled) entry.task(); + count += 1; + if (count > limit) throw new Error("Scheduler did not settle."); + } + } +} + +const rowsFrom = (from: number, count: number): Row[] => + Array.from({ length: count }, (_, offset) => ({ + id: from + offset, + score: from + offset, + label: `row ${from + offset}`, + })); + +function createWindowedController(spacers: { + leadingRows: number; + trailingRows: number; +}): { + readonly controller: RowLayoutController; + readonly model: PretableRowModel; + readonly scheduler: ImmediateScheduler; +} { + const model = createLocalRowModel({ + rows: rowsFrom(0, LOADED_ROWS), + columns: modelColumns, + initialExpansion: { kind: "expanded" }, + query: { + filters: [], + sort: [{ columnId: "score", direction: "asc" }], + rowGroups: [], + }, + }); + const scheduler = new ImmediateScheduler(); + const controller = createRowLayoutController({ + model, + columns: renderColumns, + viewport: { scrollTop: 0, viewportHeight: VIEWPORT, overscan: 0 }, + scheduler, + now: () => 0, + defaultRowHeight: DEFAULT_ROW_HEIGHT, + estimateRowHeight: (row: Row) => heightAt(row.score), + getWindowSpacers: () => spacers, + }); + scheduler.flushAll(); + expect(controller.getState().status.kind).toBe("ready"); + return { controller, model, scheduler }; +} + +/** + * Report the DOM's height for every loaded row, the way a real render does. + * + * These are measurements, not estimates: they land in the height index's + * measurement cache, which is the only record of what a row is actually worth + * once it stops being drawn. + */ +function measureLoadedRows( + controller: RowLayoutController, + from: number, + count: number, +): void { + for (let offset = 0; offset < count; offset += 1) { + const index = from + offset; + controller.measure(data(index), heightAt(index)); + } +} + +describe("window spacer height", () => { + test("the spacer is sized from what rows measured, not from the default height", () => { + const spacers = { + leadingRows: LEADING_ROWS, + trailingRows: TRAILING_ROWS, + }; + const { controller } = createWindowedController(spacers); + measureLoadedRows(controller, 0, LOADED_ROWS); + + const state = controller.getState(); + const loadedHeight = state.rowHeights.getTotalHeight(); + // The measurements landed, so everything below is about the spacer rather + // than about nothing having happened. + expect(loadedHeight).toBe(sumHeights(0, LOADED_ROWS)); + + const leadingTruth = LEADING_ROWS * MEASURED_MEAN; + const trailingTruth = TRAILING_ROWS * MEASURED_MEAN; + + expect + .soft(state.leadingHeight, "the leading spacer at the measured mean") + .toBeCloseTo(leadingTruth, 6); + expect + .soft(state.totalHeight, "the whole extent at the measured mean") + .toBeCloseTo(leadingTruth + loadedHeight + trailingTruth, 6); + // The extent is the population at the mean, end to end. + expect + .soft(state.totalHeight, "10,000 rows at the measured mean") + .toBeCloseTo( + (LEADING_ROWS + LOADED_ROWS + TRAILING_ROWS) * MEASURED_MEAN, + 6, + ); + }); + + test("a spacer for EVICTED rows is sized from the measurements they left behind", () => { + // Rows 0..49 are loaded and measured, then the window moves on to rows + // 50..99. The first fifty stop being rows and become spacer, and their + // heights survive only as retained measurements — which is the case the + // eviction feature exists for. + const spacers = { + leadingRows: LEADING_ROWS, + trailingRows: TRAILING_ROWS, + }; + const { controller, model, scheduler } = createWindowedController(spacers); + measureLoadedRows(controller, 0, LOADED_ROWS); + + model.setRows(rowsFrom(LOADED_ROWS, LOADED_ROWS)); + spacers.leadingRows = LEADING_ROWS + LOADED_ROWS; + spacers.trailingRows = TRAILING_ROWS - LOADED_ROWS; + scheduler.flushAll(); + + const state = controller.getState(); + expect(state.status.kind).toBe("ready"); + // The window really did move, and NOT ONE loaded row is measured — so any + // calibration the spacer shows can only have come from the fifty evicted + // rows' retained measurements. + expect(state.rowHeights.keyAt(0)).toEqual(data(LOADED_ROWS)); + for (let offset = 0; offset < LOADED_ROWS; offset += 1) { + expect( + state.rowHeights.hasMeasurement(data(LOADED_ROWS + offset)), + `loaded row ${LOADED_ROWS + offset} is unmeasured`, + ).toBe(false); + } + + expect + .soft(state.leadingHeight, "5,050 evicted rows at their measured mean") + .toBeCloseTo(spacers.leadingRows * MEASURED_MEAN, 6); + expect + .soft(state.totalHeight, "the extent after the window moved") + .toBeCloseTo( + spacers.leadingRows * MEASURED_MEAN + + state.rowHeights.getTotalHeight() + + spacers.trailingRows * MEASURED_MEAN, + 6, + ); + }); + + test("CONTROL: nothing measured, so the spacer is still the default height", () => { + // Cold start, and every non-windowed grid: with no measurement to + // calibrate against, the spacer is `rows × defaultRowHeight` exactly as + // before. This is the regression guard for the unwindowed path. + const { controller } = createWindowedController({ + leadingRows: LEADING_ROWS, + trailingRows: TRAILING_ROWS, + }); + + const state = controller.getState(); + expect(state.leadingHeight).toBe(LEADING_ROWS * DEFAULT_ROW_HEIGHT); + expect(state.totalHeight).toBe( + LEADING_ROWS * DEFAULT_ROW_HEIGHT + + state.rowHeights.getTotalHeight() + + TRAILING_ROWS * DEFAULT_ROW_HEIGHT, + ); + }); +}); diff --git a/packages/renderer-dom/src/row-layout-controller.ts b/packages/renderer-dom/src/row-layout-controller.ts index 6fd60a13..70d04580 100644 --- a/packages/renderer-dom/src/row-layout-controller.ts +++ b/packages/renderer-dom/src/row-layout-controller.ts @@ -711,17 +711,35 @@ export function createRowLayoutController< // Resolved once per window prepare, not per estimate pass: the window // does not move mid-convergence, and re-reading a caller-supplied getter // inside the pass loop would risk it disagreeing with itself across - // passes. Row counts, not pixel heights — multiplied by the SAME - // `defaultRowHeight` floor every unmeasured row already estimates at, so - // the spacer and the rows it flanks are drawn to one consistent scale. + // passes. const spacers = readWindowSpacers(); + // `getWindowSpacers` reports row COUNTS, so the controller has to supply a + // per-row height for rows it is not drawing and cannot identify. + // + // `defaultRowHeight` — which is what shipped — is that height only on a + // grid whose rows are all the default height. On a wrapping grid it is a + // systematic understatement: rows average whatever they wrap to, the + // spacer is sized at the floor, and the scroll extent comes out a fraction + // of the truth. The mean of what rows have ACTUALLY measured is the + // unbiased estimator of the same quantity, and it costs nothing to read. + // + // It stays an estimate — rows are not uniform, so the region's real height + // is not recoverable from a count. The residual is what viewport anchoring + // absorbs. + // + // Read from `initialRoot`, once, for the same reason `spacers` is: the + // estimate passes below add ESTIMATES, which never enter the measurement + // cache, so this is invariant across them and taking it once makes that + // explicit. + const spacerRowHeight = + initialRoot.getMeasuredHeightMean() ?? defaultRowHeight; const leadingHeight = Math.max( 0, - (spacers?.leadingRows ?? 0) * defaultRowHeight, + (spacers?.leadingRows ?? 0) * spacerRowHeight, ); const trailingHeight = Math.max( 0, - (spacers?.trailingRows ?? 0) * defaultRowHeight, + (spacers?.trailingRows ?? 0) * spacerRowHeight, ); const requestedScrollTop = resolveScrollRequest(request, leadingHeight); for (let pass = 0; pass < MAX_ESTIMATE_PLAN_PASSES; pass += 1) { diff --git a/packages/renderer-dom/src/types.ts b/packages/renderer-dom/src/types.ts index 4a9ff604..e84a672a 100644 --- a/packages/renderer-dom/src/types.ts +++ b/packages/renderer-dom/src/types.ts @@ -386,10 +386,12 @@ export interface CreateRowLayoutControllerOptions< * changes on a timescale of its own — often without the row model changing * at all. * - * Row COUNTS, not pixel heights: the controller multiplies by - * `defaultRowHeight`, the same floor every unmeasured row is already - * estimated at, so the spacer and the rows it flanks are drawn to one - * consistent scale rather than two independently-sourced ones. + * Row COUNTS, not pixel heights: the controller multiplies them by the mean + * height of every row it has measured so far, falling back to + * `defaultRowHeight` until something has been measured. The spacer is + * therefore an ESTIMATE of the region's height — good enough that the scroll + * extent tracks the population's real size, never exact, because a count + * cannot say which rows are out there or what any one of them is worth. * * The caller is responsible for the honesty gate — whether the window is * trustworthy enough to report at all (external authority, no grouping, an From 0a88d15075b454fbe4285f5bb97b43524275e9d3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 16 Aug 2026 20:54:24 -0700 Subject: [PATCH 4/4] docs(server-data): the spacer is an estimate, and says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `eviction.mdx` claimed that "where the retained heights are exact the spacer reproduces the region's height precisely, so the scroll extent is the same number after the eviction as before it and nothing shifts at all." No code path could produce that: the spacer is fed row counts, and there is no arrangement of retained heights that makes a count exact. Replaced with what it computes — the mean of every measured height, the theme's row height until one exists — and what that costs: the geometry does change across an eviction, and the viewport anchor absorbs it. `windowing.mdx` gains the one sentence a reader of the extent claim needs: a spacer is a row count, so the extent is accurate about how many rows are out there and approximate about how tall they are. The spec's §4 cost model was inverted — it had evicting measured rows free and only unmeasured rows costing an anchor correction. Every eviction costs one. §3's block collapse is unbuilt and its premise does not hold either: a retained per-block total has nowhere to go while the spacer is fed counts, so it is flagged for redesign rather than left as a plan. Co-Authored-By: Claude Opus 5 --- .changeset/window-spacer-measured-mean.md | 38 ++++++++++++++ .../content/docs/server-data/eviction.mdx | 4 +- .../content/docs/server-data/windowing.mdx | 2 + .../specs/2026-08-14-eviction-design.md | 52 +++++++++++++------ 4 files changed, 79 insertions(+), 17 deletions(-) create mode 100644 .changeset/window-spacer-measured-mean.md diff --git a/.changeset/window-spacer-measured-mean.md b/.changeset/window-spacer-measured-mean.md new file mode 100644 index 00000000..32c1e8c7 --- /dev/null +++ b/.changeset/window-spacer-measured-mean.md @@ -0,0 +1,38 @@ +--- +"@pretable/core": minor +"@pretable/react": minor +--- + +Windowed spacers are sized from what rows have measured, not from the default +row height. + +A windowed grid reserves the unloaded regions as spacers, and `getWindowSpacers` +reports those regions as row **counts** — how many rows sit before and after the +loaded window. The controller turned a count into pixels by multiplying by +`defaultRowHeight`. Its own comment said so: _"Row counts, not pixel heights."_ + +That is the region's real height only on a grid whose rows are all the default +height. On a grid whose rows wrap it is a systematic understatement of the whole +scroll extent, by the ratio between a wrapped row and the unwrapped default — +and the retained-measurement cache, which knows exactly what those rows were +worth, was never consulted for geometry at all. It is keyed by row identity +while the spacer arrives as a count, so the two systems had no way to meet. + +The controller now prices a spacer's rows at +`RowHeightIndex.getMeasuredHeightMean()` — the mean of every height the DOM has +reported, the retained heights of evicted rows included — falling back to +`defaultRowHeight` until something has been measured. A grid that has measured +nothing, and every grid with no window at all, is byte-for-byte unchanged. + +It remains an **estimate**: a count cannot say which rows are out there, so the +extent tracks the result's size without reproducing its height. The docs +previously claimed the spacer "reproduces the region's height precisely" where +retained heights were exact, which the code could not do and now does not claim. +`eviction.mdx` and `windowing.mdx` say what it actually computes, and that the +viewport anchor is what absorbs the residual. + +The mean is aggregated structurally — every hash node in the persistent height +index carries the sum of its values beside the count it already carried — rather +than threaded as a running total through `measure`, `apply`, retention eviction +and the cooperative replacement builder, so a copy-on-write rebuild cannot leave +it stale. diff --git a/apps/website/content/docs/server-data/eviction.mdx b/apps/website/content/docs/server-data/eviction.mdx index 6ec50a1e..3fb292b3 100644 --- a/apps/website/content/docs/server-data/eviction.mdx +++ b/apps/website/content/docs/server-data/eviction.mdx @@ -24,7 +24,9 @@ So the discriminator is the window, and everything below rides the same honesty Retention is bounded, because a ledger of every row a long-lived grid has ever shown is the memory eviction exists to bound. Past the bound — a hundred thousand retained measurements — the coldest entries go first, and a row returning from beyond it is estimated again like any row the grid has never seen. -**The scroll position does not move under the reader.** An evicted region does not stop occupying space: windowing's spacers cover it, sized from the population rather than from what is loaded. Where the retained heights are exact the spacer reproduces the region's height precisely, so the scroll extent is the same number after the eviction as before it and nothing shifts at all. Where the spacer is an estimate — rows that were never measured, or measurements that fell past the retention bound — the geometry genuinely does change, and the viewport anchor absorbs it: the row the reader is looking at keeps the position on screen it had, while the coordinates around it are rebuilt. Without the spacer the extent would collapse to the loaded rows, which is what makes that a claim rather than a tautology. +**The scroll position does not move under the reader.** An evicted region does not stop occupying space: windowing's spacers cover it, sized from the population rather than from what is loaded. What the grid knows about that region is how many rows are in it, never which ones, so it sizes the spacer at the mean height of every row it has actually measured — the ones on screen and the retained heights of the ones that have left — and falls back to the theme's row height until it has measured anything at all. That keeps the scroll extent tracking the size of the result even on a grid whose rows wrap well past the default height, which sizing at the default does not. + +It is an estimate, and it stays one. The evicted rows are not all worth the mean, so the geometry genuinely does change across an eviction, and it changes again as more rows are measured and the mean improves. The viewport anchor is what absorbs that: the row the reader is looking at keeps the position on screen it had, while the coordinates around it are rebuilt. Without the spacer the extent would collapse to the loaded rows, which is what makes that a claim rather than a tautology. **A cell selection survives its rows being released.** This is the hard one, because a cell range is defined by its two endpoint rows, and endpoints are exactly what eviction takes away. A range therefore records the dataset span it covers as well as its endpoints, and the span is what answers questions while the rows are gone: how many rows are selected is arithmetic over the span with nothing loaded — `getCellSelectionSummary()` on the grid handle reads it — and whether a rendered row is selected is containment on that row's dataset position. The ordinary sliding case is the one that matters and is covered: a slide that clears the range's start while its end is still loaded keeps the range whole rather than collapsing it onto the survivor, and scrolling back repaints the rows that were never unselected. A row proven **deleted** inside the loaded span still prunes the range, which is the same rule from the other side. diff --git a/apps/website/content/docs/server-data/windowing.mdx b/apps/website/content/docs/server-data/windowing.mdx index 9eea51b1..8049edf8 100644 --- a/apps/website/content/docs/server-data/windowing.mdx +++ b/apps/website/content/docs/server-data/windowing.mdx @@ -42,6 +42,8 @@ Two things change, and they are the same claim seen from two sides. **The scroll extent describes the population.** The unmaterialized regions are reserved as spacers — `start` rows ahead of the window, and whatever the total says follows its end behind it — so the scrollbar measures 480 rows while a hundred are in memory, and a reader dragging it is moving through the result rather than through your cache. A total on its own never does this: as [Totals and honesty](/docs/server-data/totals) says, a grid with 200 rows and a claimed 10,000 scrolls 200 rows. The window is the part that says where the other rows would be. +A spacer is a row count, not a pixel height, so the grid prices those rows at the mean height of the rows it has measured — the theme's row height until it has measured one. The extent is therefore an estimate of the result's height, accurate about how many rows are out there and approximate about how tall they are. [Eviction](/docs/server-data/eviction#what-survives) covers what that costs and what absorbs it. + **Every row reports its dataset position.** `aria-rowindex` on a body row counts from the population, not from the array: with the window above, the row holding record 100 publishes `aria-rowindex="102"` — one for a zero-based index becoming ARIA's one-based one, and one for the header row, which is always row 1. Those two are gated together, by one rule: diff --git a/docs/superpowers/specs/2026-08-14-eviction-design.md b/docs/superpowers/specs/2026-08-14-eviction-design.md index 296b4beb..c9d6227b 100644 --- a/docs/superpowers/specs/2026-08-14-eviction-design.md +++ b/docs/superpowers/specs/2026-08-14-eviction-design.md @@ -118,31 +118,49 @@ retained**. Beyond the 100_000-measurement bound, an evicted block collapses to single retained total — one number per block rather than N per row — so the geometry stays exact while per-row detail is dropped. +**Unbuilt, and its premise is wrong as written.** Block collapse would only +preserve geometry the spacer could spend, and per §4 the spacer is fed row +counts, not heights: a retained per-block total has nowhere to go. Making an +evicted region's height exact needs `getWindowSpacers` to carry heights or row +keys — the API change §4 does not take — and this section should be redesigned +against that before any of it is built. + This matters because **the tombstone cache is itself the memory eviction exists to bound.** Retaining every height forever bounds nothing. Block collapse is what makes the feature real, and it is the least-designed part of this spec. -### 4. Anchoring is for drift, not for eviction +### 4. Anchoring is for drift, and every eviction drifts + +**Corrected twice.** The first draft had this backwards. So did the correction: +it assumed the spacer is built out of retained heights, and it is not. -**Corrected by spike; the first draft of this design had it wrong.** +The spacer's inputs are row **counts**. `getWindowSpacers` reports +`leadingRows` / `trailingRows` and nothing else, while the measurement cache is +keyed by row identity — so the controller knows how many rows are out there and +never which, and cannot sum their heights even in principle. It prices them at +`RowHeightIndex.getMeasuredHeightMean()`, the mean of every height the DOM has +reported (visible measurements and retained ones alike), falling back to +`defaultRowHeight` while nothing has been measured. -With **exact** retained heights the spacer reproduces the evicted region's height -precisely, global coordinates do not change, and **nothing moves** — no anchoring -is involved. A test asserting "anchoring keeps the row in place" across such an -eviction passes with the anchor restore deleted. It is a tautology. +That is an unbiased estimator of the region, not a reproduction of it. Before +the calibration landed it was `rows × defaultRowHeight`, which understates a +wrapping grid's extent by the ratio of a wrapped row's height to the default — +the case this feature exists for. -Anchoring is needed only where the spacer **differs** from the true height: -rows that were never measured, or a collapsed block carrying an approximation. -Measured with a 5% estimate error over 100 evicted rows: +So the cost model is: **every eviction costs an anchor correction.** Its size is +the gap between the evicted rows' real heights and the mean — small once a +representative sample has been measured, never zero. Measured with a 5% error +over 100 evicted rows: | | Row's on-screen position | | --------------- | ------------------------ | | Anchor restored | **120px** — unchanged | | Anchor removed | **325px** — a 205px jump | -So the cost model is: **evicting measured rows is free; evicting unmeasured rows -costs an anchor correction.** Drift absorbs below the viewport, so nothing the -user is looking at moves. +There is no "exact" case to write a test against, which is a stronger statement +than the vacuity warning below: a test that hands `planViewport` the exact sum +of the evicted rows' heights is not merely tautological, it is asserting over a +number production never computes. ### 5. Focus @@ -153,11 +171,13 @@ re-seat target is the nearest surviving row in the direction of travel. ## Testing -- **Geometry**, `layout-core`: extent unchanged across eviction; a control - proving it collapses without the spacer. +- **Geometry**, `renderer-dom`: the spacer's PIXEL height, driven through + `createRowLayoutController` — the only thing that turns a row count into a + height. `window-spacer-height.test.ts`. A `planViewport`-level test cannot + see this: it is handed the height it is meant to be checking. - **Anchoring**, `layout-core`: the drift case above, with the mutation that - reddens it — a test written against the _exact_ case is vacuous and must not - be written. + reddens it — a test written against an _exact_ spacer is vacuous and must not + be written, and per §4 there is no such case to write one against anyway. - **Selection**, `grid-core`: count over a span with no rows loaded; containment for a returning row; span splitting; and that a **deleted** row still prunes while an **evicted** one does not.