diff --git a/apps/website/content/docs/headless/getting-started.mdx b/apps/website/content/docs/headless/getting-started.mdx
index 1a0562206..bd832bea8 100644
--- a/apps/website/content/docs/headless/getting-started.mdx
+++ b/apps/website/content/docs/headless/getting-started.mdx
@@ -6,7 +6,7 @@ nav: Headless engine
A headless renderer starts with `createLocalRowModel`. Add `createGrid` only when your renderer needs UI state. The grid below is exactly that: 75 services rendered from a plain `
`, with `createLocalRowModel` driving sort and filter and `createGrid` driving row selection.
-`setQuery` — triggered here by typing into the filter — does not settle synchronously. The model rebuilds cooperatively, yielding between slices so a large query cannot block the frame, and publishes progress and failures through `status`. (Sorting a column is the exception: a sort-only change on ungrouped data re-orders rows the model has already indexed, so it settles synchronously.) Two things follow, and the example does both: **select** what you subscribe to, or you re-render on every slice, and **read `status`**, or a rebuild that fails leaves stale rows on screen with nothing to say so.
+`setQuery` does not settle synchronously in the general case — the model rebuilds cooperatively, yielding between slices so a large query cannot block the frame, and publishes progress and failures through `status`. Sort-only and filter-only changes on ungrouped data are the exception, including typing into the filter here: each re-orders or re-selects rows the model has already indexed, so it settles synchronously with no `rebuilding` phase. Grouped and mixed changes still rebuild cooperatively — see [Snapshot & subscribe](/docs/headless/state-model) for a demo. Two things are still worth doing, and the example does both: **select** what you subscribe to, so a cooperative rebuild elsewhere doesn't re-render you on every slice, and **read `status`**, so a failed rebuild doesn't leave stale rows on screen with nothing to say so.
diff --git a/apps/website/content/docs/headless/state-model.mdx b/apps/website/content/docs/headless/state-model.mdx
index 29d39fba3..a613b727f 100644
--- a/apps/website/content/docs/headless/state-model.mdx
+++ b/apps/website/content/docs/headless/state-model.mdx
@@ -6,7 +6,7 @@ nav: Headless engine
The row model and UI grid are independent observable stores. Subscribe only to the state your renderer uses.
-A `setQuery` that changes the filter does not settle synchronously — the model rebuilds cooperatively, yielding between slices so a large query cannot block the frame. A sort-only change on ungrouped data is the one exception: it re-orders rows the model has already indexed, so it settles synchronously and never publishes a `rebuilding` phase — a plain sort needs no progress UI. On a small dataset even the cooperative rebuild is over before a human (or React) can see it happen, which is why the button below filters 150,000 rows instead of 75: watch `status` cycle through `rebuilding` with a live percentage, then settle back to `ready`.
+A `setQuery` that changes row grouping does not settle synchronously — the model rebuilds cooperatively, yielding between slices so a large query cannot block the frame. Sort-only and filter-only changes on ungrouped data are the exception: each re-orders or re-selects rows the model has already indexed, so it settles synchronously and never publishes a `rebuilding` phase — plain sorting and filtering need no progress UI. Grouped and mixed changes still rebuild cooperatively. On a small dataset even the cooperative rebuild is over before a human (or React) can see it happen, which is why the button below groups 150,000 rows instead of 75: watch `status` cycle through `rebuilding` with a live percentage, then settle back to `ready`.
@@ -43,7 +43,7 @@ committed, and mutations keep committing into it meanwhile: `setRows`,
`applyTransaction` and both expansion paths publish a new snapshot while a
rebuild runs. A renderer that stops re-reading the snapshot during a rebuild
drops those, which is exactly the streaming-plus-filter case. The table in the
-example above stays on the last committed filter result throughout, exactly
+example above stays on the last committed result throughout, exactly
like this.
`completedRows` and `totalRows` count the rebuild's **work units, not rows**.
diff --git a/apps/website/content/examples/headless-rebuild-progress/RebuildProgressDemo.tsx b/apps/website/content/examples/headless-rebuild-progress/RebuildProgressDemo.tsx
index 78585d7d1..857f03fce 100644
--- a/apps/website/content/examples/headless-rebuild-progress/RebuildProgressDemo.tsx
+++ b/apps/website/content/examples/headless-rebuild-progress/RebuildProgressDemo.tsx
@@ -19,8 +19,8 @@ export function RebuildProgressDemo() {
// Selecting `snapshot` (not the whole state) means this component bails
// out on identity between rebuild slices — it only renders once, when the
- // filter actually lands. `RebuildProgress` above is the one re-rendering on
- // every slice in the meantime.
+ // grouping change actually lands. `RebuildProgress` above is the one
+ // re-rendering on every slice in the meantime.
const readSnapshot = useCallback(
() => rowModel.getState().snapshot,
[rowModel],
@@ -31,28 +31,29 @@ export function RebuildProgressDemo() {
readSnapshot,
);
- const [filtered, setFiltered] = useState(false);
+ const [grouped, setGrouped] = useState(false);
- // A FILTER change, not a sort: a sort-only change on ungrouped data
- // settles synchronously and never publishes a `rebuilding` phase, so it
- // could not demonstrate the progress readout at all.
- const toggleFilter = () => {
- const next = !filtered;
- setFiltered(next);
+ // A GROUPING change, not a filter or a sort: both of those settle
+ // synchronously on ungrouped data (the sort fast path and the filter fast
+ // path each require `rowGroups.length === 0`), so neither could
+ // demonstrate the progress readout anymore. Grouping never takes a fast
+ // path — it always rebuilds cooperatively — which is exactly why it is the
+ // vehicle here.
+ const toggleGrouped = () => {
+ const next = !grouped;
+ setGrouped(next);
rowModel.setQuery({
...snapshot.query,
- filters: next
- ? [{ columnId: "region", operator: "equals", value: "west" }]
- : [],
+ rowGroups: next ? [{ columnId: "region" }] : [],
});
};
return (
-
diff --git a/apps/website/content/examples/headless-rebuild-progress/__tests__/RebuildProgressDemo.test.tsx b/apps/website/content/examples/headless-rebuild-progress/__tests__/RebuildProgressDemo.test.tsx
index ad46a4707..a264038f5 100644
--- a/apps/website/content/examples/headless-rebuild-progress/__tests__/RebuildProgressDemo.test.tsx
+++ b/apps/website/content/examples/headless-rebuild-progress/__tests__/RebuildProgressDemo.test.tsx
@@ -43,15 +43,12 @@ describe("RebuildProgressDemo", () => {
});
fireEvent.click(
- screen.getByRole("button", { name: /filter 150,000 orders/i }),
+ screen.getByRole("button", { name: /group 150,000 orders/i }),
);
await waitFor(
() => {
expect(status).toHaveTextContent("Ready.");
- // The filter landed: only the 30,000 west-region orders survive,
- // and every preview row is one of them.
- expect(screen.getByText(/30,000 rows indexed/)).toBeInTheDocument();
},
{ timeout: REBUILD_TIMEOUT },
);
@@ -60,22 +57,36 @@ describe("RebuildProgressDemo", () => {
// Proves the rebuild actually published at least one intermediate
// `rebuilding` slice before landing on `ready` — the whole reason this
// example exists. On the small 75-row custom-renderer example this
- // would be a coin flip; at 150,000 rows it is not. A sort-only change
- // could never pass this: on ungrouped data it settles synchronously
- // with no `rebuilding` phase at all.
+ // would be a coin flip; at 150,000 rows it is not. A sort-only or
+ // filter-only change could never pass this: on ungrouped data both
+ // settle synchronously with no `rebuilding` phase at all. Grouping is
+ // the one change vehicle that is cooperative by design, not omission.
expect(sawRebuilding).toBe(true);
+ // The grouping landed: every visible region group has surfaced as its
+ // own row (5 regions), distinct from the plain data rows.
const previewRows = screen.getAllByRole("row").slice(1);
expect(previewRows.length).toBeGreaterThan(0);
- for (const row of previewRows) {
- expect(row).toHaveTextContent("west");
- }
+ const groupRows = previewRows.filter((row) =>
+ /\(\d+\)/.test(row.textContent ?? ""),
+ );
+ expect(groupRows.length).toBeGreaterThan(0);
+
+ // Group rows sit alongside the 150,000 data rows in the indexed
+ // count, so it goes up, not down, once grouping lands.
+ const rowsIndexedText = screen.getByText(/rows indexed/).textContent;
+ const indexedCount = Number(
+ rowsIndexedText
+ ?.match(/^([\d,]+) rows indexed/)?.[1]
+ ?.replace(/,/g, ""),
+ );
+ expect(indexedCount).toBeGreaterThan(150_000);
},
REBUILD_TIMEOUT + 5_000,
);
it(
- "clears the filter cooperatively on the second click",
+ "ungroups cooperatively on the second click",
async () => {
render();
await waitFor(() => screen.getByText(/150,000 rows indexed/), {
@@ -83,11 +94,15 @@ describe("RebuildProgressDemo", () => {
});
fireEvent.click(
- screen.getByRole("button", { name: /filter 150,000 orders/i }),
+ screen.getByRole("button", { name: /group 150,000 orders/i }),
+ );
+ await waitFor(
+ () => {
+ const status = screen.getByRole("status");
+ expect(status).toHaveTextContent("Ready.");
+ },
+ { timeout: REBUILD_TIMEOUT },
);
- await waitFor(() => screen.getByText(/30,000 rows indexed/), {
- timeout: REBUILD_TIMEOUT,
- });
let sawRebuilding = false;
const status = screen.getByRole("status");
@@ -102,9 +117,7 @@ describe("RebuildProgressDemo", () => {
subtree: true,
});
- fireEvent.click(
- screen.getByRole("button", { name: /show all 150,000 orders/i }),
- );
+ fireEvent.click(screen.getByRole("button", { name: /ungroup/i }));
await waitFor(
() => {
@@ -115,10 +128,17 @@ describe("RebuildProgressDemo", () => {
);
observer.disconnect();
- // Removing a filter re-runs the same cooperative path over all
+ // Removing the grouping re-runs the same cooperative path over all
// 150,000 source rows, so the toggle demonstrates progress in both
// directions.
expect(sawRebuilding).toBe(true);
+
+ // Ungrouped: no group rows remain, only plain data rows.
+ const previewRows = screen.getAllByRole("row").slice(1);
+ const groupRows = previewRows.filter((row) =>
+ /\(\d+\)/.test(row.textContent ?? ""),
+ );
+ expect(groupRows.length).toBe(0);
},
REBUILD_TIMEOUT + 5_000,
);
diff --git a/apps/website/content/examples/headless-rebuild-progress/data.ts b/apps/website/content/examples/headless-rebuild-progress/data.ts
index a173c2562..222580fac 100644
--- a/apps/website/content/examples/headless-rebuild-progress/data.ts
+++ b/apps/website/content/examples/headless-rebuild-progress/data.ts
@@ -8,7 +8,7 @@ export interface Order {
const REGIONS = ["north", "south", "east", "west", "central"];
// Deliberately large and deterministic (no Math.random): big enough that a
-// filter change cannot settle inside one animation frame, so the rebuild
+// grouping change cannot settle inside one animation frame, so the rebuild
// really does publish multiple `rebuilding` slices instead of jumping
// straight to `ready` — see the note on the smaller custom-renderer example.
export const ORDER_COUNT = 150_000;
diff --git a/apps/website/content/examples/headless-rebuild-progress/example.ts b/apps/website/content/examples/headless-rebuild-progress/example.ts
index d4558f711..b04bf4226 100644
--- a/apps/website/content/examples/headless-rebuild-progress/example.ts
+++ b/apps/website/content/examples/headless-rebuild-progress/example.ts
@@ -3,7 +3,7 @@ import { defineExample } from "../../../lib/docs/examples/define";
export default defineExample({
title: "Watching a rebuild",
description:
- "Filtering 150,000 rows cannot settle inside one animation frame, so status.kind cycles through rebuilding with a live completedRows/totalRows progress readout before returning to ready.",
+ "Grouping 150,000 rows cannot settle inside one animation frame, so status.kind cycles through rebuilding with a live completedRows/totalRows progress readout before returning to ready.",
files: [
"RebuildProgressDemo.tsx",
"RebuildProgress.tsx",
diff --git a/docs/superpowers/plans/2026-08-19-filter-subset-rebuild.md b/docs/superpowers/plans/2026-08-19-filter-subset-rebuild.md
new file mode 100644
index 000000000..8e6e37584
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-19-filter-subset-rebuild.md
@@ -0,0 +1,61 @@
+# Filter Subset Rebuild Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Filter-only `setQuery` on ungrouped data settles at TanStack parity (50k: ~240ms → ≤ same-run TanStack ~58ms) via a synchronous subset rebuild that carries unflipped records by identity and never sorts the full set.
+
+**Architecture:** `isFilterOnlyChange` classifier gate → new `packages/row-model/src/filter-rebuild.ts` (verdict diff; O(flipped) record + map updates; merge of surviving visible order with the sorted flipped-in subset; bulk tree build) → wired into `setQuery` beside the sort fast path, publishing an ordinary barrier.
+
+**Spec:** `docs/superpowers/specs/2026-08-19-filter-subset-rebuild-design.md` — read first. Baseline + attribution: scratchpad `filter-baseline.md`.
+
+**Conventions:** as the #457 arc (TDD, mutation-harden, constraints-only comments, prettier/lint/typecheck per commit, verify HEAD before amends, never touch ~/repos/pretable). Branch: `blove/filter-fast-path` off `a438efb0`.
+
+**Grounding facts (verify only if contradicted):** classifier facets live in `classifyQueryDelta` (compiled-query.ts); `isSortOnlyChange` is the pattern to mirror. Sort-rebuild (`sort-rebuild.ts`) shows: guard style, `fillSortKeysFromPrevious` per-row fill, decorated `{record, keys}` entries feeding `createOrderStatisticTreeFromSortedEntries`, `publishCommittedRoot` publish, error semantics. The merged per-row cache entry holds sortKeys (unguarded) + metadata (guarded). `#finalizeMetadata` is the one metadata constructor. Filter predicates run in `evaluate` via `evaluateFilter(filter, column, value)` per active filter — locate and reuse, do not duplicate predicate semantics. `visible.rows` tree entries are `OrderedRowEntry {record, keys}`; in-order walk via `entries()`/`range`. Counters wire through diagnostics.ts (interface + init + reset list).
+
+---
+
+### Task F1: `isFilterOnlyChange` classifier
+
+**Files:** `packages/row-model/src/compiled-query.ts`; test `__tests__/query-delta.test.ts`.
+
+- [ ] TDD: mirror the `isSortOnlyChange` suite — true only when runtime filters differ and nothing else does; false for each other facet changing alongside; false when authorities differ; false under external filter authority both sides (runtime filters empty twice); false for foreign plans. Export `isFilterOnlyChange` beside `isSortOnlyChange`, derived from the same delta.
+- [ ] Mutation: flip the derivation to ignore `sortChanged` → a combined sort+filter case must fail.
+- [ ] Suite green (413 baseline), commit: `feat(row-model): classify filter-only plan changes`.
+
+### Task F2: `filter-rebuild.ts` — the subset rebuild
+
+**Files:** create `packages/row-model/src/filter-rebuild.ts`; modify `diagnostics.ts` (counters: `filterRebuilds`, `filterRowsFlipped`, `filterMergeSortedInsertions`, and `filterRebuildMs` or reuse — report choice); test `__tests__/filter-fast-path.test.ts` (create).
+
+Signature mirrors `rebuildRootForSortOnlyChange`: `rebuildRootForFilterOnlyChange({captured, nextPlan, revision, now, instrumentation})`. Guards: TypeError unless `isFilterOnlyChange`; TypeError if grouped.
+
+Algorithm (spec Design §subset rebuild — follow it exactly):
+- One pass over `captured.sourceOrder.entries()`: per record — `fillSortKeysFromPrevious(nextPlan, captured.queryPlan, record, instrumentation)` (100% carries); compute the NEW verdict via the plan's predicate machinery over the row (reuse `evaluateFilter` + accessor reads — expose a plan-internal `filterVerdict(plan, row)` static+free function if needed; do NOT re-implement predicate semantics); diff vs `record.metadata.filterPasses`.
+- Unflipped: carry. Flipped: build the new record through the existing metadata construction (a plan-internal helper that rebuilds metadata with a new `filterPasses` around carried values — the `#finalizeMetadata` seam; keys already in the store). Transient-set flipped rows only; zero flips → rows map carries by identity (pin this).
+- Visible: in-order walk of `captured.visible.rows` collecting still-passing entries (reuse entry objects when the record carried; flipped-out skipped); flipped-in rows become fresh decorated entries of their NEW records, sorted among themselves with `compareWithSortKeys` + id tiebreak; single merge (both sequences already strictly sorted by the same total order) → `createOrderStatisticTreeFromSortedEntries`.
+- Root: revision/parentRevision/cause as sort-rebuild; `sourceOrder`/`expansion` carried. Instrumentation: counts + wall time.
+
+- [ ] TDD red-first per the spec's Testing list items 2–5 (equivalence incl. filter-to-empty/empty-to-filter/zero-flip; identity; merge fixture with interleaved + tied flip-ins; counters). Cold-model oracles throughout; fixture controls asserted (flip-ins interleave, tie pair's source order opposes id order).
+- [ ] Mutations: (a) merge order broken (append instead of merge) → bulk constructor throw or equivalence fail; (b) rebuild ALL records → identity test fails; (c) verdict diff inverted → equivalence fails.
+- [ ] Suite green, typecheck, lint, prettier. Commit: `feat(row-model): synchronous subset rebuild for filter-only changes`.
+
+### Task F3: wire into `setQuery`
+
+**Files:** `packages/row-model/src/create-local-row-model.ts`; test `__tests__/filter-fast-path.test.ts`.
+
+- [ ] Branch beside the sort fast path (after it, same shape): `isFilterOnlyChange && ungrouped` → cancelActive("superseded"), rebuild, `publishCommittedRoot(root, prev, rev)` (default barrier reason — NOT "reorder"), resolved transition; error path mirrors the sort branch exactly (shared error-construction helper if extraction is clean — implementer judgment, report).
+- [ ] TDD: mirror the sort fast-path model-level suite — synchronous (no scheduler entries), notify-once, supersede in-flight cooperative, accessor/predicate failure shape pinned against the slow path first, recovery, setRows-after behaves, equivalence vs cold model, `changesSince` reports a NORMAL barrier reason (pin "bulk-replace", NOT "reorder" — a wrong reorder here would corrupt the renderer; this is the highest-stakes assertion in the cycle, mutation-verify it: publish "reorder" → the test must fail).
+- [ ] Existing cooperative-path tests that used filter-only changes as their subject vehicle now take the fast path — same edit discipline as cycle-1 Task 5 (justify each; keep cooperative subjects on cooperative changes, e.g. sort+filter combined).
+- [ ] Full package suite green; root build+typecheck. Commit: `feat(row-model): filter-only setQuery completes synchronously on flat queries`.
+
+### Task F4: verification
+
+- [ ] Full repo: build, typecheck, lint, test, api (no report drift expected — nothing public changes).
+- [ ] Bench protocol (filter-baseline.md method): both filter scripts × both scales × both adapters, repeats 3; PLUS the no-regression sweep: sort both scales, grouped gate (3 quiet runs or the paired-control method if loaded), mount metric. Evaluate the four spec bars.
+- [ ] One trace of 50k filter-metadata: confirm the settle tail collapsed and the renderer replacement still runs its cooperative retained-state path.
+- [ ] Write `/filter-cycle-results.md` with verdicts. STOP: merge decision to the user with numbers.
+
+## Self-review notes (applied)
+
+- Spec coverage: classifier → F1; rebuild+counters → F2; wiring+journal pin → F3; bars → F4. Reserve lever and rejected approach have no tasks.
+- Names: `isFilterOnlyChange`, `rebuildRootForFilterOnlyChange`, `filterRebuilds`/`filterRowsFlipped`/`filterMergeSortedInsertions`, plan-internal `filterVerdict` (working name; implementer may improve, reporting it).
+- The one semantic decision delegated with a pin requirement: zero-flip behavior (new revision, wholesale carry) — F2 decides and pins it; the spec's Testing item 2 names it.
diff --git a/docs/superpowers/plans/2026-08-24-columnar-verdicts.md b/docs/superpowers/plans/2026-08-24-columnar-verdicts.md
new file mode 100644
index 000000000..b37f57e2c
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-24-columnar-verdicts.md
@@ -0,0 +1,54 @@
+# Columnar Verdict Cache Implementation Plan (Amendment J)
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace the per-row verdict pass in the filter fast path with compiled predicates scanning slot-indexed value vectors, per Amendment J (`docs/superpowers/specs/2026-08-24-dense-handle-amendment-j-columnar-verdicts.md` — read it first; its five design deltas are binding).
+
+**Architecture:** All inside `packages/row-model`. `CompiledRowInput` gains `slot`; the plan's shared evaluation cache gains a per-filter-column `SlotVector` written wherever metadata is evaluated (the freshness invariant), adopted by reference across filter-only changes; `compileFilterPredicate` hoists operator dispatch out of the row loop; `filter-rebuild` consumes a bulk verdict scan. Per-row `filterVerdict` keeps its exact semantics for k-sized/grouped paths.
+
+**Worktree:** `/Users/blove/repos/pretable/.claude/worktrees/homepage-hero-demo-3878ef`, branch `blove/filter-fast-path`. Test filter: `pnpm --filter @pretable-internal/row-model test` (557 green).
+
+**Verified anchors** (re-verify; lines drift): `compiled-query.ts` — `CompiledRowInput` ~80; `CachedEvaluation` + cache comment ~262-300; constructor/active-columns ~1440-1470; `evaluate` ~1471 (collects `values` Map over `#active`, calls `#filterVerdict((id) => values.get(id))`); `#filterVerdict` ~1623 (the one predicate loop, `evaluateFilter(filter, #byId.get(...), valueOf(...))`); static `filterVerdict` ~1650 (memo guard then live accessor reads); `adoptEvaluationCache` static ~1900-1930 (`nextPlan.#evaluationCache = previousPlan.#evaluationCache`); `evaluateFilter` + `FILTER_OPERATORS` (search; operand validation at ~735-800 shows the operator/type space: number/date/text/enum/boolean, between/dateBetween ranges, string ops). `filter-rebuild.ts` — the `forEachSlotEntry` walk calling `filterVerdict(nextPlan, previous as never)` per row. Callers of `evaluate`: `row-store.ts` (buildRowStore, rebuildRowStoreForQuery — the latter is DEAD code, do not thread it), `transaction-draft.ts` `createRecord` (~290) and its callers, `cooperative-transition.ts`. Callers of `filterVerdict`: `filter-rebuild.ts`, `transaction-draft.ts` (`passesNext` memo), possibly `distinct-values.ts`/`group-index.ts` — grep and list them in your report.
+
+**Bars:** Amendment J §Bars. Grouped and k-sized paths byte-identical in behavior; zero public API drift (`pnpm api` in the final task must show NOTHING — `CompiledRowInput` is internal; if it turns out to be re-exported through a governed barrel, STOP and report).
+
+---
+
+### Task 1: `CompiledRowInput.slot` + threading
+
+**Files:** `compiled-query.ts` (the input type), `row-store.ts`, `transaction-draft.ts`, `cooperative-transition.ts`, `filter-rebuild.ts`, `sort-rebuild.ts`, plus every other `evaluate`/`filterVerdict`/`sortKeysOf`/`fillSortKeysFromPrevious` call site the compiler finds.
+
+- Add `readonly slot: number` to `CompiledRowInput` (REQUIRED — the compiler enumerates the call sites; every caller has the record or just allocated the slot). Records passed `as never` (whole-record inputs) already carry `.slot`, so most sites compile untouched — verify which literal-input sites need an explicit `slot`.
+- No behavior change; the field is unread this task. Tests: full suite green with zero existing-test edits (except fixtures that build `CompiledRowInput` literals — sanctioned, list them). Commit: `feat(row-model): thread slots into compiled-query inputs`.
+
+### Task 2: compiled predicates
+
+**Files:** `compiled-query.ts` (+ its test file)
+
+- `compileFilterPredicate(filter, column): (value: unknown) => boolean` — one specialized closure per runtime filter, built at plan construction (`#compiledPredicates: readonly ((value: unknown) => boolean)[]` parallel to `#runtimeQuery.filters`), hoisting operand normalization out of the loop. It must reproduce `evaluateFilter`'s semantics EXACTLY — implement it by refactoring `evaluateFilter` into "compile" + "apply" so the semantics exist once (the current `evaluateFilter(filter, column, value)` becomes `compileFilterPredicate(filter, column)(value)` internally, or delegates — no duplicated predicate logic).
+- `#filterVerdict` switches to the compiled array (same `every` shape, no `#byId` get per row).
+- Tests: an exhaustive operator-semantics equivalence sweep — for every (column type, operator) pair in `FILTER_OPERATORS` with representative + edge values (empty string, NaN-adjacent, null/undefined cells, between bounds inclusive/exclusive as today), assert compiled predicate ≡ the pre-refactor behavior (pin against expected outcomes, not against the refactored code). Red-first for the new API; full suite green. Commit: `perf(row-model): compile filter predicates once per plan`.
+
+### Task 3: columnar value store + freshness writes
+
+**Files:** `compiled-query.ts` (+ tests)
+
+- The shared-cache object (whatever `adoptEvaluationCache` moves — currently the `#evaluationCache` WeakMap reference) grows a sibling `#columnarValues: Map}>` for FILTER columns, adopted in the same static call. Because plans currently share the WeakMap by direct field assignment, decide the container: wrap both in one `#sharedEvaluationState` object so adoption stays ONE assignment — refactor `adoptEvaluationCache` accordingly (its doc comment updates).
+- **REVISED per Amendment J §3 (rev. 2026-08-24)**: `evaluate` NEVER writes cells (two ingest paths hand it a `-1` placeholder slot, and aborted drafts must not leave cell writes). The bulk scan (Task 4) is the only writer. THIS task implements the store plus the commit-side CLEARS: in `applyFlatTransactionDraft`'s success path (beside the `slotWrites` block), clear the cells of every prepared and removed row's slot on the current plan's shared state; `replaceFlatRowsDraft` and the initial build start from empty vectors (fresh shared state or a wholesale reset — pick the shape that matches how shared state is created there and document it). The Task-1 review's placeholder-site table (`d64fba85` review) is the authority on which slots are real. The vectors are COW (`slotVectorWithAll`) — but per-row writes during an O(n) ingest would copy chunks per row; use the transient pattern instead: accumulate writes per evaluation BURST... simpler and correct: `evaluate` calls land during bulk builds and k-sized updates alike, so give the store an explicit mutable-fill discipline mirroring the codebase's transient/freeze idiom: cells are written into a MUTABLE chunk representation owned by the shared state (plans on the same shared state never race — the model is single-threaded and the store is not revision-scoped; it is a CACHE, not a source of truth — old snapshots never read it). Document exactly that: the columnar store is cache-not-truth, mutable-in-place, keyed by (columnId, slot), correct because of the freshness invariant, and NEVER consulted by snapshot reads. This avoids COW entirely — record the deviation from the plan-of-record wording (Amendment J §2 says chunked COW; in-place-mutable-cache is simpler and sound because nothing revision-scoped reads it; note it in the amendment via a one-line edit in this task's commit).
+- Slot-capacity growth: vectors grow like the allocator (chunk table extension on demand).
+- Tests: freshness invariant oracle — a scripted model (ingest → filter commit → update changed values → remove+add reusing a slot → filter commit → a transaction whose accessor THROWS mid-draft (aborted) → filter commit) asserting the columnar answer equals a fresh accessor read for EVERY (filter column, live slot) after every step. Mutation (perform, restore, report): skip the commit-side clear for prepared rows → the update step's oracle fails. Commit: `feat(row-model): columnar filter-value cache with write-through freshness`.
+
+### Task 4: bulk verdict scan + filter-rebuild consumption
+
+**Files:** `compiled-query.ts`, `filter-rebuild.ts` (+ tests)
+
+- New internal `bulkFilterVerdicts(plan, recordsBySlot, slotCapacity): MembershipBitset` — per filter: loop live slots (holes-aware walk over `recordsBySlot`), read the cell (hole → live accessor read + write-through fill), apply the compiled predicate, AND across filters into the bitset (first filter sets, subsequent filters clear — or evaluate all filters per slot in one pass; CHOOSE the one-pass-per-slot shape so a row's cells are read with locality and the fallback fill happens at most once per row, and document the choice).
+- `filter-rebuild.ts`: the walk keeps its shape but the per-row `filterVerdict` call is replaced by a bitset lookup from ONE `bulkFilterVerdicts` call before the walk (or fold the flip-diff into the scan loop — keep the existing walk + `testMembershipBit(nextVisibleSlots, slot)` reads; simplest coherent shape wins, document it). Zero-flip identity carry, merge, `derivedById`, instrumentation counters all unchanged; add a `columnarVerdictScans` work counter.
+- Per-row `filterVerdict` (static) is UNTOUCHED for k-sized/grouped callers.
+- Tests: the columnar-vs-per-row equivalence oracle on randomized query scripts (reuse the Task 3 fixture style; include newly-referenced columns mid-script to exercise the hole-fill path); all existing filter fast-path pins (order-independence, zero record rebuilds, `filterRowsFlipped` counts) must pass UNCHANGED. Mutation: make the scan skip the hole-fill fallback → the newly-referenced-column script step fails. Commit: `perf(row-model): filter rebuild verdicts from columnar scan`.
+
+### Task 5: gates + measurement
+
+- `pnpm build && pnpm api` — expect ZERO `.api.md` drift (all internal). Full root `pnpm test` (react flake rule). Lint, prettier.
+- Bench per the established protocol (M1+M2 plan Task 8 verbatim; scales `target` + `hypothesis`; baseline = the commit before this plan's Task 1; TanStack controls in band; interleaved paired sides; no grep|head; traced share run AFTER headlines for the verdict-share ≲3% bar).
+- Deliverable: `docs/superpowers/specs/2026-08-24-columnar-verdicts-results.md` — table, deltas vs 108.3/116.8, both bar verdicts, fitness, conclusion (including what remains: rebuild-body ~17%, HAMT ~9%, render/commit ~23%). Commit.
diff --git a/docs/superpowers/plans/2026-08-24-dense-handle-m0-probe.md b/docs/superpowers/plans/2026-08-24-dense-handle-m0-probe.md
new file mode 100644
index 000000000..9a612a3b7
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-24-dense-handle-m0-probe.md
@@ -0,0 +1,343 @@
+# Dense-Handle M0 Pricing Probe Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Measure, in isolated Node microbenchmarks, the cost of the three new primitives from the dense-handle spec (membership bitsets, chunked COW slot vectors, columnar verdict scans) at 50k rows, and produce a go/no-go verdict against the spec's projected filter-commit window.
+
+**Architecture:** One self-contained Node script (no imports from the repo — these primitives don't exist yet; the probe IS their first draft) that implements minimal versions of each primitive, verifies them against naive oracles so we never time broken code, then times (a) each primitive in isolation, (b) a composed "filter-commit equivalent" that mirrors the model-side work of a 50k filter flip, and (c) chunk-COW maintenance under a streaming transaction mix. Results are compared against the trace attribution of the code being replaced (persistent-map 42.5ms, verdict pass 18.3ms, double-lookup 11.9ms — from `filter-final-results.md`).
+
+**Tech Stack:** Node (v20+, plain JS, no deps). Method precedent: `index-representation-probe.md`, whose Node prediction landed within 2ms of the browser number.
+
+**Context for the executor:**
+- Spec: `docs/superpowers/specs/2026-08-24-dense-handle-core-design.md` (read it first).
+- The probe file is throwaway measurement tooling — it lives in the session scratchpad, NOT in the repo. Only the results document is committed.
+- Machine-load rule: before timing, run `uptime`; if 1-min load ≥ ~8 on this 10-core Mac, report that and rely on the spread check below rather than absolute trust. The fitness test is the control's spread: rep-to-rep spread of any median ≤ 20% or the run is invalid — rerun.
+- Numbers rule: never report a single run; report median of 5 timed reps after 2 warmup reps, and report the min/max spread.
+
+**Scratchpad directory (create the probe here):**
+`/private/tmp/claude-501/-Users-blove-repos-pretable--claude-worktrees-running-examples-component-d29c33/e6a8fc40-eb1a-438e-8b29-07506e9af41d/scratchpad`
+
+---
+
+### Task 1: Probe primitives with correctness oracles
+
+**Files:**
+- Create: `/m0-probe.mjs`
+
+- [ ] **Step 1: Write the primitives and their oracle checks**
+
+Create `m0-probe.mjs` with the following content (this is the complete file for Task 1; Task 2 appends to it):
+
+```js
+// M0 pricing probe — dense-handle core primitives at 50k.
+// Spec: docs/superpowers/specs/2026-08-24-dense-handle-core-design.md
+// Throwaway measurement code; committed artifact is the results doc only.
+
+const N = 50_000;
+const CHUNK = 1024;
+const WARMUP = 2, REPS = 5;
+
+// ---------- membership bitset (immutable-by-copy) ----------
+const bsNew = (n) => new Uint32Array((n + 31) >>> 5);
+const bsClone = (b) => b.slice();
+const bsSet = (b, i) => { b[i >>> 5] |= 1 << (i & 31); };
+const bsTest = (b, i) => (b[i >>> 5] >>> (i & 31)) & 1;
+const bsXor = (a, b) => { const out = new Uint32Array(a.length); for (let i = 0; i < a.length; i++) out[i] = a[i] ^ b[i]; return out; };
+const bsPopcount = (b) => { let c = 0; for (let i = 0; i < b.length; i++) { let w = b[i]; w -= (w >>> 1) & 0x55555555; w = (w & 0x33333333) + ((w >>> 2) & 0x33333333); c += (((w + (w >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24; } return c; };
+// iterate set bits, calling f(index); returns count
+function bsForEach(b, f) {
+ let count = 0;
+ for (let w = 0; w < b.length; w++) {
+ let word = b[w];
+ const base = w << 5;
+ while (word !== 0) {
+ const t = word & -word;
+ f(base + (31 - Math.clz32(t)));
+ word ^= t;
+ count++;
+ }
+ }
+ return count;
+}
+
+// ---------- chunked COW slot vector ----------
+function vecFromArray(arr) {
+ const chunks = [];
+ for (let i = 0; i < arr.length; i += CHUNK) chunks.push(arr.slice(i, i + CHUNK));
+ return { chunks, length: arr.length };
+}
+const vecGet = (v, i) => v.chunks[i >>> 10][i & 1023];
+// COW write: copies the chunk table + the one touched chunk
+function vecWith(v, i, val) {
+ const chunks = v.chunks.slice();
+ const c = chunks[i >>> 10].slice();
+ c[i & 1023] = val;
+ chunks[i >>> 10] = c;
+ return { chunks, length: v.length };
+}
+// batched COW write for one commit: copies table once, each touched chunk once
+function vecWithAll(v, writes /* [i, val][] */) {
+ const chunks = v.chunks.slice();
+ const copied = new Set();
+ for (const [i, val] of writes) {
+ const ci = i >>> 10;
+ if (!copied.has(ci)) { chunks[ci] = chunks[ci].slice(); copied.add(ci); }
+ chunks[ci][i & 1023] = val;
+ }
+ return { chunks, length: v.length };
+}
+
+// ---------- fixture ----------
+// Mirrors the S2 bench shape loosely: numeric column + string column.
+function mulberry32(seed) { return () => { let t = (seed += 0x6d2b79f5); t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
+const rnd = mulberry32(42);
+const records = new Array(N);
+const priceCol = new Float64Array(N); // columnar numeric cache
+const nameCol = new Array(N); // columnar string cache
+for (let s = 0; s < N; s++) {
+ const price = Math.floor(rnd() * 10_000) / 10;
+ const name = `instrument-${(s * 7919) % N}-${s % 97}`;
+ records[s] = { id: `row-${s}`, slot: s, price, name };
+ priceCol[s] = price;
+ nameCol[s] = name;
+}
+const live = bsNew(N);
+for (let s = 0; s < N; s++) bsSet(live, s);
+
+// ---------- oracles (run before ANY timing; abort on mismatch) ----------
+function assertEq(actual, expected, label) {
+ if (actual !== expected) { console.error(`ORACLE FAIL: ${label}: ${actual} !== ${expected}`); process.exit(1); }
+}
+
+// Oracle 1: numeric columnar scan matches naive filter
+{
+ const bs = bsNew(N);
+ for (let s = 0; s < N; s++) if (priceCol[s] >= 250 && priceCol[s] < 500) bsSet(bs, s);
+ let naive = 0;
+ for (let s = 0; s < N; s++) if (records[s].price >= 250 && records[s].price < 500) { naive++; assertEq(bsTest(bs, s), 1, `numeric bit ${s}`); }
+ assertEq(bsPopcount(bs), naive, "numeric scan popcount");
+}
+// Oracle 2: xor-diff enumerates exactly the flipped slots
+{
+ const a = bsNew(N), b = bsNew(N);
+ for (let s = 0; s < N; s++) { if (s % 3 === 0) bsSet(a, s); if (s % 5 === 0) bsSet(b, s); }
+ const diff = bsXor(a, b);
+ const flipped = [];
+ bsForEach(diff, (s) => flipped.push(s));
+ const expected = [];
+ for (let s = 0; s < N; s++) if ((s % 3 === 0) !== (s % 5 === 0)) expected.push(s);
+ assertEq(flipped.length, expected.length, "xor flip count");
+ for (let i = 0; i < expected.length; i++) assertEq(flipped[i], expected[i], `xor flip order at ${i}`);
+}
+// Oracle 3: COW vector — writes land, snapshot validity holds under overwrite
+{
+ const v0 = vecFromArray(records);
+ const v1 = vecWithAll(v0, [[5, { id: "row-X", slot: 5 }], [40_000, { id: "row-Y", slot: 40_000 }]]);
+ assertEq(vecGet(v1, 5).id, "row-X", "cow write 5");
+ assertEq(vecGet(v1, 40_000).id, "row-Y", "cow write 40000");
+ assertEq(vecGet(v0, 5).id, "row-5", "snapshot validity 5"); // old snapshot unchanged
+ assertEq(vecGet(v0, 40_000).id, "row-40000", "snapshot validity 40000");
+ assertEq(vecGet(v1, 6).id, "row-6", "untouched neighbor");
+}
+console.log("oracles: PASS");
+```
+
+- [ ] **Step 2: Run the oracles**
+
+Run: `node /m0-probe.mjs`
+Expected: `oracles: PASS`, exit code 0. If any `ORACLE FAIL` prints, fix the primitive before proceeding — do not time broken code.
+
+---
+
+### Task 2: Timed sections — isolation, composed commit, streaming maintenance
+
+**Files:**
+- Modify: `/m0-probe.mjs` (append after the oracle block)
+
+- [ ] **Step 1: Append the timing harness and measurement sections**
+
+```js
+// ---------- timing harness ----------
+function bench(label, fn) {
+ for (let i = 0; i < WARMUP; i++) fn();
+ const times = [];
+ for (let i = 0; i < REPS; i++) { const t0 = performance.now(); fn(); times.push(performance.now() - t0); }
+ times.sort((x, y) => x - y);
+ const med = times[REPS >> 1], min = times[0], max = times[REPS - 1];
+ const spreadPct = med > 0 ? ((max - min) / med) * 100 : 0;
+ console.log(`${label}: median ${med.toFixed(3)}ms (min ${min.toFixed(3)} / max ${max.toFixed(3)}, spread ${spreadPct.toFixed(0)}%)`);
+ return { med, min, max, spreadPct };
+}
+let sink = 0; // defeat dead-code elimination
+
+const results = {};
+
+// --- A. isolation: primitives ---
+results.numericScan = bench("A1 numeric columnar scan -> bitset (50k)", () => {
+ const bs = bsNew(N);
+ for (let s = 0; s < N; s++) if (priceCol[s] >= 250 && priceCol[s] < 500) bs[s >>> 5] |= 1 << (s & 31);
+ sink += bs[0];
+});
+results.stringScan = bench("A2 string columnar scan .includes -> bitset (50k)", () => {
+ const bs = bsNew(N);
+ for (let s = 0; s < N; s++) if (nameCol[s].includes("7")) bs[s >>> 5] |= 1 << (s & 31);
+ sink += bs[0];
+});
+// old membership: ~25% visible; new: shifted band flips roughly half in/half out
+const oldBs = bsNew(N);
+for (let s = 0; s < N; s++) if (priceCol[s] >= 250 && priceCol[s] < 500) bsSet(oldBs, s);
+const newBs = bsNew(N);
+for (let s = 0; s < N; s++) if (priceCol[s] >= 375 && priceCol[s] < 625) bsSet(newBs, s);
+results.xorDiff = bench("A3 xor + enumerate flipped slots", () => {
+ const diff = bsXor(oldBs, newBs);
+ let acc = 0;
+ bsForEach(diff, (s) => { acc += s; });
+ sink += acc;
+});
+const recordsVec = vecFromArray(records);
+results.vecReads = bench("A4 50k vecGet reads (records by slot)", () => {
+ let acc = 0;
+ for (let s = 0; s < N; s++) acc += vecGet(recordsVec, s).price;
+ sink += acc;
+});
+// baseline for comparison: same reads through a string-keyed Map (NOT the HAMT,
+// which the trace prices at 42.5ms — this bounds how much of that is stringiness)
+const byIdMap = new Map(records.map((r) => [r.id, r]));
+const idList = records.map((r) => r.id);
+results.mapReads = bench("A5 50k string-keyed Map.get reads (baseline)", () => {
+ let acc = 0;
+ for (let s = 0; s < N; s++) acc += byIdMap.get(idList[s]).price;
+ sink += acc;
+});
+
+// --- B. composed filter-commit equivalent (model-side work, minus tree build) ---
+// scan -> xor -> enumerate -> resolve flip-in records -> sort flip-ins by key -> merge survivors
+results.composed = bench("B composed filter-commit equivalent", () => {
+ // 1. verdict scan
+ const next = bsNew(N);
+ for (let s = 0; s < N; s++) if (priceCol[s] >= 375 && priceCol[s] < 625) next[s >>> 5] |= 1 << (s & 31);
+ // 2. diff
+ const diff = bsXor(oldBs, next);
+ const flippedIn = [], flippedOut = [];
+ bsForEach(diff, (s) => { if ((next[s >>> 5] >>> (s & 31)) & 1) flippedIn.push(s); else flippedOut.push(s); });
+ // 3. resolve + sort flip-ins by sort key (price asc, mirroring an active sort)
+ flippedIn.sort((x, y) => priceCol[x] - priceCol[y]);
+ // 4. merge survivors (walk old membership in slot order as a stand-in for the
+ // old-tree range walk) with sorted flip-ins into the new visible order
+ const survivors = [];
+ bsForEach(oldBs, (s) => { if ((next[s >>> 5] >>> (s & 31)) & 1) survivors.push(s); });
+ survivors.sort((x, y) => priceCol[x] - priceCol[y]);
+ const merged = new Array(survivors.length + flippedIn.length);
+ let i = 0, j = 0, k = 0;
+ while (i < survivors.length && j < flippedIn.length)
+ merged[k++] = priceCol[survivors[i]] <= priceCol[flippedIn[j]] ? survivors[i++] : flippedIn[j++];
+ while (i < survivors.length) merged[k++] = survivors[i++];
+ while (j < flippedIn.length) merged[k++] = flippedIn[j++];
+ // touch resolved records so the read isn't elided
+ let acc = 0;
+ for (let m = 0; m < merged.length; m++) acc += vecGet(recordsVec, merged[m]).slot;
+ sink += acc + merged.length;
+});
+// NOTE for results doc: the real path does NOT re-sort survivors (order comes
+// proven from the old tree walk); the survivors.sort here is deliberate
+// overcounting — call it out as slack in the projection.
+
+// --- C. streaming maintenance: chunk-COW under a transaction mix ---
+const TXN_COUNT = 1000, TXN_SIZE = 100;
+const txns = [];
+{
+ const r2 = mulberry32(7);
+ for (let t = 0; t < TXN_COUNT; t++) {
+ const writes = [];
+ for (let w = 0; w < TXN_SIZE; w++) { const s = Math.floor(r2() * N); writes.push([s, records[s]]); }
+ txns.push(writes);
+ }
+}
+results.streaming = bench(`C ${TXN_COUNT} commits x ${TXN_SIZE} random writes (chunk-COW)`, () => {
+ let v = recordsVec;
+ for (const writes of txns) v = vecWithAll(v, writes);
+ sink += v.chunks.length;
+});
+console.log(` per-commit: ${(results.streaming.med / TXN_COUNT * 1000).toFixed(1)}µs`);
+results.bitsetClone = bench("C2 1000 whole bitset clones (6.25KB each)", () => {
+ let b = oldBs;
+ for (let t = 0; t < TXN_COUNT; t++) b = bsClone(b);
+ sink += b[0];
+});
+
+// --- verdict ---
+const newParts = results.numericScan.med + results.xorDiff.med + results.composed.med;
+console.log("---");
+console.log(`replaced (trace attribution): persistent-map 42.5 + verdict 18.3 + double-lookup 11.9 = 72.7ms`);
+console.log(`new (probe, overcounted): scan+diff+composed = ${newParts.toFixed(1)}ms`);
+console.log(`sink: ${sink}`); // keep the JIT honest
+```
+
+- [ ] **Step 2: Check machine load, then run the full probe**
+
+Run: `uptime`, then `node /m0-probe.mjs > /m0-probe-output.txt 2>&1; echo "exit=$?"` and then print the output file.
+Expected: `oracles: PASS`, all sections print medians, `exit=0`. Redirect to a file and check the exit code — never pipe through `grep|head` (SIGPIPE truncates gates).
+
+- [ ] **Step 3: Validate the run's fitness**
+
+Check every reported spread. If any section's spread exceeds 20%, or `uptime` 1-min load was ≥ 8: rerun the probe once; if still noisy, report the noise explicitly in the results doc rather than hiding it.
+
+---
+
+### Task 3: Results document, go/no-go, commit
+
+**Files:**
+- Create: `docs/superpowers/specs/2026-08-24-dense-handle-m0-results.md` (in the `homepage-hero-demo-3878ef` worktree — results are load-bearing conclusions, so they live with the specs, per the "if the session directory is gone, the specs carry the conclusions" rule)
+
+- [ ] **Step 1: Write the results document**
+
+Structure (fill with the actual measured numbers — no placeholders may survive):
+
+```markdown
+# M0 pricing probe — results
+
+Date: 2026-08-24. Probe: `m0-probe.mjs` (session scratchpad, throwaway).
+Machine load at run: . Fitness: all spreads ≤ 20%? .
+
+## Isolation numbers (median of 5, 2 warmups)
+
+| section | median | spread |
+|---|---|---|
+| A1 numeric columnar scan → bitset | ... | ... |
+| A2 string columnar scan → bitset | ... | ... |
+| A3 xor + enumerate flips | ... | ... |
+| A4 50k records-by-slot vecGet | ... | ... |
+| A5 50k string-keyed Map.get (baseline) | ... | ... |
+| B composed filter-commit equivalent | ... | ... |
+| C streaming: per-commit chunk-COW (100 writes) | ...µs | ... |
+| C2 whole-bitset clone | ... | ... |
+
+## Read-across
+
+- Replaced work (trace attribution, `filter-final-results.md`): 72.7ms.
+- New-structure equivalent (overcounted — includes a survivors sort the real
+ path does not perform): ms.
+- Streaming regression check: per-commit COW cost µs vs the HAMT's
+ structural sharing — verdict on whether 60Hz streaming survives: .
+- Caveat: Node numbers; the precedent probe landed within 2ms of the browser,
+ but browser certification is M7's job, not M0's.
+
+## Go/no-go
+
+
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+cd /Users/blove/repos/pretable/.claude/worktrees/homepage-hero-demo-3878ef
+git add docs/superpowers/specs/2026-08-24-dense-handle-m0-results.md
+git commit -m "docs: M0 pricing probe results for the dense-handle core
+
+Co-Authored-By: Claude Opus 5 "
+```
+
+Expected: clean commit on `blove/filter-fast-path`.
diff --git a/docs/superpowers/plans/2026-08-24-dense-handle-m1-m2.md b/docs/superpowers/plans/2026-08-24-dense-handle-m1-m2.md
new file mode 100644
index 000000000..2309f2dfd
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-24-dense-handle-m1-m2.md
@@ -0,0 +1,855 @@
+# Dense-Handle M1+M2 Implementation Plan (slots + membership bitsets)
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Give every row a dense integer slot for its lifetime, carry a per-revision `recordsBySlot` chunked-COW vector and a `visibleSlots` membership bitset, and rewrite the filter-only rebuild to consume them — eliminating the 18.5ms records-HAMT walk and the 11.9ms old-verdict double lookup at 50k.
+
+**Architecture:** Three new row-model-internal primitives (spec: `docs/superpowers/specs/2026-08-24-dense-handle-core-design.md`; pricing: `...m0-results.md`, GO) sit UNDER the existing persistent structures. `RevisionRoot` gains two REQUIRED fields so the TypeScript compiler enumerates every construction site; a decision table in Task 5 says what each site does. String ids remain the public currency; public API diff must be zero.
+
+**Tech Stack:** TypeScript, vitest (`pnpm --filter @pretable/row-model test`), existing bench harness (`scripts/bench-matrix.mjs`).
+
+**Worktree:** `/Users/blove/repos/pretable/.claude/worktrees/homepage-hero-demo-3878ef`, branch `blove/filter-fast-path`. All paths below are relative to it.
+
+**Plan-level deviation from the spec (deliberate, record it in the results doc):** the spec names a per-revision "live set" bitset; it is NOT built. Hole-skipping iteration over `recordsBySlot` (undefined = free slot) serves as the live domain, which removes a whole structure and its maintenance. The visible set IS built (M2).
+
+**Design invariants (repeat in code comments where noted):**
+1. A slot binds to one row for that row's lifetime; release only on permanent removal; reuse allowed afterwards.
+2. Old snapshots stay valid under slot reuse because every revision holds its own immutable chunk table — revision N's `recordsBySlot` still binds slot s to whatever row owned s at revision N.
+3. `visibleSlots` is REAL only for flat (ungrouped) roots; grouped roots carry the `EMPTY_MEMBERSHIP` sentinel and keep answering membership from the group index (`filter-membership.ts` is unchanged).
+4. Membership IS the verdict (H-cycle invariant) — the bitset is a faster index of the same structural answer, never a stored verdict.
+
+---
+
+### Task 1: `membership-bitset.ts`
+
+**Files:**
+- Create: `packages/row-model/src/membership-bitset.ts`
+- Test: `packages/row-model/src/__tests__/membership-bitset.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { describe, expect, it } from "vitest";
+import {
+ EMPTY_MEMBERSHIP,
+ cloneMembership,
+ createMembership,
+ clearMembershipBit,
+ setMembershipBit,
+ testMembershipBit,
+} from "../membership-bitset";
+
+describe("membership bitset", () => {
+ it("round-trips set/clear/test across word boundaries", () => {
+ const bits = createMembership(100);
+ for (const slot of [0, 31, 32, 63, 64, 99]) {
+ expect(testMembershipBit(bits, slot)).toBe(false);
+ setMembershipBit(bits, slot);
+ expect(testMembershipBit(bits, slot)).toBe(true);
+ }
+ clearMembershipBit(bits, 32);
+ expect(testMembershipBit(bits, 32)).toBe(false);
+ expect(testMembershipBit(bits, 31)).toBe(true);
+ expect(testMembershipBit(bits, 63)).toBe(true);
+ });
+
+ it("clone is independent of the original", () => {
+ const bits = createMembership(64);
+ setMembershipBit(bits, 10);
+ const copy = cloneMembership(bits, 64);
+ clearMembershipBit(copy, 10);
+ setMembershipBit(copy, 20);
+ expect(testMembershipBit(bits, 10)).toBe(true);
+ expect(testMembershipBit(bits, 20)).toBe(false);
+ });
+
+ it("clone can grow capacity, preserving low bits", () => {
+ const bits = createMembership(32);
+ setMembershipBit(bits, 31);
+ const grown = cloneMembership(bits, 200);
+ expect(testMembershipBit(grown, 31)).toBe(true);
+ setMembershipBit(grown, 199);
+ expect(testMembershipBit(grown, 199)).toBe(true);
+ });
+
+ it("reads beyond a bitset's words answer false (EMPTY sentinel contract)", () => {
+ expect(testMembershipBit(EMPTY_MEMBERSHIP, 0)).toBe(false);
+ expect(testMembershipBit(EMPTY_MEMBERSHIP, 12345)).toBe(false);
+ const bits = createMembership(32);
+ expect(testMembershipBit(bits, 500)).toBe(false);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --filter @pretable/row-model test -- membership-bitset`
+Expected: FAIL — module not found.
+
+- [ ] **Step 3: Write the implementation**
+
+```ts
+/**
+ * Membership bitsets: one bit per SLOT (see `slot-allocator`). A committed
+ * root's verdict is its membership (the filter-membership invariant); the
+ * bitset is a faster INDEX of that same structural answer for flat roots,
+ * never a stored verdict. Grouped roots carry `EMPTY_MEMBERSHIP` and keep
+ * answering from the group index.
+ *
+ * Mutable while a producer is building the next revision's set; frozen by
+ * convention once a root captures it (no Object.freeze — typed arrays do not
+ * support it; discipline is "producers build fresh or clone, never write a
+ * captured root's bitset", the same convention every persistent structure
+ * here relies on).
+ *
+ * Whole-copy on change is the point: 50k rows is 6.25KB, negligible per
+ * commit (M0 measured ~1µs), so no COW machinery exists at this layer.
+ */
+
+export type MembershipBitset = Uint32Array;
+
+/** Shared sentinel for roots whose membership lives elsewhere (grouped). */
+export const EMPTY_MEMBERSHIP: MembershipBitset = new Uint32Array(0);
+
+export function createMembership(capacity: number): MembershipBitset {
+ return new Uint32Array((capacity + 31) >>> 5);
+}
+
+/** Clone, growing to `capacity` when it exceeds the source's words. */
+export function cloneMembership(
+ bits: MembershipBitset,
+ capacity: number,
+): MembershipBitset {
+ const words = Math.max(bits.length, (capacity + 31) >>> 5);
+ const next = new Uint32Array(words);
+ next.set(bits);
+ return next;
+}
+
+export function setMembershipBit(bits: MembershipBitset, slot: number): void {
+ bits[slot >>> 5]! |= 1 << (slot & 31);
+}
+
+export function clearMembershipBit(
+ bits: MembershipBitset,
+ slot: number,
+): void {
+ bits[slot >>> 5]! &= ~(1 << (slot & 31));
+}
+
+/** Out-of-range slots read as false — the EMPTY sentinel relies on this. */
+export function testMembershipBit(
+ bits: MembershipBitset,
+ slot: number,
+): boolean {
+ const word = bits[slot >>> 5];
+ return word === undefined ? false : ((word >>> (slot & 31)) & 1) === 1;
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --filter @pretable/row-model test -- membership-bitset`
+Expected: PASS (4 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/row-model/src/membership-bitset.ts packages/row-model/src/__tests__/membership-bitset.test.ts
+git commit -m "feat(row-model): membership bitset primitive"
+```
+
+---
+
+### Task 2: `slot-allocator.ts`
+
+**Files:**
+- Create: `packages/row-model/src/slot-allocator.ts`
+- Test: `packages/row-model/src/__tests__/slot-allocator.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { describe, expect, it } from "vitest";
+import { createSlotAllocator } from "../slot-allocator";
+
+describe("slot allocator", () => {
+ it("allocates dense sequential slots from zero", () => {
+ const slots = createSlotAllocator();
+ expect([slots.allocate(), slots.allocate(), slots.allocate()]).toEqual([
+ 0, 1, 2,
+ ]);
+ expect(slots.capacity).toBe(3);
+ });
+
+ it("reuses released slots before growing", () => {
+ const slots = createSlotAllocator();
+ slots.allocate();
+ const b = slots.allocate();
+ slots.allocate();
+ slots.release(b);
+ expect(slots.allocate()).toBe(b);
+ expect(slots.capacity).toBe(3);
+ });
+
+ it("capacity is monotonic and counts the high-water mark", () => {
+ const slots = createSlotAllocator();
+ for (let i = 0; i < 10; i += 1) slots.allocate();
+ for (let i = 0; i < 10; i += 1) slots.release(i);
+ expect(slots.capacity).toBe(10);
+ for (let i = 0; i < 10; i += 1) slots.allocate();
+ expect(slots.capacity).toBe(10);
+ });
+
+ it("throws on double release", () => {
+ const slots = createSlotAllocator();
+ const a = slots.allocate();
+ slots.release(a);
+ expect(() => slots.release(a)).toThrow(/released|live/i);
+ });
+
+ it("throws on releasing a never-allocated slot", () => {
+ const slots = createSlotAllocator();
+ expect(() => slots.release(5)).toThrow();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --filter @pretable/row-model test -- slot-allocator`
+Expected: FAIL — module not found.
+
+- [ ] **Step 3: Write the implementation**
+
+```ts
+/**
+ * Per-MODEL slot allocator: every row gets a small dense integer for its
+ * lifetime, assigned at ingest and released only on permanent removal.
+ * Mutable by design — this is instance state, not revision state; the
+ * revision-scoped structures (`slot-vector`, `membership-bitset`) are what
+ * keep old snapshots valid when a released slot is reused.
+ *
+ * Capacity is the high-water mark and never shrinks, so slot-indexed
+ * structures never renumber. Release is fail-loud (double release would hand
+ * one slot to two live rows, which corrupts every slot-indexed structure
+ * from that commit on).
+ */
+
+export interface SlotAllocator {
+ readonly capacity: number;
+ allocate(): number;
+ release(slot: number): void;
+}
+
+export function createSlotAllocator(): SlotAllocator {
+ const free: number[] = [];
+ let next = 0;
+ let live = new Uint8Array(1024);
+ const ensure = (slot: number) => {
+ if (slot < live.length) return;
+ const grown = new Uint8Array(Math.max(live.length * 2, slot + 1));
+ grown.set(live);
+ live = grown;
+ };
+ return {
+ get capacity() {
+ return next;
+ },
+ allocate() {
+ const slot = free.length > 0 ? free.pop()! : next++;
+ ensure(slot);
+ live[slot] = 1;
+ return slot;
+ },
+ release(slot) {
+ if (!Number.isInteger(slot) || slot < 0 || slot >= next) {
+ throw new RangeError(`Slot ${slot} was never allocated.`);
+ }
+ if (live[slot] !== 1) {
+ throw new RangeError(`Slot ${slot} is not live (double release).`);
+ }
+ live[slot] = 0;
+ free.push(slot);
+ },
+ };
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --filter @pretable/row-model test -- slot-allocator`
+Expected: PASS (5 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/row-model/src/slot-allocator.ts packages/row-model/src/__tests__/slot-allocator.test.ts
+git commit -m "feat(row-model): per-model slot allocator"
+```
+
+---
+
+### Task 3: `slot-vector.ts`
+
+**Files:**
+- Create: `packages/row-model/src/slot-vector.ts`
+- Test: `packages/row-model/src/__tests__/slot-vector.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { describe, expect, it } from "vitest";
+import {
+ SLOT_VECTOR_CHUNK,
+ emptySlotVector,
+ forEachSlotEntry,
+ slotVectorFromEntries,
+ slotVectorGet,
+ slotVectorWithAll,
+} from "../slot-vector";
+
+describe("slot vector", () => {
+ it("stores entries at their slots, holes read undefined", () => {
+ const vec = slotVectorFromEntries([[0, "a"], [2, "c"], [1500, "far"]], 2000);
+ expect(slotVectorGet(vec, 0)).toBe("a");
+ expect(slotVectorGet(vec, 1)).toBeUndefined();
+ expect(slotVectorGet(vec, 2)).toBe("c");
+ expect(slotVectorGet(vec, 1500)).toBe("far");
+ expect(slotVectorGet(vec, 1999)).toBeUndefined();
+ });
+
+ it("withAll writes and clears land; result reports chunks copied", () => {
+ const base = slotVectorFromEntries([[0, "a"], [1, "b"]], 10);
+ const { next, chunksTouched } = slotVectorWithAll(
+ base,
+ [[0, "A"], [1, undefined], [5, "f"]],
+ 10,
+ );
+ expect(slotVectorGet(next, 0)).toBe("A");
+ expect(slotVectorGet(next, 1)).toBeUndefined();
+ expect(slotVectorGet(next, 5)).toBe("f");
+ expect(chunksTouched).toBe(1); // all three slots share chunk 0
+ });
+
+ it("old snapshots survive later writes, including slot overwrite (COW pin)", () => {
+ const v0 = slotVectorFromEntries([[5, "old-5"], [1030, "old-1030"]], 2048);
+ const { next: v1 } = slotVectorWithAll(
+ v0,
+ [[5, "new-5"], [1030, undefined]],
+ 2048,
+ );
+ // v1 sees the writes...
+ expect(slotVectorGet(v1, 5)).toBe("new-5");
+ expect(slotVectorGet(v1, 1030)).toBeUndefined();
+ // ...and v0 is byte-identical to before: the snapshot-validity invariant
+ // that makes slot REUSE safe for held revisions.
+ expect(slotVectorGet(v0, 5)).toBe("old-5");
+ expect(slotVectorGet(v0, 1030)).toBe("old-1030");
+ });
+
+ it("a commit touching k slots in one chunk copies exactly one chunk", () => {
+ const entries: [number, string][] = [];
+ for (let s = 0; s < 4096; s += 1) entries.push([s, `v${s}`]);
+ const base = slotVectorFromEntries(entries, 4096);
+ const writes: [number, string][] = [];
+ for (let s = 100; s < 150; s += 1) writes.push([s, `w${s}`]);
+ const { next, chunksTouched } = slotVectorWithAll(base, writes, 4096);
+ expect(chunksTouched).toBe(1);
+ // untouched chunks are carried by reference, not copied
+ expect(next.chunks[1]).toBe(base.chunks[1]);
+ expect(next.chunks[0]).not.toBe(base.chunks[0]);
+ });
+
+ it("withAll can grow capacity for slots beyond the old table", () => {
+ const base = slotVectorFromEntries([[0, "a"]], 1);
+ const { next } = slotVectorWithAll(base, [[5000, "far"]], 5001);
+ expect(slotVectorGet(next, 5000)).toBe("far");
+ expect(slotVectorGet(next, 0)).toBe("a");
+ expect(slotVectorGet(base, 5000)).toBeUndefined();
+ });
+
+ it("forEachSlotEntry skips holes and visits every live entry once", () => {
+ const vec = slotVectorFromEntries([[3, "c"], [SLOT_VECTOR_CHUNK + 1, "x"]], 3000);
+ const seen: Array<[number, string]> = [];
+ forEachSlotEntry(vec, (value, slot) => seen.push([slot, value]));
+ expect(seen).toEqual([[3, "c"], [SLOT_VECTOR_CHUNK + 1, "x"]]);
+ });
+
+ it("emptySlotVector reads undefined everywhere", () => {
+ expect(slotVectorGet(emptySlotVector(), 0)).toBeUndefined();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --filter @pretable/row-model test -- slot-vector`
+Expected: FAIL — module not found.
+
+- [ ] **Step 3: Write the implementation**
+
+```ts
+/**
+ * Immutable chunked slot-indexed vector: a chunk table over
+ * `SLOT_VECTOR_CHUNK`-element chunks, copy-on-write per commit. A commit
+ * touching k slots copies the table plus each touched chunk once — this is
+ * what keeps old snapshots valid under slot reuse: every revision holds its
+ * own table, so revision N still binds slot s to whatever row owned s at
+ * revision N no matter what later commits do (M0 priced maintenance at
+ * ~33–98µs per 100-write commit).
+ *
+ * Holes (`undefined`) are free slots. Iteration hole-skips, which is why no
+ * separate "live" bitset exists (recorded plan deviation from the spec).
+ */
+
+export const SLOT_VECTOR_CHUNK = 1024;
+
+export interface SlotVector {
+ /** Sparse table: a missing/undefined chunk reads as all holes. */
+ readonly chunks: ReadonlyArray | undefined>;
+}
+
+const EMPTY: SlotVector = Object.freeze({ chunks: Object.freeze([]) });
+
+export function emptySlotVector(): SlotVector {
+ return EMPTY;
+}
+
+export function slotVectorFromEntries(
+ entries: Iterable,
+ capacity: number,
+): SlotVector {
+ const chunks: Array | undefined> = new Array(
+ Math.ceil(capacity / SLOT_VECTOR_CHUNK),
+ );
+ for (const [slot, value] of entries) {
+ const index = (slot / SLOT_VECTOR_CHUNK) | 0;
+ let chunk = chunks[index];
+ if (chunk === undefined) {
+ chunk = new Array(SLOT_VECTOR_CHUNK);
+ chunks[index] = chunk;
+ }
+ chunk[slot % SLOT_VECTOR_CHUNK] = value;
+ }
+ return { chunks };
+}
+
+export function slotVectorGet(
+ vector: SlotVector,
+ slot: number,
+): T | undefined {
+ const chunk = vector.chunks[(slot / SLOT_VECTOR_CHUNK) | 0];
+ return chunk === undefined ? undefined : chunk[slot % SLOT_VECTOR_CHUNK];
+}
+
+/**
+ * One commit's writes (`undefined` value = clear the slot), COW: table copied
+ * once, each touched chunk copied once. `capacity` may exceed the old
+ * table's reach (allocator growth).
+ */
+export function slotVectorWithAll(
+ vector: SlotVector,
+ writes: ReadonlyArray,
+ capacity: number,
+): { readonly next: SlotVector; readonly chunksTouched: number } {
+ const tableSize = Math.max(
+ vector.chunks.length,
+ Math.ceil(capacity / SLOT_VECTOR_CHUNK),
+ );
+ const chunks: Array | ReadonlyArray | undefined> =
+ new Array(tableSize);
+ for (let i = 0; i < vector.chunks.length; i += 1) chunks[i] = vector.chunks[i];
+ const copied = new Set();
+ for (const [slot, value] of writes) {
+ const index = (slot / SLOT_VECTOR_CHUNK) | 0;
+ if (!copied.has(index)) {
+ const existing = chunks[index];
+ chunks[index] =
+ existing === undefined
+ ? new Array(SLOT_VECTOR_CHUNK)
+ : existing.slice();
+ copied.add(index);
+ }
+ (chunks[index] as Array)[slot % SLOT_VECTOR_CHUNK] = value;
+ }
+ return { next: { chunks }, chunksTouched: copied.size };
+}
+
+/** Hole-skipping walk in slot order. */
+export function forEachSlotEntry(
+ vector: SlotVector,
+ callback: (value: T, slot: number) => void,
+): void {
+ for (let index = 0; index < vector.chunks.length; index += 1) {
+ const chunk = vector.chunks[index];
+ if (chunk === undefined) continue;
+ const base = index * SLOT_VECTOR_CHUNK;
+ for (let offset = 0; offset < chunk.length; offset += 1) {
+ const value = chunk[offset];
+ if (value !== undefined) callback(value, base + offset);
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --filter @pretable/row-model test -- slot-vector`
+Expected: PASS (7 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/row-model/src/slot-vector.ts packages/row-model/src/__tests__/slot-vector.test.ts
+git commit -m "feat(row-model): chunked copy-on-write slot vector"
+```
+
+---
+
+### Task 4: Stamp `slot` on `RowRecord`; thread the allocator through record creation
+
+**Files:**
+- Modify: `packages/row-model/src/internal-types.ts` (RowRecord, ~line 22)
+- Modify: `packages/row-model/src/row-store.ts` (`buildRowStore`, `BuildRowStoreInput`; `rebuildRowStoreForQuery` needs NO change — its `{ ...previous, metadata }` spread carries `slot`)
+- Modify: `packages/row-model/src/transaction-draft.ts` (`prepareRecord` ~line 290; `applyFlatTransactionDraft` ~line 753; `replaceFlatRowsDraft` ~line 1247)
+- Modify: `packages/row-model/src/create-local-row-model.ts` (create the allocator, pass it down)
+- Test: `packages/row-model/src/__tests__/slot-lifecycle.test.ts` (new)
+
+**The lifecycle rules (put this decision logic exactly where each case lives):**
+
+| Event | Slot action |
+|---|---|
+| New row ingested (initial build, set-rows add, transaction add) | `slots.allocate()`, stamped on the frozen record |
+| Row updated (transaction update, set-rows carry, metadata re-evaluation) | carry `previous.slot` |
+| Row permanently removed (transaction remove, set-rows drop) | `slots.release(previous.slot)` — AFTER the draft is known effective (see abandon rule) |
+| Draft abandoned (`effective: false` return, or the `catch`→`remap` path) | release every slot the draft allocated; release NO removed slot |
+
+**Abandon rule rationale (comment it):** a draft allocates slots while preparing records, but `applyFlatTransactionDraft` can still return ineffective or throw after that; leaked allocations would pin free-list slots forever. Track `allocatedSlots: number[]` in the draft; on the two failure paths release them; on the success path release `removedSlots` instead.
+
+- [ ] **Step 1: Write the failing test**
+
+The row-model package's test suite constructs models via `createLocalRowModel` (see any existing test in `packages/row-model/src/__tests__/` for the fixture idiom — reuse the simplest existing fixture columns/rows pattern; do not invent a new fixture style). The assertions below reach the committed root via the model's internals: check how existing tests access roots — if none do, export a test-only accessor from the module under `/** @internal test-only */` or assert through `getState().snapshot` plus instrumentation counters, whichever existing tests already use. The behaviors to pin:
+
+```ts
+// slot-lifecycle.test.ts — behavior pins (adapt fixture idiom from existing tests):
+// 1. "slots are dense from zero at initial build": model with rows A,B,C →
+// records carry slots {0,1,2} (order = source order).
+// 2. "update carries the slot": transaction updating B → B's new record has
+// B's old slot; A and C untouched (same record identity).
+// 3. "remove releases; a later add reuses": transaction removing B, then a
+// separate transaction adding D → D's record carries B's former slot, and
+// allocator capacity stays 3.
+// 4. "set-rows replacement carries intersecting ids": setRows([B', E]) after
+// {A,B,C} → B' keeps B's slot; E gets a released slot (0 or 2), capacity
+// stays 3.
+// 5. "abandoned draft leaks nothing": a transaction that is entirely
+// ineffective (e.g. removing a nonexistent id) followed by an add — the
+// add's slot shows no gap (capacity grew by exactly the rows actually
+// added since build).
+```
+
+Write them as real vitest tests with the fixture idiom you found. Every pin must be able to fail: e.g. for pin 3, verify it fails if you temporarily make release a no-op (mutation check — actually run it once, then restore).
+
+- [ ] **Step 2: Run to verify the suite fails to compile / assert**
+
+Run: `pnpm --filter @pretable/row-model test -- slot-lifecycle`
+Expected: FAIL — `slot` does not exist on RowRecord yet.
+
+- [ ] **Step 3: Implement**
+
+3a. `internal-types.ts` — add to `RowRecord`:
+
+```ts
+ /**
+ * Dense integer handle, assigned at ingest, stable for the row's lifetime
+ * (updates carry it; only permanent removal releases it). Slot-indexed
+ * structures (`recordsBySlot`, `visibleSlots`) are the array-resident fast
+ * path that replaces string-keyed lookups on O(n) walks.
+ */
+ readonly slot: number;
+```
+
+3b. `row-store.ts` — `BuildRowStoreInput` gains `readonly slots: SlotAllocator;`. In `buildRowStore`'s record loop: `const slot = previous !== undefined ? previous.slot : input.slots.allocate();` and add `slot` to the frozen record literal. After the loop, when `input.previous !== undefined`, release dropped ids:
+
+```ts
+ if (input.previous !== undefined) {
+ for (const [rowId, record] of input.previous.entries()) {
+ if (!seen.has(rowId)) input.slots.release(record.slot);
+ }
+ }
+```
+
+(`seen` is the existing duplicate-id Set — it already holds exactly the new id set.)
+
+3c. `transaction-draft.ts` — `prepareRecord` gains a `slot: number` parameter and stamps it in the frozen record. Call sites: adds allocate (`input.slots.allocate()`, recorded in `allocatedSlots`), updates pass `input.root.rows.get(rowId)!.slot` (the previous record is already fetched adjacent to every prepare call — reuse it, do not add a second `get`). `applyFlatTransactionDraft` and `replaceFlatRowsDraft` inputs gain `readonly slots: SlotAllocator`. Apply the lifecycle table: success path releases removed slots just before the `effective: true` return; the `effective: false` return (~line 982) and the `catch (error) { return remap(error); }` tail release `allocatedSlots`.
+
+3d. `create-local-row-model.ts` — `const slots = createSlotAllocator();` beside the other instance state; pass to `buildRowStore` (initial + any other call) and into every draft-input literal (the compiler finds them once the input types require it).
+
+3e. `rebuildRowStoreForQuery` in `row-store.ts`: verify the `{ ...previous, metadata }` spread now carries `slot` (it does — no code change; leave a one-line comment noting slot carries by spread).
+
+- [ ] **Step 4: Run the full package suite**
+
+Run: `pnpm --filter @pretable/row-model test`
+Expected: PASS — including all 515 pre-existing tests (slot threading must not disturb any behavior) and the new lifecycle pins. Also run `pnpm --filter @pretable/row-model typecheck`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A packages/row-model/src
+git commit -m "feat(row-model): stamp lifetime slots on row records"
+```
+
+---
+
+### Task 5: `RevisionRoot.recordsBySlot` — required field, all sites
+
+**Files:**
+- Modify: `packages/row-model/src/internal-types.ts` (RevisionRoot)
+- Modify: `packages/row-model/src/row-store.ts`, `transaction-draft.ts`, `create-local-row-model.ts`, `filter-rebuild.ts`, `sort-rebuild.ts`, `cooperative-transition.ts`
+- Test: `packages/row-model/src/__tests__/records-by-slot.test.ts` (new)
+
+- [ ] **Step 1: Add the field and let the compiler enumerate sites**
+
+`RevisionRoot` gains:
+
+```ts
+ /**
+ * Slot-indexed view of `rows` — same records, array-resident. Per-revision
+ * immutable (chunked COW), which is what keeps THIS root's bindings valid
+ * when the allocator later reuses a slot. Invariant, test-pinned:
+ * slotVectorGet(recordsBySlot, record.slot) === record for every record in
+ * `rows`, at every committed root.
+ */
+ readonly recordsBySlot: SlotVector>;
+ /**
+ * The slot-space size this root's slot-indexed structures were built for
+ * (the allocator's capacity at commit time). A root must be
+ * SELF-DESCRIBING: readers size bitsets and walks from this field, never
+ * from the live allocator — reading the allocator would let later growth
+ * leak into a held snapshot's domain.
+ */
+ readonly slotCapacity: number;
+```
+
+`slotCapacity` per site: wherever the table below says "carried", carry it; drafted/built sites stamp `input.slots.capacity` (or the threaded capacity value, for the cooperative transition) at commit time.
+
+Run `pnpm --filter @pretable/row-model typecheck` — the errors are the site list. Decision table:
+
+| Site | `recordsBySlot` |
+|---|---|
+| `create-local-row-model.ts:609` (initial) | from `buildRowStore` result (see 5a) |
+| `create-local-row-model.ts` expansion/spread sites (~944, ~1400) | carried by `...previousRoot` — no edit |
+| `create-local-row-model.ts` drafted sites (~1073, ~1137) | from the draft result (see 5b) |
+| `filter-rebuild.ts:188` | `captured.recordsBySlot` (rows carried by identity) |
+| `sort-rebuild.ts:115` | `captured.recordsBySlot` (rows carried by identity) |
+| `cooperative-transition.ts:710` (`finish`) | from the transition state (see 5c) |
+
+5a. `BuiltRowStore` gains `readonly recordsBySlot: SlotVector<...>`; `buildRowStore` computes it after the loop:
+
+```ts
+ recordsBySlot: slotVectorFromEntries(
+ records.map((record) => [record.slot, record] as const),
+ input.slots.capacity,
+ ),
+```
+
+5b. `TransactionDraftResult` and `RowsReplacementDraftResult` gain the field. In `applyFlatTransactionDraft`, build the write list on the success path — removals clear, prepared records write:
+
+```ts
+ const slotWrites: Array | undefined]> = [
+ ...removedRecords.map((record) => [record.slot, undefined] as const),
+ ...prepared.map((record) => [record.slot, record] as const),
+ ];
+ const { next: recordsBySlot, chunksTouched } = slotVectorWithAll(
+ input.root.recordsBySlot,
+ slotWrites,
+ input.slots.capacity,
+ );
+```
+
+(`removedRecords` = the records behind `effectiveRemoves`, already fetched for `groupedRemovals` — hoist ONE array instead of fetching twice.) Add `chunksTouched` to instrumentation: `input.instrumentation.work.slotChunksTouched += chunksTouched` (add the counter to `LocalRowModelInstrumentation.work` in `diagnostics.ts`, initialized 0, alongside its neighbors). `replaceFlatRowsDraft`: same pattern over its own removed/added/carried records; if it internally rebuilds wholesale, `slotVectorFromEntries` over the final records is acceptable there (set-rows is O(n) already).
+
+5c. `cooperative-transition.ts`: `rebuildRowStoreForQuery`'s `Pick<...>` return gains `recordsBySlot` built with `slotVectorFromEntries` over its `records` (capacity: pass `slots.capacity` in — thread the allocator reference or just `capacity: number` through the existing options of the transition; prefer passing the allocator's capacity value at capture time, since the transition must NOT observe later growth). `finish()` reads it off the retained state.
+
+- [ ] **Step 2: Write the invariant test**
+
+`records-by-slot.test.ts` — one scripted sequence covering: initial build → update transaction → remove+add transaction (slot reuse) → set-rows replacement → filter-only setQuery → sort-only setQuery. After EVERY committed revision assert, for every record in the root's `rows`: `slotVectorGet(root.recordsBySlot, record.slot) === record` (identity, not equality), and count(records) === count(live entries via `forEachSlotEntry`). Plus the held-snapshot pin: capture the root BEFORE the remove+add, run the remove+add (slot reused), assert the CAPTURED root still resolves the old record at that slot. Use the same root-access idiom as Task 4.
+
+- [ ] **Step 3: Run**
+
+`pnpm --filter @pretable/row-model test` and `typecheck` — all green, zero remaining compile errors (that's the proof all sites are handled).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A packages/row-model/src
+git commit -m "feat(row-model): per-revision recordsBySlot slot vector"
+```
+
+---
+
+### Task 6: `RevisionRoot.visibleSlots` — required field, flat-real / grouped-empty
+
+**Files:**
+- Modify: `packages/row-model/src/internal-types.ts`, plus the same six files as Task 5
+- Modify: `packages/row-model/src/visible-index.ts` (helper)
+- Test: `packages/row-model/src/__tests__/visible-slots.test.ts` (new)
+
+- [ ] **Step 1: Add the field and the helper**
+
+`RevisionRoot` gains:
+
+```ts
+ /**
+ * Flat roots: one bit per slot, set iff the row is a member of
+ * `visible.rows` — the same structural verdict `filter-membership`
+ * resolves, indexed for O(1)/word-scan access. Grouped roots carry
+ * `EMPTY_MEMBERSHIP` (their membership lives in the group index) and every
+ * reader must treat it per that module's contract. Never mutated after the
+ * root commits.
+ */
+ readonly visibleSlots: MembershipBitset;
+```
+
+`visible-index.ts` gains:
+
+```ts
+/** Membership bitset of a FLAT visible tree: one pass, entry.record.slot. */
+export function membershipFromFlatTree<...>(
+ rows: VisibleIndexRoot<...>["rows"],
+ capacity: number,
+): MembershipBitset {
+ const bits = createMembership(capacity);
+ for (const entry of rows.range(0, rows.size)) {
+ setMembershipBit(bits, entry.record.slot);
+ }
+ return bits;
+}
+```
+
+Decision table (same compiler-driven enumeration):
+
+| Site | `visibleSlots` |
+|---|---|
+| initial (609) | grouped query → `EMPTY_MEMBERSHIP`; flat → `membershipFromFlatTree(visibleTree, slots.capacity)` |
+| spread sites | carried automatically — CHECK each: the two expansion sites only re-attach group indexes over the same flat tree/groups, membership unchanged → correct to carry |
+| drafted sites | from the draft result (Step 2) |
+| `filter-rebuild.ts` | Task 7 rewrites this producer — for THIS task make it compile honestly: zero-flip arm carries `captured.visibleSlots`; non-zero arm `membershipFromFlatTree(newTree, capacity)` (temporary; Task 7 replaces it with the verdict-pass bitset) |
+| `sort-rebuild.ts` | `captured.visibleSlots` — a sort-only change keeps the member SET identical |
+| cooperative `finish` | grouped → `EMPTY_MEMBERSHIP`; flat → `membershipFromFlatTree(state.flatRows, capacity)` |
+
+- [ ] **Step 2: Draft maintenance in `applyFlatTransactionDraft`**
+
+On the success path, flat roots (`previousGroups === undefined`):
+
+```ts
+ const visibleSlots = cloneMembership(input.root.visibleSlots, input.slots.capacity);
+ for (const record of removedRecords) clearMembershipBit(visibleSlots, record.slot);
+ for (const record of prepared) {
+ if (passesNext(record)) setMembershipBit(visibleSlots, record.slot);
+ else clearMembershipBit(visibleSlots, record.slot);
+ }
+```
+
+Grouped roots: `const visibleSlots = EMPTY_MEMBERSHIP;`. `replaceFlatRowsDraft`: flat → `membershipFromFlatTree` over its final flat tree (O(n), and set-rows is O(n) already); grouped → sentinel.
+
+- [ ] **Step 3: Write the oracle test**
+
+`visible-slots.test.ts`: (1) equivalence oracle — after each step of a scripted sequence (build with a filter active → transaction flipping some rows across the filter boundary → remove a visible row → filter-only setQuery), assert for a FLAT root that `testMembershipBit(root.visibleSlots, record.slot) === (root.visible.rows.get(record.rowId) !== undefined)` for EVERY record; (2) grouped roots carry the `EMPTY_MEMBERSHIP` sentinel by identity; (3) mutation-hardening: run the oracle once with `clearMembershipBit` in the removal loop commented out and confirm it FAILS, then restore (do this as a one-time verification, note it in the task report — do not leave a permanently-mutated test).
+
+- [ ] **Step 4: Run**
+
+`pnpm --filter @pretable/row-model test` and `typecheck` — green.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A packages/row-model/src
+git commit -m "feat(row-model): per-revision visibleSlots membership bitset"
+```
+
+---
+
+### Task 7: Rewrite the filter-only rebuild on slots + bitsets
+
+**Files:**
+- Modify: `packages/row-model/src/filter-rebuild.ts` (the walk, ~lines 78–110, and the visibleSlots wiring from Task 6)
+- Test: existing `filter-*` suites must pass unchanged; add one order-independence pin
+
+- [ ] **Step 1: Replace the walk**
+
+The current walk iterates `captured.sourceOrder.range(...)` and pays `captured.rows.get(rowId)` (HAMT, 18.5ms at 50k) plus `rowPassesFilter(captured, rowId)` (`visible.rows.get`, 11.9ms). Replace with a hole-skipping slot walk that computes the new bitset as it goes:
+
+```ts
+ const nextVisibleSlots = createMembership(capacity); // capacity: see note
+ const flippedIn: OrderedRowEntry[] = [];
+ const flippedOut = new Set();
+ // Slot order, not source order — sound because nothing downstream reads
+ // this walk's order: flippedIn is comparator-sorted below, flippedOut is a
+ // set, and the merge consumes the OLD TREE's walk. recordsBySlot replaces
+ // the rows-HAMT get; visibleSlots replaces the old-verdict membership get.
+ forEachSlotEntry(captured.recordsBySlot, (previous) => {
+ const passes = filterVerdict(nextPlan, previous as never);
+ if (passes) setMembershipBit(nextVisibleSlots, previous.slot);
+ if (passes === testMembershipBit(captured.visibleSlots, previous.slot)) return;
+ if (passes) {
+ const keys = sortKeysOf(nextPlan, previous as never) as readonly CompiledSortKey[];
+ flippedIn.push(Object.freeze({ record: previous, keys }));
+ } else {
+ flippedOut.add(previous.rowId);
+ }
+ });
+```
+
+Capacity note: the rebuild has no allocator reference and must not read one (roots are self-describing — see `slotCapacity`, added in Task 5). Use `captured.slotCapacity` as `capacity` here; the new root carries the same value.
+
+Zero-flip arm: `visibleSlots: captured.visibleSlots` (drop `nextVisibleSlots`). Non-zero arm: `visibleSlots: nextVisibleSlots`, replacing Task 6's temporary `membershipFromFlatTree` call. Everything else — flip sort, merge, `orderIsProven`, `derivedById`, journal reason, instrumentation — is UNCHANGED.
+
+- [ ] **Step 2: Order-independence pin**
+
+Add to the existing filter fast-path suite: a model whose transaction history makes slot order ≠ source order ≠ visible order (e.g. build A,B,C,D; remove B; add E — E reuses B's slot), then a filter-only setQuery flipping E and A. Assert the visible sequence equals what the public API contract requires (compare against a freshly-built model with the same final rows and query — same visible order). This is the pin that fails if anyone later makes the walk order-sensitive.
+
+- [ ] **Step 3: Run the full package suite + the repo's react suite**
+
+`pnpm --filter @pretable/row-model test` (all 515+ green) and `pnpm --filter @pretable/react test` (the 1246-test suite exercises setQuery end to end; local flake rule — a timed-out test re-runs once before it counts).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A packages/row-model/src
+git commit -m "perf(row-model): filter-only rebuild walks slots and diffs membership bitsets"
+```
+
+---
+
+### Task 8: Node A/B + browser bench measurement
+
+**Files:**
+- Create: results section appended to `docs/superpowers/specs/2026-08-24-dense-handle-m0-results.md` (new `## M1+M2 measured` section) — or a sibling `...-m1-m2-results.md` if the section grows past a screen
+
+- [ ] **Step 1: Rebuild and A/B in the browser bench**
+
+Protocol (violations produced three wrong conclusions in the prior arc — follow exactly):
+1. `lsof -i :4173` — if held by another process, STOP and report; never kill the holder (parallel session).
+2. Baseline side = this branch BEFORE Task 1 (`git log` — the commit before the first M1 commit; use a throwaway worktree at that commit if the machine is loaded, interleaving paired runs). Variant side = HEAD. ONE variable: the M1+M2 commits.
+3. Per side: `pnpm --filter @pretable/app-bench build` (rebuilds dependency dists via prepare:deps), then `pnpm --filter @pretable/app-bench preview:bench` (background), then:
+ `node scripts/bench-matrix.mjs --adapters=pretable,tanstack --scenarios=S2 --scale=target --scripts=filter-metadata,filter-text --repeats=3`
+ (Check `shared/bench-adapter-families.js` for the exact TanStack adapter id before running; `--scale=target` is the 50k tier — confirm against existing `status/*-s2-target-*` summary filenames. Also run `--scale=dev` for the 3k tier.)
+4. Read the summary JSONs the run writes under `status/` — field names as in the existing `chromium-pretable-default-s2-target-filter-*.summary.json` files (settle, `post_interaction_long_tasks_ms`, interaction latency). Redirect any gate/script output to files and check exit codes — no `grep|head` pipelines.
+5. Fitness: TanStack same-run numbers must sit in the historical band (compare to the baseline side's TanStack numbers — that IS the band); if they moved, the regime changed — rerun, don't conclude.
+
+- [ ] **Step 2: Write the results**
+
+Table: baseline-side vs variant-side for 50k settle (both filter scripts), 3k settle, block/long-tasks, interaction latency, TanStack controls. Name the delta against the branch's pre-M1 numbers (158.3/157.5ms @50k, 34.5/33.6ms @3k) and against the est. −30ms. State plainly if the gain is smaller than estimated and where the time went (trace only if needed: `node scripts/analyze-cdp.mjs --window=interaction ` — traces skew absolutes ~2×, shares only).
+
+- [ ] **Step 3: Full gates**
+
+`pnpm build && pnpm api` (expect ZERO `.api.md` drift — everything here is internal; any drift is a defect, stop and fix), `pnpm lint`, full `pnpm test` at repo root (react vitest flake rule: 1–2 random timeouts per full run locally — re-run before believing).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docs/superpowers/specs/
+git commit -m "docs: M1+M2 measured results (slots + membership bitsets)"
+```
diff --git a/docs/superpowers/plans/2026-08-24-dense-layout-seam.md b/docs/superpowers/plans/2026-08-24-dense-layout-seam.md
new file mode 100644
index 000000000..106659810
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-24-dense-layout-seam.md
@@ -0,0 +1,132 @@
+# Dense-Identity Layout Seam Implementation Plan (Amendment I)
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Give `RowHeightIndex` a dense lane keyed by row-model slots so a 50k refilter performs zero per-survivor string work, and feed it through the renderer-dom controller from new `ɵ`-internal row-model snapshot reads — attacking the ~33% layout share of the remaining filter-settle window.
+
+**Architecture:** Per Amendment I (`docs/superpowers/specs/2026-08-24-dense-handle-amendment-i-layout-seam.md` — read it first). Dense mode is a PER-GENERATION, all-or-nothing property of the index: a generation whose every entry carries a `denseKey` maintains a visible-slots bitset instead of the `visibleKeys` HAMT and runs slot-indexed refilter/reorder; any input that cannot supply dense keys drops the whole index back to the string lane via the controller's existing full-replacement fallback. Measurements and tombstones stay string-identity-keyed in BOTH lanes (slot reuse must never touch retention — the amendment's §3 trap).
+
+**Tech Stack:** TypeScript; vitest (`pnpm --filter @pretable-internal/layout-core test`, `pnpm --filter @pretable-internal/row-model test`, renderer-dom + react suites — check each package.json for exact names); bench harness per M1+M2 Task 8.
+
+**Worktree:** `/Users/blove/repos/pretable/.claude/worktrees/homepage-hero-demo-3878ef`, branch `blove/filter-fast-path`.
+
+**Verified code anchors** (re-verify line numbers before editing; they drift):
+- `packages/layout-core/src/row-height-index.ts`: `refilter` at ~1582; `apply` at ~1326; `retainMeasurement` at ~1273; `reorder` ends ~1563; replacement builder class begins ~1841 (`#visibleKeys` at 1841, ingest hashSet at ~2161); `#next(...)` generation constructor ~1804; `HeightValue` carries `ref/identity/estimatedHeight/height/measured`.
+- `packages/layout-core/src/types.ts`: `RowHeightReplacementSource` (~line 259 area), operation types.
+- `packages/renderer-dom/src/row-layout-controller.ts`: `rowRef` 297 (allocates a frozen ref PER CALL), `identityOf` 305, `replacementSourceOf` 1444 (per-row `rowAt` = O(log n) rank descent each), apply-ops construction ~831/~877, refilter/reorder dispatch ~1794–1855 (fallback-on-throw contract lives here).
+- `packages/row-model/src/create-local-row-model.ts`: `createSnapshot` 86 (wraps `createFlatSnapshot(root)`; instrumented variant spreads and re-wraps reads — new reads must be added to BOTH). The snapshot's flat implementation is in `visible-index.ts` or adjacent (find `createFlatSnapshot`).
+- Row-model facts: `RevisionRoot` has `recordsBySlot`, `visibleSlots`, `slotCapacity`; records carry `.slot`; `ɵ` prefix is the repo's internal-export convention (see `ɵfilterAuthority`).
+
+**Bars (from Amendment I):** untraced 50k filter settle ≤ ~95ms; refilterFallbackCount 0 on the happy path; 3k no regression; TanStack controls in band; every `.api.md` line of drift reviewed and intended; docs api-surface guard green (update registered tables in the same commit if it fires).
+
+---
+
+### Task 1: layout-core dense primitives + type surface
+
+**Files:**
+- Create: `packages/layout-core/src/dense-membership.ts`
+- Modify: `packages/layout-core/src/types.ts`
+- Test: `packages/layout-core/src/__tests__/dense-membership.test.ts`
+
+- [ ] **Step 1: Failing test for the bitset** — mirror row-model's `membership-bitset.test.ts` behaviors (set/clear/test across word boundaries; out-of-range reads false; clone-with-growth). layout-core cannot import row-model, so this is a deliberate ~40-line duplicate; the module header must say so and name the original.
+- [ ] **Step 2:** Red run: `pnpm --filter @pretable-internal/layout-core test -- dense-membership` (verify the package's real name first).
+- [ ] **Step 3:** Implement `dense-membership.ts` (same function shapes as row-model's `membership-bitset.ts`: `createDenseMembership(capacity)`, `setDenseBit`, `clearDenseBit`, `testDenseBit`, `cloneDenseMembership`). Header comment: duplicated from `@pretable-internal/row-model` `membership-bitset.ts` by design — layout-core stays dependency-free; keep the two in sync by hand.
+- [ ] **Step 4:** Green run.
+- [ ] **Step 5: Types.** In `types.ts`: `RowHeightReplacementSource` gains `readonly denseCapacity?: number`, and its `entryAt` row shape gains `readonly denseKey?: number`. Every `RowHeightOperation` variant's ref-bearing shape gains `readonly denseKey?: number`. Doc comments state the contract from Amendment I §1: dense keys are OPTIONAL; a generation is dense only when EVERY entry carries one and `denseCapacity` is present; a dense key is the row's CURRENT model slot, valid only while the model binds that slot (the caller owns that currency); mixed input falls back to the string lane wholesale.
+- [ ] **Step 6:** Typecheck + lint the package; commit:
+
+```bash
+git add packages/layout-core/src/dense-membership.ts packages/layout-core/src/types.ts packages/layout-core/src/__tests__/dense-membership.test.ts
+git commit -m "feat(layout-core): dense-membership primitive and dense-key type surface"
+```
+
+---
+
+### Task 2: dense generations in `RowHeightIndex` — state, builder ingest, guards
+
+**Files:**
+- Modify: `packages/layout-core/src/row-height-index.ts`
+- Test: `packages/layout-core/src/__tests__/row-height-index.test.ts` (extend)
+
+**Design (decision-complete):**
+- `HeightValue` gains `readonly denseKey: number | undefined` — stamped at every ingest site from the input's `denseKey`.
+- The index gains two generation fields threaded through `#next` and the builder: `#denseCapacity: number | undefined` and `#visibleSlots: DenseMembership | undefined`. INVARIANT (comment it at the field): `#visibleSlots !== undefined` ⇔ this generation is dense ⇔ every sequence entry has a `denseKey` < `#denseCapacity`. A dense generation does NOT maintain `#visibleKeys` (it stays `null`); a string generation never allocates `#visibleSlots`.
+- The replacement BUILDER decides the lane at `begin`: source has `denseCapacity` AND every ingested entry carries `denseKey` → dense (build the bitset as it ingests, skip `hashSet(visibleKeys, ...)` entirely); the first entry missing a `denseKey` when `denseCapacity` was declared → throw `RowHeightReplacementLifecycleError` (the controller's existing catch → full string replacement... no: throw would loop). Instead: missing `denseKey` with declared capacity is a CALLER BUG — throw; the controller only declares `denseCapacity` when the snapshot guarantees slots (Task 4 makes that guarantee). No capacity declared → string lane, exactly today's code.
+- Guards on a dense index: `apply` insert dup-check and `retainMeasurement`'s visible-check use `testDenseBit(#visibleSlots, op.denseKey)`. An op WITHOUT `denseKey` reaching a dense index throws `RowHeightReplacementLifecycleError` with a message naming the contract ("dense index requires dense-keyed operations; fall back to a full replacement") — the dispatch sites in the controller already treat throws as fallback (verify: the ~1794–1855 block and the apply call site; if apply throws are NOT already routed to fallback, Task 5 wires that).
+- `apply` insert/remove maintain `#visibleSlots` (set/clear by denseKey) in dense mode, `#visibleKeys` in string mode — never both.
+- Measurements/tombstones: UNTOUCHED in both lanes (string identity). `measure()` keeps working by identity on both lanes.
+
+- [ ] **Step 1: Failing tests first** (extend the existing suite in its own describe): (a) a dense-built index answers `apply` insert-dup and `retainMeasurement` guards correctly (both accept/reject cases); (b) an op without `denseKey` on a dense index throws the lifecycle error; (c) a string-built index is bit-for-bit unaffected (run a representative existing scenario through both construction styles and compare observables); (d) dense builder with a missing entry denseKey under declared capacity throws.
+- [ ] **Step 2:** Red run. **Step 3:** Implement. **Step 4:** Green run + full layout-core suite (133+ tests) — string-lane tests must pass UNCHANGED (zero expectation edits; if one needs editing, stop: the lane leaked).
+- [ ] **Step 5:** Commit `feat(layout-core): dense generations — builder ingest, bitset membership, guarded ops`.
+
+---
+
+### Task 3: dense `refilter` and `reorder`
+
+**Files:**
+- Modify: `packages/layout-core/src/row-height-index.ts` (refilter ~1582, reorder ending ~1563)
+- Test: `packages/layout-core/src/__tests__/row-height-index.test.ts` (extend)
+
+**Design (decision-complete) — dense refilter:**
+- Old pass (in-order walk, unchanged shape): additionally build `unconsumedBySlot: (HeightValue | undefined)[]` sized `#denseCapacity` (one allocation per refilter; 50k pointers ≈ 400KB transient — fine) instead of the string `unconsumed` Map. `previousValues` stays.
+- New-order walk, per row: `denseKey` REQUIRED (absent → throw lifecycle error → controller fallback); dup-check via a local `seen` DenseMembership; `nextVisibleSlots` bit set per row; survivor = `unconsumedBySlot[denseKey]` — reuse verbatim, clear the array cell, NO identity string computed. Entrant: compute `identity` NOW (only entrants pay the string), then the existing measured/tombstone lookups verbatim; stamp `denseKey` on the new HeightValue.
+- Leavers: cells still set in `unconsumedBySlot` — walk it (or keep a parallel count/list; simplest: iterate the array once, `for (let s = 0; s < capacity; s++)`) and run the existing retire logic (identity is already ON the HeightValue). ORDER CAUTION: today's leaver pass follows the OLD-SEQUENCE order via the Map's insertion order, and tombstone tickets are assigned in that order (comment at ~1668 says so, and ticket order is observable via cap eviction). A slot-index walk breaks that order. Preserve it: iterate `previousValues` in order and retire those whose `unconsumedBySlot[value.denseKey]` cell is still occupied (then clear it). Pin this with a test: cap-limited tombstones + a narrowing refilter → eviction order identical between lanes.
+- `unchanged` detection, work counters, and every observable stay as today.
+- **Dense reorder:** same substitution — the `unconsumed` Map in `reorder` becomes `unconsumedBySlot`; keys resolve by `denseKey`; missing key → lifecycle throw.
+- refilter on a dense index yields a dense generation (`nextVisibleSlots` becomes its `#visibleSlots`); on a string index, today's code verbatim.
+
+- [ ] **Step 1: Failing tests:** (a) **lane-equivalence oracle**: a randomized script (seeded PRNG, ~200 rows, 30 steps of narrowing/widening/reordering refilters + measures) run through a string-lane index and a dense-lane index in lockstep; after every step compare heights sequence, `totalHeight`, work-counter observables that are lane-independent (`refilterEntriesReused/Inserted/Retired`), tombstone count. (b) the ticket-order pin above. (c) **the Amendment §3 slot-reuse trap**: measure row X; refilter X out (X tombstoned, slot kept); simulate permanent removal + slot reuse by presenting a NEW identity Y with X's old denseKey in the next FULL replacement (dense builder), then refilter Y in and out: Y must ingest at estimate (never X's measurement), X's measurement must return only for X's identity. Mutation-harden: key the hot measurement path by denseKey on purpose (temporary) and watch (c) fail; restore.
+- [ ] **Step 2:** Red. **Step 3:** Implement. **Step 4:** Green + full package suite, string tests untouched.
+- [ ] **Step 5:** Commit `perf(layout-core): slot-indexed refilter and reorder for dense generations`.
+
+---
+
+### Task 4: row-model `ɵ` snapshot dense reads
+
+**Files:**
+- Modify: wherever `createFlatSnapshot` lives (find it: `grep -rn "createFlatSnapshot" packages/row-model/src`), `create-local-row-model.ts` (createSnapshot wrapper at 86 — add the new reads to the instrumented spread too), `packages/row-model/src/index.ts` if snapshot type is exported there
+- Test: `packages/row-model/src/__tests__/` (extend an existing snapshot-adjacent suite or add one)
+
+**Design:** the snapshot gains three `ɵ`-prefixed reads, documented as the renderer seam (flat roots only):
+- `ɵvisibleSlotRange(start, end): readonly number[]` — slots of visible rows in order, from the tree's materialized `range` walk (entries carry `record.slot`). Grouped root → returns `undefined` (type: `readonly number[] | undefined`) — the caller must fall back.
+- `ɵslotOfRowId(rowId): number | undefined` — one HAMT get (`root.rows.get`); for k-sized op stamping only (doc comment MUST say "k-sized paths only — never call per visible row").
+- `ɵslotCapacity(): number | undefined` — `root.slotCapacity` for flat roots, `undefined` for grouped.
+Wire the instrumented wrapper (createSnapshot at 86) to pass them through (count `snapshotOutputRowsRead` for the range read, mirroring `range`).
+
+- [ ] Steps: failing test (flat: slots match `rowAt(i)`-resolved records' slots across a filter change; grouped: all three return undefined; `ɵslotOfRowId` on missing id → undefined) → red → implement → green (full row-model suite; expect NO existing-test edits) → commit `feat(row-model): internal dense snapshot reads for the renderer seam`.
+
+---
+
+### Task 5: renderer-dom controller — dense sources, ops stamping, bulk walk, pooled refs
+
+**Files:**
+- Modify: `packages/renderer-dom/src/row-layout-controller.ts`
+- Test: renderer-dom's controller suite (find it; 154 tests exist in the package)
+
+**Design (decision-complete):**
+- `replacementSourceOf` (1444): resolve `const slots = target.ɵvisibleSlotRange(0, target.visibleRowCount)` and `const capacity = target.ɵslotCapacity()` ONCE; when both defined, the source declares `denseCapacity: capacity` and `entryAt` returns `{ key, denseKey: slots[index] }`. ALSO replace the per-row `target.rowAt(index)` with a bulk `target.range(0, rowCount)` materialized ONCE per source construction (kills the O(n log n) rank descents) — entryAt indexes the array; keep the same omitted-row error on a hole. Grouped/undefined → today's shape verbatim (string lane).
+- Apply-ops construction (~831/~877): stamp `denseKey: target.ɵslotOfRowId(row.rowId)` — when it returns undefined on a dense index the op will throw in layout-core and the dispatch's existing catch routes to full replacement; VERIFY the apply call site is inside the fallback-on-throw protection (the refilter dispatch at ~1794–1855 is; if the apply path isn't, wrap it to the same contract and count it in `refilterFallbackCount`-adjacent diagnostics — read how the controller currently handles apply throws first and match that convention).
+- Pooled refs (spec M5's "rowRefs pooled by slot"): `rowRef` (297) allocates a frozen object per call. Add a slot-indexed pool (plain array on the controller instance, grown to capacity) so a data-row ref for slot s is created once and reused while the rowId matches (`pool[s]?.rowId === row.rowId ? pool[s] : (pool[s] = freeze({...}))`). Group refs unpooled. This is safe because refs are value-compared via `identityOf` everywhere (sameRef at ~317) — but VERIFY no consumer relies on ref allocation identity per call; grep the controller for `===` comparisons on refs before pooling; if any exist, report NEEDS_CONTEXT rather than guessing.
+- `identityOf` calls on the n-sized paths should now be rare; do NOT micro-optimize further here.
+
+- [ ] Steps: failing tests (dense source construction: a fake snapshot with slots → source entries carry denseKeys and bulk range is called once — spy/count; fallback: grouped snapshot → no denseCapacity; pooled refs: two source constructions reuse ref objects for unchanged rows — assert identity) → red → implement → green (renderer-dom suite + react suite `pnpm --filter @pretable/react test`) → commit `perf(renderer-dom): dense-keyed layout sources, bulk visible walk, slot-pooled refs`.
+
+---
+
+### Task 6: end-to-end pins + API surface + docs guard
+
+**Files:**
+- Modify: `.api.md` reports via `pnpm build && pnpm api`; docs tables ONLY if the guard fires
+- Test: react integration suite; website suite if docs tables changed
+
+- [ ] **Step 1:** An end-to-end react test (extend the existing filter fast-path integration suite in packages/react if one exists — find where `refilterPathCount` is asserted): drive a 200-row grid through filter-on → narrow → widen → filter-off; assert `refilterPathCount` advanced and `refilterFallbackCount === 0` (the dense path didn't silently fall back), and row heights survive (a measured row keeps its height across a flip-out/flip-in). Mutation: break denseKey stamping (stamp undefined) → the test must catch it via `refilterFallbackCount > 0` — this is the pin that keeps the dense lane from silently rotting back to fallback.
+- [ ] **Step 2:** `pnpm build && pnpm api` — REVIEW the `.api.md` diff line by line: expected drift is layout-core types (`denseKey`/`denseCapacity`/HeightValue if exported) and row-model `ɵ` snapshot reads. Anything else → stop and fix. Commit the reports with the code that caused them if not already.
+- [ ] **Step 3:** Full repo `pnpm test` (react flake rule applies) + `pnpm lint`. If the docs api-surface guard fails, update the registered tables per the guard's own error message in the same commit.
+- [ ] **Step 4:** Commit `test(react): dense layout seam end-to-end pins` (+ api/docs updates).
+
+---
+
+### Task 7: measurement
+
+Same protocol as M1+M2 Task 8 (`docs/superpowers/plans/2026-08-24-dense-handle-m1-m2.md` Task 8 — reuse verbatim: port check, load, interleaved paired sides, TanStack controls, medians of 3, no grep|head, no tracing for headlines). Baseline side = the commit before this plan's Task 1. Scales: `target` (50k) and `hypothesis` (3k — NOT `dev`, which is 750 rows; verified in the M1+M2 run). Scripts: filter-metadata, filter-text. Deliverable: `docs/superpowers/specs/2026-08-24-dense-layout-seam-results.md` with the table, deltas vs 116.8/125.6ms, the ≤~95ms bar verdict, fitness statement, and what remains (columnar cache next). Optionally one traced run AFTER the headline numbers for the share re-attribution (layout share ≤10% bar). Commit the doc.
diff --git a/docs/superpowers/specs/2026-08-19-filter-subset-rebuild-design-amendment-g.md b/docs/superpowers/specs/2026-08-19-filter-subset-rebuild-design-amendment-g.md
new file mode 100644
index 000000000..45e37f984
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-19-filter-subset-rebuild-design-amendment-g.md
@@ -0,0 +1,103 @@
+# Amendment G: renderer membership path + model levers
+
+**Amends:** `2026-08-19-filter-subset-rebuild-design.md` (its "Renderer:
+untouched" section is superseded — decision 2026-08-19 after F4's NO-GO).
+**Evidence:** `blank-viewport-diagnosis.md`, `reingest-composition.md`
+(scratchpad).
+
+## What F4 + diagnosis established
+
+1. A latent controller defect (scroll during any active replacement leaves
+ the stale window unpainted → blank) was made deterministic by the
+ synchronous filter commit. **Fixed in G1** (commit 0deb7e90): the
+ active-branch `setViewport` republishes a stale-but-visible window,
+ preserving `rebuilding` status and the old observedRevision.
+2. The settle attribution was corrected by profile: the controller's
+ cooperative replacement costs ~4µs/survivor (~45–63ms); the rest is the
+ model's own synchronous rebuild. Journal-ops lose to a subset layout
+ path above k≈2–9k flips; `refilter()` ceiling ≈ 20–25ms at 50k.
+
+## G-workstream design
+
+### G2 — RebuildProgressDemo retarget (website)
+
+The demo's filter toggle is synchronous now; its subject is cooperative
+progress. Retarget the toggle to a GROUPED change (rowGroups on region) —
+grouped stays cooperative by design and reads naturally in the demo's
+prose. Same migration discipline as the cycle-1 sort→filter retarget:
+demo + test + embedding-page prose all truthful.
+
+### G3a — journal reason `"refilter"`
+
+Reset reason union gains `"refilter"`: asserts the visible row ORDER of
+surviving rows is unchanged and row identities are stable — membership
+changed (rows entered/left), nothing else. Published by the filter fast
+path instead of `"bulk-replace"`. Fail-closed exactly like `"reorder"`
+(unaware consumers treat any reset as full replacement — grid-core pin
+extended; api reports regenerated; docs guards checked — the B1+B2
+playbook).
+
+### G3b — layout-core `refilter(source)`
+
+Sibling of `reorder()`: walks the new order; reuses existing entries by
+key (measurements ride); keys ABSENT from existing entries are inserted
+with the estimate-or-default height rule; existing keys absent from the
+new order leave (tombstone per the retention policy — read what the
+cooperative path does with measured leavers and match it). Synchronous;
+same immutability/counters/diagnostics discipline as `reorder`. Throws
+only on structural impossibilities (duplicate keys, bad rowCount);
+membership deltas are its PURPOSE, not an error.
+
+### G3c — controller refilter path
+
+`synchronize`: reset reason `"refilter"` with aligned revision → capture
+anchor → `rowHeights.refilter(replacementSourceOf(target))` → anchor
+restore → publishReady; ANY throw → `startReplacement` fallback
+(counters: `refilterPathCount`, `refilterFallbackCount` on the existing
+seam). Mid-replacement: fail-closed (restart) this cycle — no compose
+(membership + pending catch-up is exactly the complexity the reorder
+compose rule excluded; the FINAL-retarget machinery is not extended).
+
+### G3d — model store sharing (the −17ms lever)
+
+On a filter-only change the new plan's sort configuration is identical
+(classifier-guaranteed), so `filter-rebuild` ADOPTS the previous plan's
+sort-key store rather than refilling per row: a plan-internal seam
+(`adoptSortKeyStore(nextPlan, previousPlan)`, guarded by TypeError unless
+the caller holds a filter-only delta — document caller-owned precondition)
+points the new plan's `#sortKeys`-bearing cache at the previous plan's
+map... **Design constraint:** the merged cache entry also holds guarded
+metadata keyed to the OLD plan's evaluations; adopting the map wholesale
+would leak OLD metadata into NEW-plan cache hits, which is WRONG (verdicts
+changed). Resolution: adopt only if the metadata-hit guard also checks
+plan epoch, OR share at the sortKeys level only — the implementer
+proposes the minimal sound mechanism (options: entry-level plan tag;
+separate keys map shared by reference while metadata cache stays fresh —
+note this re-splits what the rehash fix merged, so it must NOT reintroduce
+a second per-row WeakMap fill on the hot path: sharing BY REFERENCE means
+zero new sets). If no sound mechanism exists without re-growing per-row
+work, drop G3d and record why — it is an optimization, not a bar
+requirement.
+
+## Bar (unchanged) and projection
+
+50k settle ≤ same-run TanStack (~58ms band): model rebuild at realistic
+flip counts (~36ms verdicts + merge/build + O(k)) + refilter ~10–25ms +
+frame. The bench's 75%-flip cell is the worst case and may land above
+TanStack's own worst case — evaluate the bar on the MEASURED numbers and
+report honestly; the reserve lever (per-filter verdict decomposition)
+remains named for a miss.
+
+## Testing
+
+G2: demo tests assert the cooperative cycle over the grouped toggle.
+G3a: B1's journal-suite pattern (all-refilter range → "refilter"; mixed →
+degrade; unaware-consumer pin in grid-core).
+G3b: reorder's suite pattern + membership cases (enter/leave/disjoint,
+measured leavers tombstoned, estimates for entrants; equivalence oracle vs
+full replace at every rank; mutation-hardened).
+G3c: B4's suite pattern (happy path, fallback flavors, anchor semantics,
+mid-replacement restart).
+G3d (if kept): cache-correctness adversarial tests — a NEW-plan evaluate
+after adoption must NOT hit OLD metadata; keys resolve with zero refills.
+End-to-end: F4 re-run, all four bars.
diff --git a/docs/superpowers/specs/2026-08-19-filter-subset-rebuild-design.md b/docs/superpowers/specs/2026-08-19-filter-subset-rebuild-design.md
new file mode 100644
index 000000000..336a61a96
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-19-filter-subset-rebuild-design.md
@@ -0,0 +1,147 @@
+# Filter subset rebuild: settling filters at TanStack parity
+
+**Follows:** the #457 arc (merged PR #479). This cycle applies the same
+philosophy to filter-only changes. Baseline: `filter-baseline.md`
+(scratchpad, 2026-08-19).
+
+**Date:** 2026-08-19
+
+## Problem
+
+Filter interaction latency already beats TanStack (15–18ms vs 32–54ms at
+50k, first changed frame in 1). The gap is the **settle tail**: ~240ms of
+cooperative rebuild at 50k vs TanStack's ~58ms. Trace attribution: 76%
+row-model — persistent-map rebuild 59ms, transition driver 37ms, predicate
+re-evaluation 36ms, tree rebuild 26ms; renderer height re-ingest is only
+13ms; paint negligible.
+
+A filter-only change produces a **subset-or-superset of an already-sorted
+set**: values, sort keys, group paths, and order are unchanged; only
+membership verdicts change. Rebuilding every record and both persistent
+structures is work the change does not logically require.
+
+## Decisions (brainstorm, 2026-08-19)
+
+- **Bar:** 50k S2 filter settle ≤ same-run TanStack settle (~58ms band).
+- **Approach 1 adopted:** synchronous filter-delta subset rebuild — records
+ rebuilt ONLY for verdict-flipped rows; merge-based visible tree; journal
+ stays a barrier (renderer share too small to chase).
+- **Approach 2 rejected:** moving `filterPasses` out of metadata ripples
+ through `filteredLeaf`/aggregate machinery for savings Approach 1 already
+ captures when flip counts are small.
+- **Approach 3 named as the reserve lever:** per-filter verdict bitsets so
+ an edit to one filter re-runs one predicate. Pulled only on a missed bar.
+
+## Success criteria
+
+1. 50k S2 filter-metadata AND filter-text: `completed ×3`,
+ settle ≤ same-run TanStack settle (~15% tolerance for run noise).
+2. Interaction latency stays in its current band (15–18ms) — the fast
+ first frame must not regress.
+3. No regressions: 3k bands, sort bands (15–17ms), grouped gate ≤ 8,
+ mount, full repo suites, api reports unchanged.
+4. Work-based assertions: on a filter-only change, unflipped rows' records
+ carry by identity; the rows-map transient performs O(flipped) sets; the
+ visible tree is built without a comparator sort (merge + bulk build —
+ assert zero Array.sort of the full set, via instrumentation counters).
+
+## Design
+
+### Classifier
+
+`isFilterOnlyChange(previous, next)` derived from the existing
+`classifyQueryDelta`: filtersChanged AND nothing else changed (runtime
+facets; authorities equal). Fast-path gate additionally requires ungrouped
+(both plans) and operation `set-query`. Conservative as always: any doubt →
+cooperative path.
+
+### Synchronous subset rebuild (`filter-rebuild.ts`, sibling of sort-rebuild)
+
+Per source-order row (all records, not just visible):
+
+1. **Verdict:** run the NEW plan's filter predicates against stored values
+ where retained, accessors where not. Note: predicate inputs are column
+ VALUES — the sort-key store retains values only for sort columns; filter
+ columns' values are not retained (cycle-1 finding). So predicates re-run
+ with accessor reads per row — the measured ~36ms. (The reserve lever
+ attacks this; not this phase.)
+2. **Diff:** compare against `previous.metadata.filterPasses`.
+ - Unflipped: record carries BY IDENTITY. No map write.
+ - Flipped: new record via the existing `#finalizeMetadata`-equivalent
+ path (new `filterPasses`, new `filteredLeaf` around the carried
+ aggregate values; sort keys carried from the plan store — the new plan
+ needs its store filled per row exactly as sort-rebuild does via
+ `fillSortKeysFromPrevious`; a filter-only change carries ALL sort
+ columns, so fills are 100% carries).
+ - Rows map: ONE transient over the captured map, `set` only flipped
+ rows.
+3. **Visible tree by merge:** walk the OLD visible tree in order emitting
+ still-passing entries (their decorated `{record, keys}` entries carry —
+ flipped-out rows are skipped; flipped-in rows are NOT in the old tree);
+ merge with the newly-passing rows sorted by their stored keys
+ (`compareWithSortKeys` over the decorated pairs — sorting only the
+ flipped-in set, k log k); the merged strictly-sorted array feeds
+ `createOrderStatisticTreeFromSortedEntries`. Flipped-in entries must
+ reference the NEW records (rebuilt in step 2); still-passing entries
+ whose records carried MUST reference the carried records (entry reuse by
+ identity where possible — a reused entry object is valid because record
+ and keys are both unchanged).
+4. **Publish:** `publishCommittedRoot` with the default barrier reason
+ (NOT "reorder" — the row set changed). Error semantics identical to
+ sort-rebuild (accessor/predicate failure → same error status shape,
+ state untouched).
+
+### The new plan's sort-key store
+
+Filled per row during the same pass (all carries — assert via the existing
+`sortKeyCarries` counter). This keeps the A-invariant: any tree bound to
+the new plan resolves keys fail-loud-safely.
+
+### Instrumentation
+
+`work.filterRebuilds`, `work.filterRowsFlipped`, and reuse of
+`synchronousRebuildMs` (or a sibling `filterRebuildMs` — implementer picks,
+consistently). The zero-full-sort assertion rides the merge design: add
+`work.filterMergeSortedInsertions` (= flipped-in count) so tests can pin
+that only the flipped-in subset was sorted.
+
+### Renderer
+
+Untouched. The barrier-driven cooperative replacement remains; its 13ms
+share is accepted. (The bulk path from C2a does NOT apply — the base has
+retained measurements — and must not: assert the retained-state path still
+runs, no behavior change.)
+
+## Out of scope
+
+- Per-filter verdict decomposition (reserve lever).
+- `filterPasses` ownership move (rejected).
+- Grouped filter changes (cooperative, as before).
+- Any renderer/journal change.
+- External filter authority: under `filterAuthority: "external"` the
+ runtime filters are empty — a public filter change classifies as no
+ runtime change (same containment as sort authority in cycle 1).
+
+## Testing
+
+House standard (TDD, mutation-hardened, positive/negative twins):
+
+1. Classifier: table-driven, mirroring the sort classifier's suite.
+2. Equivalence: fast-path result vs cold model — visible order, counts,
+ aggregates, distinct values — across: narrowing, widening, disjoint
+ flip (both directions at once), filter-to-empty, empty-to-filter,
+ no-op verdict change (filter changed but zero rows flip: still a new
+ revision, tree may carry wholesale — decide and pin).
+3. Identity: unflipped records `toBe` across the change; rows-map root
+ changes ONLY when flips exist; flipped records new with correct
+ `filteredLeaf`.
+4. The merge: fixture where flipped-in rows interleave arbitrarily with
+ survivors (ties included — sourceOrder resolution); mutation: break the
+ merge order → bulk constructor throws or equivalence fails.
+5. Counters: flipped counts exact; merge-sorted-insertions == flipped-in;
+ sortKeyCarries == rowCount, evaluations == 0.
+6. Stale-hazard heir: `setRows` after a filter fast path re-evaluates
+ correctly (the moved-row check and journal behavior unchanged).
+7. Supersede/error paths: mirror the sort fast-path suite.
+8. End-to-end: bench protocol, both filter scripts, both scales, plus the
+ full no-regression sweep (sort, grouped gate, mount).
diff --git a/docs/superpowers/specs/2026-08-19-membership-verdicts-design.md b/docs/superpowers/specs/2026-08-19-membership-verdicts-design.md
new file mode 100644
index 000000000..5103d745f
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-19-membership-verdicts-design.md
@@ -0,0 +1,117 @@
+# Membership verdicts: cutting the filter commit's ordering rebuild
+
+**Follows:** `2026-08-19-filter-subset-rebuild-design.md` + Amendment G.
+F4's re-run (scratchpad `filter-cycle-results-2.md`) passed three bars and
+missed settle 4x: 50k filter settle 216–227ms vs TanStack ~57ms, with 69%
+of the window in the row model — persistent-map 45ms, order-statistic-tree
+35ms.
+
+**Date:** 2026-08-19
+
+## What the exploration established (facts, cited in the map)
+
+- The 80ms splits into **disjoint halves**: record/metadata rebuild + rows-map
+ writes (~45ms, scales with flipped rows) and the visible tree + `byId`
+ HAMT rebuild (~35ms, scales with survivors).
+- `filteredLeaf` is **not** a per-leaf flag — filtered aggregation is
+ membership in a separate aggregate tree (`aggregate-tree.ts:23-39` has no
+ flag). The wrapper exists only to tell `updateAggregateRoots` insert vs
+ remove, and both derivation sites hold a live plan.
+- **The visible tree already is the membership set.** `nearestVisibleRef`
+ (`visible-index.ts:212-216`) uses `rankOf(rowId) === undefined` as its
+ visibility predicate today.
+- Membership-as-tree-measure (considered, **rejected**): no measure-update
+ primitive exists — one flip costs remove+insert (2× O(log n) node copies +
+ a HAMT set), so at high flip counts it loses to the bulk build it would
+ replace; it also grows the **sort** path (all rows in the tree) and breaks
+ `nearestVisibleRef`. Recorded here so it is not re-proposed.
+
+## Success criteria
+
+1. 50k S2 filter-metadata and filter-text: settle ≤ same-run TanStack
+ (~57ms band), `completed ×3`, zero blank frames.
+2. No regression: 3k filter (currently 42/50ms), sort both scales, grouped
+ gate ≤8, mount, full repo suites, api reports (this cycle DOES change the
+ public `CompiledRowMetadata` shape — the reason must be the only diff).
+3. Work assertions: a filter-only change performs **zero** record
+ reconstructions and zero rows-map writes (counters); the visible tree's
+ `byId` is derived by removal, not refilled (counter).
+
+## Workstream H1 — verdicts come from root membership
+
+`CompiledRowMetadata` loses `filterPasses`. Nothing stores a per-row
+verdict; membership in the root's visible structure IS the verdict.
+
+- **Resolution seam:** one internal helper, e.g.
+ `passesFilter(root, rowId)` — flat: `root.visible.rows.get(rowId) !==
+undefined`; grouped: the group index's leaf membership (the grouped path
+ has its own leaf trees — the helper must answer correctly for both, or
+ grouped callers use a grouped-specific accessor; implementer picks one
+ coherent seam and documents it).
+- **Producers** (`evaluate` / `#finalizeMetadata` / `refilterRecordMetadata`)
+ stop writing the field. The verdict a producer _computes_ still drives
+ where the row is inserted — it becomes a local, not stored state.
+ `#finalizeMetadata` stops allocating the per-aggregate-column wrapper
+ carrying `filteredLeaf`; `updateAggregateRoots` /
+ `updateMutableAggregates` decide insert-vs-remove from the computed
+ verdict passed alongside the record (both sites already receive plan +
+ record — thread the verdict explicitly rather than re-deriving).
+- **Consumers** (the 19 sites in the map) reroute: filter-rebuild's diff
+ reads the CAPTURED root's membership; transaction-draft's six
+ old-verdict sites read `input.root` (previous) and its new-verdict sites
+ read the draft's own visible structure; distinct-values reads the root it
+ already holds; group-index's `filteredCount` accumulation takes the
+ computed verdict.
+- **`refilterRecordMetadata` is deleted.** With `filterPasses` and the
+ `filteredLeaf` wrapper gone from metadata, a flipped row needs **no new
+ record at all** — filter-rebuild's rows map carries by identity in every
+ case, exactly as sort-rebuild's does. This is the 45ms.
+- **`rebaseSourceOrder`** (the one plan-less metadata producer,
+ `transaction-draft.ts:700`) simply stops carrying the field.
+
+## Workstream H3 — cheaper bulk tree construction
+
+`createOrderStatisticTreeFromSortedEntries` gains an internal variant (or
+options) for callers that can prove their input:
+
+- **Derived `byId`:** accept a base map and a leaver set — the new map is
+ the old one minus k leavers (k removes) instead of n inserts. Filter's
+ survivors are always a subset of the captured tree's entries.
+- **Trusted order:** skip the n−1 verification comparisons when the caller
+ passes a proof token (filter's merge produces strictly-sorted output by
+ construction from two strictly-sorted sequences; sort's `Array.sort` +
+ tiebreak likewise). The verification stays the default and stays
+ unconditional for untrusted callers — the rationale comment at the
+ existing site explains why it exists; extend it with when it may be
+ skipped.
+- Both are internal primitives; no package-index exposure.
+
+## Out of scope
+
+- Membership-as-measure (rejected above).
+- G3d sort-key store adoption (still deferred; ~17ms, revisit only if the
+ bar is missed after H1+H3).
+- Per-filter verdict decomposition (the standing reserve lever).
+- Grouped fast paths (still cooperative).
+
+## Testing
+
+House standard; the sortKeys migration (cycle 2) is the template.
+
+H1: equivalence oracles vs cold models across flat and GROUPED paths
+(grouped exercises the rerouted aggregate/filteredCount consumers even
+though it takes no fast path); the transaction-draft old-verdict sites get
+adversarial tests — same-reference mutation flipping a row's verdict must
+still emit the correct remove/insert change ops (this is the case the
+row-keyed store could not serve; membership resolution must); aggregates
+correct under filtered and all populations; distinct-values `filtered`
+population correct; identity assertions (zero record rebuilds on a filter
+change — counter-pinned); `nearestVisibleRef` semantics unchanged.
+
+H3: byId-by-removal equals byId-by-refill (structural equality across every
+key); trusted-order variant equals verified build; a deliberately
+misordered trusted input is NOT silently accepted in tests (assert the
+variant is only used where order is proven — a mutation that feeds it
+misordered input must be caught by an equivalence oracle downstream).
+
+End-to-end: the F4 protocol, all bars, plus the api-report review.
diff --git a/docs/superpowers/specs/2026-08-24-columnar-verdicts-results.md b/docs/superpowers/specs/2026-08-24-columnar-verdicts-results.md
new file mode 100644
index 000000000..7bea36e4b
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-24-columnar-verdicts-results.md
@@ -0,0 +1,273 @@
+# Columnar verdict cache measured results (Amendment J) — 2026-08-24
+
+Browser-bench A/B of the columnar verdict cache: compiled per-plan filter
+predicates, the mutable columnar filter-value store with commit-side clears,
+and the filter rebuild consuming per-record columnar verdict scans.
+
+- **Variant**: `683ecd93` (HEAD of `blove/filter-fast-path` — plan Tasks 1–4:
+ `d64fba85`, `ec871e6f`, `30c43223`, `683ecd93`).
+- **Baseline**: `eab3b893` (the commit before Task 1; verified via
+ `git log eab3b893..HEAD -- packages/` that exactly those four columnar
+ commits are the only `packages/` changes — one variable).
+- **Design**: throwaway worktree at the baseline commit, fresh install +
+ `pnpm --filter @pretable/app-bench build` per side, one preview server on
+ 4173 at a time (port verified free first), interleaved paired rounds, 3
+ repeats per cell, medians reported. Because round A's metadata cells
+ disagreed with its text cells, a second full 50k paired round was run
+ (rounds A and B below; 6 repeats per 50k cell pooled). No CDP tracing for
+ headline numbers; runner output redirected to files, exit codes checked.
+- **Machine load**: heavy — 1-min load 35–110 across rounds (10-core Mac,
+ 8.7GB of 10GB swap used, parallel sessions active). Same regime as the
+ M1+M2 (33–54) and seam (24–46) runs, with a worse spike (110) during the
+ variant 3k round; interleaving plus TanStack same-run controls are the
+ fitness arbiter as before.
+- **Scale note**: 3k tier is `--scale=hypothesis` (3,000 rows; `rowCount`
+ confirmed in the summaries).
+
+## Gates
+
+- `pnpm build`: PASS. `pnpm api`: PASS with **zero `.api.md` drift** —
+ everything this milestone touched is row-model-internal.
+- Full root `pnpm test`: all 11 packages + both apps green. One react test
+ (`external-filter-authority.test.tsx` — an aria header-state assertion)
+ failed once inside the full run at 1-min load 35, then passed 3/3 in
+ isolation and a full `@pretable/react` re-run (1247/1247) — load flake,
+ not a regression. The `apps/*` phase (skipped by pnpm after that first
+ failure) was run separately: green.
+- `pnpm lint`: PASS. `prettier --check .`: fails on 4 docs files
+ (`dense-handle-core-design`, `dense-handle-m0-results`,
+ `dense-handle-m1-m2-results`, `dense-layout-seam-results`) that fail
+ identically at the baseline commit — pre-existing on the branch, not
+ introduced here, left untouched per the commit-only-this-file rule.
+
+## Results (settle quantizes to ~8.3ms frame steps)
+
+### 50k rows (S2, `--scale=target`) — two paired rounds, medians of 3
+
+| Metric | Script | Base A | Var A | Base B | Var B | Pooled base (6) | Pooled var (6) |
+| --------------------------------- | --------------- | ----------- | ---------- | ----------- | ----------- | --------------- | -------------- |
+| settle_duration_ms | filter-metadata | 107.4 | 108.9 | 98.9 | 107.1 | 107.3 | 108.6 |
+| settle_duration_ms | filter-text | 108.3 | 100.0 | 108.4 | 108.3 | 108.4 | 104.3 |
+| post_interaction_long_tasks_ms | metadata / text | 88 / 91 | 88 / 87 | 87 / 93 | 87 / 89 | — | — |
+| interaction_latency_ms | metadata / text | 17.1 / 16.7 | 8.4 / 16.7 | 16.9 / 17.7 | 17.1 / 16.4 | — | — |
+| post_interaction_blank_gap_frames | both | 0 | 0 | 0 | 0 | 0 | 0 |
+| TanStack control settle | filter-metadata | 49.7 | 57.4 | 58.0 | 57.6 | — | — |
+| TanStack control settle | filter-text | 57.4 | 50.1 | 51.7 | 57.1 | — | — |
+
+Repeat spreads tell the story: every 50k pretable cell on BOTH sides bounces
+between the 12-frame (~99–100ms) and 13-frame (~107–109ms) quantization
+bins — baseline metadata [98.3, 98.9, 107.1, 107.4, 107.4, 107.5], variant
+metadata [99.5, 107.1, 108.3, 108.9, 109.1, 116.7], variant text [99.0,
+100.0, 100.2, 108.3, 109.6, 109.7]. Round A's apparent text win (−8.3) did
+not reproduce in round B (0.0); round B's apparent metadata loss (+8.2) is
+the same bin-bounce in the other direction (round A: +1.5). The variant
+metadata 8.4ms latency in round A likewise read 17.1 in round B.
+
+### 3k rows (S2, `--scale=hypothesis`) — one paired round
+
+| Metric | Script | Baseline | Variant | Δ |
+| --------------------------------- | --------------- | ----------- | ----------- | ------- |
+| settle_duration_ms | filter-metadata | 33.3 | 33.3 | 0 |
+| settle_duration_ms | filter-text | 34.3 | 33.9 | ~0 |
+| post_interaction_long_tasks_ms | both | 0 | 0 | 0 |
+| post_interaction_blank_gap_frames | both | 0 | 0 | 0 |
+| TanStack control settle | metadata / text | 24.9 / 24.3 | 25.7 / 25.1 | in band |
+
+## Deltas vs the seam and the cumulative arc
+
+- Vs the seam record (108.3 metadata / 116.8 text): pooled variant medians
+ are 108.6 / 104.3 — metadata unchanged, text ~one bin better than the
+ RECORD but indistinguishable from today's PAIRED baseline (108.4), which
+ itself sat a bin under the old text record. The paired comparison is the
+ honest one: **no reproducible settle change in either script.**
+- Cumulative arc: pre-M1 166.6/158.4 → M1+M2 116.8/125.6 → seam
+ 108.3/116.8 → columnar **~108/~104–108 (flat)**. The branch's total gain
+ remains the ~1.4–1.5× of the first two milestones; this milestone added
+ none that the settle metric can resolve.
+
+## Bar verdicts
+
+- **50k settle improves in BOTH scripts vs 108.3/116.8: MISSED.** Both
+ scripts are flat within one frame across two interleaved paired rounds;
+ the only sub-frame signal (pooled text −4.1ms) is half a quantization bin
+ and did not survive the confirmation round as a per-round delta.
+- **Traced verdict share ≲3%: MISSED — ~17% (vs 17.5% pre-columnar,
+ essentially unchanged).** Breakdown in the table below.
+- **3k no regression: MET** (0 / −0.4ms).
+- **Zero blank frames: MET** (0 in all 18 pretable summaries, both sides,
+ both scales).
+- **TanStack controls in band: MET** — all 50k control cells straddle the
+ same 48–58ms one-frame band on both sides and both rounds (49.7–58.0);
+ 3k controls 24.3–25.7. The regime held; the flat result is trustworthy.
+
+## Traced share (variant, 50k filter-metadata, `--window=interaction`)
+
+One traced run after the headlines (`PLAYWRIGHT_PERF_TRACE=1`, repeats=1),
+`analyze-cdp.mjs --window=interaction` with the build's sourcemap. Window
+68.0ms (seam trace: 74.2ms). Traced absolutes skew ~2× — shares only.
+Groups sum to ~81%; the rest is the sub-0.3% frame tail.
+
+| Subsystem (self time) | Share |
+| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- |
+| **Verdict evaluation, columnar path** — `bulkFilterVerdictScan` 7.1% + exported wrapper 0.9% + `textCell` normalization 4.4% + compiled `contains` predicate 2.1% + `columnarGetCell` 2.2% + `assertRealSlot` 0.4% | **17.1%** |
+| filter-rebuild walk body (`rebuildRootForFilterOnlyChange` 9.6% + walk callback 9.6%) | 19.2% |
+| persistent HAMT (`persistent-map.js`) | 12.2% |
+| layout-core dense refilter (`refilter` + `#refilterDense`) | 6.8% |
+| order-statistic tree | 3.2% |
+| slot-vector + visible-index + plan-equality misc | 2.5% |
+| react render/commit + DOM (react pkg, react-dom, `getBoundingClientRect`, `measureText`, `querySelectorAll`, adapter) | ~15.5% |
+| (program) + GC | 4.2% |
+
+Why the share did not move: the cache eliminated the accessor read and the
+per-row values-Map get, but those were never the dominant term. What
+remains is (a) **per-row dispatch** — the walk calls `bulkFilterVerdictScan`
+once per record through the exported wrapper, paying wrapper + `instanceof`
+guard + per-cell `assertRealSlot` for every one of 50k rows (~8% scan
+machinery, plus its share of the 9.6% walk-callback self time), and (b)
+**per-cell value normalization** — the compiled text predicate still runs
+`String(value).toLocaleLowerCase()` on every raw cell (`textCell` 4.4% +
+predicate 2.1%), because the columnar store caches RAW values, not
+normalized ones. The compile step hoisted operand normalization; cell
+normalization stayed in the loop.
+
+## Fitness statement
+
+- TanStack same-run controls agree across sides within one frame on every
+ cell in every round; the pretable repeat spreads overlap across sides.
+ The interleaved paired design plus in-band controls make the "flat"
+ conclusion trustworthy despite 1-min load 35–110.
+- The round-B baseline run's matrix process exited 1: a side spec
+ (`row-height-error-applicability`, a 60s `waitForFunction`) timed out
+ under load AFTER the last cell's bench completed — all 12 summaries in
+ that round are `status: completed` and are used here.
+- Settle cannot resolve sub-frame changes in this regime: both sides
+ bin-bounce between 12 and 13 frames. The traced share is the sharper
+ instrument, and it independently confirms the flat settle result.
+
+## Conclusion
+
+The columnar verdict cache **did not deliver**: 50k filter settle is flat
+(~108ms metadata / ~104–108ms text vs the seam's 108.3/116.8) and the
+traced verdict share is ~17%, unchanged from 17.5% pre-columnar — because
+the cost it removed (accessor reads, Map gets) was not where the time was.
+The gap to TanStack (~50–58ms same-run controls) remains ~50ms, and the
+window now names its levers precisely: make the scan actually bulk (hoist
+the per-row wrapper/guard dispatch into one loop inside the plan — the
+plan-of-record's original one-call-before-the-walk shape; ~8% scan
+machinery + part of the 19.2% walk body), cache normalized values for text
+filters instead of raw ones (~6.5%), and only then the structural terms —
+the HAMT (12.2%), the rebuild walk body itself, and render/commit (~15.5%).
+The mechanism (store, freshness clears, compiled predicates) is sound,
+tested, and API-silent; its payoff is gated on removing the per-row and
+per-cell overheads that this measurement surfaced.
+
+## Fix cycle (one-call sweep + normalized cells) — 2026-08-25
+
+Both levers the trace named were implemented and re-measured.
+
+### What changed
+
+- **One-call bulk sweep.** `bulkFilterVerdictScan` (one exported-wrapper +
+ `instanceof` + per-cell `assertRealSlot` call per record) is GONE, folded
+ into `CompiledQueryPlan.bulkFilterVerdictSweep`: ONE call per rebuild that
+ owns the `forEachSlotEntry` walk. Plan resolution, the `instanceof` guard,
+ filter columns, predicate arrays, normalizers, and each filter's column
+ vector are hoisted out of the row loop; cells are read through the new
+ assert-free `columnarGetCellTrusted` (walk slots are nonnegative integers
+ by construction — trust-by-construction documented at the sweep, and a
+ wrong-slot fill mutation is caught by the equivalence oracle).
+ `filter-rebuild.ts` passes a `(record, passes)` callback that keeps its
+ flip-set/bitset logic — still one closure call per row, but zero wrapper /
+ guard / assert / Map-get work per row.
+- **Normalized cells.** The columnar store now caches the SCAN
+ representation, normalized once at fill (`normalizeCellForScan`): text
+ lowercased (`textCell`), dates as `toDayMs` day-ms, enum `String`-coerced,
+ boolean `booleanValue`-coerced, numbers raw. Predicates gained normalized
+ twins (`compileFilterPredicateForNormalized`) whose closures skip the
+ per-row normalization; the operator sweep gained twin tests pinned to the
+ same literal expectations. The per-row `filterVerdict`/`evaluate` paths
+ keep raw values and raw predicates, unchanged.
+- **isEmpty/garbage-date resolution.** Emptiness is a RAW-value property the
+ normalized forms cannot preserve (raw `NaN` in a text column is empty but
+ normalizes to non-empty `"nan"`; garbage and empty dates both normalize to
+ `NaN`, and `isEmpty` must stay FALSE on garbage while comparisons fail on
+ both). Rather than a two-field cell, `isEmpty`/`isNotEmpty` filters stay
+ on live accessor reads through the raw predicate inside the sweep —
+ documented at `normalizeCellForScan`, pinned by an explicit
+ garbage-vs-empty date test (garbage fails `on`/`after` AND `isEmpty`;
+ empty fails comparisons AND passes `isEmpty`).
+
+Verification: 654 row-model tests green (616 + 38 added), full root
+`pnpm test` green (exit 0, no flakes this run). Mutations (performed,
+caught, restored): wrong-slot fill → 30 failures including the randomized
+equivalence oracle; fill stores RAW value → both new warm-cell membership
+pins fail; normalizer drops the lowercase → 8 failures across the
+normalized twins and warm pins.
+
+### Paired 50k re-measure (round A; medians of 3; load 10.9–14.1)
+
+Same protocol: baseline throwaway worktree at `891d6cb4` (fresh install),
+bench rebuilt per side, port 4173 verified free, one matrix-managed server
+at a time (build-identity asserted), TanStack same-run controls. Machine
+load 10.9–14.1 — far lighter than the original run's 35–110.
+
+| Metric | Script | Base `891d6cb4` | Fixed | Δ |
+| ---------------------- | --------------- | --------------- | ----------- | ------- |
+| settle_duration_ms | filter-metadata | 99.9 | 100.8 | +0.9 |
+| settle_duration_ms | filter-text | 99.6 | 100.0 | +0.4 |
+| interaction_latency_ms | metadata / text | 16.6 / 16.3 | 16.6 / 16.6 | ~0 |
+| long tasks / blank | both | 84–87 / 0 | 82–88 / 0 | ~0 |
+| TanStack control | metadata / text | 58.0 / 50.7 | 57.5 / 50.8 | in band |
+
+Both scripts' cells agree (both flat, sub-frame Δ), so the
+disagreement-triggered second round was not needed. Note both SIDES sit in
+the 12-frame bin (~99–101ms) that the heavier original session only
+bounced into — the lighter machine, not the fix.
+
+### Traced share (fixed variant, filter-metadata, `--window=interaction`, 65.3ms window)
+
+Verdict machinery now: sweep row closure 1.9% + `forEachSlotEntry` 1.9% +
+`columnarGetCellTrusted` 1.7% + fill-side `columnarSetCell` 2.0% +
+`#readColumnValue` 1.1% + `normalizeCellForScan` 0.5% + `textCell` 3.3% +
+normalized `contains` predicate 3.0% = **~15.4%** (was ~17.1%). The
+rebuild walk body is 8.9% + 11.5% callback = 20.4% (was 19.2% — the
+callback now absorbs frames the old per-row scan call held). HAMT,
+tree, layout-core, react terms unchanged in kind.
+
+### Why it is STILL flat — the structural finding
+
+The bench scripts apply ONE filter commit against a COLD store: the
+measured interaction IS the fill. So the fill-time normalization
+(`textCell` 3.3%, `columnarSetCell` 2.0%, accessor reads 1.1%) runs inside
+the same window it used to run in — it moved from "per predicate call" to
+"per fill", but with exactly one commit those are the same count. The
+dispatch overhead the sweep removed (wrapper + instanceof + per-cell
+asserts, ~1.3–1.5% traced) was real but half a frame. The warm-path win —
+repeat filter commits verdicting over already-normalized cells with ZERO
+fills, which the new tests prove — is structurally invisible to a
+single-commit script and to the settle metric.
+
+### Verdict
+
+**STILL FLAT.** Settle: +0.9ms metadata / +0.4ms text (sub-frame, controls
+in band). Traced verdict share: ~15.4% vs ~17.1% — a real but small
+reduction, and the remainder is cold-fill work the script's shape makes
+unavoidable plus the walk/merge/HAMT structure the fix never targeted. Per
+the arc's standard this does not clear the bar; the revert decision goes
+back to the controller.
+
+## Decision: reverted
+
+The store, scan, and normalization — `30c43223` (columnar store +
+commit-side clears), `683ecd93` (scan + filter-rebuild consumption +
+setDerivations reset), `73f1ae24` (one-call sweep + normalized cells) —
+are reverted in `revert(row-model): drop the columnar verdict store —
+measured flat twice`. Kept: `ec871e6f` (compiled per-plan filter
+predicates, operator sweep and malformed-operand pins included) and
+`d64fba85` (`CompiledRowInput.slot` threading). Rationale: flat twice —
+a warm-path saving of ~3ms inside a ~100ms settle does not buy the
+machinery, and git history preserves it if the calculus changes. The
+lesson worth carrying: the bench scripts apply one filter commit against
+a COLD store, so the measured interaction IS the fill — any
+cache-the-fill design is structurally invisible to a single-commit
+script, and that must be checked before building the cache.
diff --git a/docs/superpowers/specs/2026-08-24-dense-handle-amendment-i-layout-seam.md b/docs/superpowers/specs/2026-08-24-dense-handle-amendment-i-layout-seam.md
new file mode 100644
index 000000000..a05d9b85d
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-24-dense-handle-amendment-i-layout-seam.md
@@ -0,0 +1,101 @@
+# Amendment I — dense-identity layout seam
+
+Date: 2026-08-24. Status: design, pre-plan. Amends
+`2026-08-24-dense-handle-core-design.md` (executes its "renderer seam"
+milestone, promoted AHEAD of the columnar cache by measurement).
+
+## Why the promotion
+
+M1+M2 took 50k filter settle 166.6 → 116.8ms (`...m1-m2-results.md`). The
+2026-08-24 re-attribution of the remaining ~117ms (traced interaction window,
+shares): **layout `refilter` ≈ 33%** (16.8% walk + 13.6% string-HAMT
+`hashString`/`lookupEntry`/`set` + 2.4% OST rank reads), row-model rebuild +
+verdict pass ≈ 29%, renderer/react commit ≈ 25%. The columnar verdict cache
+(spec M3) caps at ~14%. The layout seam is the same string-identity disease
+M1+M2 cured in row-model, and it is now the single largest lever. Columnar
+follows as the next milestone after this one.
+
+## What the seam pays today (measured anatomy, 50k refilter)
+
+1. `replacementSourceOf` (`renderer-dom/row-layout-controller.ts:1444`):
+ `entryAt(index)` → `snapshot.rowAt(index)` — an O(log n) order-statistic
+ rank descent PER ROW → O(n log n) for the walk, plus ref/identity
+ stringification per row.
+2. `RowHeightIndex.refilter` (`layout-core/row-height-index.ts:1582`): per
+ row, ~4–5 string-hash operations — `unconsumed` Map set (old pass), `seen`
+ Set add, `visibleKeys` HAMT insert (rebuilt FROM SCRATCH every refilter),
+ `unconsumed.get`; entrants add `hashGet(measurements)` (+ tombstone
+ lookups).
+
+## Design
+
+### 1. Dense keys cross the seam as an OPTIONAL contract
+
+- Row-model snapshot gains an internal-facing visible-row read that exposes
+ the record's `slot` and the root's `slotCapacity` alongside the existing
+ ref (exact shape decided in the plan after reading the snapshot module;
+ the public `publicRow` object is NOT touched). It must also expose a bulk
+ in-order visible walk (the tree's materialized `range`) so the source stops
+ paying a rank descent per row.
+- `RowHeightReplacementSource` entries gain optional `denseKey: number` and
+ the source gains optional `denseCapacity: number`. When every entry of a
+ replacement/refilter/reorder carries a dense key, `RowHeightIndex` runs its
+ dense lane; any entry without one falls back to the string lane wholesale
+ (no mixed-lane pass). String `identity` REMAINS the durable identity in
+ both lanes.
+
+### 2. Two-tier measurement store inside `RowHeightIndex`
+
+- **Hot lane (dense, slot-keyed):** the per-pass O(n) structures — the
+ refilter walk's `unconsumed`/`seen`, the `visibleKeys` membership, and the
+ current-measurement lookup for LIVE rows — become bitsets / dense arrays
+ sized by `denseCapacity`.
+- **Cold lane (string-keyed, unchanged):** tombstones (`tombstones`,
+ `tombstoneOrder`) and any measurement whose row is not currently live.
+ Tombstones CANNOT go dense — a slot is lifetime-bound and REUSED after
+ permanent removal, while a tombstone's whole purpose is to outlive the row.
+
+### 3. The slot-reuse invalidation contract (THE trap of this milestone)
+
+A slot-keyed measurement is valid exactly as long as the slot binds the same
+row. `refilter` never crosses a slot release (filter leavers keep their model
+slots — they are hidden, not removed). Permanent removals reach the layout
+index only through full replacements (`beginReplacement`) — the plan pins
+that claim in code review before anything is built on it — so:
+
+- refilter/reorder may trust slot-keyed hot state unconditionally;
+- a FULL replacement rebuilds hot state from its own walk and must fold
+ retiring measured rows into the cold (string) tier by identity, exactly as
+ today's retention semantics demand;
+- the pin that keeps this honest: filter row X out (tombstoned), permanently
+ remove X, add row Y that REUSES X's slot, refilter Y visible — Y must
+ ingest at estimate height, and X's retained measurement must return only
+ on X's identity, never attach to Y. Mutation-hardened both ways.
+
+### 4. What stays put
+
+- Retention semantics, ticket order, cap eviction, the fallback-on-throw
+ contract, and every observable of `refilter`/`reorder` are UNCHANGED —
+ equivalence oracle: dense lane vs string lane must produce identical
+ observable sequences on randomized flip scripts.
+- The blank-viewport latch and anchor semantics (G3c) are untouched.
+
+## Bars
+
+- 50k refilter's layout share (walk + its HAMT + OST reads, traced shares)
+ drops to ≲ 10% of the window; untraced 50k filter settle ≤ ~95ms
+ (from 116.8/125.6) with long-tasks reduced accordingly; 3k no regression;
+ TanStack controls in band; zero blank frames; refilterFallbackCount 0.
+- Deliberate API surface: layout-core types and row-model internal snapshot
+ reads WILL move `.api.md` — every line of that diff is reviewed and
+ intended (M1+M2's zero-drift property ends here by design). The docs
+ api-surface guard is test-pinned to those reports; if it fires, the
+ registered tables update in the same commit, per the guard's contract.
+
+## Explicitly out of scope
+
+- The columnar verdict cache (next milestone).
+- Renderer/react commit costs (~25%) — different levers (memoization,
+ windowed commit), different milestone.
+- Any change to grouped replacement paths beyond keeping them compiling on
+ the string lane.
diff --git a/docs/superpowers/specs/2026-08-24-dense-handle-amendment-j-columnar-verdicts.md b/docs/superpowers/specs/2026-08-24-dense-handle-amendment-j-columnar-verdicts.md
new file mode 100644
index 000000000..918856d85
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-24-dense-handle-amendment-j-columnar-verdicts.md
@@ -0,0 +1,97 @@
+# Amendment J — columnar verdict cache
+
+> **Status: CLOSED — reverted.** Built, measured flat twice (original run
+> and the one-call-sweep + normalized-cells fix cycle), and reverted per the
+> no-unmeasured-budget standard. Compiled predicates and
+> `CompiledRowInput.slot` threading were kept; the store, scan, and
+> normalization were dropped. Full record:
+> `2026-08-24-columnar-verdicts-results.md`.
+
+Date: 2026-08-24. Status: design, pre-plan. Executes the core spec's
+"Columnar evaluation cache" milestone (M3), narrowed to VERDICTS; amends it
+with the mechanics the spec left open. Follows Amendment I (measured:
+`...dense-layout-seam-results.md`).
+
+## Why now, and the target
+
+Post-seam re-attribution of the 50k filter window (74.2ms traced): verdict
+evaluation (`#filterVerdict` + `evaluateFilter` accessor loop) ≈ **17.5%**,
+the largest single row-model share. Per row it pays: a resolver closure, a
+`#byId` Map get per filter, a live accessor call per filter, and
+`evaluateFilter`'s per-row operator dispatch. M0 priced the replacement — a
+monomorphic scan over a slot-indexed value vector — at 0.26ms (numeric) /
+0.70ms (string `.includes`) per 50k pass.
+
+Target: verdict share → ≲3%; untraced 50k settle from 108.3/116.8 toward
+~95–105ms. Sort keys are OUT of scope (they have their own store); grouped
+paths keep the per-row route.
+
+## Design deltas beyond the core spec
+
+### 1. `CompiledRowInput` gains `slot`
+
+Every caller already holds the record (or is creating it and just allocated
+the slot). `evaluate` and the verdict paths receive the slot so columnar
+cells can be written and read without any string key.
+
+### 2. Columnar store: per-column `SlotVector` on the shared cache
+
+A `Map>` of RESOLVED accessor values for
+FILTER columns, living beside the evaluation cache and adopted by reference
+in the same `adoptEvaluationCache` call (same validity argument: a
+filter-only change preserves every accessor's semantics). Storage revised
+at implementation (`30c43223`): mutable-in-place chunked vectors with
+per-chunk presence bitsets (`mutable-columnar.ts`), NOT the COW
+`slot-vector` — sound because the store is cache-not-truth and nothing
+revision-scoped ever reads it; the module header carries the argument.
+
+### 3. Freshness invariant (the load-bearing rule — REVISED)
+
+Revised 2026-08-24 during Task 1 review, which found the original wording
+("written wherever metadata is evaluated") unsound: drafts evaluate rows
+BEFORE the draft is known effective, so an aborted draft would leave cells
+reflecting values that never committed, at slots the committed root still
+owns. Also, two ingest paths present a `-1` placeholder slot to `evaluate`
+(allocation is deliberately deferred past the throwing accessor — the
+capacity-leak fix in `d64fba85`), so `evaluate` cannot be the writer anyway.
+
+**The bulk scan is the ONLY writer** (write-through on holes). Commit-side
+maintenance only CLEARS: every committed transaction clears the cells of its
+changed and removed rows (k-sized, beside the existing `slotWrites` block);
+a full set-rows/initial build starts from empty vectors; a non-filter-only
+plan change compiles fresh shared state (vectors start empty and refill on
+the next scan — extending adoption to sort-only changes is a follow-up, not
+this milestone). Aborted drafts never touched the cells; entrants and
+updated rows are holes until the next scan reads them once.
+
+Pinned by: the equivalence oracle (columnar ≡ per-row verdicts on
+randomized scripts including update, slot-reuse, AND a throwing-accessor
+aborted-draft step), and a mutation test that skips the commit-side clear.
+
+### 4. Holes fall back per cell
+
+A vector missing a cell (column newly active, or a row ingested under a
+plan that didn't reference the column) answers by live accessor read AND
+fills the cell (write-through). First filter commit on a newly-referenced
+column therefore pays one O(n) accessor pass — the same pass it pays today
+every commit — and subsequent commits scan.
+
+### 5. Compiled predicates
+
+Per filter, resolve column + operator ONCE into a monomorphic
+`(value) => boolean` closure (`compileFilterPredicate`), hoisting operand
+normalization (e.g. between-bounds, lowercased needles) out of the row
+loop. The bulk scan is: per filter, loop live slots over the vector,
+AND into the verdict bitset. `filter-rebuild`'s walk consumes the bitset
+instead of calling `filterVerdict` per row; the per-row `filterVerdict`
+remains for k-sized and grouped paths, unchanged in semantics.
+
+## Bars
+
+- 50k filter-metadata AND filter-text settle improve vs the seam baseline
+ (108.3/116.8) with TanStack controls in band; traced verdict share ≲3%.
+- 3k no regression; zero blank frames; grouped gate untouched.
+- Zero public API drift (everything is row-model-internal; `CompiledRowInput`
+ is internal — verify, and if it leaks into a public type, stop and review).
+- Equivalence oracle + freshness mutation + existing 557 row-model tests, all
+ work-count pins intact.
diff --git a/docs/superpowers/specs/2026-08-24-dense-handle-core-design.md b/docs/superpowers/specs/2026-08-24-dense-handle-core-design.md
new file mode 100644
index 000000000..6e7f2af16
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-24-dense-handle-core-design.md
@@ -0,0 +1,190 @@
+# Dense-handle core: slots, bitsets, and columnar evaluation
+
+Date: 2026-08-24. Status: approved design, pre-plan.
+Branch: continues `blove/filter-fast-path` (held unmerged by decision; the
+filter fast-path branch folds into this arc and lands with it).
+
+## Problem
+
+The filter fast-path arc (F/G/H cycles + post-H commits, HEAD `27e8168c`)
+took 50k S2 filter settle from 241.8ms to 158.3ms, but the spec bar — settle
+≤ same-run TanStack (~67ms) — is missed at 2.4–2.7×, and the final trace
+attribution (`filter-final-results.md` §4) says the bar is not reachable from
+the current representation: the remaining 134ms window is dominated not by any
+algorithm but by a single architectural fact — **string row ids are the
+currency of every hot path**. Each per-row step pays string hash + HAMT
+traversal, several times over:
+
+- `sourceOrder` entries are `{rowId, sourceOrder}` — resolving the record is
+ a 50k-lookup HAMT walk (18.5ms attributed to `filter-rebuild.ts` directly).
+- The old verdict is a second lookup on the same string
+ (`rowPassesFilter` → `visible.rows.get` → byId HAMT, 11.9ms).
+- `hashString` alone is 10.4ms — re-hashing strings whose hashes never change.
+- The per-row verdict pass is row-at-a-time polymorphic evaluation (18.3ms).
+- The renderer repeats the pattern: a `visibleKeys` HAMT rebuilt per
+ replacement (~7ms), a frozen rowRef allocated per row.
+
+Sort pays the same currencies: 50k sort settle is 266ms with a 247ms block on
+this branch (300/279 on `main`).
+
+## Goal and bars
+
+Make the whole row-model commit pipeline — filter, sort, transactions,
+grouping, and the renderer seam — speak **dense integer handles** internally,
+so every O(n) pass is array-resident. String ids remain the public currency;
+the public API is expected to be unchanged (any diff must be deliberate).
+
+Success bars (bench protocol: medians of 3, same-run TanStack controls in
+band, port 4173 free, no `grep|head` on gates):
+
+1. **50k S2 filter-metadata AND filter-text settle ≤ same-run TanStack**,
+ completed ×3, zero blank frames.
+2. **50k S2 sort settle ≤ same-run TanStack** (bar value read from the same
+ run's TanStack settle, not the latency band).
+3. **Any single main-thread block ≤ 50ms** on every measured script. A path
+ that cannot meet this at some scale must size-gate to the retained
+ cooperative path at that scale rather than ship a longer block.
+4. **No regressions**: 3k cells, mount, interaction latency (15–18ms band),
+ grouped gate (`rebuild_slice_max_ms` ≤ 8), anchor/focus/selection
+ preservation, full repo suites, `api:check`.
+
+## Non-goals
+
+- Replacing the persistent structures as source of truth (the full-columnar
+ / flat-array core was probed and rejected: old-snapshot validity is
+ load-bearing at four call sites and flat mutation measured 4.7–401ms per
+ commit vs the tree's 0.16ms — `index-representation-probe.md`).
+- Refinement incrementality (monotone filter-as-you-type narrowing scans
+ only current members). Orthogonal, semantic, filed as follow-up.
+- Worker offload, WASM, or SharedArrayBuffer anything.
+- Re-adding stored per-record verdicts. Membership IS the verdict (H-cycle
+ invariant) — it just becomes a bitset.
+
+## Architecture
+
+Three new row-model-internal primitives sit UNDER the existing persistent
+structures. The HAMT and order-statistic trees remain the identity/order
+source of truth; rank queries, snapshot semantics, `orderIsProven`, and
+`derivedById` all carry over.
+
+### Slot allocator (per model instance, not per revision)
+
+Every row gets a small integer slot for its lifetime: assigned at ingest,
+stamped on the record (`record.slot`), released to a free-list on permanent
+removal, reused. Capacity grows monotonically — streaming inserts never
+renumber existing rows.
+
+### Slot vectors (immutable, chunked, copy-on-write)
+
+Two-level arrays: a chunk table over 1024-element chunks. A commit touching
+k rows copies ~min(k, ceil(n/1024)) chunks plus the table; reads are two
+indexed loads. Used for `recordsBySlot` and the columnar caches.
+
+**This is what keeps old snapshots valid under slot reuse**: each revision
+holds its own immutable chunk table, so revision N's arrays still bind slot
+s to whatever row owned s at revision N. A held snapshot's answers cannot
+change when a later revision frees and reuses the slot.
+
+### Membership bitsets (immutable, whole-copied)
+
+50k rows = 6.25KB; whole-copy per commit is negligible even at streaming
+rates — no COW machinery. Two bitsets matter per revision: the **live set**
+(slots currently bound to rows — the scan domain) and the **visible set**
+(filter membership, ungrouped). Old-vs-new verdict diff = XOR + word-scan of
+set bits, replacing ~100k HAMT lookups.
+
+### Columnar evaluation cache
+
+`compiled-query`'s evaluation cache becomes per-referenced-column slot-indexed
+vectors. The verdict pass becomes, per filter, a monomorphic scan over the
+live-set domain producing a bitset, combined across filters by AND. Cache
+adoption across filter-only plan changes generalizes the existing
+`adoptEvaluationCache` (by-reference, tagged by the writing plan). Keys that
+encode numerically take typed-array storage, which also gives sort a numeric
+fast path.
+
+Memory character: ~n × referenced columns × 8B (~2MB at 50k × 5 columns).
+M0 measures how much of this the current cache already pays.
+
+## Component inventory
+
+| Component | Change |
+| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
+| `slot-allocator.ts` / `slot-vector.ts` / `membership-bitset.ts` | new, row-model internal |
+| `compiled-query.ts` | columnar cache; monomorphic per-filter scans; bitset AND combine |
+| `filter-rebuild.ts` | records via `recordsBySlot`; diff via bitset XOR; merge + bulk build unchanged in shape |
+| `sort-rebuild.ts` | sort survivor slot arrays on columnar keys; bulk tree build |
+| `transaction-draft.ts` / `row-store.ts` | slot assignment/release; chunk-COW writes; live-set maintenance |
+| `cooperative-transition.ts` | RETAINED as the size-gate fallback; reads the same slot structures |
+| `persistent/order-statistic-tree.ts` | entries carry slot; internal byId keyed by slot (no string hashing in `get`) |
+| `group-index.ts` | leaf trees hold slots; `rowParents` a slot-indexed vector |
+| react + layout-core seam | `visibleKeys` HAMT → bitset; rowRefs pooled by slot; refilter/reorder consume slot permutations |
+
+## Data flow — filter-only commit, ungrouped, 50k
+
+1. Classifier (existing) proves filter-only.
+2. Next plan adopts the columnar cache by reference.
+3. Per-filter columnar scan over the live set → AND → new visible bitset.
+4. XOR old visible bitset; iterate flipped words → flippedIn / flippedOut.
+5. Flip-ins resolve records by `recordsBySlot[slot]`, keys from columnar
+ vectors; k log k sort of flip-ins only.
+6. Linear merge with the old tree walk (`range`, materialized), bulk build
+ with `orderIsProven` + `derivedById` gated on removals < survivors — all
+ as shipped today.
+7. Publish with `"refilter"` journal reason; renderer permutes heights
+ bitset-driven.
+
+Estimated window from the trace attribution: ~55–75ms settle, model block
+well under 50ms. Estimates are estimates; M0 and per-milestone measurement
+are the authority.
+
+## Error handling / invariants
+
+- Dev-mode fail-loud: `recordsBySlot[record.slot] === record` per revision;
+ allocator double-release / out-of-range access throws.
+- Test-time equivalence oracle: bitset membership ≡ tree membership on every
+ rebuilt path.
+- Instrumentation counters extended: bitset diffs, columnar scans, chunk
+ copies — so work assertions stay pinnable and mutation-hardened.
+
+## Testing
+
+- **Property equivalence**: random transaction/query sequences run through
+ the new pipeline and compared exactly (visible order + membership) against
+ the current implementation's results.
+- **Snapshot-validity pin**: hold a snapshot; remove a row; insert a new row
+ that REUSES its slot; assert the held snapshot's answers are unchanged.
+ Mutation twin: break the chunk-COW deliberately and watch it fail.
+- **Existing work assertions survive**: filter-only commit rebuilds zero
+ records, writes zero rows-map nodes (`filter-verdicts.test.ts`,
+ `filter-fast-path.test.ts` pins carry forward).
+- **Chunk-copy bound pinned**: a commit touching k rows copies ≤ f(k) chunks.
+- Bench certification per the bars above, under the measurement protocol
+ (one variable at a time, rebuilt dist per side, controls in band, gates
+ redirected to files and exit codes checked).
+
+## Milestones (each independently measured; M0 gates the arc)
+
+- **M0 — Node pricing probe.** Bitset diff, columnar scan, and slot-vector
+ maintenance under a streaming transaction mix, at 50k, in isolation
+ (method proven by `index-representation-probe.md`, whose browser
+ prediction landed within 2ms). Go/no-go numbers before production code.
+- **M1** — allocator + vectors + `recordsBySlot`; rebuild loops stop calling
+ `rows.get` (−18.5ms est.).
+- **M2** — membership bitsets + XOR diff replace the double lookup (−22ms est.).
+- **M3** — columnar verdict scan (−13ms est.).
+- **M4** — sort on slot arrays + columnar keys.
+- **M5** — renderer seam (bitset `visibleKeys`, pooled refs).
+- **M6** — grouping conversion (leaf trees + `rowParents` on slots).
+- **M7** — certification against all bars; size-gate any path missing the
+ 50ms block bar; PR of the combined arc (this + the held filter branch).
+
+## Carried context (do not re-litigate)
+
+- G3d (`fillSortKeysFromPrevious` adoption) was collected by `27e8168c`;
+ close it, don't execute it.
+- The order-statistic tree is finished as a target (8.5ms).
+- A verdict-Set probe and a memoized grouped comparator both measured flat
+ and were reverted; the waste is re-hashing, not the container.
+- Membership-as-tree-measure rejected with numbers (H spec).
+- `derivedById` is correctly declined at 50/50 flip ratios (pinned at 49/51).
diff --git a/docs/superpowers/specs/2026-08-24-dense-handle-m0-results.md b/docs/superpowers/specs/2026-08-24-dense-handle-m0-results.md
new file mode 100644
index 000000000..4e399c446
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-24-dense-handle-m0-results.md
@@ -0,0 +1,69 @@
+# M0 pricing probe — results
+
+Date: 2026-08-24. Probe: `m0-probe.mjs` (session scratchpad, throwaway).
+
+Machine load at run: `load averages: 37.32 23.69 22.12` (run 1) and `22.49 21.92 21.57` (run 2) on a 10-core Mac — well above the ≥8 caution threshold both times; parallel sessions were saturating the machine. Fitness: **NOT all spreads ≤ 20%** in either run. Run 1 (reported below) had four sections over: A3 195%, A5 25%, B 79%, C2 539%. The mandated rerun (run 2, second column) was noisier still (A1 78%, A3 102%, A5 88%, B 91%, C 27%, C2 484%), so the noise is reported rather than hidden. All oracles printed `oracles: PASS` before any timing in both runs.
+
+Why the verdict survives the noise: the two runs' medians agree to within ~1.4x on every section, the _worst observed maximum_ of every relevant section is still 3–10x inside its go-threshold, and the noisy sections are the sub-millisecond ones where scheduler jitter dominates a tiny denominator. The noise widens the error bars; it cannot move any number across a decision boundary.
+
+## Isolation numbers (median of 5, 2 warmups)
+
+| section | run 1 median (spread) | run 2 median (spread) |
+| ---------------------------------------------- | --------------------- | --------------------- |
+| A1 numeric columnar scan → bitset | 0.263ms (4%) | 0.266ms (78%) |
+| A2 string columnar scan `.includes` → bitset | 0.693ms (1%) | 0.701ms (1%) |
+| A3 xor + enumerate flips | 0.131ms (195%) | 0.129ms (102%) |
+| A4 50k records-by-slot vecGet | 0.100ms (12%) | 0.105ms (5%) |
+| A5 50k string-keyed Map.get (baseline) | 1.218ms (25%) | 1.615ms (88%) |
+| B composed filter-commit equivalent | 3.764ms (79%) | 5.227ms (91%) |
+| C streaming: per-commit chunk-COW (100 writes) | 33.1µs (10%) | 42.1µs (27%) |
+| C2 1000 whole-bitset clones | 1.005ms (539%) | 1.207ms (484%) |
+
+Notes on individual sections:
+
+- A4 vs A5: slot-indexed vecGet (0.10ms) is ~12x cheaper than string-keyed
+ `Map.get` (1.2–1.6ms) for the same 50k reads — and the Map baseline is itself
+ far cheaper than the HAMT the trace prices at 42.5ms. Stringiness alone does
+ not explain the HAMT cost; the dense-slot representation removes both the
+ string hashing and the persistent-tree pointer chasing.
+- A3's absolute times are 0.08–0.34ms; the huge spread percentages are jitter
+ on a tiny denominator, not instability of the primitive.
+- C2: cloning a 6.25KB bitset 1000 times costs ~1ms total (~1µs each) — a
+ whole-membership clone per commit is effectively free.
+
+## Read-across
+
+- Replaced work (trace attribution, `filter-final-results.md`):
+ persistent-map 42.5 + verdict pass 18.3 + double-lookup 11.9 = **72.7ms**.
+- New-structure equivalent, deliberately **overcounted**: A1 + A3 + B =
+ **4.2ms** (run 1) / **5.6ms** (run 2). The overcount: section B re-sorts the
+ ~12.5k survivors from scratch (2 × `Array.prototype.sort` dominates its
+ 3.8–5.2ms), whereas the real path takes survivor order proven from the old
+ tree walk and only sorts the flip-ins. B alone is therefore an upper bound
+ on the model-side commit work, and it also already contains its own scan and
+ xor, so A1+A3+B double-counts those too. Even so: ≥13x under the replaced
+ 72.7ms.
+- Streaming regression check: per-commit chunk-COW cost for 100 random writes
+ is **33–42µs** against the ≲500µs comfort bound — 12–15x headroom. Adding a
+ per-commit whole-bitset clone (~1µs) doesn't change that. The HAMT's
+ structural sharing is not being given up for anything close to its cost.
+- Caveat: these are Node numbers, not browser numbers. The precedent probe
+ (`index-representation-probe.md`) landed within 2ms of the browser result;
+ browser certification remains M7's job, not M0's.
+
+## Go/no-go
+
+**GO.**
+
+- New-primitive total (A1 + A3 + B, overcounted): 4.2–5.6ms ≲ 15ms required. ✓
+- Per-commit streaming COW: 33–42µs ≲ 500µs required. ✓
+- Load sensitivity: the per-run maxima are observations under their own runs,
+ not ceilings. A third independent run (spec review, load 22.6) measured
+ A1+A3+B = 8.25ms median (B max 23.3ms) and 98.4µs per commit — medians
+ still ≥2× inside every bound, but expect the maxima to scale with load.
+- Against the 72.7ms of replaced work, the new primitives price at roughly
+ 6–8% of the cost they displace, leaving the spec's ~55–75ms filter-commit
+ window dominated by the parts M0 did not model (tree build / render), which
+ is exactly where the budget was expected to live.
+
+No primitive priced out. Proceed to M1.
diff --git a/docs/superpowers/specs/2026-08-24-dense-handle-m1-m2-results.md b/docs/superpowers/specs/2026-08-24-dense-handle-m1-m2-results.md
new file mode 100644
index 000000000..7d463312e
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-24-dense-handle-m1-m2-results.md
@@ -0,0 +1,90 @@
+# M1+M2 measured results — slots + membership bitsets (2026-08-24)
+
+Browser-bench A/B of the M1+M2 arc: filter-only rebuild walks `recordsBySlot`
+and diffs membership bitsets, deleting two 50k string-HAMT lookup passes.
+
+- **Variant**: `e529d9d0` (HEAD of `blove/filter-fast-path`).
+- **Baseline**: `8f2b63b2` (last commit before M1; verified via
+ `git log 8f2b63b2..HEAD -- packages/` that every intervening `packages/`
+ commit is M1+M2 row-model work — one variable).
+- **Design**: throwaway worktree at the baseline commit, fresh
+ `pnpm install --frozen-lockfile` + bench build per side, interleaved paired
+ rounds (baseline 50k → variant 50k → baseline 3k → variant 3k), 3 repeats
+ per cell, medians reported. No CDP perf tracing (`PLAYWRIGHT_PERF_TRACE`
+ unset) — these are headline-grade absolutes.
+- **Machine load**: heavy and falling — 1-min load 38.1 at start, 33–54
+ during the four rounds (10-core Mac, parallel sessions active). Runs were
+ interleaved specifically so both sides saw the same regime; the TanStack
+ same-run controls below are the fitness arbiter.
+- **Scale note**: the 3k tier is `--scale=hypothesis` (3,000 rows). The plan's
+ `--scale=dev` is the 750-row tier — confirmed against existing summary
+ `rowCount` fields before running.
+
+## Results (medians of 3; settle quantizes to ~8.3ms frame steps)
+
+### 50k rows (S2, `--scale=target`)
+
+| Metric | Script | Baseline | Variant (M1+M2) | Δ |
+| ------------------------------ | --------------- | -------- | --------------- | --------- |
+| settle_duration_ms | filter-metadata | 166.6 | 116.8 | **−49.8** |
+| settle_duration_ms | filter-text | 158.4 | 125.6 | **−32.8** |
+| post_interaction_long_tasks_ms | filter-metadata | 148 | 105 | −43 |
+| post_interaction_long_tasks_ms | filter-text | 141 | 109 | −32 |
+| interaction_latency_ms | filter-metadata | 16.7 | 16.6 | ~0 |
+| interaction_latency_ms | filter-text | 16.6 | 16.6 | ~0 |
+| TanStack control settle | filter-metadata | 58.3 | 58.4 | +0.1 |
+| TanStack control settle | filter-text | 50.1 | 50.5 | +0.4 |
+
+Repeat spreads: baseline metadata [158.3, 166.6, 174.3]; variant metadata
+[116.1, 116.8, 124.5]; variant filter-text had one 474.1ms outlier (load
+spike — median unaffected), the other two repeats were 124.9/125.6.
+
+### 3k rows (S2, `--scale=hypothesis`)
+
+| Metric | Script | Baseline | Variant (M1+M2) | Δ |
+| ------------------------------ | --------------- | ----------- | --------------- | ---------------- |
+| settle_duration_ms | filter-metadata | 41.7 | 41.7 | 0 |
+| settle_duration_ms | filter-text | 41.8 | 33.5 | −8.3 (one frame) |
+| post_interaction_long_tasks_ms | both | 0 | 0 | 0 |
+| interaction_latency_ms | both | 16.6 / 15.8 | 16.6 / 16.5 | ~0 |
+| TanStack control settle | filter-metadata | 25.0 | 25.0 | 0 |
+| TanStack control settle | filter-text | 32.6 | 25.0 | −7.6* |
+
+\* Baseline text repeats were [24.4, 32.6, 34.1] vs variant [24.8, 25.0,
+32.9] — same one-frame band, median landed on different sides of a frame
+boundary. Within normal spread, not a regime change.
+
+## Deltas vs the branch's pre-M1 record
+
+Pre-M1 (recorded earlier on this branch): 50k settle 158.3 (metadata) /
+157.5 (text), long-tasks 141; 3k settle 34.5 / 33.6.
+
+- The baseline side reproduced those numbers under today's load (158.4–166.6
+ @50k; 41.7–41.8 @3k, one frame above the recorded 33.6–34.5 — consistent
+ with load, and why the paired baseline, not the old record, is the
+ comparison basis).
+- 50k improvement vs paired baseline: **−49.8ms (metadata) / −32.8ms
+ (text)** settle, −43/−32ms long-tasks. Against the trace-attribution
+ estimate of ~−30ms: **met on filter-text, exceeded on filter-metadata**.
+- 3k: at most one frame of improvement — expected; the deleted HAMT passes
+ are small in absolute terms at 3k and settle is frame-quantized.
+
+## Fitness statement
+
+- TanStack same-run controls agree across sides within one frame on all four
+ cells (50k: 58.3→58.4, 50.1→50.5; 3k: 25.0→25.0, 32.6→25.0 with
+ overlapping repeat ranges). The regime did not move between sides.
+- Load was high (1-min 33–54 on 10 cores) throughout; the interleaved paired
+ design plus in-band controls make the deltas trustworthy, but individual
+ absolutes carry load noise (see the 474ms variant outlier and the baseline
+ 3k 75.5ms outlier — both single repeats, both excluded by the median).
+
+## Conclusion
+
+M1+M2 delivered: 50k filter settle dropped from ~158–167ms to ~117–126ms
+(−33 to −50ms), meeting the ~−30ms estimate, with interaction latency
+unchanged and long-tasks down proportionally. Pretable still trails the
+TanStack bar (~50–58ms settle in the same runs, vs the ~67ms historical
+bar): roughly **60–75ms of remaining settle gap is M3's problem** — the
+filter-commit path after M1+M2 is no longer dominated by row-record lookups,
+so the next attribution pass starts from a fresh trace, not this doc.
diff --git a/docs/superpowers/specs/2026-08-24-dense-layout-seam-results.md b/docs/superpowers/specs/2026-08-24-dense-layout-seam-results.md
new file mode 100644
index 000000000..6a48fb23a
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-24-dense-layout-seam-results.md
@@ -0,0 +1,142 @@
+# Dense layout seam measured results (Amendment I) — 2026-08-24
+
+Browser-bench A/B of the dense layout seam: layout-core dense generations
+(slot-indexed refilter/reorder, bitset membership), row-model internal dense
+snapshot reads, and renderer-dom dense-keyed layout sources.
+
+- **Variant**: `a9cfac08` (HEAD of `blove/filter-fast-path` — the complete
+ seam, Tasks 1–6).
+- **Baseline**: `93ff4054` (the commit before seam Task 1; verified via
+ `git log 93ff4054..HEAD -- packages/` that all six intervening `packages/`
+ commits are seam work — one variable). Note the baseline INCLUDES M1+M2,
+ so it should and does reproduce the 116.8/125.6ms record.
+- **Design**: throwaway worktree at the baseline commit, fresh
+ `pnpm install --frozen-lockfile` + `pnpm --filter @pretable/app-bench build`
+ per side, one preview server on 4173 at a time (port verified free first),
+ interleaved paired rounds (baseline 50k → variant 50k → baseline 3k →
+ variant 3k), 3 repeats per cell, medians reported. No CDP tracing for the
+ headline numbers; runner output redirected to files, exit codes checked.
+- **Machine load**: heavy — 1-min load 24–46 across the four rounds (10-core
+ Mac, 9GB of 10GB swap used, parallel sessions active). Interleaving plus
+ the TanStack same-run controls are the fitness arbiter, as in the M1+M2
+ run which saw the same regime (33–54).
+- **Scale note**: the 3k tier is `--scale=hypothesis` (3,000 rows;
+ `rowCount` confirmed in the summaries). `--scale=dev` is 750 rows and was
+ not used.
+
+## Results (medians of 3; settle quantizes to ~8.3ms frame steps)
+
+### 50k rows (S2, `--scale=target`)
+
+| Metric | Script | Baseline | Variant (seam) | Δ |
+| --------------------------------- | --------------- | -------- | -------------- | --------- |
+| settle_duration_ms | filter-metadata | 125.3 | 108.3 | **−17.0** |
+| settle_duration_ms | filter-text | 125.0 | 116.8 | **−8.2** |
+| post_interaction_long_tasks_ms | filter-metadata | 111 | 89 | −22 |
+| post_interaction_long_tasks_ms | filter-text | 103 | 93 | −10 |
+| interaction_latency_ms | filter-metadata | 16.3 | 17.4 | ~0 |
+| interaction_latency_ms | filter-text | 16.6 | 15.7 | ~0 |
+| post_interaction_blank_gap_frames | both | 0 | 0 | 0 |
+| TanStack control settle | filter-metadata | 50.0 | 49.6 | −0.4 |
+| TanStack control settle | filter-text | 50.8 | 50.0 | −0.8 |
+
+Repeat spreads: baseline metadata [125.0, 125.3, 140.8]; variant metadata
+[108.0, 108.3, 108.4] — unusually tight for this machine; variant text
+[109.0, 116.8, 123.4].
+
+### 3k rows (S2, `--scale=hypothesis`)
+
+| Metric | Script | Baseline | Variant (seam) | Δ |
+| --------------------------------- | --------------- | ----------- | -------------- | ---------------- |
+| settle_duration_ms | filter-metadata | 33.5 | 33.4 | 0 |
+| settle_duration_ms | filter-text | 41.6 | 33.7 | −7.9 (one frame) |
+| post_interaction_long_tasks_ms | both | 0 | 0 | 0 |
+| interaction_latency_ms | metadata / text | 16.8 / 17.0 | 16.6 / 24.0* | ~0 / +1 frame* |
+| post_interaction_blank_gap_frames | both | 0 | 0 | 0 |
+| TanStack control settle | filter-metadata | 33.0 | 25.0 | −8.0** |
+| TanStack control settle | filter-text | 25.7 | 23.9 | −1.8 |
+
+\* Variant 3k text latency repeats straddled a frame boundary under load;
+settle (the governing metric) improved a frame. Not a regression signal.
+
+\*\* Baseline metadata repeats [24.3, 33.0, 34.0] vs variant [24.7, 25.0,
+34.1] — same one-frame band, medians landed on different sides of a frame
+boundary. Same pattern (in the other direction) appeared in the M1+M2 run;
+within normal spread, not a regime change.
+
+## Deltas vs M1+M2 and the cumulative arc
+
+- Today's baseline side (= the M1+M2 state) read 125.3/125.0 @50k and
+ 33.5/41.6 @3k — the M1+M2 record (116.8/125.6 @50k; 41.7/33.5 @3k) within
+ one frame under today's load, which is why the paired baseline, not the
+ record, is the comparison basis.
+- Seam vs M1+M2 record: 116.8 → 108.3 (metadata, −8.5) and 125.6 → 116.8
+ (text, −8.8). Vs paired baseline: **−17.0 / −8.2ms**, long-tasks −22/−10.
+- Cumulative arc (pre-M1: 166.6/158.4): 50k filter settle is now
+ **108.3/116.8 — −58.3/−41.6ms total, a ~1.4–1.5× speedup** on the branch.
+
+## Bar verdicts
+
+- **Untraced 50k settle ≤ ~95ms: MISSED.** 108.3 (metadata) / 116.8 (text).
+ The improvement is real and the controls prove the regime held, but the
+ seam bought ~1–2 frames, not the ~3 the bar assumed. Long-tasks dropped
+ proportionally (111→89, 103→93), consistent with the win being genuine
+ main-thread work removed, not measurement drift.
+- **Layout share ≲ 10% of the traced window: MET** (~7.4%, table below).
+ The two verdicts together say the amendment's attribution was right —
+ layout's walk is no longer the problem — but the pre-seam trace charged
+ more of the window to layout than the layout walk alone actually cost;
+ part of that share was the row-model rebuild it overlapped with.
+- **3k no regression: MET** (0 / −7.9ms).
+- **TanStack controls in band: MET** (all four cells within one frame across
+ sides; 50k controls 49.6–50.8 both sides).
+- **Zero blank frames: MET** (`post_interaction_blank_gap_frames` 0 in all
+ 12 pretable summaries, both sides, both scales).
+- `refilterFallbackCount === 0` is pinned by the react e2e suite (seam
+ Task 6, commit `a9cfac08`), mutation-hardened — not re-measured here.
+
+## Traced share re-attribution (variant, 50k filter-metadata)
+
+One traced run AFTER the headlines (`PLAYWRIGHT_PERF_TRACE=1`, repeats=1),
+`analyze-cdp.mjs --window=interaction` with the build's sourcemap. Traced
+absolutes skew ~2× — shares only. Window 74.2ms, 74.2ms sampled.
+
+| Subsystem (self time) | Share |
+| ----------------------------------------------------------------------- | -------- |
+| layout-core refilter walk (`refilter` + `#refilterDense` + window prep) | **7.4%** |
+| row-model filter-rebuild walk (`filter-rebuild.js`) | 17.4% |
+| compiled-query verdict evaluation (`compiled-query.js`) | 17.5% |
+| persistent HAMT (`persistent-map.js`) | 9.2% |
+| order-statistic tree | 3.6% |
+| slot-vector | 2.9% |
+| visible-index | 2.8% |
+| change-journal | 0.5% |
+| react render/commit + DOM (react-dom, measure, style/attr, grapheme) | ~23% |
+| (program) + GC | 7.7% |
+
+The layout walk (dense lane) is 7.4% — under the 10% bar. The window is now
+dominated by the row-model rebuild + verdict evaluation (~35% combined) and
+the render/commit side (~23%). The HAMT share that remains (9.2%) belongs to
+row-model's own persistent structures, not layout reads.
+
+## Fitness statement
+
+- TanStack same-run controls agree across sides within one frame on all four
+ cells; the 3k metadata −8ms is a frame-boundary artifact with overlapping
+ repeat ranges. The regime did not move between sides.
+- Load was heavy (1-min 24–46 on 10 cores, swap nearly full) throughout; the
+ interleaved paired design plus in-band controls make the deltas
+ trustworthy. Individual absolutes carry load noise (baseline metadata's
+ 140.8 first repeat), excluded by the medians.
+
+## Conclusion
+
+The dense layout seam delivered a real but smaller-than-estimated win: 50k
+filter settle dropped from ~125ms to **108.3 (metadata) / 116.8 (text)** —
+−17.0/−8.2ms with long-tasks down proportionally and zero blank frames —
+missing the ~95ms bar, while the traced layout share fell to ~7.4%, meeting
+the ≤10% attribution bar. Layout is no longer where the time goes: the
+remaining ~58–67ms gap to the TanStack bar (~50ms settle in these same
+runs) now sits in the row-model rebuild and verdict evaluation (~35% of the
+window) plus render/commit (~23%) — which is exactly the **columnar verdict
+cache, the next milestone**.
diff --git a/packages/core/core.api.md b/packages/core/core.api.md
index 8bbc0098c..44b37e9bc 100644
--- a/packages/core/core.api.md
+++ b/packages/core/core.api.md
@@ -264,7 +264,7 @@ export type PretableChangeSequence = {
} | {
readonly kind: "reset";
readonly toRevision: number;
- readonly reason: "unknown-revision" | "journal-evicted" | "bulk-replace" | "reorder";
+ readonly reason: "unknown-revision" | "journal-evicted" | "bulk-replace" | "reorder" | "refilter";
};
// @public (undocumented)
@@ -1177,6 +1177,12 @@ export interface PretableRowModelSnapshot {
expect(dataRowReads).toBe(100_000);
});
- test('handles a "reorder" reset exactly as a "bulk-replace" reset', () => {
- // Fail-closed pin: grid-core is deliberately reorder-UNAWARE. A reset
- // whose reason is "reorder" (or any reason this suite has never heard
- // of) must take the same full-rebuild path as "bulk-replace" — the
- // reason field is advisory for consumers that opt into it, never a
- // requirement for correctness.
+ test('handles "reorder" and "refilter" resets exactly as a "bulk-replace" reset', () => {
+ // Fail-closed pin: grid-core is deliberately reorder- and
+ // refilter-UNAWARE. A reset whose reason is "reorder" or "refilter" (or
+ // any reason this suite has never heard of) must take the same
+ // full-rebuild path as "bulk-replace" — the reason field is advisory
+ // for consumers that opt into it, never a requirement for correctness.
const rows = [1, 2, 3, 4].map((id) => ({ id, team: "a", score: id }));
const model = createLocalRowModel({
rows,
@@ -796,16 +796,19 @@ describe("indexed row selection", () => {
model.setRows([rows[3]!, rows[2]!, rows[1]!, rows[0]!]);
const snapshot = model.getState().snapshot;
- const project = (reason: "reorder" | "bulk-replace" | "unknown-revision") =>
+ const project = (
+ reason: "reorder" | "refilter" | "bulk-replace" | "unknown-revision",
+ ) =>
projectIndexedSelection(selected, previous, snapshot, {
kind: "reset",
toRevision: snapshot.revision,
reason,
});
const viaReorder = project("reorder");
+ const viaRefilter = project("refilter");
const viaBulkReplace = project("bulk-replace");
- for (const projected of [viaReorder, viaBulkReplace]) {
+ for (const projected of [viaReorder, viaRefilter, viaBulkReplace]) {
expect(getIndexedSelectionSummary(projected, snapshot)).toEqual(
getIndexedSelectionSummary(viaBulkReplace, snapshot),
);
diff --git a/packages/layout-core/src/__tests__/dense-membership.test.ts b/packages/layout-core/src/__tests__/dense-membership.test.ts
new file mode 100644
index 000000000..888eeb1a3
--- /dev/null
+++ b/packages/layout-core/src/__tests__/dense-membership.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest";
+import {
+ cloneDenseMembership,
+ clearDenseBit,
+ createDenseMembership,
+ setDenseBit,
+ testDenseBit,
+} from "../dense-membership";
+
+describe("dense membership bitset", () => {
+ it("round-trips set/clear/test across word boundaries", () => {
+ const bits = createDenseMembership(100);
+ for (const slot of [0, 31, 32, 63, 64, 99]) {
+ expect(testDenseBit(bits, slot)).toBe(false);
+ setDenseBit(bits, slot);
+ expect(testDenseBit(bits, slot)).toBe(true);
+ }
+ clearDenseBit(bits, 32);
+ expect(testDenseBit(bits, 32)).toBe(false);
+ expect(testDenseBit(bits, 31)).toBe(true);
+ expect(testDenseBit(bits, 63)).toBe(true);
+ });
+
+ it("clone is independent of the original", () => {
+ const bits = createDenseMembership(64);
+ setDenseBit(bits, 10);
+ const copy = cloneDenseMembership(bits, 64);
+ clearDenseBit(copy, 10);
+ setDenseBit(copy, 20);
+ expect(testDenseBit(bits, 10)).toBe(true);
+ expect(testDenseBit(bits, 20)).toBe(false);
+ });
+
+ it("clone can grow capacity, preserving low bits", () => {
+ const bits = createDenseMembership(32);
+ setDenseBit(bits, 31);
+ const grown = cloneDenseMembership(bits, 200);
+ expect(testDenseBit(grown, 31)).toBe(true);
+ setDenseBit(grown, 199);
+ expect(testDenseBit(grown, 199)).toBe(true);
+ });
+
+ it("reads beyond a bitset's words answer false", () => {
+ const bits = createDenseMembership(32);
+ expect(testDenseBit(bits, 500)).toBe(false);
+ expect(testDenseBit(createDenseMembership(0), 12345)).toBe(false);
+ });
+});
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 2b0e91daf..0430781b9 100644
--- a/packages/layout-core/src/__tests__/row-height-index.test.ts
+++ b/packages/layout-core/src/__tests__/row-height-index.test.ts
@@ -1722,3 +1722,1016 @@ describe("bulk replacement when the base holds no retained state", () => {
expect(result.getHeight(0)).toBe(63);
});
});
+
+describe("synchronous refilter over existing height entries", () => {
+ /**
+ * A base index with mixed measured and estimated entries: rows 0..N-1 with
+ * varied estimates (including `undefined` → default height), every third row
+ * measured to a height its estimate could not predict.
+ */
+ function refilterFixture(count = 25, maxRetainedMeasurements?: number) {
+ const keys = Array.from({ length: count }, (_, index) =>
+ index % 5 === 0 ? group(String(index)) : data(String(index)),
+ );
+ const estimates = keys.map((_, index) =>
+ index % 4 === 3 ? undefined : 18 + (index % 7) * 3,
+ );
+ let base = createIndex(
+ keys.map((key, index) => entry(key, estimates[index])),
+ 30,
+ maxRetainedMeasurements,
+ );
+ for (let index = 0; index < count; index += 3) {
+ base = base.measure(index, keys[index]!, 51 + index);
+ }
+ const entries = keys.map((key, index) => entry(key, estimates[index]));
+ return { keys, estimates, entries, base, count };
+ }
+
+ function sourceOf(
+ rows: readonly RowHeightEntry[],
+ ): RowHeightReplacementSource {
+ return { rowCount: rows.length, entryAt: (index) => rows[index]! };
+ }
+
+ /** Every rank's offset, height, and key, plus the total: full geometry. */
+ function rankTable(index: RowHeightIndex) {
+ return {
+ rowCount: index.rowCount,
+ total: index.getTotalHeight(),
+ keys: Array.from({ length: index.rowCount }, (_, rank) =>
+ index.keyAt(rank),
+ ),
+ offsets: Array.from({ length: index.rowCount + 1 }, (_, rank) =>
+ index.getOffsetForIndex(rank),
+ ),
+ heights: Array.from({ length: index.rowCount }, (_, rank) =>
+ index.getHeight(rank),
+ ),
+ };
+ }
+
+ /** The retained-state observables: cache, tombstones, visible measurements. */
+ function retainedState(index: RowHeightIndex) {
+ const diagnostics = getRowHeightIndexDiagnosticsForTesting(index);
+ return {
+ measurementCacheCount: diagnostics.measurementCacheCount,
+ tombstoneCount: diagnostics.tombstoneCount,
+ visibleMeasurementCount: diagnostics.visibleMeasurementCount,
+ };
+ }
+
+ function expectMatchesReplaceOracle(
+ base: RowHeightIndex,
+ rows: readonly RowHeightEntry[],
+ ): RowHeightIndex {
+ const refiltered = base.refilter(sourceOf(rows));
+ const replaced = base.replace(rows);
+ expect(rankTable(refiltered)).toEqual(rankTable(replaced));
+ expect(retainedState(refiltered)).toEqual(retainedState(replaced));
+ return refiltered;
+ }
+
+ test("pure shrink matches the full replacement oracle", () => {
+ const { entries, base } = refilterFixture();
+ expectMatchesReplaceOracle(
+ base,
+ entries.filter((_, index) => index % 2 === 0),
+ );
+ });
+
+ test("pure grow matches the full replacement oracle", () => {
+ const { entries, base } = refilterFixture(10);
+ expectMatchesReplaceOracle(base, [
+ ...entries.slice(0, 4),
+ entry(data("entrant-a"), 44),
+ ...entries.slice(4),
+ entry(data("entrant-b")),
+ entry(group("entrant-c"), 61),
+ ]);
+ });
+
+ test("a disjoint same-count membership matches the oracle", () => {
+ const { base, count } = refilterFixture(10);
+ const disjoint = Array.from({ length: count }, (_, index) =>
+ entry(data(`other-${index}`), 20 + index),
+ );
+ const refiltered = expectMatchesReplaceOracle(base, disjoint);
+ expect(refiltered.rowCount).toBe(count);
+ });
+
+ test("empty→populated and populated→empty match the oracle", () => {
+ const { entries, base } = refilterFixture(8);
+ const emptied = expectMatchesReplaceOracle(base, []);
+ expect(emptied.rowCount).toBe(0);
+ const empty = createIndex([]);
+ expectMatchesReplaceOracle(empty, entries.slice(0, 5));
+ });
+
+ test("a measured leaver's measurement survives and is restored on return", () => {
+ const { keys, entries, base } = refilterFixture();
+ // Row 4 is unmeasured, row 6 is measured (51 + 6). Drop both.
+ const measuredLeaver = keys[6]!;
+ const unmeasuredLeaver = keys[4]!;
+ const without = entries.filter((_, index) => index !== 4 && index !== 6);
+ const shrunk = base.refilter(sourceOf(without));
+
+ // The measured leaver tombstones; the unmeasured one simply vanishes.
+ expect(shrunk.hasMeasurement(measuredLeaver)).toBe(true);
+ expect(shrunk.hasMeasurement(unmeasuredLeaver)).toBe(false);
+ expect(retainedState(shrunk)).toEqual(retainedState(base.replace(without)));
+
+ // A later refilter that brings the measured leaver back restores its
+ // measurement — the retention rule, observed behaviorally.
+ const stillWithoutFour = entries.filter((_, index) => index !== 4);
+ const returned = shrunk.refilter(sourceOf(stillWithoutFour));
+ expect(returned.getHeight(5)).toBe(51 + 6);
+ expect(retainedState(returned)).toEqual(
+ retainedState(base.replace(without).replace(stillWithoutFour)),
+ );
+ });
+
+ test("estimate-carrying entrants use the estimate-or-default ingest rule", () => {
+ const { entries, base } = refilterFixture(6);
+ const grown = base.refilter(
+ sourceOf([
+ entry(data("with-estimate"), 47),
+ ...entries,
+ entry(data("no-estimate")),
+ ]),
+ );
+ expect(grown.getHeight(0)).toBe(47);
+ expect(grown.getHeight(grown.rowCount - 1)).toBe(30);
+ });
+
+ test("survivor entries are reused verbatim: measurements and estimates ride", () => {
+ const { keys, base } = refilterFixture();
+ // Hand the source lying estimates for every surviving row: a refilter
+ // must not re-estimate or re-measure survivors, so original heights ride.
+ const lying = keys
+ .map((key) => entry(key, 999))
+ .filter((_, index) => index % 2 === 0);
+ const shrunk = base.refilter(sourceOf(lying));
+ expect(shrunk.rowCount).toBe(13);
+ for (let rank = 0; rank < shrunk.rowCount; rank += 1) {
+ expect(shrunk.getHeight(rank)).toBe(base.getHeight(rank * 2));
+ }
+ });
+
+ test("counts reused, inserted, and retired entries exactly", () => {
+ const { entries, base, count } = refilterFixture();
+ const survivors = entries.filter((_, index) => index % 2 === 0);
+ const entrants = [entry(data("new-1"), 21), entry(data("new-2"))];
+ const next = base.refilter(sourceOf([...survivors, ...entrants]));
+ expect(getRowHeightIndexDiagnosticsForTesting(next)).toMatchObject({
+ refilterEntriesReused: survivors.length,
+ refilterEntriesInserted: entrants.length,
+ refilterEntriesRetired: count - survivors.length,
+ });
+
+ const disjoint = base.refilter(
+ sourceOf(entries.map((_, index) => entry(data(`d-${index}`)))),
+ );
+ expect(getRowHeightIndexDiagnosticsForTesting(disjoint)).toMatchObject({
+ refilterEntriesReused: 0,
+ refilterEntriesInserted: count,
+ refilterEntriesRetired: count,
+ });
+ });
+
+ test("an identical membership and order is a no-op returning the same index", () => {
+ const { entries, base } = refilterFixture();
+ expect(base.refilter(sourceOf(entries))).toBe(base);
+ const empty = createIndex([]);
+ expect(empty.refilter(sourceOf([]))).toBe(empty);
+ });
+
+ test("duplicate keys throw; membership deltas do not", () => {
+ const { entries, base } = refilterFixture();
+ const duplicated = [...entries.slice(0, 10), entries[4]!];
+ expect(() => base.refilter(sourceOf(duplicated))).toThrow(
+ /duplicate stable row-height key/i,
+ );
+ // A missing existing key is a LEAVER, not an error — refilter's purpose.
+ expect(() => base.refilter(sourceOf(entries.slice(1)))).not.toThrow();
+ });
+
+ test("a bad rowCount throws a RangeError", () => {
+ const { entries, base } = refilterFixture(4);
+ for (const rowCount of [0.5, -1, Number.NaN]) {
+ let thrown: unknown;
+ try {
+ base.refilter({ rowCount, entryAt: (index) => entries[index]! });
+ } catch (error) {
+ thrown = error;
+ }
+ expect(thrown).toBeInstanceOf(RangeError);
+ }
+ expect(() =>
+ base.refilter({
+ rowCount: 1,
+ entryAt: "nope" as unknown as (index: number) => RowHeightEntry,
+ }),
+ ).toThrow(TypeError);
+ });
+
+ test("maxRetainedMeasurements zero drops a measured leaver's measurement", () => {
+ const { keys, entries, base } = refilterFixture(9, 0);
+ const without = entries.filter((_, index) => index !== 6);
+ const shrunk = base.refilter(sourceOf(without));
+ expect(shrunk.hasMeasurement(keys[6]!)).toBe(false);
+ expect(retainedState(shrunk)).toEqual(retainedState(base.replace(without)));
+ });
+
+ test("retention-cap pressure evicts the oldest tombstones like replace", () => {
+ const { entries, base } = refilterFixture(13, 2);
+ // Rows 0, 3, 6, 9, 12 are measured; dropping them all retires five
+ // measured leavers into a cap of two: only the two NEWEST tickets stay.
+ const survivors = entries.filter((_, index) => index % 3 !== 0);
+ const refiltered = base.refilter(sourceOf(survivors));
+ const replaced = base.replace(survivors);
+ expect(retainedState(refiltered)).toEqual(retainedState(replaced));
+ // Behavioral pin on WHICH measurements survived: bring every measured
+ // leaver back; both paths must restore the same subset.
+ expect(rankTable(refiltered.refilter(sourceOf(entries)))).toEqual(
+ rankTable(replaced.replace(entries)),
+ );
+ });
+
+ test("leaves the old index untouched", () => {
+ const { entries, base } = refilterFixture();
+ const before = rankTable(base);
+ const beforeState = retainedState(base);
+ const shrunk = base.refilter(sourceOf(entries.slice(0, 10)));
+ expect(shrunk).not.toBe(base);
+ expect(rankTable(base)).toEqual(before);
+ expect(retainedState(base)).toEqual(beforeState);
+ });
+
+ test("post-refilter mutations behave exactly like a replace-built index", () => {
+ const { keys, entries, base } = refilterFixture();
+ const survivors = entries.filter((_, index) => index % 2 === 0);
+ const viaRefilter = base.refilter(sourceOf(survivors));
+ const viaReplace = base.replace(survivors);
+
+ // measure
+ const measuredA = viaRefilter.measure(2, keys[4]!, 83);
+ const measuredB = viaReplace.measure(2, keys[4]!, 83);
+ expect(rankTable(measuredA)).toEqual(rankTable(measuredB));
+
+ // reorder
+ const reversed = [...survivors].reverse();
+ expect(rankTable(measuredA.reorder(sourceOf(reversed)))).toEqual(
+ rankTable(measuredB.reorder(sourceOf(reversed))),
+ );
+
+ // refilter again (chain), then a full replacement
+ const next = [...survivors.slice(3), entry(data("late"), 26)];
+ const chainA = measuredA.refilter(sourceOf(next));
+ const chainB = measuredB.refilter(sourceOf(next));
+ expect(rankTable(chainA)).toEqual(rankTable(chainB));
+ expect(retainedState(chainA)).toEqual(retainedState(chainB));
+ const final = [entry(data("z-1"), 31), entry(data("z-2"))];
+ expect(rankTable(chainA.replace(final))).toEqual(
+ rankTable(chainB.replace(final)),
+ );
+ });
+});
+
+describe("dense generations (Amendment I, Task 2)", () => {
+ const denseEntry = (
+ key: Key,
+ denseKey: number | undefined,
+ estimatedHeight?: number,
+ ): RowHeightEntry => ({ key, estimatedHeight, denseKey });
+
+ function denseSource(
+ rows: readonly RowHeightEntry[],
+ denseCapacity: number | undefined,
+ ): RowHeightReplacementSource {
+ return {
+ rowCount: rows.length,
+ denseCapacity,
+ entryAt: (index) => rows[index]!,
+ };
+ }
+
+ function rebuild(
+ base: RowHeightIndex,
+ rows: readonly RowHeightEntry[],
+ denseCapacity: number | undefined,
+ ): RowHeightIndex {
+ const builder = base.beginReplacement(denseSource(rows, denseCapacity));
+ while (!builder.done) builder.advance({ maxUnits: 256 });
+ return builder.finish();
+ }
+
+ test("builds a dense generation whose apply guards work by slot", () => {
+ const a = data("a");
+ const b = data("b");
+ const base = rebuild(
+ createIndex([]),
+ [denseEntry(a, 0, 20), denseEntry(b, 2, 30)],
+ 8,
+ );
+ expect(base.rowCount).toBe(2);
+ expect(base.getTotalHeight()).toBe(50);
+
+ // Insert into a free slot is accepted; a duplicated slot is rejected.
+ const c = data("c");
+ const inserted = base.apply([
+ { kind: "insert", ref: c, index: 2, estimatedHeight: 40, denseKey: 5 },
+ ]);
+ expect(inserted.rowCount).toBe(3);
+ expect(inserted.getTotalHeight()).toBe(90);
+ expect(() =>
+ inserted.apply([
+ {
+ kind: "insert",
+ ref: data("x"),
+ index: 0,
+ estimatedHeight: 10,
+ denseKey: 5,
+ },
+ ]),
+ ).toThrow(/duplicate/i);
+
+ // Remove clears the slot so it can be reinserted, and the measurement
+ // returns by STRING identity, exactly as on the string lane.
+ const measured = inserted.measure(1, b, 47);
+ const removed = measured.apply([
+ { kind: "remove", ref: b, previousIndex: 1, denseKey: 2 },
+ ]);
+ expect(removed.rowCount).toBe(2);
+ const restored = removed.apply([
+ {
+ kind: "insert",
+ ref: data("b"),
+ index: 1,
+ estimatedHeight: 12,
+ denseKey: 2,
+ },
+ ]);
+ expect(restored.getHeight(1)).toBe(47);
+ });
+
+ test("retainMeasurement guards visibility by slot on a dense generation", () => {
+ const a = data("a");
+ const base = rebuild(createIndex([], 30, 4), [denseEntry(a, 1, 20)], 8);
+ // Absent row (slot 5 unoccupied): retained.
+ const gone = data("gone");
+ const retained = base.retainMeasurement(gone, 73, 5);
+ expect(retained.hasMeasurement(gone)).toBe(true);
+ // Visible row (slot 1 occupied): rejected, as on the string lane.
+ expect(() => base.retainMeasurement(a, 80, 1)).toThrow(/visible row/i);
+ // No denseKey on a dense index: lifecycle error, not a silent accept.
+ expectReplacementLifecycleError(
+ () => base.retainMeasurement(gone, 73),
+ "failed",
+ );
+ });
+
+ test("an operation without a denseKey on a dense generation throws the lifecycle error", () => {
+ const a = data("a");
+ const base = rebuild(createIndex([]), [denseEntry(a, 0, 20)], 4);
+ expectReplacementLifecycleError(
+ () =>
+ base.apply([
+ { kind: "insert", ref: data("b"), index: 1, estimatedHeight: 10 },
+ ]),
+ "failed",
+ );
+ expectReplacementLifecycleError(
+ () => base.apply([{ kind: "remove", ref: a, previousIndex: 0 }]),
+ "failed",
+ );
+ expectReplacementLifecycleError(
+ () =>
+ base.apply([{ kind: "update", ref: a, index: 0, estimatedHeight: 25 }]),
+ "failed",
+ );
+ });
+
+ test("a missing entry denseKey under a declared capacity throws (bulk and cooperative)", () => {
+ const rows: readonly RowHeightEntry[] = [
+ denseEntry(data("a"), 0, 20),
+ { key: data("b"), estimatedHeight: 30 },
+ ];
+ // Bulk path: no retained state.
+ {
+ const builder = createIndex([]).beginReplacement(denseSource(rows, 8));
+ expectReplacementLifecycleError(() => {
+ while (!builder.done) builder.advance({ maxUnits: 256 });
+ }, "failed");
+ }
+ // Cooperative path: a retained measurement forces the phased builder.
+ {
+ const base = createIndex([], 30, 4).retainMeasurement(data("gone"), 51);
+ const builder = base.beginReplacement(denseSource(rows, 8));
+ expectReplacementLifecycleError(() => {
+ while (!builder.done) builder.advance({ maxUnits: 256 });
+ }, "failed");
+ }
+ // A denseKey at or above the declared capacity is the same broken promise.
+ {
+ const builder = createIndex([]).beginReplacement(
+ denseSource([denseEntry(data("a"), 8, 20)], 8),
+ );
+ expectReplacementLifecycleError(() => {
+ while (!builder.done) builder.advance({ maxUnits: 256 });
+ }, "failed");
+ }
+ });
+
+ test("dense refilter narrows and widens by slot and stays dense", () => {
+ const a = data("a");
+ const b = data("b");
+ const c = data("c");
+ const base = rebuild(
+ createIndex([]),
+ [denseEntry(a, 0, 20), denseEntry(b, 1, 30), denseEntry(c, 5, 40)],
+ 8,
+ ).measure(1, b, 47);
+
+ // Narrow: b leaves (tombstoned), a and c survive with heights intact.
+ const narrowed = base.refilter(
+ denseSource([denseEntry(a, 0, 20), denseEntry(c, 5, 40)], 8),
+ );
+ expect(narrowed.rowCount).toBe(2);
+ expect(narrowed.getTotalHeight()).toBe(60);
+ expect(narrowed.hasMeasurement(b)).toBe(true);
+
+ // Widen: b returns as an entrant and gets its retained measurement back.
+ const widened = narrowed.refilter(
+ denseSource(
+ [denseEntry(a, 0, 20), denseEntry(b, 1, 12), denseEntry(c, 5, 40)],
+ 8,
+ ),
+ );
+ expect(widened.rowCount).toBe(3);
+ expect(widened.getHeight(1)).toBe(47);
+ expect(getRowHeightIndexDiagnosticsForTesting(widened)).toMatchObject({
+ tombstoneCount: 0,
+ });
+
+ // The result is a DENSE generation: its guards still demand dense keys.
+ expectReplacementLifecycleError(
+ () =>
+ widened.apply([
+ { kind: "insert", ref: data("d"), index: 3, estimatedHeight: 10 },
+ ]),
+ "failed",
+ );
+ // An identical membership and order is a no-op returning the same index.
+ expect(
+ widened.refilter(
+ denseSource(
+ [denseEntry(a, 0, 20), denseEntry(b, 1, 12), denseEntry(c, 5, 40)],
+ 8,
+ ),
+ ),
+ ).toBe(widened);
+ });
+
+ test("dense reorder permutes by slot and stays dense", () => {
+ const a = data("a");
+ const b = data("b");
+ const c = data("c");
+ const base = rebuild(
+ createIndex([]),
+ [denseEntry(a, 2, 20), denseEntry(b, 4, 30), denseEntry(c, 7, 40)],
+ 8,
+ ).measure(2, c, 55);
+
+ const reordered = base.reorder(
+ denseSource(
+ [denseEntry(c, 7, 40), denseEntry(a, 2, 20), denseEntry(b, 4, 30)],
+ 8,
+ ),
+ );
+ expect([0, 1, 2].map((index) => reordered.keyAt(index))).toEqual([c, a, b]);
+ expect(reordered.getHeight(0)).toBe(55);
+ expect(reordered.getTotalHeight()).toBe(105);
+
+ // Identity permutation is a no-op returning the same index.
+ expect(
+ base.reorder(
+ denseSource(
+ [denseEntry(a, 2, 20), denseEntry(b, 4, 30), denseEntry(c, 7, 40)],
+ 8,
+ ),
+ ),
+ ).toBe(base);
+
+ // A slot that matches no existing row (missing or duplicated) throws.
+ expect(() =>
+ base.reorder(
+ denseSource(
+ [denseEntry(c, 7, 40), denseEntry(a, 2, 20), denseEntry(b, 3, 30)],
+ 8,
+ ),
+ ),
+ ).toThrow(/does not match an existing row/i);
+ expect(() =>
+ base.reorder(
+ denseSource(
+ [denseEntry(c, 7, 40), denseEntry(a, 2, 20), denseEntry(b, 7, 30)],
+ 8,
+ ),
+ ),
+ ).toThrow(/does not match an existing row/i);
+ // The permutation contract still demands equal row counts.
+ expect(() => base.reorder(denseSource([denseEntry(a, 2, 20)], 8))).toThrow(
+ RangeError,
+ );
+ // The result is a DENSE generation: its guards still demand dense keys.
+ expectReplacementLifecycleError(
+ () =>
+ reordered.apply([
+ { kind: "insert", ref: data("d"), index: 3, estimatedHeight: 10 },
+ ]),
+ "failed",
+ );
+ });
+
+ test("dense refilter and reorder validate dense keys like the builder ingest", () => {
+ const a = data("a");
+ const b = data("b");
+ const base = rebuild(
+ createIndex([]),
+ [denseEntry(a, 0, 20), denseEntry(b, 1, 30)],
+ 4,
+ );
+ // Missing key: lifecycle error (controller falls back to a replacement).
+ expectReplacementLifecycleError(
+ () => base.refilter(denseSource([denseEntry(a, 0, 20), entry(b, 30)], 4)),
+ "failed",
+ );
+ expectReplacementLifecycleError(
+ () => base.reorder(denseSource([denseEntry(b, 1, 30), entry(a, 20)], 4)),
+ "failed",
+ );
+ // Out-of-range and malformed keys: the same lifecycle error class.
+ for (const badKey of [4, -1, 1.5, Number.NaN]) {
+ expectReplacementLifecycleError(
+ () =>
+ base.refilter(
+ denseSource([denseEntry(a, 0, 20), denseEntry(b, badKey, 30)], 4),
+ ),
+ "failed",
+ );
+ expectReplacementLifecycleError(
+ () =>
+ base.reorder(
+ denseSource([denseEntry(b, badKey, 30), denseEntry(a, 0, 20)], 4),
+ ),
+ "failed",
+ );
+ }
+ // A duplicated slot in a refilter's new order throws like the builder.
+ expect(() =>
+ base.refilter(
+ denseSource([denseEntry(a, 0, 20), denseEntry(data("c"), 0, 10)], 4),
+ ),
+ ).toThrow(/duplicate dense row-height slot/i);
+ });
+
+ test("retainMeasurement rejects malformed dense keys before reading the bitset", () => {
+ const a = data("a");
+ const base = rebuild(createIndex([], 30, 4), [denseEntry(a, 1, 20)], 8);
+ const gone = data("gone");
+ // A negative or fractional key must fail loud: 1.5's `&31` truncation
+ // would otherwise silently read a DIFFERENT row's bit.
+ for (const badKey of [-1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 2]) {
+ expect(() => base.retainMeasurement(gone, 73, badKey)).toThrow(
+ /non-negative safe integer/i,
+ );
+ }
+ // The well-formed absent-slot retain still works after the guard.
+ expect(base.retainMeasurement(gone, 73, 5).hasMeasurement(gone)).toBe(true);
+ });
+
+ test("apply rejects an operation whose denseKey drifted from the entry's slot", () => {
+ const a = data("a");
+ const b = data("b");
+ const base = rebuild(
+ createIndex([]),
+ [denseEntry(a, 0, 20), denseEntry(b, 2, 30)],
+ 8,
+ );
+ expectReplacementLifecycleError(
+ () =>
+ base.apply([{ kind: "remove", ref: b, previousIndex: 1, denseKey: 3 }]),
+ "failed",
+ );
+ expectReplacementLifecycleError(
+ () =>
+ base.apply([
+ { kind: "move", ref: a, previousIndex: 0, index: 1, denseKey: 2 },
+ ]),
+ "failed",
+ );
+ expectReplacementLifecycleError(
+ () =>
+ base.apply([
+ {
+ kind: "update",
+ ref: a,
+ index: 0,
+ estimatedHeight: 25,
+ denseKey: 1,
+ },
+ ]),
+ "failed",
+ );
+ // The matching key still works on all three variants.
+ const applied = base.apply([
+ { kind: "update", ref: a, index: 0, estimatedHeight: 25, denseKey: 0 },
+ { kind: "move", ref: a, previousIndex: 0, index: 1, denseKey: 0 },
+ { kind: "remove", ref: b, previousIndex: 0, denseKey: 2 },
+ ]);
+ expect(applied.rowCount).toBe(1);
+ expect(applied.getHeight(0)).toBe(25);
+ });
+
+ test("a source without denseCapacity runs the string lane, dense stamps and all", () => {
+ const rows = [entry(data("a"), 20), entry(data("b")), entry(data("c"), 40)];
+ const stampedRows = [
+ denseEntry(data("a"), 0, 20),
+ denseEntry(data("b"), 1),
+ denseEntry(data("c"), 2, 40),
+ ];
+ const plain = rebuild(createIndex([]), rows, undefined);
+ const stamped = rebuild(createIndex([]), stampedRows, undefined);
+ for (const index of [plain, stamped]) {
+ expect(index.rowCount).toBe(3);
+ expect(index.getTotalHeight()).toBe(90);
+ }
+ expect(getRowHeightIndexDiagnosticsForTesting(stamped)).toEqual(
+ getRowHeightIndexDiagnosticsForTesting(plain),
+ );
+ // The string lane still guards duplicates through the HAMT and still
+ // accepts refilter — nothing dense leaked in.
+ const refiltered = stamped.refilter(
+ denseSource([entry(data("b"))], undefined),
+ );
+ expect(refiltered.rowCount).toBe(1);
+ expect(() =>
+ stamped.apply([{ kind: "insert", ref: data("a"), index: 0 }]),
+ ).toThrow(/duplicate/i);
+ });
+
+ test("a dense rebuild with reassigned slots is not a no-op and guards by the NEW slots", () => {
+ const a = data("a");
+ const b = data("b");
+ const first = rebuild(
+ createIndex([]),
+ [denseEntry(a, 0, 20), denseEntry(b, 1, 30)],
+ 8,
+ );
+ // Identical rows AND identical slots: a no-op returns the base generation.
+ const same = rebuild(
+ first,
+ [denseEntry(a, 0, 20), denseEntry(b, 1, 30)],
+ 8,
+ );
+ expect(same).toBe(first);
+ // The same identities on NEW slots must build a new generation whose
+ // bitset answers for the new slots, never the stale ones.
+ const moved = rebuild(
+ first,
+ [denseEntry(a, 4, 20), denseEntry(b, 5, 30)],
+ 8,
+ );
+ expect(moved).not.toBe(first);
+ const inserted = moved.apply([
+ {
+ kind: "insert",
+ ref: data("c"),
+ index: 2,
+ estimatedHeight: 10,
+ denseKey: 0,
+ },
+ ]);
+ expect(inserted.rowCount).toBe(3);
+ expect(() =>
+ moved.apply([
+ {
+ kind: "insert",
+ ref: data("c"),
+ index: 2,
+ estimatedHeight: 10,
+ denseKey: 4,
+ },
+ ]),
+ ).toThrow(/duplicate/i);
+ });
+
+ test("a no-op replacement never crosses lanes: a dense source over a string base rebuilds", () => {
+ const rows = [denseEntry(data("a"), 0, 20), denseEntry(data("b"), 1, 30)];
+ const stringBase = rebuild(createIndex([]), rows, undefined);
+ // Identical rows, but the source declares a capacity: the result must be
+ // a DENSE generation (its guards demand dense keys), not the string base.
+ const dense = rebuild(stringBase, rows, 8);
+ expect(dense).not.toBe(stringBase);
+ expectReplacementLifecycleError(
+ () =>
+ dense.apply([
+ { kind: "insert", ref: data("c"), index: 2, estimatedHeight: 10 },
+ ]),
+ "failed",
+ );
+ // And the reverse: a capacity-less source over a dense base returns to
+ // the string lane even when the rows are identical.
+ const stringAgain = rebuild(dense, rows, undefined);
+ expect(stringAgain).not.toBe(dense);
+ const applied = stringAgain.apply([
+ { kind: "insert", ref: data("c"), index: 2, estimatedHeight: 10 },
+ ]);
+ expect(applied.rowCount).toBe(3);
+ });
+});
+
+describe("dense refilter and reorder (Amendment I, Task 3)", () => {
+ const denseEntry = (
+ key: Key,
+ denseKey: number | undefined,
+ estimatedHeight?: number,
+ ): RowHeightEntry => ({ key, estimatedHeight, denseKey });
+
+ function sourceOf(
+ rows: readonly RowHeightEntry[],
+ denseCapacity?: number,
+ ): RowHeightReplacementSource {
+ return {
+ rowCount: rows.length,
+ denseCapacity,
+ entryAt: (index) => rows[index]!,
+ };
+ }
+
+ function rebuild(
+ base: RowHeightIndex,
+ rows: readonly RowHeightEntry[],
+ denseCapacity?: number,
+ ): RowHeightIndex {
+ const builder = base.beginReplacement(sourceOf(rows, denseCapacity));
+ while (!builder.done) builder.advance({ maxUnits: 256 });
+ return builder.finish();
+ }
+
+ /** Every rank's offset, height, and key, plus the total: full geometry. */
+ function rankTable(index: RowHeightIndex) {
+ return {
+ rowCount: index.rowCount,
+ total: index.getTotalHeight(),
+ keys: Array.from({ length: index.rowCount }, (_, rank) =>
+ index.keyAt(rank),
+ ),
+ offsets: Array.from({ length: index.rowCount + 1 }, (_, rank) =>
+ index.getOffsetForIndex(rank),
+ ),
+ heights: Array.from({ length: index.rowCount }, (_, rank) =>
+ index.getHeight(rank),
+ ),
+ };
+ }
+
+ /** The lane-INDEPENDENT observables the equivalence oracle compares. */
+ function laneObservables(index: RowHeightIndex) {
+ const diagnostics = getRowHeightIndexDiagnosticsForTesting(index);
+ return {
+ refilterEntriesReused: diagnostics.refilterEntriesReused,
+ refilterEntriesInserted: diagnostics.refilterEntriesInserted,
+ refilterEntriesRetired: diagnostics.refilterEntriesRetired,
+ reorderEntriesReused: diagnostics.reorderEntriesReused,
+ tombstoneCount: diagnostics.tombstoneCount,
+ measurementCacheCount: diagnostics.measurementCacheCount,
+ visibleMeasurementCount: diagnostics.visibleMeasurementCount,
+ };
+ }
+
+ /** mulberry32 — a tiny deterministic PRNG for the oracle script. */
+ function prng(seed: number): () => number {
+ let state = seed >>> 0;
+ return () => {
+ state = (state + 0x6d2b79f5) >>> 0;
+ let t = state;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+ }
+
+ test("lane-equivalence oracle: a randomized flip script produces identical observables", () => {
+ // NOT the authority on leaver TICKET ORDER: the oracle's observables
+ // compare counts and heights, and a randomized script only occasionally
+ // drives tombstones through cap eviction, so a slot-ordered leaver pass
+ // could survive this test. The dedicated old-sequence ticket pin below
+ // ("dense leavers take tombstone tickets in OLD-SEQUENCE order") is the
+ // authority — never delete that pin in this oracle's favor.
+ const count = 200;
+ const random = prng(0xd15ea5e);
+ // Slots are a shuffled permutation of 0..count-1 so slot order and
+ // sequence order genuinely disagree (a slot-ordered bug cannot hide).
+ const slots = Array.from({ length: count }, (_, index) => index);
+ for (let index = count - 1; index > 0; index -= 1) {
+ const other = Math.floor(random() * (index + 1));
+ [slots[index], slots[other]] = [slots[other]!, slots[index]!];
+ }
+ const keys = Array.from({ length: count }, (_, index) =>
+ data(`row-${index}`),
+ );
+ const estimates = Array.from(
+ { length: count },
+ (_, index) => 16 + (index % 9) * 4,
+ );
+ const rowOf = (index: number): RowHeightEntry =>
+ denseEntry(keys[index]!, slots[index]!, estimates[index]!);
+
+ const cap = 8;
+ let stringLane: RowHeightIndex = createIndex([], 30, cap);
+ let denseLane: RowHeightIndex = createIndex([], 30, cap);
+ const all = Array.from({ length: count }, (_, index) => index);
+ stringLane = rebuild(stringLane, all.map(rowOf), undefined);
+ denseLane = rebuild(denseLane, all.map(rowOf), count);
+
+ let visible = [...all];
+ const compare = () => {
+ expect(rankTable(denseLane)).toEqual(rankTable(stringLane));
+ expect(laneObservables(denseLane)).toEqual(laneObservables(stringLane));
+ };
+ compare();
+
+ for (let step = 0; step < 30; step += 1) {
+ const kind = Math.floor(random() * 4);
+ if (kind === 0) {
+ // Narrowing refilter: keep each visible row with p = 0.6.
+ const survivors = visible.filter(() => random() < 0.6);
+ visible = survivors;
+ const rows = visible.map(rowOf);
+ stringLane = stringLane.refilter(sourceOf(rows));
+ denseLane = denseLane.refilter(sourceOf(rows, count));
+ } else if (kind === 1) {
+ // Widening refilter: splice each hidden row back in with p = 0.4,
+ // at a random position, so entrants interleave with survivors.
+ const visibleSet = new Set(visible);
+ const next = [...visible];
+ for (const index of all) {
+ if (visibleSet.has(index)) continue;
+ if (random() >= 0.4) continue;
+ next.splice(Math.floor(random() * (next.length + 1)), 0, index);
+ }
+ visible = next;
+ const rows = visible.map(rowOf);
+ stringLane = stringLane.refilter(sourceOf(rows));
+ denseLane = denseLane.refilter(sourceOf(rows, count));
+ } else if (kind === 2 && visible.length > 1) {
+ // Reorder: shuffle the visible order (pure permutation).
+ const next = [...visible];
+ for (let index = next.length - 1; index > 0; index -= 1) {
+ const other = Math.floor(random() * (index + 1));
+ [next[index], next[other]] = [next[other]!, next[index]!];
+ }
+ visible = next;
+ const rows = visible.map(rowOf);
+ stringLane = stringLane.reorder(sourceOf(rows));
+ denseLane = denseLane.reorder(sourceOf(rows, count));
+ } else if (visible.length > 0) {
+ // Measure a random visible row to a height its estimate cannot guess.
+ const position = Math.floor(random() * visible.length);
+ const height = 20 + Math.round(random() * 320) / 4;
+ const key = keys[visible[position]!]!;
+ stringLane = stringLane.measure(position, key, height);
+ denseLane = denseLane.measure(position, key, height);
+ }
+ compare();
+ }
+ // The script must have actually exercised retention pressure.
+ expect(
+ getRowHeightIndexDiagnosticsForTesting(denseLane).measurementCacheCount,
+ ).toBeGreaterThan(0);
+ });
+
+ test("dense leavers take tombstone tickets in OLD-SEQUENCE order, pinned via cap eviction", () => {
+ // THE authority on leaver ticket order. The lane-equivalence oracle
+ // above does not reliably cover it (its observables are counts and
+ // heights, and cap eviction rarely engages under its random script), so
+ // this pin must stay even though the oracle looks like it subsumes it.
+ const count = 10;
+ const cap = 2;
+ // Slots run OPPOSITE to sequence order: position p holds slot count-1-p.
+ // A leaver pass that iterated by slot index would assign tickets in
+ // exactly the reversed order, so cap eviction would keep the WRONG rows.
+ const keys = Array.from({ length: count }, (_, index) =>
+ data(`t-${index}`),
+ );
+ const rowOf = (index: number): RowHeightEntry =>
+ denseEntry(keys[index]!, count - 1 - index, 20 + index);
+ const all = Array.from({ length: count }, (_, index) => index);
+
+ let stringLane: RowHeightIndex = rebuild(
+ createIndex([], 30, cap),
+ all.map(rowOf),
+ undefined,
+ );
+ let denseLane: RowHeightIndex = rebuild(
+ createIndex([], 30, cap),
+ all.map(rowOf),
+ count,
+ );
+ // Measure positions 0..4; the narrowing refilter retires all five into a
+ // cap of two, so only the two NEWEST tickets survive — and tickets are
+ // assigned in old-sequence order, so the survivors are rows 3 and 4.
+ for (let position = 0; position < 5; position += 1) {
+ const height = 60 + position;
+ stringLane = stringLane.measure(position, keys[position]!, height);
+ denseLane = denseLane.measure(position, keys[position]!, height);
+ }
+ const survivors = all.slice(5).map(rowOf);
+ const narrowedString = stringLane.refilter(sourceOf(survivors));
+ const narrowedDense = denseLane.refilter(sourceOf(survivors, count));
+
+ // Direct pin: rows 3 and 4 keep their measurements, rows 0..2 lost them.
+ for (const lane of [narrowedString, narrowedDense]) {
+ expect(lane.hasMeasurement(keys[3]!)).toBe(true);
+ expect(lane.hasMeasurement(keys[4]!)).toBe(true);
+ expect(lane.hasMeasurement(keys[0]!)).toBe(false);
+ expect(lane.hasMeasurement(keys[1]!)).toBe(false);
+ expect(lane.hasMeasurement(keys[2]!)).toBe(false);
+ }
+ // And the behavioral twin: widening everything back must restore the
+ // identical height table on both lanes (63 and 64 return, the rest
+ // re-enter at their estimates).
+ const restoredString = narrowedString.refilter(sourceOf(all.map(rowOf)));
+ const restoredDense = narrowedDense.refilter(
+ sourceOf(all.map(rowOf), count),
+ );
+ expect(rankTable(restoredDense)).toEqual(rankTable(restoredString));
+ expect(restoredDense.getHeight(3)).toBe(63);
+ expect(restoredDense.getHeight(4)).toBe(64);
+ expect(restoredDense.getHeight(0)).toBe(20);
+ });
+
+ test("slot reuse never leaks a measurement across identities (Amendment I §3)", () => {
+ const r0 = data("r0");
+ const r1 = data("r1");
+ const x = data("x");
+ const y = data("y");
+ const capacity = 8;
+ const base = rebuild(
+ createIndex([], 30, 4),
+ [denseEntry(r0, 0, 20), denseEntry(r1, 1, 30), denseEntry(x, 3, 25)],
+ capacity,
+ );
+
+ // Measure X, then refilter X out: X is tombstoned, slot 3 still X's.
+ const measured = base.measure(2, x, 77);
+ const withoutX = measured.refilter(
+ sourceOf([denseEntry(r0, 0, 20), denseEntry(r1, 1, 30)], capacity),
+ );
+ expect(withoutX.hasMeasurement(x)).toBe(true);
+
+ // Permanent removal + slot reuse: a FULL dense replacement presents a
+ // NEW identity Y on X's old denseKey. Y must ingest at its estimate —
+ // the retained 77 belongs to X's identity, not to slot 3.
+ const reused = rebuild(
+ withoutX,
+ [denseEntry(r0, 0, 20), denseEntry(r1, 1, 30), denseEntry(y, 3, 25)],
+ capacity,
+ );
+ expect(reused.getHeight(2)).toBe(25);
+ expect(reused.hasMeasurement(y)).toBe(false);
+ expect(reused.hasMeasurement(x)).toBe(true);
+
+ // Refilter Y out and back in: the dense entrant path must resolve the
+ // measurement lookup by IDENTITY, so Y still ingests at estimate.
+ const withoutY = reused.refilter(
+ sourceOf([denseEntry(r0, 0, 20), denseEntry(r1, 1, 30)], capacity),
+ );
+ const withY = withoutY.refilter(
+ sourceOf(
+ [denseEntry(r0, 0, 20), denseEntry(r1, 1, 30), denseEntry(y, 3, 25)],
+ capacity,
+ ),
+ );
+ expect(withY.getHeight(2)).toBe(25);
+ expect(withY.hasMeasurement(y)).toBe(false);
+
+ // X's measurement returns ONLY for X's identity: presenting X again on a
+ // fresh slot restores 77.
+ const withXBack = withY.refilter(
+ sourceOf(
+ [
+ denseEntry(r0, 0, 20),
+ denseEntry(r1, 1, 30),
+ denseEntry(y, 3, 25),
+ denseEntry(x, 5, 25),
+ ],
+ capacity,
+ ),
+ );
+ expect(withXBack.getHeight(3)).toBe(77);
+ expect(withXBack.getHeight(2)).toBe(25);
+ });
+});
diff --git a/packages/layout-core/src/dense-membership.ts b/packages/layout-core/src/dense-membership.ts
new file mode 100644
index 000000000..a1d00121b
--- /dev/null
+++ b/packages/layout-core/src/dense-membership.ts
@@ -0,0 +1,41 @@
+/**
+ * Membership bitset: one bit per dense key (row-model SLOT). Duplicated from
+ * `@pretable-internal/row-model`'s `membership-bitset.ts` BY DESIGN —
+ * layout-core stays dependency-free (it cannot import row-model), so this
+ * ~40-line primitive is hand-copied rather than shared. Keep the two files
+ * in sync by hand; if one changes shape, mirror the change in the other.
+ *
+ * See `docs/superpowers/specs/2026-08-24-dense-handle-amendment-i-layout-seam.md`
+ * for the dense lane this backs.
+ */
+
+export type DenseMembership = Uint32Array;
+
+export function createDenseMembership(capacity: number): DenseMembership {
+ return new Uint32Array((capacity + 31) >>> 5);
+}
+
+/** Clone, growing to `capacity` when it exceeds the source's words. */
+export function cloneDenseMembership(
+ bits: DenseMembership,
+ capacity: number,
+): DenseMembership {
+ const words = Math.max(bits.length, (capacity + 31) >>> 5);
+ const next = new Uint32Array(words);
+ next.set(bits);
+ return next;
+}
+
+export function setDenseBit(bits: DenseMembership, slot: number): void {
+ bits[slot >>> 5]! |= 1 << (slot & 31);
+}
+
+export function clearDenseBit(bits: DenseMembership, slot: number): void {
+ bits[slot >>> 5]! &= ~(1 << (slot & 31));
+}
+
+/** Out-of-range slots read as false. */
+export function testDenseBit(bits: DenseMembership, slot: number): boolean {
+ const word = bits[slot >>> 5];
+ return word === undefined ? false : ((word >>> (slot & 31)) & 1) === 1;
+}
diff --git a/packages/layout-core/src/row-height-index.ts b/packages/layout-core/src/row-height-index.ts
index 41222521f..901e150b5 100644
--- a/packages/layout-core/src/row-height-index.ts
+++ b/packages/layout-core/src/row-height-index.ts
@@ -1,3 +1,11 @@
+import {
+ clearDenseBit,
+ cloneDenseMembership,
+ createDenseMembership,
+ setDenseBit,
+ testDenseBit,
+ type DenseMembership,
+} from "./dense-membership";
import type {
CreateRowHeightIndexOptions,
RowHeightAnchor,
@@ -20,6 +28,9 @@ interface Work {
sortComparisons: number;
reorderEntriesReused: number;
reorderEntriesRemeasured: number;
+ refilterEntriesReused: number;
+ refilterEntriesInserted: number;
+ refilterEntriesRetired: number;
}
function createWork(entriesVisited = 0): Work {
@@ -33,6 +44,9 @@ function createWork(entriesVisited = 0): Work {
sortComparisons: 0,
reorderEntriesReused: 0,
reorderEntriesRemeasured: 0,
+ refilterEntriesReused: 0,
+ refilterEntriesInserted: 0,
+ refilterEntriesRetired: 0,
};
}
@@ -42,6 +56,11 @@ interface HeightValue {
readonly estimatedHeight: number | undefined;
readonly height: number;
readonly measured: boolean;
+ /**
+ * The row's model slot as stamped by the ingesting input (Amendment I §1).
+ * Defined on every entry of a dense generation; inert on the string lane.
+ */
+ readonly denseKey: number | undefined;
}
interface SequenceNode {
@@ -162,6 +181,16 @@ export interface RowHeightIndexDiagnostics {
* asserting zero by fiat.
*/
readonly reorderEntriesRemeasured: number;
+ /** Surviving entries relinked as-is by `refilter` — measurements ride. */
+ readonly refilterEntriesReused: number;
+ /** New rows `refilter` ingested with the estimate-or-default height rule. */
+ readonly refilterEntriesInserted: number;
+ /**
+ * Rows `refilter` removed from the visible set. Measured leavers keep their
+ * measurement as tombstones (see `tombstoneCount`); unmeasured leavers had
+ * nothing to retain.
+ */
+ readonly refilterEntriesRetired: number;
readonly visibleMeasurementCount: number;
readonly tombstoneCount: number;
readonly measurementCacheCount: number;
@@ -209,6 +238,8 @@ interface ReplacementBase {
readonly getKey: (key: TKey) => string | number;
readonly root: SequenceNode | null;
readonly visibleKeys: HashNode | null;
+ /** The base generation's dense capacity, for lane-aware no-op detection. */
+ readonly denseCapacity: number | undefined;
readonly measurements: HashNode | null;
readonly tombstones: HashNode | null;
readonly tombstoneOrder: KeyMapNode | null;
@@ -1072,6 +1103,17 @@ class PersistentRowHeightIndex implements RowHeightIndex {
readonly #getKey: (key: TKey) => string | number;
readonly #root: SequenceNode | null;
readonly #visibleKeys: HashNode | null;
+ /**
+ * INVARIANT (Amendment I §1): `#visibleSlots !== undefined` ⇔ this
+ * generation is DENSE ⇔ every sequence entry carries a `denseKey` less
+ * than `#denseCapacity`. A dense generation does NOT maintain
+ * `#visibleKeys` (it stays `null`); a string generation never allocates
+ * `#visibleSlots`. Measurements, tombstones, and retention order stay
+ * string-identity-keyed in BOTH lanes — slot reuse must never touch
+ * retention (the amendment's §3 trap).
+ */
+ readonly #denseCapacity: number | undefined;
+ readonly #visibleSlots: DenseMembership | undefined;
readonly #measurements: HashNode | null;
readonly #tombstones: HashNode | null;
readonly #tombstoneOrder: KeyMapNode | null;
@@ -1084,6 +1126,8 @@ class PersistentRowHeightIndex implements RowHeightIndex {
readonly getKey: (key: TKey) => string | number;
readonly root: SequenceNode | null;
readonly visibleKeys: HashNode | null;
+ readonly denseCapacity: number | undefined;
+ readonly visibleSlots: DenseMembership | undefined;
readonly measurements: HashNode | null;
readonly tombstones: HashNode | null;
readonly tombstoneOrder: KeyMapNode | null;
@@ -1095,6 +1139,8 @@ class PersistentRowHeightIndex implements RowHeightIndex {
this.#getKey = options.getKey;
this.#root = options.root;
this.#visibleKeys = options.visibleKeys;
+ this.#denseCapacity = options.denseCapacity;
+ this.#visibleSlots = options.visibleSlots;
this.#measurements = options.measurements;
this.#tombstones = options.tombstones;
this.#tombstoneOrder = options.tombstoneOrder;
@@ -1110,6 +1156,9 @@ class PersistentRowHeightIndex implements RowHeightIndex {
previousEntriesScanned: options.work.previousEntriesScanned,
sortComparisons: options.work.sortComparisons,
reorderEntriesReused: options.work.reorderEntriesReused,
+ refilterEntriesReused: options.work.refilterEntriesReused,
+ refilterEntriesInserted: options.work.refilterEntriesInserted,
+ refilterEntriesRetired: options.work.refilterEntriesRetired,
reorderEntriesRemeasured: options.work.reorderEntriesRemeasured,
visibleMeasurementCount:
hashCount(options.measurements) - hashCount(options.tombstones),
@@ -1243,6 +1292,8 @@ class PersistentRowHeightIndex implements RowHeightIndex {
return this.#next(
root,
this.#visibleKeys,
+ this.#denseCapacity,
+ this.#visibleSlots,
measurements,
this.#tombstones,
this.#tombstoneOrder,
@@ -1251,11 +1302,38 @@ class PersistentRowHeightIndex implements RowHeightIndex {
);
}
- retainMeasurement(ref: TKey, height: number): RowHeightIndex {
+ retainMeasurement(
+ ref: TKey,
+ height: number,
+ denseKey?: number,
+ ): RowHeightIndex {
const normalized = normalizeHeight(height, "Measured row height");
const identity = this.#identity(ref);
const work = createWork(1);
- if (hashGet(this.#visibleKeys, identity, work) !== undefined) {
+ if (this.#visibleSlots !== undefined) {
+ // Dense generation: `#visibleKeys` is null, so the string-lane guard
+ // below would be vacuous — visibility is answered by the slot bitset.
+ if (denseKey === undefined) {
+ throw new RowHeightReplacementLifecycleError(
+ "failed",
+ "A dense row-height index requires dense-keyed operations; " +
+ "fall back to a full replacement.",
+ );
+ }
+ if (!Number.isSafeInteger(denseKey) || denseKey < 0) {
+ // Mirror the insert arm: a negative or fractional key would silently
+ // mis-read here (1.5's `&31` truncation reads a DIFFERENT row's bit).
+ throw new RangeError(
+ `Dense row-height key ${denseKey} must be a non-negative ` +
+ "safe integer.",
+ );
+ }
+ if (testDenseBit(this.#visibleSlots, denseKey)) {
+ throw new RangeError(
+ "Cannot retain an absent measurement for a visible row.",
+ );
+ }
+ } else if (hashGet(this.#visibleKeys, identity, work) !== undefined) {
throw new RangeError(
"Cannot retain an absent measurement for a visible row.",
);
@@ -1295,6 +1373,8 @@ class PersistentRowHeightIndex implements RowHeightIndex {
return this.#next(
this.#root,
this.#visibleKeys,
+ this.#denseCapacity,
+ this.#visibleSlots,
measurements,
tombstones,
tombstoneOrder,
@@ -1307,18 +1387,44 @@ class PersistentRowHeightIndex implements RowHeightIndex {
if (operations.length === 0) return this;
let root = this.#root;
let visibleKeys = this.#visibleKeys;
+ let denseCapacity = this.#denseCapacity;
+ let visibleSlots = this.#visibleSlots;
+ // The slot bitset is MUTABLE, so the persistent contract demands a clone
+ // before the first membership write of this call; reference equality with
+ // `this.#visibleSlots` doubles as the no-op signal below.
+ let slotsShared = true;
let measurements = this.#measurements;
let tombstones = this.#tombstones;
let tombstoneOrder = this.#tombstoneOrder;
- let nextTicket = this.#nextTicket;
const work = createWork();
+ let nextTicket = this.#nextTicket;
for (const operation of operations) {
work.entriesVisited += 1;
+ if (visibleSlots !== undefined && operation.denseKey === undefined) {
+ throw new RowHeightReplacementLifecycleError(
+ "failed",
+ "A dense row-height index requires dense-keyed operations; " +
+ "fall back to a full replacement.",
+ );
+ }
if (operation.kind === "insert") {
assertInsertIndex(operation.index, nodeCount(root));
const identity = this.#identity(operation.ref);
- if (hashGet(visibleKeys, identity, work) !== undefined) {
+ if (visibleSlots !== undefined) {
+ const denseKey = operation.denseKey!;
+ if (!Number.isSafeInteger(denseKey) || denseKey < 0) {
+ throw new RangeError(
+ `Dense row-height key ${denseKey} must be a non-negative ` +
+ "safe integer.",
+ );
+ }
+ if (testDenseBit(visibleSlots, denseKey)) {
+ throw new Error(
+ `Duplicate dense row-height slot ${denseKey}: ${identity}`,
+ );
+ }
+ } else if (hashGet(visibleKeys, identity, work) !== undefined) {
throw new Error(`Duplicate stable row-height key: ${identity}`);
}
const estimatedHeight = this.#estimated(operation.estimatedHeight);
@@ -1333,10 +1439,24 @@ class PersistentRowHeightIndex implements RowHeightIndex {
estimatedHeight: operation.estimatedHeight,
height: measuredHeight ?? estimatedHeight,
measured: measuredHeight !== undefined,
+ denseKey: operation.denseKey,
},
work,
);
- visibleKeys = hashSet(visibleKeys, identity, true, work);
+ if (visibleSlots !== undefined) {
+ const denseKey = operation.denseKey!;
+ const neededCapacity = Math.max(denseCapacity!, denseKey + 1);
+ if (slotsShared) {
+ visibleSlots = cloneDenseMembership(visibleSlots, neededCapacity);
+ slotsShared = false;
+ } else if (denseKey >>> 5 >= visibleSlots.length) {
+ visibleSlots = cloneDenseMembership(visibleSlots, neededCapacity);
+ }
+ setDenseBit(visibleSlots, denseKey);
+ denseCapacity = neededCapacity;
+ } else {
+ visibleKeys = hashSet(visibleKeys, identity, true, work);
+ }
if (retainedTicket !== undefined) {
tombstones = hashDelete(tombstones, identity, work);
tombstoneOrder = mapDelete(
@@ -1356,10 +1476,36 @@ class PersistentRowHeightIndex implements RowHeightIndex {
const identity = this.#identity(operation.ref);
const current = sequenceAt(root, sourceIndex)!;
this.#assertIdentity(current, identity);
+ // Dense slot-drift guard for every variant that resolves a current
+ // entry (remove, move, update — insert has no current entry; its slot
+ // is range-validated and duplicate-checked in its own arm instead): a
+ // caller whose slot view drifted from the entry's stamped slot must
+ // fail loud rather than silently clearing or riding the wrong bit.
+ if (
+ visibleSlots !== undefined &&
+ operation.denseKey !== current.denseKey
+ ) {
+ throw new RowHeightReplacementLifecycleError(
+ "failed",
+ `Dense slot drift: operation denseKey ${operation.denseKey} does ` +
+ `not match the entry's stamped slot ${current.denseKey} for ` +
+ `${identity}; fall back to a full replacement.`,
+ );
+ }
if (operation.kind === "remove") {
root = removeSequence(root!, sourceIndex, work).root;
- visibleKeys = hashDelete(visibleKeys, identity, work);
+ if (visibleSlots !== undefined) {
+ // Clear by the ENTRY's stamped slot — the one that set the bit —
+ // which the identity assertion above ties to the caller's ref.
+ if (slotsShared) {
+ visibleSlots = cloneDenseMembership(visibleSlots, denseCapacity!);
+ slotsShared = false;
+ }
+ clearDenseBit(visibleSlots, current.denseKey!);
+ } else {
+ visibleKeys = hashDelete(visibleKeys, identity, work);
+ }
if (current.measured) {
if (this.#maxRetainedMeasurements === 0) {
measurements = hashDelete(measurements, identity, work);
@@ -1427,6 +1573,8 @@ class PersistentRowHeightIndex implements RowHeightIndex {
if (
root === this.#root &&
visibleKeys === this.#visibleKeys &&
+ visibleSlots === this.#visibleSlots &&
+ denseCapacity === this.#denseCapacity &&
measurements === this.#measurements &&
tombstones === this.#tombstones &&
tombstoneOrder === this.#tombstoneOrder &&
@@ -1437,6 +1585,8 @@ class PersistentRowHeightIndex implements RowHeightIndex {
return this.#next(
root,
visibleKeys,
+ denseCapacity,
+ visibleSlots,
measurements,
tombstones,
tombstoneOrder,
@@ -1445,6 +1595,11 @@ class PersistentRowHeightIndex implements RowHeightIndex {
);
}
+ /**
+ * Builds a source with NO `denseCapacity`, so on a dense generation this
+ * is a deliberate LANE EXIT: the result is a string-lane generation (the
+ * full-replacement path is where the lane is legally re-decided).
+ */
replace(rows: readonly RowHeightEntry[]): RowHeightIndex {
const builder = this.beginReplacement({
rowCount: rows.length,
@@ -1484,6 +1639,14 @@ class PersistentRowHeightIndex implements RowHeightIndex {
}
if (rowCount === 0) return this;
const boundEntryAt = entryAt.bind(source);
+ if (this.#visibleSlots !== undefined) {
+ // Dense generation (Amendment I): resolve the permutation by slot.
+ // The string-lane body below stays INLINE and byte-identical to its
+ // pre-dense form deliberately (the Task 3 pin: the string lane must be
+ // provably untouched by diff alone): do not unify the lanes or hoist
+ // the shared walks into helpers.
+ return this.#reorderDense(boundEntryAt, rowCount);
+ }
const work = createWork();
// One in-order pass over the current sequence: the by-identity lookup
@@ -1534,6 +1697,8 @@ class PersistentRowHeightIndex implements RowHeightIndex {
return this.#next(
root,
this.#visibleKeys,
+ this.#denseCapacity,
+ this.#visibleSlots,
this.#measurements,
this.#tombstones,
this.#tombstoneOrder,
@@ -1542,6 +1707,458 @@ class PersistentRowHeightIndex implements RowHeightIndex {
);
}
+ /**
+ * Dense-lane reorder (Amendment I): the permutation resolves each source
+ * row by its `denseKey` (model slot) against a slot-indexed array — ZERO
+ * identity strings on the hot path; identities are computed only to name
+ * a row in an error. Key validation mirrors the builder's
+ * `#ingestDenseKey` cases (missing / malformed / out-of-range → lifecycle
+ * error), which the controller's fallback-on-throw contract routes to a
+ * full replacement that re-decides the lane. Membership is unchanged by
+ * construction — reorder permutes the existing rows — so the generation
+ * carries `#visibleSlots` and `#denseCapacity` forward untouched.
+ *
+ * TRUST BOUNDARY: a resolved row's `row.key` is trusted to match the
+ * slot's stored identity and deliberately never re-verified — that
+ * verification IS the per-row string cost this lane exists to delete.
+ * `apply` guards slot drift instead, because its operations are k-sized.
+ */
+ #reorderDense(
+ boundEntryAt: (index: number) => RowHeightEntry,
+ rowCount: number,
+ ): RowHeightIndex {
+ const capacity = this.#denseCapacity!;
+ const work = createWork();
+
+ // One in-order pass over the current sequence: the by-slot lookup table
+ // for the walk below, and the old order for no-op detection.
+ const previousValues: HeightValue[] = [];
+ const unconsumedBySlot: (HeightValue | undefined)[] = new Array<
+ HeightValue | undefined
+ >(capacity);
+ {
+ const stack: SequenceNode[] = [];
+ let cursor = this.#root;
+ while (cursor !== null || stack.length > 0) {
+ while (cursor !== null) {
+ stack.push(cursor);
+ cursor = cursor.left;
+ }
+ const node = stack.pop()!;
+ previousValues.push(node.value);
+ unconsumedBySlot[node.value.denseKey!] = node.value;
+ work.previousEntriesScanned += 1;
+ cursor = node.right;
+ }
+ }
+
+ // Walk the new order, relinking each EXISTING entry verbatim. Estimates
+ // and measurements ride along untouched inside the reused entry objects,
+ // so the source's `estimatedHeight`s are deliberately ignored.
+ const values: HeightValue[] = new Array>(rowCount);
+ let unchanged = true;
+ for (let index = 0; index < rowCount; index += 1) {
+ const row = boundEntryAt(index);
+ work.entriesVisited += 1;
+ const denseKey = row.denseKey;
+ if (denseKey === undefined) {
+ throw new RowHeightReplacementLifecycleError(
+ "failed",
+ "A dense row-height index requires a denseKey on every source " +
+ `row (${this.#identity(row.key)} carries none); fall back to ` +
+ "a full replacement.",
+ );
+ }
+ if (
+ !Number.isSafeInteger(denseKey) ||
+ denseKey < 0 ||
+ denseKey >= capacity
+ ) {
+ throw new RowHeightReplacementLifecycleError(
+ "failed",
+ `Dense key ${denseKey} for row ${this.#identity(row.key)} is ` +
+ `outside the dense generation's capacity ${capacity}.`,
+ );
+ }
+ const value = unconsumedBySlot[denseKey];
+ if (value === undefined) {
+ throw new Error(
+ `Reorder slot does not match an existing row (missing, or ` +
+ `duplicated in the new order): ${denseKey}`,
+ );
+ }
+ unconsumedBySlot[denseKey] = undefined;
+ values[index] = value;
+ work.reorderEntriesReused += 1;
+ if (value !== previousValues[index]) unchanged = false;
+ }
+ if (unchanged) return this;
+
+ const root = buildBalancedSequence(values, 0, rowCount, work);
+ return this.#next(
+ root,
+ this.#visibleKeys,
+ this.#denseCapacity,
+ this.#visibleSlots,
+ this.#measurements,
+ this.#tombstones,
+ this.#tombstoneOrder,
+ this.#nextTicket,
+ work,
+ );
+ }
+
+ /**
+ * Synchronous BY DESIGN (Amendment G, G3b): a filter-only commit changes
+ * MEMBERSHIP while surviving rows keep their relative order and their
+ * already-measured heights, so the cooperative replacement's per-row
+ * re-ingest and its sliced interval — the window the blank-viewport defect
+ * lived in — are pure overhead here. Measured in Node against dist:
+ * a 50k→12.5k narrowing runs 15–26ms (the amendment's 20–25ms ceiling);
+ * a 12.5k→50k widening runs ~57–80ms, dominated by the fresh ingest of
+ * 37.5k entrants — above the cost model's 30–35ms projection but still one
+ * synchronous pass with no replacement interval, versus the 45–63ms sliced
+ * cooperative flush (`reingest-composition.md`). Retention semantics deliberately mirror the
+ * cooperative path observable-for-observable: entrants reuse a retained
+ * (tombstoned) measurement when one exists, measured leavers tombstone in
+ * old-sequence ticket order (or drop outright at cap 0), unmeasured
+ * leavers vanish, and cap pressure evicts oldest-first. The caller
+ * (renderer-dom's row-layout controller) falls back to a full replacement
+ * on ANY throw.
+ */
+ refilter(source: RowHeightReplacementSource): RowHeightIndex {
+ const rowCount = source.rowCount;
+ if (!Number.isSafeInteger(rowCount) || rowCount < 0) {
+ throw new RangeError(
+ "Refilter source rowCount must be a non-negative safe integer.",
+ );
+ }
+ const entryAt = source.entryAt;
+ if (typeof entryAt !== "function") {
+ throw new TypeError("Refilter source entryAt must be a function.");
+ }
+ const boundEntryAt = entryAt.bind(source);
+ if (this.#visibleSlots !== undefined) {
+ // Dense generation (Amendment I): resolve survivors by slot.
+ // The string-lane body below stays INLINE and byte-identical to its
+ // pre-dense form deliberately (the Task 3 pin: the string lane must be
+ // provably untouched by diff alone): do not unify the lanes or hoist
+ // the shared walks into helpers.
+ return this.#refilterDense(boundEntryAt, rowCount);
+ }
+ const work = createWork();
+
+ // One in-order pass over the current sequence: the by-identity lookup
+ // table for the walk below, and the old order for no-op detection.
+ const previousValues: HeightValue[] = [];
+ const unconsumed = new Map>();
+ {
+ const stack: SequenceNode[] = [];
+ let cursor = this.#root;
+ while (cursor !== null || stack.length > 0) {
+ while (cursor !== null) {
+ stack.push(cursor);
+ cursor = cursor.left;
+ }
+ const node = stack.pop()!;
+ previousValues.push(node.value);
+ unconsumed.set(node.value.identity, node.value);
+ work.previousEntriesScanned += 1;
+ cursor = node.right;
+ }
+ }
+
+ // Walk the new order: survivors relink verbatim (their measurements and
+ // estimates ride, exactly as in `reorder`), entrants ingest fresh under
+ // the cooperative path's rule — measured-lookup against the FULL cache,
+ // so a returning tombstoned key gets its retained measurement back and
+ // sheds its tombstone.
+ const values: HeightValue[] = new Array>(rowCount);
+ const seen = new Set();
+ let visibleKeys: HashNode | null = null;
+ let measurements = this.#measurements;
+ let tombstones = this.#tombstones;
+ let tombstoneOrder = this.#tombstoneOrder;
+ let nextTicket = this.#nextTicket;
+ let unchanged = rowCount === previousValues.length;
+ for (let index = 0; index < rowCount; index += 1) {
+ const row = boundEntryAt(index);
+ const identity = this.#identity(row.key);
+ work.entriesVisited += 1;
+ work.identityLookups += 1;
+ if (seen.has(identity)) {
+ throw new Error(`Duplicate stable row-height key: ${identity}`);
+ }
+ seen.add(identity);
+ visibleKeys = hashSet(visibleKeys, identity, true, work);
+ const existing = unconsumed.get(identity);
+ if (existing !== undefined) {
+ unconsumed.delete(identity);
+ values[index] = existing;
+ work.refilterEntriesReused += 1;
+ if (existing !== previousValues[index]) unchanged = false;
+ continue;
+ }
+ unchanged = false;
+ const estimatedHeight = this.#estimated(row.estimatedHeight);
+ const measuredHeight = hashGet(measurements, identity, work);
+ if (measuredHeight !== undefined) {
+ work.measurementEntriesScanned += 1;
+ // A tombstone can only exist where a retained measurement does, so
+ // the lookup is skipped for the (common) genuinely-new entrant.
+ const retainedTicket = hashGet(tombstones, identity, work);
+ if (retainedTicket !== undefined) {
+ tombstones = hashDelete(tombstones, identity, work);
+ tombstoneOrder = mapDelete(
+ tombstoneOrder,
+ ticketKey(retainedTicket),
+ work,
+ );
+ }
+ }
+ values[index] = {
+ ref: row.key,
+ identity,
+ estimatedHeight: row.estimatedHeight,
+ height: measuredHeight ?? estimatedHeight,
+ measured: measuredHeight !== undefined,
+ denseKey: row.denseKey,
+ };
+ work.refilterEntriesInserted += 1;
+ }
+
+ // Whatever the walk left unconsumed has LEFT the visible set. The Map
+ // preserves old-sequence order, so measured leavers take new tickets in
+ // exactly the order the cooperative scan-visible phase would assign them.
+ for (const value of unconsumed.values()) {
+ unchanged = false;
+ work.refilterEntriesRetired += 1;
+ if (!value.measured) continue;
+ if (this.#maxRetainedMeasurements === 0) {
+ measurements = hashDelete(measurements, value.identity, work);
+ continue;
+ }
+ const ticket = nextTicket;
+ nextTicket = takeNextTicket(nextTicket);
+ tombstones = hashSet(tombstones, value.identity, ticket, work);
+ tombstoneOrder = mapSet(
+ tombstoneOrder,
+ ticketKey(ticket),
+ value.identity,
+ work,
+ );
+ }
+ while (hashCount(tombstones) > this.#maxRetainedMeasurements) {
+ const oldest: KeyMapNode | undefined =
+ minimumMapEntry(tombstoneOrder);
+ if (oldest === undefined) {
+ throw new Error("Removed-measurement retention is inconsistent.");
+ }
+ tombstoneOrder = mapDelete(tombstoneOrder, oldest.key, work);
+ tombstones = hashDelete(tombstones, oldest.value, work);
+ measurements = hashDelete(measurements, oldest.value, work);
+ }
+ if (unchanged) return this;
+
+ const root = buildBalancedSequence(values, 0, rowCount, work);
+ return this.#next(
+ root,
+ visibleKeys,
+ this.#denseCapacity,
+ this.#visibleSlots,
+ measurements,
+ tombstones,
+ tombstoneOrder,
+ nextTicket,
+ work,
+ );
+ }
+
+ /**
+ * Dense-lane refilter (Amendment I): survivors resolve by `denseKey`
+ * (model slot) against a slot-indexed array — ZERO identity strings for
+ * the (dominant) survivor population. Only ENTRANTS compute an identity,
+ * because measurements and tombstones stay string-identity-keyed in both
+ * lanes: a slot is lifetime-bound and reused after permanent removal, so
+ * slot reuse must never touch retention (the amendment's §3 trap). Key
+ * validation mirrors the builder's `#ingestDenseKey` cases (missing /
+ * malformed / out-of-range → lifecycle error → the controller's
+ * fallback-on-throw contract), and the duplicate check shares one bitset
+ * with the next generation's membership, exactly as the builder does.
+ *
+ * TRUST BOUNDARY: a survivor's `row.key` is trusted to match the slot's
+ * stored identity and deliberately never re-verified — that verification
+ * IS the per-row string cost this lane exists to delete. `apply` guards
+ * slot drift instead, because its operations are k-sized.
+ *
+ * LEAVER ORDER IS LOAD-BEARING: measured leavers take tombstone tickets
+ * in OLD-SEQUENCE order (the string lane inherits this from its Map's
+ * insertion order), and ticket order is observable through cap eviction.
+ * The leaver pass therefore walks `previousValues` in order and consumes
+ * the slot cells — NEVER the slot array by index, whose order is
+ * unrelated to the sequence.
+ */
+ #refilterDense(
+ boundEntryAt: (index: number) => RowHeightEntry,
+ rowCount: number,
+ ): RowHeightIndex {
+ const capacity = this.#denseCapacity!;
+ const work = createWork();
+
+ // One in-order pass over the current sequence: the by-slot lookup table
+ // for the walk below, and the old order for no-op detection and the
+ // leaver pass.
+ const previousValues: HeightValue[] = [];
+ const unconsumedBySlot: (HeightValue | undefined)[] = new Array<
+ HeightValue | undefined
+ >(capacity);
+ {
+ const stack: SequenceNode[] = [];
+ let cursor = this.#root;
+ while (cursor !== null || stack.length > 0) {
+ while (cursor !== null) {
+ stack.push(cursor);
+ cursor = cursor.left;
+ }
+ const node = stack.pop()!;
+ previousValues.push(node.value);
+ unconsumedBySlot[node.value.denseKey!] = node.value;
+ work.previousEntriesScanned += 1;
+ cursor = node.right;
+ }
+ }
+
+ // Walk the new order: survivors relink verbatim by slot (measurements
+ // and estimates ride, exactly as in `reorder`), entrants ingest fresh
+ // under the cooperative path's rule — measured-lookup against the FULL
+ // cache BY IDENTITY, so a returning tombstoned key gets its retained
+ // measurement back and sheds its tombstone. `nextVisibleSlots` doubles
+ // as the duplicate check and the next generation's membership.
+ const values: HeightValue[] = new Array>(rowCount);
+ const nextVisibleSlots = createDenseMembership(capacity);
+ let measurements = this.#measurements;
+ let tombstones = this.#tombstones;
+ let tombstoneOrder = this.#tombstoneOrder;
+ let nextTicket = this.#nextTicket;
+ let unchanged = rowCount === previousValues.length;
+ for (let index = 0; index < rowCount; index += 1) {
+ const row = boundEntryAt(index);
+ work.entriesVisited += 1;
+ const denseKey = row.denseKey;
+ if (denseKey === undefined) {
+ throw new RowHeightReplacementLifecycleError(
+ "failed",
+ "A dense row-height index requires a denseKey on every source " +
+ `row (${this.#identity(row.key)} carries none); fall back to ` +
+ "a full replacement.",
+ );
+ }
+ if (
+ !Number.isSafeInteger(denseKey) ||
+ denseKey < 0 ||
+ denseKey >= capacity
+ ) {
+ throw new RowHeightReplacementLifecycleError(
+ "failed",
+ `Dense key ${denseKey} for row ${this.#identity(row.key)} is ` +
+ `outside the dense generation's capacity ${capacity}.`,
+ );
+ }
+ if (testDenseBit(nextVisibleSlots, denseKey)) {
+ throw new Error(
+ `Duplicate dense row-height slot ${denseKey}: ` +
+ this.#identity(row.key),
+ );
+ }
+ setDenseBit(nextVisibleSlots, denseKey);
+ const existing = unconsumedBySlot[denseKey];
+ if (existing !== undefined) {
+ unconsumedBySlot[denseKey] = undefined;
+ values[index] = existing;
+ work.refilterEntriesReused += 1;
+ if (existing !== previousValues[index]) unchanged = false;
+ continue;
+ }
+ unchanged = false;
+ const identity = this.#identity(row.key);
+ work.identityLookups += 1;
+ const estimatedHeight = this.#estimated(row.estimatedHeight);
+ const measuredHeight = hashGet(measurements, identity, work);
+ if (measuredHeight !== undefined) {
+ work.measurementEntriesScanned += 1;
+ // A tombstone can only exist where a retained measurement does, so
+ // the lookup is skipped for the (common) genuinely-new entrant.
+ const retainedTicket = hashGet(tombstones, identity, work);
+ if (retainedTicket !== undefined) {
+ tombstones = hashDelete(tombstones, identity, work);
+ tombstoneOrder = mapDelete(
+ tombstoneOrder,
+ ticketKey(retainedTicket),
+ work,
+ );
+ }
+ }
+ values[index] = {
+ ref: row.key,
+ identity,
+ estimatedHeight: row.estimatedHeight,
+ height: measuredHeight ?? estimatedHeight,
+ measured: measuredHeight !== undefined,
+ denseKey,
+ };
+ work.refilterEntriesInserted += 1;
+ }
+
+ // Whatever the walk left unconsumed has LEFT the visible set. Iterate
+ // the OLD SEQUENCE (see the doc comment above — ticket order is
+ // observable via cap eviction) and consume each leaver's slot cell.
+ for (const value of previousValues) {
+ const slot = value.denseKey!;
+ if (unconsumedBySlot[slot] === undefined) continue;
+ unconsumedBySlot[slot] = undefined;
+ unchanged = false;
+ work.refilterEntriesRetired += 1;
+ if (!value.measured) continue;
+ if (this.#maxRetainedMeasurements === 0) {
+ measurements = hashDelete(measurements, value.identity, work);
+ continue;
+ }
+ const ticket = nextTicket;
+ nextTicket = takeNextTicket(nextTicket);
+ tombstones = hashSet(tombstones, value.identity, ticket, work);
+ tombstoneOrder = mapSet(
+ tombstoneOrder,
+ ticketKey(ticket),
+ value.identity,
+ work,
+ );
+ }
+ while (hashCount(tombstones) > this.#maxRetainedMeasurements) {
+ const oldest: KeyMapNode | undefined =
+ minimumMapEntry(tombstoneOrder);
+ if (oldest === undefined) {
+ throw new Error("Removed-measurement retention is inconsistent.");
+ }
+ tombstoneOrder = mapDelete(tombstoneOrder, oldest.key, work);
+ tombstones = hashDelete(tombstones, oldest.value, work);
+ measurements = hashDelete(measurements, oldest.value, work);
+ }
+ if (unchanged) return this;
+
+ const root = buildBalancedSequence(values, 0, rowCount, work);
+ return this.#next(
+ root,
+ null,
+ this.#denseCapacity,
+ nextVisibleSlots,
+ measurements,
+ tombstones,
+ tombstoneOrder,
+ nextTicket,
+ work,
+ );
+ }
+
beginReplacement(
source: RowHeightReplacementSource,
): RowHeightReplacementBuilder {
@@ -1555,7 +2172,17 @@ class PersistentRowHeightIndex implements RowHeightIndex {
if (typeof entryAt !== "function") {
throw new TypeError("Replacement source entryAt must be a function.");
}
+ const denseCapacity = source.denseCapacity;
+ if (
+ denseCapacity !== undefined &&
+ (!Number.isSafeInteger(denseCapacity) || denseCapacity < 0)
+ ) {
+ throw new RangeError(
+ "Replacement source denseCapacity must be a non-negative safe integer.",
+ );
+ }
return new PersistentRowHeightReplacementBuilder({
+ denseCapacity,
// With no retained state every ingest lookup would miss (see
// `hasRetainedState`'s derivation), so the builder may build everything
// in one synchronous O(n) pass instead of cooperative phases.
@@ -1566,6 +2193,7 @@ class PersistentRowHeightIndex implements RowHeightIndex {
getKey: this.#getKey,
root: this.#root,
visibleKeys: this.#visibleKeys,
+ denseCapacity: this.#denseCapacity,
measurements: this.#measurements,
tombstones: this.#tombstones,
tombstoneOrder: this.#tombstoneOrder,
@@ -1628,6 +2256,8 @@ class PersistentRowHeightIndex implements RowHeightIndex {
#next(
root: SequenceNode | null,
visibleKeys: HashNode | null,
+ denseCapacity: number | undefined,
+ visibleSlots: DenseMembership | undefined,
measurements: HashNode | null,
tombstones: HashNode | null,
tombstoneOrder: KeyMapNode | null,
@@ -1639,6 +2269,8 @@ class PersistentRowHeightIndex implements RowHeightIndex {
getKey: this.#getKey,
root,
visibleKeys,
+ denseCapacity,
+ visibleSlots,
measurements,
tombstones,
tombstoneOrder,
@@ -1665,6 +2297,9 @@ class PersistentRowHeightReplacementBuilder<
#sequenceBuildStack: SequenceBuildFrame[] | null = [];
#retentionBuildStack: RetentionBuildFrame[] | null = [];
#visibleKeys: HashNode | null = null;
+ /** Dense lane (Amendment I §1): declared by the source at `begin`. */
+ readonly #denseCapacity: number | undefined;
+ #visibleSlots: DenseMembership | null;
#measurements: HashNode | null;
#tombstones: HashNode | null = null;
#tombstoneOrder: KeyMapNode | null = null;
@@ -1701,10 +2336,16 @@ class PersistentRowHeightReplacementBuilder<
readonly rowCount: number;
readonly entryAt: (index: number) => RowHeightEntry;
readonly bulk: boolean;
+ readonly denseCapacity: number | undefined;
}) {
this.#base = options.base;
this.#entryAt = options.entryAt;
this.#sourceRowCount = options.rowCount;
+ this.#denseCapacity = options.denseCapacity;
+ this.#visibleSlots =
+ options.denseCapacity === undefined
+ ? null
+ : createDenseMembership(options.denseCapacity);
this.#measurements = options.base.measurements;
this.#nextTicket = options.base.nextTicket;
this.#bulk = options.bulk;
@@ -1888,6 +2529,8 @@ class PersistentRowHeightReplacementBuilder<
getKey: base.getKey,
root: this.#root,
visibleKeys: this.#visibleKeys,
+ denseCapacity: this.#denseCapacity,
+ visibleSlots: this.#visibleSlots ?? undefined,
measurements: this.#measurements,
tombstones: this.#tombstones,
tombstoneOrder: this.#tombstoneOrder,
@@ -1955,6 +2598,53 @@ class PersistentRowHeightReplacementBuilder<
}
}
+ /**
+ * The dense lane's per-row ingest (Amendment I §1). With no declared
+ * capacity the input's `denseKey` is stamped verbatim and stays inert
+ * (string lane). Under a declared capacity every row MUST carry an
+ * in-range `denseKey` — the source promised dense coverage, so a missing
+ * or out-of-range key is a CALLER BUG and throws rather than silently
+ * falling back (the controller only declares `denseCapacity` when the
+ * snapshot guarantees slots). The visible-slot bitset is built here, in
+ * place of the `#visibleKeys` HAMT the dense lane skips entirely; the
+ * string-identity `#identities` duplicate check still runs first at both
+ * call sites, because retention stays identity-keyed in both lanes.
+ */
+ #ingestDenseKey(
+ row: RowHeightEntry,
+ identity: string,
+ ): number | undefined {
+ const capacity = this.#denseCapacity;
+ if (capacity === undefined) return row.denseKey;
+ const denseKey = row.denseKey;
+ if (denseKey === undefined) {
+ throw new RowHeightReplacementLifecycleError(
+ "failed",
+ `Replacement source declared denseCapacity ${capacity} but a row ` +
+ `(${identity}) carries no denseKey; the source broke its dense ` +
+ "promise.",
+ );
+ }
+ if (
+ !Number.isSafeInteger(denseKey) ||
+ denseKey < 0 ||
+ denseKey >= capacity
+ ) {
+ throw new RowHeightReplacementLifecycleError(
+ "failed",
+ `Dense key ${denseKey} for row ${identity} is outside the declared ` +
+ `denseCapacity ${capacity}.`,
+ );
+ }
+ if (testDenseBit(this.#visibleSlots!, denseKey)) {
+ throw new Error(
+ `Duplicate dense row-height slot ${denseKey}: ${identity}`,
+ );
+ }
+ setDenseBit(this.#visibleSlots!, denseKey);
+ return denseKey;
+ }
+
/**
* The whole replacement in one pass, valid only when the base has no
* retained state (`hasRetainedState === false`, checked by
@@ -1984,28 +2674,38 @@ class PersistentRowHeightReplacementBuilder<
row.estimatedHeight === undefined
? base.defaultHeight
: normalizeHeight(row.estimatedHeight, "Estimated row height");
- this.#visibleKeys = hashSet(
- this.#visibleKeys,
- identity,
- true,
- this.#work,
- );
+ const denseKey = this.#ingestDenseKey(row, identity);
+ if (this.#denseCapacity === undefined) {
+ this.#visibleKeys = hashSet(
+ this.#visibleKeys,
+ identity,
+ true,
+ this.#work,
+ );
+ }
values.push({
ref: row.key,
identity,
estimatedHeight: row.estimatedHeight,
height: estimatedHeight,
measured: false,
+ denseKey,
});
this.#ingestIndex += 1;
}
this.#entryAt = null;
// Same no-op predicate as `#stepVisibleTraversal`: every prior visible
- // entry matches its candidate's identity and estimate, and the counts
- // agree. A count mismatch skips the scan entirely, so a true mount (empty
+ // entry matches its candidate's identity, estimate, AND dense stamp, and
+ // the counts and lanes agree. A no-op finishes to the BASE generation, so
+ // lane disagreement (or a reassigned slot) must rebuild even when the
+ // identities match — otherwise a stale bitset would answer for new slots.
+ // A count mismatch skips the scan entirely, so a true mount (empty
// base, populated source) pays nothing here.
- if (nodeCount(base.root) === values.length) {
+ if (
+ nodeCount(base.root) === values.length &&
+ this.#denseCapacity === base.denseCapacity
+ ) {
let equal = true;
const stack: SequenceNode[] = [];
let cursor = base.root;
@@ -2020,7 +2720,8 @@ class PersistentRowHeightReplacementBuilder<
this.#work.previousEntriesScanned += 1;
if (
candidate.identity !== node.value.identity ||
- candidate.estimatedHeight !== node.value.estimatedHeight
+ candidate.estimatedHeight !== node.value.estimatedHeight ||
+ candidate.denseKey !== node.value.denseKey
) {
equal = false;
}
@@ -2061,18 +2762,22 @@ class PersistentRowHeightReplacementBuilder<
if (measuredHeight !== undefined) {
this.#work.measurementEntriesScanned += 1;
}
- this.#visibleKeys = hashSet(
- this.#visibleKeys,
- identity,
- true,
- this.#work,
- );
+ const denseKey = this.#ingestDenseKey(row, identity);
+ if (this.#denseCapacity === undefined) {
+ this.#visibleKeys = hashSet(
+ this.#visibleKeys,
+ identity,
+ true,
+ this.#work,
+ );
+ }
values.push({
ref: row.key,
identity,
estimatedHeight: row.estimatedHeight,
height: measuredHeight ?? estimatedHeight,
measured: measuredHeight !== undefined,
+ denseKey,
});
this.#ingestIndex += 1;
return;
@@ -2127,7 +2832,8 @@ class PersistentRowHeightReplacementBuilder<
if (frame === undefined) {
if (
this.#equalVisible &&
- this.#work.previousEntriesScanned === this.#values!.length
+ this.#work.previousEntriesScanned === this.#values!.length &&
+ this.#denseCapacity === this.#base!.denseCapacity
) {
this.#noOp = true;
this.#phase = "done";
@@ -2156,7 +2862,8 @@ class PersistentRowHeightReplacementBuilder<
if (
candidate === undefined ||
candidate.identity !== value.identity ||
- candidate.estimatedHeight !== value.estimatedHeight
+ candidate.estimatedHeight !== value.estimatedHeight ||
+ candidate.denseKey !== value.denseKey
) {
this.#equalVisible = false;
}
@@ -2336,6 +3043,7 @@ class PersistentRowHeightReplacementBuilder<
this.#sequenceBuildStack = null;
this.#retentionBuildStack = null;
this.#visibleKeys = null;
+ this.#visibleSlots = null;
this.#measurements = null;
this.#tombstones = null;
this.#tombstoneOrder = null;
@@ -2365,6 +3073,8 @@ export function createRowHeightIndex(
getKey: options.getKey,
root: null,
visibleKeys: null,
+ denseCapacity: undefined,
+ visibleSlots: undefined,
measurements: null,
tombstones: null,
tombstoneOrder: null,
diff --git a/packages/layout-core/src/types.ts b/packages/layout-core/src/types.ts
index 567b2d9f9..84f4d8959 100644
--- a/packages/layout-core/src/types.ts
+++ b/packages/layout-core/src/types.ts
@@ -35,10 +35,22 @@ export interface RowMetricsIndex extends RowMetricsReader {
updateHeight(index: number, height: number): void;
}
-/** One visible row and its optional unmeasured height estimate. @internal */
+/**
+ * One visible row and its optional unmeasured height estimate.
+ *
+ * `denseKey` is the OPTIONAL dense-lane contract (Amendment I §1): when
+ * present it is the row's CURRENT row-model slot, valid only while the
+ * model binds that slot — the caller (the renderer-dom controller) owns
+ * that currency, not this package. A replacement source runs the dense
+ * lane only when EVERY entry carries a `denseKey` and the source declares
+ * `denseCapacity`; a single entry missing one falls the whole generation
+ * back to the string lane.
+ * @internal
+ */
export interface RowHeightEntry {
readonly key: TKey;
readonly estimatedHeight?: number;
+ readonly denseKey?: number;
}
/**
@@ -60,6 +72,12 @@ export interface CreateRowHeightIndexOptions {
* root. Moves and removals retain measurements by stable identity; updates
* invalidate the affected measurement so the estimate is used until the row
* is measured again.
+ *
+ * Every variant carries an OPTIONAL `denseKey` (Amendment I §1): the row's
+ * CURRENT row-model slot, valid only while the model binds that slot — the
+ * caller owns that currency. An op reaching a dense-lane index without one
+ * is a lifecycle error (the dense lane requires every op to be dense-keyed);
+ * an op reaching a string-lane index ignores it.
* @internal
*/
export type RowHeightOperation =
@@ -68,23 +86,27 @@ export type RowHeightOperation =
readonly ref: TKey;
readonly index: number;
readonly estimatedHeight?: number;
+ readonly denseKey?: number;
}
| {
readonly kind: "remove";
readonly ref: TKey;
readonly previousIndex: number;
+ readonly denseKey?: number;
}
| {
readonly kind: "move";
readonly ref: TKey;
readonly previousIndex: number;
readonly index: number;
+ readonly denseKey?: number;
}
| {
readonly kind: "update";
readonly ref: TKey;
readonly index: number;
readonly estimatedHeight?: number;
+ readonly denseKey?: number;
};
/** A captured pixel position within a stable logical row. @internal */
@@ -93,9 +115,21 @@ export interface RowHeightAnchor {
readonly offset: number;
}
-/** Indexed replacement input; rows are read lazily by cooperative builders. @internal */
+/**
+ * Indexed replacement input; rows are read lazily by cooperative builders.
+ *
+ * `denseCapacity` is the OPTIONAL dense-lane declaration (Amendment I §1):
+ * when present, the builder requires every ingested entry's `entryAt(...)`
+ * to carry a `denseKey` less than this capacity and builds the dense
+ * (slot-bitset) lane; a missing `denseKey` under a declared capacity is a
+ * caller bug (the source promised dense coverage and broke the promise) and
+ * throws rather than silently falling back. Omitting `denseCapacity` runs
+ * today's string lane unconditionally.
+ * @internal
+ */
export interface RowHeightReplacementSource {
readonly rowCount: number;
+ readonly denseCapacity?: number;
entryAt(index: number): RowHeightEntry;
}
@@ -174,8 +208,19 @@ export interface RowHeightIndex extends RowMetricsReader {
*/
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;
+ /**
+ * Retains a bounded measured height for a stable key absent from the view.
+ *
+ * `denseKey` follows the Amendment I §1 op contract: on a dense generation
+ * it is REQUIRED — the visible-row guard is answered by the slot bitset,
+ * so a call without one throws the replacement lifecycle error and the
+ * caller falls back to a full replacement. A string generation ignores it.
+ */
+ retainMeasurement(
+ ref: TKey,
+ height: number,
+ denseKey?: number,
+ ): RowHeightIndex;
apply(operations: readonly RowHeightOperation[]): RowHeightIndex;
replace(rows: readonly RowHeightEntry[]): RowHeightIndex;
/**
@@ -186,6 +231,19 @@ export interface RowHeightIndex extends RowMetricsReader {
* permutation of the current rows; callers fall back to `beginReplacement`.
*/
reorder(source: RowHeightReplacementSource): RowHeightIndex;
+ /**
+ * Rebuilds the ordered structure for a MEMBERSHIP change, synchronously:
+ * surviving keys reuse their existing entries verbatim (measurements and
+ * estimates ride; source `estimatedHeight`s for survivors are ignored, as
+ * with `reorder`), keys absent from the existing entries are ingested fresh
+ * with the estimate-or-default rule (a returning key's retained measurement
+ * is restored), and existing keys absent from the new order leave under the
+ * cooperative path's retention policy (measured leavers tombstone,
+ * unmeasured leavers vanish). Membership deltas are the purpose, not an
+ * error — throws only on structural impossibilities (duplicate keys, bad
+ * rowCount); callers fall back to `beginReplacement` on any throw.
+ */
+ refilter(source: RowHeightReplacementSource): RowHeightIndex;
beginReplacement(
source: RowHeightReplacementSource,
): RowHeightReplacementBuilder;
diff --git a/packages/react/react.api.md b/packages/react/react.api.md
index 02e57f2bf..a242282cb 100644
--- a/packages/react/react.api.md
+++ b/packages/react/react.api.md
@@ -533,7 +533,7 @@ export type PretableChangeSequence = {
} | {
readonly kind: "reset";
readonly toRevision: number;
- readonly reason: "unknown-revision" | "journal-evicted" | "bulk-replace" | "reorder";
+ readonly reason: "unknown-revision" | "journal-evicted" | "bulk-replace" | "reorder" | "refilter";
};
// @public (undocumented)
@@ -1877,6 +1877,12 @@ export interface PretableRowModelSnapshot();
+const columns = [
+ column.accessor("score", { type: "number" }),
+ column.accessor("label", { type: "text", wrap: true, widthPx: 200 }),
+] as const;
+
+const rows: readonly Row[] = Array.from({ length: 200 }, (_, index) => ({
+ id: index,
+ score: index,
+ label: `row ${index}`,
+}));
+
+type Controller = RowLayoutController;
+
+const controllers: Controller[] = [];
+
+vi.mock("@pretable-internal/renderer-dom", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ const createRowLayoutController: typeof actual.createRowLayoutController = (
+ options,
+ ) => {
+ const controller = actual.createRowLayoutController(options);
+ controllers.push(controller as unknown as Controller);
+ return controller;
+ };
+ return { ...actual, createRowLayoutController };
+});
+
+const { PretableSurface } = await import("../pretable-surface");
+
+const dataRef = (rowId: number): PretableVisibleRowRef => ({
+ kind: "data",
+ rowId,
+});
+
+/**
+ * Layout-core's dense contract, used as a lane probe: a DENSE generation
+ * refuses any operation that arrives without a `denseKey`; a string-lane
+ * generation accepts it. `apply` is persistent and throws before producing
+ * anything, so probing never perturbs the published index.
+ */
+function isDenseIndex(controller: Controller): boolean {
+ const rowHeights = controller.getState().rowHeights;
+ try {
+ rowHeights.apply([{ kind: "update", ref: dataRef(0), index: 0 }]);
+ return false;
+ } catch (error) {
+ expect(error).toBeInstanceOf(Error);
+ expect((error as Error).message).toMatch(/dense/i);
+ return true;
+ }
+}
+
+afterEach(() => {
+ cleanup();
+ controllers.length = 0;
+ vi.clearAllMocks();
+});
+
+describe("dense layout seam, end to end", () => {
+ test("filter transitions ride the dense refilter path and measured heights survive a flip-out/flip-in", async () => {
+ const model = createLocalRowModel({ rows, columns });
+ const { container } = render(
+ ,
+ );
+ await waitFor(() => {
+ expect(
+ container.querySelectorAll("[data-pretable-row]").length,
+ ).toBeGreaterThan(0);
+ });
+ expect(controllers).toHaveLength(1);
+ const controller = controllers[0]!;
+ await waitFor(() => {
+ expect(controller.getState().status.kind).toBe("ready");
+ });
+
+ // The react-mounted grid actually engaged the DENSE lane — see the module
+ // docblock for why the counters alone cannot vouch for this.
+ expect(isDenseIndex(controller)).toBe(true);
+
+ // Measured OUTSIDE the planned viewport, so jsdom's zero-height DOM
+ // measurements of the visible rows never contest it.
+ const measured = dataRef(150);
+ act(() => {
+ controller.measure(measured, 63);
+ });
+ expect(controller.getState().rowHeights.hasMeasurement(measured)).toBe(
+ true,
+ );
+
+ const base = getRowLayoutControllerDiagnosticsForTesting(controller);
+
+ const setFilter = async (
+ value: number | undefined,
+ expectedVisible: number,
+ ) => {
+ await act(async () => {
+ const transition = model.setQuery({
+ filters:
+ value === undefined
+ ? []
+ : [{ columnId: "score", operator: "gt", value }],
+ sort: [],
+ rowGroups: [],
+ });
+ await transition.finished;
+ });
+ await waitFor(() => {
+ const state = controller.getState();
+ expect(state.status.kind).toBe("ready");
+ expect(state.snapshot?.visibleRowCount).toBe(expectedVisible);
+ });
+ };
+
+ // filter-on → narrow (row 150 flips OUT) → widen (row 150 flips back
+ // IN) → filter-off. Scores are 0..199, `gt` is strict.
+ await setFilter(100, 99);
+ await setFilter(180, 19);
+ await setFilter(100, 99);
+
+ // The measurement survived the flip-out/flip-in — restored from the
+ // identity-keyed retention, never re-estimated.
+ const widened = controller.getState();
+ expect(widened.rowHeights.hasMeasurement(measured)).toBe(true);
+ const rank = widened.snapshot!.indexOf(measured);
+ expect(rank).toBeGreaterThanOrEqual(0);
+ expect(widened.rowHeights.getHeight(rank)).toBe(63);
+
+ await setFilter(undefined, 200);
+ const final = controller.getState();
+ expect(final.rowHeights.getHeight(final.snapshot!.indexOf(measured))).toBe(
+ 63,
+ );
+
+ // Every transition rode the in-place refilter path, and none fell back
+ // to a full replacement — the dense fast path ran, silently-fallback
+ // free.
+ const diagnostics = getRowLayoutControllerDiagnosticsForTesting(controller);
+ expect(diagnostics.refilterPathCount - base.refilterPathCount).toBe(4);
+ expect(diagnostics.refilterFallbackCount).toBe(0);
+ expect(diagnostics.reorderFallbackCount).toBe(0);
+
+ // And the index is STILL dense — no transition quietly dropped the
+ // generation to the string lane.
+ expect(isDenseIndex(controller)).toBe(true);
+
+ model.dispose();
+ }, 30_000);
+});
diff --git a/packages/react/src/__tests__/filter-menu-row-model-boundary.test.ts b/packages/react/src/__tests__/filter-menu-row-model-boundary.test.ts
index 1e2eb95f7..17feaea97 100644
--- a/packages/react/src/__tests__/filter-menu-row-model-boundary.test.ts
+++ b/packages/react/src/__tests__/filter-menu-row-model-boundary.test.ts
@@ -18,6 +18,7 @@ import { describe, expect, it } from "vitest";
import {
compileQuery,
createColumnHelper,
+ filterVerdict,
type PretableQueryFor,
} from "@pretable-internal/row-model";
import type { ColumnType, FilterOperator } from "@pretable/core";
@@ -314,18 +315,20 @@ describe("filter menu -> row-model boundary", () => {
});
expect(
- plan.evaluate({
+ filterVerdict(plan, {
rowId: 1,
row: { id: 1, value: matchValue },
sourceOrder: 0,
- }).filterPasses,
+ slot: 0,
+ }),
).toBe(true);
expect(
- plan.evaluate({
+ filterVerdict(plan, {
rowId: 2,
row: { id: 2, value: nonMatchValue },
sourceOrder: 0,
- }).filterPasses,
+ slot: 1,
+ }),
).toBe(false);
},
);
diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts
index 24823895c..a834d2033 100644
--- a/packages/react/vitest.config.ts
+++ b/packages/react/vitest.config.ts
@@ -24,6 +24,16 @@ export default defineConfig({
__dirname,
"../layout-core/src/index.ts",
),
+ // Longer key FIRST: alias entries match in order, and the barrel entry
+ // below would otherwise swallow this subpath. The diagnostics seam is a
+ // direct-module export deliberately kept off renderer-dom's barrel; the
+ // dense-layout-seam end-to-end pin reads it through this alias (typecheck
+ // resolves it through tsconfig.typecheck.json's
+ // `@pretable-internal/renderer-dom/*` mapping).
+ "@pretable-internal/renderer-dom/row-layout-controller": resolve(
+ __dirname,
+ "../renderer-dom/src/row-layout-controller.ts",
+ ),
"@pretable-internal/renderer-dom": resolve(
__dirname,
"../renderer-dom/src/index.ts",
diff --git a/packages/renderer-dom/src/__tests__/indexed-renderer.test.ts b/packages/renderer-dom/src/__tests__/indexed-renderer.test.ts
index b9d60e72f..548504506 100644
--- a/packages/renderer-dom/src/__tests__/indexed-renderer.test.ts
+++ b/packages/renderer-dom/src/__tests__/indexed-renderer.test.ts
@@ -1225,10 +1225,20 @@ describe("indexed DOM row layout controller", () => {
expect(estimate.mock.calls.length).toBeLessThan(12);
expect(estimate.mock.calls.length).toBeGreaterThan(0);
expect(state.window.length).toBeLessThan(12);
- expect(rangeCalls.length).toBeLessThanOrEqual(2);
+ // The replacement SOURCE materializes the visible set in BOUNDED chunked
+ // `range` walks (the dense seam's bulk read — it replaced 1,000 per-row
+ // `rowAt` rank descents WITHOUT ever reading the whole dataset in one
+ // call); every OTHER range read stays window-sized, which is the
+ // projection claim under test.
+ const buildWalks = rangeCalls.filter(([start, end]) => end - start > 12);
+ expect(buildWalks.length).toBeGreaterThan(0);
expect(
- Math.max(...rangeCalls.map(([start, end]) => end - start)),
- ).toBeLessThan(12);
+ Math.max(...buildWalks.map(([start, end]) => end - start)),
+ ).toBeLessThanOrEqual(256);
+ expect(buildWalks[0]).toEqual([0, 256]);
+ expect(buildWalks[buildWalks.length - 1]).toEqual([768, 1_000]);
+ const windowReads = rangeCalls.filter(([start, end]) => end - start <= 12);
+ expect(windowReads.length).toBeLessThanOrEqual(2);
rangeCalls.length = 0;
const render = createDomRenderSnapshot({
@@ -2124,7 +2134,13 @@ describe("indexed DOM row layout controller", () => {
expect(empty.range).toEqual({ start: 0, end: 0 });
});
- test("defers viewport publication during a reset until matching geometry is ready", () => {
+ // This test previously asserted that a mid-reset viewport request published
+ // NOTHING until the replacement finished — which is exactly the defect the
+ // stale republish fixes: the last-published window was planned for the OLD
+ // scroll position, so a scroll outside it left the grid blank for the whole
+ // replacement. The deferral semantics it pinned (anchor dropped, the finish
+ // honors the requested global scrollTop) are unchanged and still asserted.
+ test("repaints a stale window at a mid-reset scroll position instead of going blank", () => {
const model = createModel(
Array.from({ length: 20 }, (_, index) => ({
id: index,
@@ -2135,7 +2151,7 @@ describe("indexed DOM row layout controller", () => {
);
const { controller, scheduler } = createReadyController(model);
// Retained state keeps the reset cooperative, so there is a rebuilding
- // interval for the viewport request to be deferred into.
+ // interval for the viewport request to land inside.
controller.measure(data(0), 45);
const notifications = vi.fn();
controller.subscribe(notifications);
@@ -2150,8 +2166,18 @@ describe("indexed DOM row layout controller", () => {
const rebuilding = controller.getState();
notifications.mockClear();
controller.setViewport({ scrollTop: 440, viewportHeight: 88, overscan: 1 });
- expect(controller.getState()).toBe(rebuilding);
- expect(notifications).not.toHaveBeenCalled();
+ // Stale-but-visible: the OLD snapshot re-projected at the NEW scrollTop,
+ // published immediately, with the in-flight rebuild still reported.
+ const repainted = controller.getState();
+ expect(repainted).not.toBe(rebuilding);
+ expect(notifications).toHaveBeenCalled();
+ expect(repainted.snapshot).toBe(rebuilding.snapshot);
+ expect(repainted.observedRevision).toBe(rebuilding.observedRevision);
+ expect(repainted.scrollTop).toBe(440);
+ expect(repainted.viewport.scrollTop).toBe(440);
+ expect(repainted.window.length).toBeGreaterThan(0);
+ expect(repainted.window.map((entry) => entry.ref)).toContainEqual(data(10));
+ expect(repainted.status).toMatchObject({ kind: "rebuilding" });
model.setRows(
Array.from({ length: 10_001 }, (_, index) => ({
id: index,
@@ -2160,8 +2186,7 @@ describe("indexed DOM row layout controller", () => {
label: `superseding ${index}`,
})),
);
- expect(controller.getState().viewport.scrollTop).toBe(0);
- expect(controller.getState().window).toBe(rebuilding.window);
+ expect(controller.getState().viewport.scrollTop).toBe(440);
scheduler.flushAll();
expect(controller.getState()).toMatchObject({
scrollTop: 440,
@@ -2171,6 +2196,113 @@ describe("indexed DOM row layout controller", () => {
expect(controller.getState().range.start).toBeGreaterThan(0);
});
+ test("scrolling during an unabsorbable filter reset keeps a stale window visible", () => {
+ const model = createModel(
+ Array.from({ length: 40 }, (_, index) => ({
+ id: index,
+ team: index < 20 ? "A" : "B",
+ score: index,
+ label: `row ${index}`,
+ })),
+ );
+ const { controller, scheduler } = createReadyController(model);
+ // Measured base: retained state keeps the bulk-replace reset cooperative,
+ // reproducing the shipped blank-viewport shape (the filter commit lands
+ // its barrier synchronously, so the controller is mid-replacement BEFORE
+ // any same-commit reveal scroll can land).
+ controller.measure(data(0), 44);
+ // The live filter fast path now publishes a "refilter" reset the
+ // controller absorbs synchronously — no replacement interval exists to
+ // scroll into (the refilter-path suite pins that). This test keeps the
+ // deferred-viewport republish covered for the resets that still replace:
+ // degrade the reason so the same commit is unabsorbable, the shape any
+ // fallback or unaware-consumer path produces.
+ const realChangesSince = model.changesSince.bind(model);
+ vi.spyOn(model, "changesSince").mockImplementation((revision) => {
+ const sequence = realChangesSince(revision);
+ return sequence.kind === "reset"
+ ? { ...sequence, reason: "bulk-replace" as const }
+ : sequence;
+ });
+ const before = controller.getState();
+ model.setQuery({
+ filters: [{ columnId: "team", operator: "equals", value: "B" }],
+ sort: [{ columnId: "score", direction: "asc" }],
+ rowGroups: [],
+ });
+ expect(controller.getState().status.kind).toBe("rebuilding");
+ // The reveal scroll, outside the published window (rows 0-3 at scrollTop
+ // 0). Nothing has driven the scheduler: pre-fix the grid stayed blank
+ // here until the replacement flushed.
+ controller.setViewport({ scrollTop: 440, viewportHeight: 88, overscan: 1 });
+ const stale = controller.getState();
+ expect(stale.observedRevision).toBe(before.observedRevision);
+ expect(stale.snapshot).toBe(before.snapshot);
+ expect(stale.scrollTop).toBe(440);
+ expect(stale.window.length).toBeGreaterThan(0);
+ // Row 10 is team A — filtered OUT of the new set — so only the stale
+ // (pre-filter) snapshot can put it on screen.
+ expect(stale.window.map((entry) => entry.ref)).toContainEqual(data(10));
+ expect(stale.status.kind).toBe("rebuilding");
+ scheduler.flushAll();
+ const final = controller.getState();
+ expect(final.status.kind).toBe("ready");
+ expect(final.observedRevision).toBe(model.getState().snapshot.revision);
+ // Anchor-less global-scroll semantics: the deferred flag mandates the
+ // requested scrollTop verbatim, not an anchor restoration to the old
+ // position.
+ expect(final.scrollTop).toBe(440);
+ expect(final.viewport.scrollTop).toBe(440);
+ // And the final window comes from the NEW (filtered) snapshot: ids 20..39
+ // sorted ascending, index 10 at 44px rows -> id 30.
+ expect(final.window.length).toBeGreaterThan(0);
+ expect(final.window.map((entry) => entry.ref)).toContainEqual(data(30));
+ for (const entry of final.window) {
+ expect(entry.ref.kind).toBe("data");
+ expect(
+ entry.ref.kind === "data" && (entry.ref.rowId as number) >= 20,
+ ).toBe(true);
+ }
+ });
+
+ test("scrolling during a combined sort+filter replacement keeps a stale window visible", async () => {
+ // The latent pre-fast-path shape: a combined sort+filter change takes the
+ // model's cooperative transition, whose commit still lands a bulk-replace
+ // barrier while a same-frame scroll can arrive mid-replacement.
+ const model = createModel(
+ Array.from({ length: 40 }, (_, index) => ({
+ id: index,
+ team: index < 20 ? "A" : "B",
+ score: index,
+ label: `row ${index}`,
+ })),
+ );
+ const { controller, scheduler } = createReadyController(model);
+ controller.measure(data(0), 44);
+ const before = controller.getState();
+ const transition = model.setQuery({
+ filters: [{ columnId: "team", operator: "equals", value: "B" }],
+ sort: [{ columnId: "score", direction: "desc" }],
+ rowGroups: [],
+ });
+ await transition.finished;
+ expect(controller.getState().status.kind).toBe("rebuilding");
+ controller.setViewport({ scrollTop: 440, viewportHeight: 88, overscan: 1 });
+ const stale = controller.getState();
+ expect(stale.observedRevision).toBe(before.observedRevision);
+ expect(stale.snapshot).toBe(before.snapshot);
+ expect(stale.scrollTop).toBe(440);
+ expect(stale.window.map((entry) => entry.ref)).toContainEqual(data(10));
+ expect(stale.status.kind).toBe("rebuilding");
+ scheduler.flushAll();
+ const final = controller.getState();
+ expect(final.status.kind).toBe("ready");
+ expect(final.observedRevision).toBe(model.getState().snapshot.revision);
+ expect(final.scrollTop).toBe(440);
+ // Descending over the filtered ids 20..39: index 10 -> id 29.
+ expect(final.window.map((entry) => entry.ref)).toContainEqual(data(29));
+ });
+
test("rolls a deferred viewport back after reset failure so the same request can retry", () => {
const model = createModel(
Array.from({ length: 40 }, (_, index) => ({
@@ -2180,7 +2312,7 @@ describe("indexed DOM row layout controller", () => {
label: `old ${index}`,
})),
);
- let failNextEstimate = false;
+ let failingEstimates = 0;
const scheduler = new ManualScheduler();
const controller = createRowLayoutController({
model,
@@ -2188,8 +2320,8 @@ describe("indexed DOM row layout controller", () => {
viewport: { scrollTop: 0, viewportHeight: 88, overscan: 0 },
scheduler,
estimateRowHeight(row) {
- if (failNextEstimate && row.id === 10) {
- failNextEstimate = false;
+ if (failingEstimates > 0 && row.id === 10) {
+ failingEstimates -= 1;
throw new Error("estimate exploded");
}
return 44;
@@ -2198,9 +2330,13 @@ describe("indexed DOM row layout controller", () => {
});
scheduler.flushAll();
// Retained state keeps the reset cooperative, so the viewport request
- // below is deferred into an ACTIVE replacement — the rollback under test.
+ // below lands inside an ACTIVE replacement — the rollback under test.
controller.measure(data(1), 44);
- failNextEstimate = true;
+ // Two failures: the first is consumed by the mid-replacement stale
+ // republish (row 10 sits in the requested window), the second by the
+ // replacement's own builder — only a replacement that FAILS exercises the
+ // rollback, since a successful one publishes the deferred scrollTop.
+ failingEstimates = 2;
model.setRows(
Array.from({ length: 40 }, (_, index) => ({
id: index,
@@ -2211,6 +2347,12 @@ describe("indexed DOM row layout controller", () => {
);
controller.measure(data(0), 123);
controller.setViewport({ scrollTop: 440, viewportHeight: 88, overscan: 0 });
+ // The stale republish failed, so the viewport was never committed: the
+ // published state still shows the old position, with an error status.
+ expect(controller.getState()).toMatchObject({
+ viewport: { scrollTop: 0 },
+ status: { kind: "error" },
+ });
scheduler.flushAll();
expect(controller.getState()).toMatchObject({
viewport: { scrollTop: 0 },
@@ -2251,20 +2393,34 @@ describe("indexed DOM row layout controller", () => {
...modelState,
snapshot: Object.freeze({
...snapshot,
- rowAt(index: number) {
- if (!superseded) {
- superseded = true;
- source.setRows(
- Array.from({ length: 301 }, (_, rowIndex) => ({
- id: rowIndex,
- team: "C",
- score: rowIndex,
- label: `latest ${rowIndex}`,
- })),
- );
- throw new Error("stale source exploded");
+ // The replacement source reads the model in chunked bulk
+ // `range` walks and its scheduled slices only index the
+ // results, so the hostile mid-slice access lives on the FIRST
+ // chunk's returned ARRAY: the first element read from a slice
+ // supersedes the build and explodes. (It used to live on
+ // `rowAt`, which the build no longer calls per row.)
+ range(start: number, end: number) {
+ const result = snapshot.range(start, end);
+ if (start !== 0) {
+ return result;
}
- return snapshot.rowAt(index);
+ return new Proxy(result, {
+ get(target, property, receiver) {
+ if (property === "0" && !superseded) {
+ superseded = true;
+ source.setRows(
+ Array.from({ length: 301 }, (_, rowIndex) => ({
+ id: rowIndex,
+ team: "C",
+ score: rowIndex,
+ label: `latest ${rowIndex}`,
+ })),
+ );
+ throw new Error("stale source exploded");
+ }
+ return Reflect.get(target, property, receiver);
+ },
+ });
},
}),
};
@@ -3330,7 +3486,7 @@ describe("indexed DOM row layout controller", () => {
expect(controller.getState()).toBe(before);
});
- describe("sort-only reorder permutation path", () => {
+ describe("synchronous reset fast paths (reorder/refilter)", () => {
const tenRows = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
team: "A",
@@ -3361,12 +3517,21 @@ describe("indexed DOM row layout controller", () => {
// structural cast reads the same frozen object the seam returns.
const heightIndexDiagnostics = (
index: unknown,
- ): { reorderEntriesReused: number; reorderEntriesRemeasured: number } =>
+ ): {
+ reorderEntriesReused: number;
+ reorderEntriesRemeasured: number;
+ refilterEntriesReused: number;
+ refilterEntriesInserted: number;
+ refilterEntriesRetired: number;
+ } =>
(
index as {
diagnostics: {
reorderEntriesReused: number;
reorderEntriesRemeasured: number;
+ refilterEntriesReused: number;
+ refilterEntriesInserted: number;
+ refilterEntriesRetired: number;
};
}
).diagnostics;
@@ -3891,6 +4056,349 @@ describe("indexed DOM row layout controller", () => {
expect(oracle.controller.getState().scrollTop).toBe(after.scrollTop);
});
});
+
+ describe("filter-only refilter path", () => {
+ // score <= 5 keeps rows 1..5 in their relative order (a narrowing);
+ // clearing the filters afterwards is the widening twin.
+ const narrowQuery = {
+ filters: [
+ { columnId: "score" as const, operator: "lte" as const, value: 5 },
+ ],
+ sort: [{ columnId: "score" as const, direction: "asc" as const }],
+ rowGroups: [],
+ };
+ const wideQuery = {
+ filters: [],
+ sort: [{ columnId: "score" as const, direction: "asc" as const }],
+ rowGroups: [],
+ };
+ // score > 4 removes rows 1..4 — including row 4, the anchor row of the
+ // shared 130px-viewport geometry — while rows 5..10 survive.
+ const dropAnchorQuery = {
+ filters: [
+ { columnId: "score" as const, operator: "gt" as const, value: 4 },
+ ],
+ sort: [{ columnId: "score" as const, direction: "asc" as const }],
+ rowGroups: [],
+ };
+ const survivorMeasurements = allMeasurements.slice(0, 5);
+
+ test("a filter-only commit refilters existing heights without a replacement", () => {
+ const model = createModel(tenRows);
+ const { controller } = createReadyController(model);
+ for (const [rowId, height] of allMeasurements) {
+ controller.measure(data(rowId), height);
+ }
+ const before = getRowLayoutControllerDiagnosticsForTesting(controller);
+ const refilterSpy = vi.spyOn(
+ controller.getState().rowHeights,
+ "refilter",
+ );
+
+ model.setQuery(narrowQuery);
+
+ // Synchronous: ready again with no scheduler flush and no replacement.
+ const after = controller.getState();
+ expect(after.status.kind).toBe("ready");
+ expect(after.snapshot?.visibleRowCount).toBe(5);
+ expect(after.observedRevision).toBe(model.getState().snapshot.revision);
+ const diagnostics =
+ getRowLayoutControllerDiagnosticsForTesting(controller);
+ expect(diagnostics.replacementStartCount).toBe(
+ before.replacementStartCount,
+ );
+ expect(diagnostics.refilterPathCount).toBe(
+ before.refilterPathCount + 1,
+ );
+ expect(diagnostics.refilterFallbackCount).toBe(
+ before.refilterFallbackCount,
+ );
+ expect(diagnostics.reorderPathCount).toBe(before.reorderPathCount);
+ expect(refilterSpy).toHaveBeenCalledTimes(1);
+ expect(after.rowHeights).toBe(refilterSpy.mock.results[0]!.value);
+
+ // Survivor measurements ride; the table equals a full replacement.
+ for (const [rowId, height] of survivorMeasurements) {
+ expect(after.rowHeights.hasMeasurement(data(rowId))).toBe(true);
+ expect(
+ after.rowHeights.getHeight(
+ model.getState().snapshot.indexOf(data(rowId)),
+ ),
+ ).toBe(height);
+ }
+ const oracle = createReplacementOracle(tenRows, allMeasurements);
+ oracle.model.setQuery(narrowQuery);
+ oracle.scheduler.flushAll();
+ const reference = oracle.controller.getState();
+ expect(reference.status.kind).toBe("ready");
+ expect(after.rowHeights.rowCount).toBe(reference.rowHeights.rowCount);
+ const rankOffsets = (
+ heights: (typeof after)["rowHeights"],
+ ): readonly number[] =>
+ Array.from({ length: 5 }, (_, rank) =>
+ heights.getOffsetForIndex(rank),
+ );
+ expect(rankOffsets(after.rowHeights)).toEqual(
+ rankOffsets(reference.rowHeights),
+ );
+ expect(after.totalHeight).toBe(reference.totalHeight);
+ });
+
+ test("a widening refilter ingests entrants under the estimate rule", () => {
+ const model = createModel(tenRows);
+ const { controller } = createReadyController(model);
+ // Only the survivors are measured, so the widening's entrants (rows
+ // 6..10) have NO retained measurement to restore and must take the
+ // estimate-or-default height.
+ for (const [rowId, height] of survivorMeasurements) {
+ controller.measure(data(rowId), height);
+ }
+ model.setQuery(narrowQuery);
+ expect(controller.getState().snapshot?.visibleRowCount).toBe(5);
+ const refilterSpy = vi.spyOn(
+ controller.getState().rowHeights,
+ "refilter",
+ );
+ const before = getRowLayoutControllerDiagnosticsForTesting(controller);
+
+ model.setQuery(wideQuery);
+
+ const after = controller.getState();
+ expect(after.status.kind).toBe("ready");
+ expect(after.snapshot?.visibleRowCount).toBe(10);
+ const diagnostics =
+ getRowLayoutControllerDiagnosticsForTesting(controller);
+ expect(diagnostics.replacementStartCount).toBe(
+ before.replacementStartCount,
+ );
+ expect(diagnostics.refilterPathCount).toBe(
+ before.refilterPathCount + 1,
+ );
+ expect(refilterSpy).toHaveBeenCalledTimes(1);
+ const layout = heightIndexDiagnostics(
+ refilterSpy.mock.results[0]!.value,
+ );
+ expect(layout.refilterEntriesReused).toBe(5);
+ expect(layout.refilterEntriesInserted).toBe(5);
+
+ // Entrants estimate at the 44px default (short non-wrapping labels);
+ // survivors keep their measurements.
+ for (const row of tenRows) {
+ const rank = model.getState().snapshot.indexOf(data(row.id));
+ expect(after.rowHeights.getHeight(rank)).toBe(
+ row.id <= 5 ? measuredHeightOf(row.id) : 44,
+ );
+ }
+ const oracle = createReplacementOracle(tenRows, survivorMeasurements);
+ oracle.model.setQuery(narrowQuery);
+ oracle.scheduler.flushAll();
+ oracle.model.setQuery(wideQuery);
+ oracle.scheduler.flushAll();
+ const reference = oracle.controller.getState();
+ expect(reference.status.kind).toBe("ready");
+ const rankOffsets = (
+ heights: (typeof after)["rowHeights"],
+ ): readonly number[] =>
+ Array.from({ length: 10 }, (_, rank) =>
+ heights.getOffsetForIndex(rank),
+ );
+ expect(rankOffsets(after.rowHeights)).toEqual(
+ rankOffsets(reference.rowHeights),
+ );
+ expect(after.totalHeight).toBe(reference.totalHeight);
+ });
+
+ test("an anchor row filtered out degrades like a full replacement", () => {
+ const model = createModel(tenRows);
+ const { controller } = createReadyController(model);
+ for (const [rowId, height] of allMeasurements) {
+ controller.measure(data(rowId), height);
+ }
+ // Anchor inside row 4 (rank 3, offsets 126..170 under the 41..50
+ // measured heights), 4px below its top. `dropAnchorQuery` removes
+ // rows 1..4: the exact ref is GONE, and the replacement path's
+ // old-order neighbor search lands on row 5 — rank 0 in the filtered
+ // set — so the anchored viewport follows it to 0 + 4 = 4px. A path
+ // that skipped anchor restoration entirely would stay at the global
+ // 130.
+ controller.setViewport({
+ scrollTop: 130,
+ viewportHeight: 88,
+ overscan: 0,
+ });
+ expect(controller.getState().scrollTop).toBe(130);
+
+ model.setQuery(dropAnchorQuery);
+
+ const after = controller.getState();
+ expect(after.status.kind).toBe("ready");
+ expect(after.snapshot?.visibleRowCount).toBe(6);
+ expect(after.scrollTop).toBe(4);
+ expect(
+ getRowLayoutControllerDiagnosticsForTesting(controller)
+ .refilterPathCount,
+ ).toBe(1);
+
+ const oracle = createReplacementOracle(tenRows, allMeasurements, {
+ scrollTop: 130,
+ viewportHeight: 88,
+ overscan: 0,
+ });
+ oracle.model.setQuery(dropAnchorQuery);
+ oracle.scheduler.flushAll();
+ expect(oracle.controller.getState().scrollTop).toBe(after.scrollTop);
+ });
+
+ test("a refilter reset with a misaligned revision falls back to replacement", () => {
+ const model = createModel(tenRows);
+ const { controller, scheduler } = createReadyController(model);
+ controller.measure(data(2), 77);
+ const before = getRowLayoutControllerDiagnosticsForTesting(controller);
+ vi.spyOn(model, "changesSince").mockImplementation((revision) => ({
+ kind: "reset" as const,
+ // One short of the committed revision: the range this reset claims
+ // to cover does not reach the snapshot the controller sees.
+ toRevision: revision,
+ reason: "refilter" as const,
+ }));
+
+ model.setQuery(narrowQuery);
+ scheduler.flushAll();
+
+ const state = controller.getState();
+ expect(state.status.kind).toBe("ready");
+ expect(state.snapshot?.visibleRowCount).toBe(5);
+ expect(state.observedRevision).toBe(model.getState().snapshot.revision);
+ const diagnostics =
+ getRowLayoutControllerDiagnosticsForTesting(controller);
+ expect(diagnostics.replacementStartCount).toBe(
+ before.replacementStartCount + 1,
+ );
+ expect(diagnostics.refilterPathCount).toBe(before.refilterPathCount);
+ expect(diagnostics.refilterFallbackCount).toBe(
+ before.refilterFallbackCount + 1,
+ );
+ });
+
+ test("a refilter() throw falls back to replacement without an error publish", () => {
+ const model = createModel(tenRows);
+ const { controller, scheduler } = createReadyController(model);
+ controller.measure(data(2), 77);
+ const before = getRowLayoutControllerDiagnosticsForTesting(controller);
+ const statuses: string[] = [];
+ controller.subscribe(() => {
+ statuses.push(controller.getState().status.kind);
+ });
+ vi.spyOn(
+ controller.getState().rowHeights,
+ "refilter",
+ ).mockImplementation(() => {
+ throw new Error("injected refilter contract violation");
+ });
+
+ model.setQuery(narrowQuery);
+ scheduler.flushAll();
+
+ const state = controller.getState();
+ expect(state.status.kind).toBe("ready");
+ expect(statuses).not.toContain("error");
+ expect(state.snapshot?.visibleRowCount).toBe(5);
+ expect(state.rowHeights.hasMeasurement(data(2))).toBe(true);
+ const diagnostics =
+ getRowLayoutControllerDiagnosticsForTesting(controller);
+ expect(diagnostics.replacementStartCount).toBe(
+ before.replacementStartCount + 1,
+ );
+ expect(diagnostics.refilterPathCount).toBe(before.refilterPathCount);
+ expect(diagnostics.refilterFallbackCount).toBe(
+ before.refilterFallbackCount + 1,
+ );
+ });
+
+ test('a "bulk-replace" reset still takes the replacement path', () => {
+ const oracle = createReplacementOracle(tenRows, [[2, 77]]);
+ const before = getRowLayoutControllerDiagnosticsForTesting(
+ oracle.controller,
+ );
+
+ oracle.model.setQuery(narrowQuery);
+ oracle.scheduler.flushAll();
+
+ const state = oracle.controller.getState();
+ expect(state.status.kind).toBe("ready");
+ expect(state.snapshot?.visibleRowCount).toBe(5);
+ const diagnostics = getRowLayoutControllerDiagnosticsForTesting(
+ oracle.controller,
+ );
+ expect(diagnostics.replacementStartCount).toBe(
+ before.replacementStartCount + 1,
+ );
+ expect(diagnostics.refilterPathCount).toBe(before.refilterPathCount);
+ expect(diagnostics.refilterFallbackCount).toBe(
+ before.refilterFallbackCount,
+ );
+ });
+
+ test("a refilter arriving mid-replacement restarts fail-closed", () => {
+ const model = createModel(tenRows);
+ const { controller, scheduler } = createReadyController(model);
+ for (const [rowId, height] of allMeasurements) {
+ controller.measure(data(rowId), height);
+ }
+ // Retained state keeps the reset cooperative, so the refilter below
+ // really does arrive MID-replacement.
+ model.setRows(tenRows.map((row) => ({ ...row, label: "x" })));
+ expect(controller.getState().status.kind).toBe("rebuilding");
+ const before = getRowLayoutControllerDiagnosticsForTesting(controller);
+
+ // This cycle's explicit scope decision: membership change + pending
+ // catch-up is exactly the complexity the reorder compose rule
+ // excluded, so a mid-replacement refilter restarts instead of
+ // composing.
+ model.setQuery(narrowQuery);
+ scheduler.flushAll();
+
+ const state = controller.getState();
+ expect(state.status.kind).toBe("ready");
+ expect(state.snapshot?.visibleRowCount).toBe(5);
+ expect(state.observedRevision).toBe(model.getState().snapshot.revision);
+ const diagnostics =
+ getRowLayoutControllerDiagnosticsForTesting(controller);
+ expect(diagnostics.refilterPathCount).toBe(before.refilterPathCount);
+ expect(diagnostics.reorderComposeCount).toBe(
+ before.reorderComposeCount,
+ );
+ // The restart IS the observable: the interrupted replacement is
+ // abandoned and a fresh one runs against the filtered target.
+ expect(diagnostics.replacementStartCount).toBe(
+ before.replacementStartCount + 1,
+ );
+ });
+
+ test("a narrowing reuses survivors, retires leavers, re-measures none", () => {
+ const model = createModel(tenRows);
+ const { controller } = createReadyController(model);
+ for (const [rowId, height] of allMeasurements) {
+ controller.measure(data(rowId), height);
+ }
+ const refilterSpy = vi.spyOn(
+ controller.getState().rowHeights,
+ "refilter",
+ );
+
+ model.setQuery(narrowQuery);
+
+ expect(refilterSpy).toHaveBeenCalledTimes(1);
+ const result = refilterSpy.mock.results[0]!.value as unknown;
+ const layout = heightIndexDiagnostics(result);
+ expect(layout.refilterEntriesReused).toBe(5);
+ expect(layout.refilterEntriesInserted).toBe(0);
+ expect(layout.refilterEntriesRetired).toBe(5);
+ // The published root IS the refilter result: nothing measured or
+ // re-ingested anything after the synchronous pass.
+ expect(controller.getState().rowHeights).toBe(result);
+ });
+ });
});
});
diff --git a/packages/renderer-dom/src/__tests__/row-layout-dense.test.ts b/packages/renderer-dom/src/__tests__/row-layout-dense.test.ts
new file mode 100644
index 000000000..d213fd6b9
--- /dev/null
+++ b/packages/renderer-dom/src/__tests__/row-layout-dense.test.ts
@@ -0,0 +1,553 @@
+import { describe, expect, test } from "vitest";
+
+import { createColumnHelper, type PretableVisibleRowRef } from "@pretable/core";
+import { createLocalRowModel } from "@pretable-internal/row-model";
+
+import {
+ createRowLayoutController,
+ getRowLayoutControllerDiagnosticsForTesting,
+ type RowLayoutScheduler,
+} from "../row-layout-controller";
+
+/**
+ * The dense-identity layout seam (Amendment I): a FLAT row-model snapshot
+ * supplies model slots (`ɵvisibleSlotRange` / `ɵslotCapacity` /
+ * `ɵslotOfRowId`), so the controller builds the height index in the DENSE
+ * lane — slot-keyed sources, slot-stamped operations, and slot-pooled frozen
+ * row refs — while a grouped snapshot falls back to today's string-identity
+ * shape wholesale.
+ */
+
+type Row = {
+ id: number | string;
+ team: string;
+ score: number;
+ label: string;
+};
+
+const helper = createColumnHelper();
+const modelColumns = [
+ helper.accessor("team", { type: "text" }),
+ helper.accessor("score", { type: "number", aggregate: "sum" }),
+ helper.accessor("label", { type: "text" }),
+] as const;
+const renderColumns = [
+ { id: "label", header: "Label", wrap: true, widthPx: 90 },
+ { id: "score", header: "Score", widthPx: 80 },
+] as const;
+
+const data = (rowId: Row["id"]): PretableVisibleRowRef => ({
+ kind: "data",
+ rowId,
+});
+
+class ManualScheduler 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;
+ };
+ }
+
+ flushOne(): boolean {
+ const entry = this.tasks.shift();
+ if (!entry) return false;
+ if (!entry.cancelled) entry.task();
+ return true;
+ }
+
+ flushAll(limit = 10_000): void {
+ let count = 0;
+ while (this.flushOne()) {
+ count += 1;
+ if (count > limit) throw new Error("Manual scheduler did not settle.");
+ }
+ }
+}
+
+const tenRows: readonly Row[] = Array.from({ length: 10 }, (_, index) => ({
+ id: `r${index}`,
+ team: index % 2 === 0 ? "A" : "B",
+ score: index,
+ label: `row ${index}`,
+}));
+
+function createModel(
+ rows: readonly Row[],
+ options: { readonly grouped?: boolean } = {},
+) {
+ return createLocalRowModel({
+ rows,
+ columns: modelColumns,
+ initialExpansion: { kind: "expanded" },
+ query: {
+ filters: [],
+ sort: [{ columnId: "score", direction: "asc" }],
+ rowGroups: options.grouped ? [{ columnId: "team" }] : [],
+ },
+ });
+}
+
+function createReadyController(
+ model: ReturnType,
+ scheduler = new ManualScheduler(),
+) {
+ const controller = createRowLayoutController({
+ model,
+ columns: renderColumns,
+ viewport: { scrollTop: 0, viewportHeight: 88, overscan: 1 },
+ scheduler,
+ now: () => 0,
+ budgetMs: 5,
+ maxUnitsPerSlice: 256,
+ });
+ scheduler.flushAll();
+ expect(controller.getState().status.kind).toBe("ready");
+ return { controller, scheduler };
+}
+
+/**
+ * The lane probe: on a DENSE generation, layout-core refuses any operation
+ * that arrives without a `denseKey` (the fallback contract). A string-lane
+ * generation accepts the same operation. `apply` is persistent and throws
+ * before producing anything, so probing never perturbs the published index.
+ */
+function isDenseIndex(
+ controller: ReturnType["controller"],
+ ref: PretableVisibleRowRef,
+): boolean {
+ const rowHeights = controller.getState().rowHeights;
+ try {
+ rowHeights.apply([{ kind: "update", ref, index: 0 }]);
+ return false;
+ } catch (error) {
+ expect(error).toBeInstanceOf(Error);
+ expect((error as Error).message).toMatch(/dense/i);
+ return true;
+ }
+}
+
+/**
+ * Wraps a model so every published snapshot counts its per-row and bulk
+ * reads. Wrapper identity is memoized per underlying snapshot: the
+ * controller compares snapshots by reference across wakes.
+ */
+function instrumentModel(model: ReturnType) {
+ type ModelSnapshot = ReturnType<
+ ReturnType["getState"]
+ >["snapshot"];
+ const counters = {
+ rowAt: 0,
+ rangeCalls: [] as Array,
+ slotRangeCalls: [] as Array,
+ };
+ const wrapped = new WeakMap