Skip to content

feat: filters become an AND/OR tree (tool panel SP2a) - #493

Merged
blove merged 15 commits into
mainfrom
blove/filter-tree-sp2a
Aug 26, 2026
Merged

feat: filters become an AND/OR tree (tool panel SP2a)#493
blove merged 15 commits into
mainfrom
blove/filter-tree-sp2a

Conversation

@blove

@blove blove commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

What this is

Filters become an arbitrary-depth AND/OR tree, so a query can express price > 10 AND (status isAnyOf [a,b] OR owner contains "x"). This is SP2a — the engine prerequisite for the tool panel's filter-builder section (SP2b), split engine-first for the same reason SP1 split out column visibility: the UI must compose against a model that exists.

Spec: docs/superpowers/specs/2026-08-25-filter-tree-design.md. Plan: docs/superpowers/plans/2026-08-25-filter-tree-sp2a.md.

No UI builds groups yet. The per-column funnel still writes top-level leaves; groups reach the engine only from a controlled query prop, a URL, or a saved view. The builder is SP2b.

The shape

filters: readonly PretableFilterNodeFor<TColumns>[]   // leaf | group, nestable
// group: { op: "and" | "or", children: readonly PretableFilterNodeFor[] }

The top level stays an implicit AND array, so every existing call site kept compiling and simple cases stay one-liners. Rejected alternatives: a single root group (ceremony on every simple consumer forever) and a parallel filterTree field (two sources of truth — the declared-but-read-by-nothing failure this repo keeps paying for). isPretableFilterGroup ships so consumers never hand-roll the discrimination.

Two semantics worth knowing

An empty group evaluates TRUE, under both operators. The naive algebra says empty-OR is false — which would let a half-built group in SP2b's builder blank the entire grid mid-edit. Identity-true is the product-safe convention, stated in the TSDoc, pinned by tests, and named in the changeset.

Nesting is bounded at 64 levels below the root, rejected by compileQuery with code: "invalid-query" and a query.filters[i].children[j]… breadcrumb. This closes a measured hazard: before the bound, depth 1000 captured successfully and then blew the stack inside equality on a later setQuery — a crash in the steady-state path on a plan the engine had already accepted. Capture is the single chokepoint every other recursion runs downstream of, so one bound there protects validate, snapshot, equality, and evaluation at once.

Engine

Capture, validate, snapshot, evaluation, equality, and distinctValues all recurse. Evaluation compiles the tree once per plan into a matcher closure — there is exactly one and implementation and one or, whatever the tree's shape, and no separate flat path.

That single path is also ~12% faster than the flat loop it replaces, because the old loop was itself a callback join allocating a closure per row. Getting that right took disproving my own instruction: I asked for the collapse on the grounds that the cost was noise, based on two full-model benchmarks. An isolated verdict loop (0.6% A/A floor, versus the full-model bench's 12%) showed the naive collapse was 4× the noise floor, a bisect proved the cost was in evaluation rather than the extra compile, and the culprit turned out to be every/some allocating per group per row — replaced with indexed loops. A variant that fused leaf-only sibling lists measured faster still and was rejected, because it put join semantics in a second place.

Surface

snapshot.filters is now the query array verbatim — the per-column record projection is gone (it would have keyed a group under undefined). Funnels light on a column's occurrence at any depth. The per-column menu reads and writes only its column's first top-level leaf and passes groups through by reference, pinned by a reference-identity assertion at onQueryChange, before the model re-captures.

One behavior moved and is documented: the deleted record was last-wins for a duplicate top-level leaf; the tree path is first-wins, and a commit collapses duplicates to one. Only a consumer hand-authoring filters can reach it.

Docs

The server-data page documents the wire contract: groups arrive verbatim, how to discriminate them server-side (where JSON.stringify has stripped the types), and that a server which only understands flat filters has to decide explicitly — reject / flatten-only-when-every-join-is-and / implement the recursion — with the flatten trap called out (the else-branch must still reject). The docs' own example server now rejects a group up front by name, because the page already promised an unanswerable filter returns an error saying which; its previous per-row rejection was reachable only if a row survived the leaves ahead of it, so a leaf matching nothing quietly answered 200 with zero rows.

Review

Six review rounds across the tasks, ~25 mutations. Wrong-results defects found and fixed before merge, none of which the green suite caught:

  • Every filter group collided onto one distinct-values cache key (unchecked leaf cast), serving stale values — reproduced, then fixed and pinned.
  • A separator-forgery: because a descriptor key joined with raw separators and embedded user strings, a filter value containing /`` could impersonate an entire sibling, making two different groups compare equal so the plan was reused and the incoming query silently discarded. Fixed by comparing structurally instead of by key.
  • Unpinning is unrelated here, but the same class recurred: a freeze test that tested the wrong object (the published snapshot, not the captured tree) — deleting the capture-level freeze passed all 616 tests until the seam was fixed.

Verification

Gate Result
row-model / core / react / ui / website unit 641 · 7 · 1330 · 90 · 562 — all green
typecheck · lint · prettier · build · api:check clean; reports already fresh (no drift)
Website e2e (full, prod build, chromium+webkit) 304 passed, 0 failed (4 designed skips)

Changesets: @pretable/core minor, @pretable/react minor. No row-model.api.md exists — row-model is private, so the group types surface through core.api.md and react.api.md only. Every temporary marker from the intermediate flattening state is gone (swept and confirmed).

Follow-up filed

external-filter-authority.test.tsx:214 asserts synchronously on header state that settles a slice later. It fails 5/5 in isolation and passes only because earlier tests in the file warm the path — the real cause of failures previously written off as load flake. Pre-existing on main, so it doesn't block this; filed with the one-word fix and the instruction to check siblings for the same shape.

🤖 Generated with Claude Code

blove and others added 15 commits August 25, 2026 18:43
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`PretableQueryFor.filters` becomes an arbitrary-depth tree: each entry is
either a typed leaf or a `PretableFilterGroupFor` joining its children with
"and" or "or". `isPretableFilterGroup` narrows one node, checked positively
on the group's own fields so an unknown shape fails closed.

Capture is now recursive: `captureFilterNode` validates the join operator,
requires a dense array of children, breadcrumbs failures as
`query.filters[i].children[j]…`, and freezes every level. Validation
recurses with the same breadcrumb, so a bogus column or operator inside a
group is rejected at compile time rather than at evaluation.

Evaluation still applies every leaf conjunctively (`filterLeavesOf`) and
group equality is a conservative descriptor-key match — real tree semantics
land in the next change. Flat queries are byte-for-byte unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hat tests capture

`filterSemanticKey` cast every filter to a leaf, so a group keyed as three
`undefined`s: two queries differing only INSIDE a group collided on one
`population: "filtered"` cache entry and the second was served the first
one's answer. The key now recurses, sorting each group's child keys the way
`canonicalRuntimeQuery` sorts the roots — both joins are commutative, so a
reordered group is the same question. `filterDescriptorKey` sorts children
for the same reason, which also lets a reordered group reuse its plan.

The deep-freeze test read `plan.query`, which re-freezes everything it hands
back: it proved `snapshotQuery` froze a copy and said nothing about capture,
and passed with the capture-level freeze deleted. It now reads the plan's own
captured tree through an internal test seam, with the snapshot half split off
into its own test.

Also corrects two comments that still called the compiled predicates parallel
to `#runtimeQuery.filters` — under a tree they are parallel to `#filterLeaves`
— and marks the temporary conjunctive flattening at both sites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… forge a sibling

The group arm of `filterNodesEqual` compared serialized descriptor keys, and
that key is raw concatenation over unframed user operands. A filter VALUE
could reproduce the separators and impersonate a sibling: an `and` group of
`contains "a"` and `contains "b"` keyed identically to a one-child group whose
operand was `a<US>sector<NUL>contains<NUL>string:b`. The keys matched, so
`filtersEqual` and then `semanticallyMatches` did too, `compileQuery` handed
back the PREVIOUS plan, and the incoming query was silently discarded with the
old filters left applied.

Groups now match structurally — join operator, then children as an unordered
multiset through `filtersEqual`, recursing for nested groups. That is the
comparison the tree needs anyway, brought forward rather than patched around.
`filterDescriptorKey` keeps its original ordering job, where the ambiguity is
harmless.

Also documents why the child-key sort in `distinct-values`'s `nodeKey` is
belt-and-braces: structural plan reuse absorbs a reordered group before this
cache can see one, and the sort stops being redundant if that changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on-free recompile check

Three comment defects this branch introduced. Two new functions had been
inserted BETWEEN an existing doc block and the function it documented, so
`filterVerdict`'s paragraph described the test seam and `compareWithSortKeys`
was left undocumented under a stacked pair; both are moved clear.
`isRuntimeFilterGroup` claimed the "same positive check" as the public guard
when it is deliberately weaker, and now says why: capture has already rejected
any node carrying `children` without a valid `op`, so over a captured tree
`children` alone is decisive. `PretableFilterGroupFor`'s public TSDoc read as
a finished feature, and now carries the one line saying `op: "or"` is accepted
and validated but still evaluated as `and`.

`PretableFilterNodeFor` names the leaf-or-group union that was spelled out at
three sites and reinvented locally in the tests. The near-identical
`filtersEqual`/`filterNodesEqual` pair becomes `filterNodeListEqual` and
`filterNodeEqual`.

`derivationsEqualForPlan` now takes the caller's `#filterLeaves` instead of
re-deriving them: it runs on every recompile check, where the pre-tree code
allocated nothing. `filterLeavesOf` is once again called exactly once per
plan, in the constructor.

Also drops a guard ternary equivalent to the equality checks it wrapped, drops
an object check `validateFilter` already performs, corrects two wording drifts
(captured vs published, leaf list vs node list), extracts a model factory in
the distinct-values group tests, and turns a bare assertion loop into
`test.each` so a failure names the shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 1 accepted, validated and compared filter groups but flattened them
for evaluation, so an `op: "or"` group behaved as an `and`. Evaluation now
walks the join structure: the root list stays conjunctive, `and` groups hold
when every child holds, `or` groups when any child does, recursively.

An EMPTY group is TRUE under both joins, by an explicit branch rather than
by accident: `some([])` is `false`, which would blank the grid the moment a
builder UI adds a group and before the user fills it in.

The tree is compiled ONCE per plan into closures — the same treatment the
flat leaf predicates always got — and a query with no groups at all keeps
the unchanged flat loop, so the per-row hot path is untouched where there is
no join to honour.

Capture now bounds nesting at 64 levels. Capture is the chokepoint every
other recursion runs downstream of, and without the bound a ~1000-level tree
was CAPTURED successfully and then overflowed the stack later, inside
equality on a plan the engine had already accepted; past ~2000 levels
`compileQuery` threw a raw RangeError instead of this module's validation
error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Evaluation briefly kept two implementations: a compiled tree for queries
holding groups, and the older flat predicate array for queries that did not.
Two implementations of one semantics is a divergence waiting to happen, and
the second one was already costing something concrete — a grouped query
compiled every leaf predicate twice, once into the flat array it then never
read.

There is now one representation. Every node, leaf or group, compiles to a
`CompiledFilterMatcher` closure; a sibling list compiles to one matcher for
its join; the query's root list is that same call with `and`, which is what a
top-level filter list has always meant. `#compiledPredicates` and the
flat-vs-tree fork are gone. `#filterLeaves` stays — it is the column
dependency set, not an evaluation order.

The join loops are indexed rather than `every`/`some`, and that detail is the
whole perf story. A callback join allocates a closure per group per row: on
an isolated 200k-row four-leaf verdict loop the callback form measured 52ms
against the old flat loop's 30ms, and the indexed form measures 30ms — level
with the code it replaces, and level again on a 100k-row model build where
the sign flips between rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…er-tree comments

Two comments pointed at `compileFilterNode` and `evaluateCompiledFilterNode`,
neither of which exists — the collapse to one evaluation path merged both into
`compileFilterNodes`, and one of the two stale pointers was written by that
same commit rather than inherited from before it.

The indexed-join comment carried hard figures for the closure-allocation cost
it explains. Two harnesses disagreed about that pair by enough to matter, and
the difference is invisible to a whole-model benchmark, so the figures come
out and the instruction to measure on an isolated verdict loop goes in. The
mechanism stands; only the numbers were unsafe to write down.

The same block claimed the single path runs "level with" the flat loop it
replaced. It does not: the flat loop was itself a callback join allocating a
closure per row, so collapsing is a net win, not a wash. Said accurately now.

Also gives the matcher tree its own `alwaysMatches` rather than borrowing the
`FilterPredicate` twin from a thousand lines away — structurally identical, so
tsc never minded, but a predicate answers about a value and a matcher about a
row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…led state

`query.filters` became an AND/OR tree in the row model; the surface's chrome
was still reading it as a flat list of leaves, through a cast that would have
keyed every group element under `undefined`.

The per-column record projection is deleted. `snapshot.filters` is now the
query's array verbatim — leaves and groups, nested — and the two questions the
chrome actually asks are answered by walks in the new `./filter-tree`:

- `columnHasFilter` lights a funnel on any occurrence of a column at any
  depth, because a filter nested in a group still removes that column's rows;
- `topLevelColumnFilter` / `withTopLevelColumnFilter` scope the column menu to
  its top-level leaf, read and write. A commit replaces that leaf in place and
  passes every other element through by reference, so a group the menu never
  authored survives byte-identical.

`LabeledGridSurface`'s `is-filtered` decoration walks the tree by the same
occurrence rule. `isPretableFilterGroup` and the node/group types are
re-exported from `@pretable/core` and `@pretable/react` — the surface needs the
guard, and so does any consumer reading `onQueryChange`'s filters.

This is the first commit on the branch where `pnpm build` succeeds again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ce teeth for the survives-test

Three review findings from the surface filter-tree commit.

`@pretable/core` gains three public exports and a widened
`PretableQueryFor.filters` in that commit but had no changeset. It has one now,
and it names the two rules a CHANGELOG reader has to learn before upgrading:
an EMPTY group holds for both `and` and `or` (naive algebra disagrees, and
would blank the grid on a half-built group), and a tree nesting deeper than 64
levels is rejected by `compileQuery` with `code: "invalid-query"` — a new
reason an otherwise well-formed query can be refused.

The docs fixture server's `DocsQuery.filters` is leaf-only while the engine's
is a tree, and nothing catches it: the type boundary is severed by
`JSON.stringify` in each example's `fetch-rows.ts`, so typecheck is green over
a real gap. It carries a verdict comment now, including the part that is better
than it looks — a group reaches `columnTypeFor(undefined)`, which throws, so
the route fails loudly rather than serving wrongly-filtered rows.

The survives-test asserted only structural equality of the pass-through group,
which a defensive clone would have satisfied. `setQuery` in `pretable-model.ts`
hands `onQueryChange` the surface's own object before the row model re-captures
it, so the group element IS assertable by reference — and now is. Cloning the
group in `withTopLevelColumnFilter` previously failed three helper tests and
left all eighteen surface tests green; it now fails the surface test too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`query.filters` is an AND/OR tree as of this branch, and the shape a
server receives changed the moment it merges — so it is documented now,
not alongside the builder UI that will produce trees from the UI.

The section overview grows "What a filter looks like on the wire": a
grouped JSON payload, and the four rules that make it mean what it says
— the top-level array is an implicit AND (which is what a filter list
has always meant, and what keeps a pre-group payload valid), leaves and
groups discriminate on structure rather than a tag (`isPretableFilterGroup`
on the client edge, `children` on the server, where no types survive
`JSON.stringify`), an empty group is TRUE under either `op` (naive
algebra says empty-OR is false, which would blank the grid under a
half-built group), and nesting is bounded at 64 with a breadcrumbed
`invalid-query` rejection.

Then the part a server cannot leave implicit: reject, flatten only when
every join is `and`, or implement the recursion — presented with what
each one actually costs, because guessing produces the failure this
section keeps warning about, a grid that looks filtered and is not.

Says plainly that no UI builds a group yet: the funnel still writes its
column's top-level leaf, and a group reaches an endpoint only from a
query the consumer seeded themselves.

The docs' own example server now REJECTS a group explicitly rather than
incidentally. It already failed — a group has no `columnId`, so
`columnTypeFor` threw — but with "Unknown column undefined", a message
about the wrong thing. `matches()` tests for `children` and names the
real reason, which is what lets the page cite it as the reject posture
and what the page's own rule (a fixture that cannot answer a filter says
which) already demanded. No group evaluation: it is a demo of the wire
contract, not a filter engine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sure helper

Three semantics moved silently when the per-column record projection was
deleted, and none of them was written down.

The record was LAST-wins: `filters[entry.columnId] = …` let a later top-level
leaf overwrite an earlier one. `topLevelColumnFilter` takes the FIRST, and
`withTopLevelColumnFilter` replaces that same one and drops the rest — the two
halves agreed at runtime, but only the write side said so. The read side's doc
said "the TOP-LEVEL leaf", singular and definite, which left the duplicate case
undefined in prose. It says FIRST now, names the change of answer, and both
rules have a test: nothing previously constructed two top-level leaves for one
column, so neither first-wins nor the duplicate-drop branch was exercised
outside the clearing path. The changeset carries the clause too — only a
hand-authored `filters` can reach it, but a consumer who does deserves to read
it there rather than discover it.

Two pieces of duplication go with them. `LabeledGridSurface`'s walk — a second
walk for a real reason, since it gates on `isColumnFilterActive` — narrowed
through four raw casts because the guard was private; it is exported as
`isSurfaceFilterGroup`, and the `as never` rationale now lives in one place.
The value-erasure cast was spelled out at three call sites with three
cross-referencing comments; `asSurfaceNodes` holds the single explanation and
each site keeps only its own tree-semantics note, which is the part that
differs.

Also corrects the core changeset's depth bound, which was off by one: the check
is `depth > 64` with the root at depth 0, so 65 node levels are accepted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e promises

The overview says the fixture endpoint errors "the moment a group
appears". It did not. The rejection lived inside `matches()`, which
`applyDocsQuery` calls per ROW inside `rows.filter(...)`, so it was
reachable only when some row survived every earlier leaf. Two shapes
therefore answered 200 with zero rows and no throw:

  filters: [{ region isAnyOf ["Nowhere"] }, { op: "or", children: [] }]
  any group at all over an empty `rows`

Zero rows and an error are not the same answer. The first reads as
"nothing matched" — a result quietly computed from less than the reader
asked for, which is the failure this whole section argues against, and
it does not stop being that because the result happens to be empty.

So `applyDocsQuery` now scans `filters` for `children` before it reads a
row. Whether a query is one this fixture can answer is a question about
the QUERY, independent of the data, and it is asked once, where that is
true. `matches()` keeps its branch as belt-and-braces for a direct
caller, and both throw through one `rejectFilterGroup` so the wording
cannot drift; the up-front path also breadcrumbs the offending index.

Four tests pin it, including the two shapes that used to slip through
and a positive control that leaf-only queries over the same shapes still
answer. Removing the scan fails exactly the three group tests.

Two staleness fixes alongside:

  - `server-data.types.tsx` said index.mdx has "both of its fences"; the
    grouped payload made it three. The page is still deliberately
    unbound — all three fences are JSON.
  - `query-ownership.mdx`'s See-also had two bullets titled "Server-side
    data" pointing at the same page. Mine is now "The filter wire
    contract".

The Reject bullet on the page now describes the up-front scan and says
why the placement, not just the check, is the part worth copying.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
pretable Ignored Ignored Aug 26, 2026 4:41am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

Vercel preview ready

Preview: https://pretable-nr4by2wby-cacheplane.vercel.app
Commit: 7ea17398362ef2d32c5da5413572963fd61fe398

Updated automatically by the deploy-preview job.

@blove
blove merged commit 0eb5236 into main Aug 26, 2026
20 checks passed
@blove
blove deleted the blove/filter-tree-sp2a branch August 26, 2026 04:57
@blove blove mentioned this pull request Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant