Skip to content

Delete our duplicate WHERE-pushdown converter, use icebird's (LLP 0222) - #721

Merged
platypii merged 5 commits into
masterfrom
fix/one-pushdown-converter
Aug 14, 2026
Merged

Delete our duplicate WHERE-pushdown converter, use icebird's (LLP 0222)#721
platypii merged 5 commits into
masterfrom
fix/one-pushdown-converter

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

What

src/core/query/parquet-pushdown.js becomes a re-export of whereToParquetFilter from icebird/src/sql/whereFilter.js. -188 / +33 lines. The public hypaware/core/query surface is unchanged, so no consumer moves.

Rationale doc: LLP 0212 (extends LLP 0098, which settled that the predicate gets pushed down; this settles whose converter pushes it).

Why

The two files were ports of the same Hyperparam original (lib/tools/parquetPushdownFilter.ts) - same function names, same structure, same De Morgan comments. icebird's copy kept moving; ours did not. Three consequences:

1. Typed literals never converted. squirreling parses TIMESTAMP '2026-08-11T00:00:00Z' as a cast node wrapping a string literal. icebird constant-folds it (staticLiteral/foldCast); ours required a bare literal operand, and because AND is all-or-nothing the whole predicate collapsed to undefined. hypscope's sessions surface bounds every day window this way, so it pushed nothing down to the cache tier.

Measured on the production central server, org hyperparam, 2026-08-12 - one grouped sessions-list scan, identical projection and rows:

predicate wall
message_created_at >= TIMESTAMP '2026-08-11T00:00:00Z' 11.4s
date >= '2026-08-11' 7.3s

2. A correctness bug. Our convertExpr unwrapped any cast at boolean position, so WHERE CAST(a = 1 AS TEXT) pushed down as a = 1. The engine evaluates that cast to the string 'false', which is truthy, so the pushdown dropped rows the query selects - and a converted filter sets appliedWhere, so the engine does not re-filter to catch it. icebird gates the unwrap to truthiness-preserving casts.

3. Bloom pruning was off for non-INT64 numerics. coerceBigInt is dropped rather than ported. filterStrict: false (which parquet-source.js and icebird both pass) compares through ==, so 5n == 5 either way, while hyparquet's hashParquetValue rejects a bigint for INT32/FLOAT/DOUBLE and returns undefined. The coercion bought nothing on INT64 and silently disabled bloom pruning everywhere else.

Dependency floor

Dropping coerceBigInt relies on hyparquet >= 1.28.1, where $in/$nin match through matchesIn -> equals(value, target, strict) rather than Array.prototype.includes. On 1.27.x a number-valued $in against an INT64 column (decoded as bigint) matches no rows. This repo pins 1.28.1; a companion one-line bump of hypaware-server's own 1.27.1 pin follows separately (that pin also governs icebird's archive reads, which have the latent bug today independent of this change).

icebird is already a direct dependency, and deep icebird/src/*.js imports are the established pattern here (src/core/cache/retention.js, src/core/cache/iceberg/stream_append.js, a dozen sites). Its exports map publishes ./src/*.js with matching types/, so no type fidelity is lost.

Tests

Existing assertions move from bigints to plain numbers. New coverage for the two shapes the drift produced:

  • the folded TIMESTAMP literal, both as a unit assertion and end to end through a real parquet scan with rows either side of the day boundary - a mis-folded literal would drop rows rather than merely lose pruning, so shape assertions alone are not enough
  • the truthiness-cast guard (CAST(... AS INT) converts, CAST(... AS TEXT) does not)

Verification

  • npm run typecheck clean.
  • npm test: 3943/3946 pass. The one failure is test/core/leave-command.test.js:253 (central-layer teardown), confirmed failing identically at master with these changes stashed - pre-existing and unrelated.
  • hypaware-server (via file:../hypaware): typecheck clean, all 65 suites pass.
  • Against 1.6 GB of real local recordings: results byte-identical before and after.

Not measured: the production speedup, which needs a deploy. The 11.4s vs 7.3s gap above is the expected magnitude, not an observed result of this patch.

Out of scope

This is one of four causes behind the slow sessions list. The dominant one is hypscope's per-batch sessions.titles query (61.6s measured, already withdrawn on hypscope master); the others are batch 0 never using the summaries rollup, and the absence of partition-level pruning (sql.js calls discoverPartitions with no WHERE, so every raw query still opens all 752 cache files before any filter runs).

🤖 Generated with Claude Code

`src/core/query/parquet-pushdown.js` and `icebird/src/sql/whereFilter.js`
were two ports of the same Hyperparam original. icebird's kept moving and
ours did not, and the drift cost query time and correctness.

Typed literals never converted. squirreling parses
`TIMESTAMP '2026-08-11T00:00:00Z'` as a `cast` wrapping a string literal;
icebird constant-folds that, ours required a bare `literal` operand and
returned undefined for the whole predicate, because AND is all-or-nothing.
Every timestamp-bounded query therefore pushed nothing down to the cache
tier. Measured on the production central server, org hyperparam: one
grouped sessions-list scan took 11.4s bounded on `message_created_at`
against 7.3s bounded on `date`, same rows, same projection.

Any cast unwrapped at boolean position. `WHERE CAST(a = 1 AS TEXT)` pushed
down as `a = 1`, but the engine evaluates that cast to the string 'false',
which is truthy, so the pushdown dropped rows the query selects, and a
converted filter sets appliedWhere so the engine does not re-filter to
catch it. icebird gates the unwrap to truthiness-preserving casts.

`coerceBigInt` is dropped rather than ported. `filterStrict: false`
compares through `==`, so `5n == 5` either way, while hyparquet's bloom
hashing rejects a bigint for INT32/FLOAT/DOUBLE: the coercion bought
nothing on INT64 and disabled bloom pruning everywhere else. That relies
on hyparquet >= 1.28.1, where `$in`/`$nin` match through `equals()` rather
than `Array.prototype.includes`; this repo pins 1.28.1. A companion bump
of hypaware-server's own 1.27.1 pin follows separately.

icebird is already a direct dependency and deep `icebird/src/*.js` imports
are the established pattern here, so this costs no new dependency and no
type fidelity. The public `hypaware/core/query` surface is unchanged.

Tests assert plain numbers where they asserted bigints, and cover the two
shapes the drift produced: the folded TIMESTAMP literal (both as a unit and
end to end through a real parquet scan, since a mis-folded literal would
drop rows rather than merely lose pruning) and the truthiness-cast guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe philcunliffe added neutral:adopt Foreign PR adopted into neutral's reconcile scope neutral:adopted Adoption completion record: merged while carrying neutral:adopt (LLP 0031) labels Aug 12, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 1 of be9917c (adopted PR). Verdict: findings - 1 blocker, 4 minor, 1 nit. Nothing pushed: the blocker's fix belongs upstream in icebird, and the regression test for it cannot go green until that lands, so changing this branch now would only churn it. The PR is labelled neutral:stuck with a report below this comment.

Everything the PR asserts was verified rather than taken on trust: npm test (3945 pass / 0 fail), typecheck and build:types all green on a fresh install; the three claimed icebird behaviours read out of the installed icebird@0.8.19 source; the dependency floor confirmed genuinely enforced. The findings are about what the swap newly enables, not about what it deletes. The deletion itself is sound.

1. blocker - newly pushed-down timestamp bounds return rows the query excludes, on nullable TIMESTAMP columns

icebird's convertBinary emits a bare relational operator, and hyparquet's matchFilter evaluates it with raw JS comparison (hyparquet/src/filter.js:70-73: if (operator === '$lte') return value <= target). In JS null <= <Date> is true (null coerces to 0), so a NULL cell satisfies a < / <= bound. parquet-source.js:51 then sets appliedWhere = Boolean(filter), so the engine never re-filters and the NULL rows are returned.

Measured in the worktree on a three-row parquet file with at nullable ({at:'2026-08-11'},{at:null},{at:'2026-08-13'}):

SELECT id FROM t WHERE at <= TIMESTAMP '2026-08-12T00:00:00Z'
  filter (this PR)  : {"at":{"$lte":"2026-08-12T00:00:00.000Z"}}
  filter (207aaf1)  : undefined
  rows (this PR)    : [1,2]     <- row 2 has at = NULL
  rows (engine only): [1]       <- correct SQL semantics

This PR is what makes it reachable on the cache tier. At 207aaf1 the local converter returned undefined for a TIMESTAMP '...' operand, so this exact predicate fell back to the engine and answered correctly. The typed-literal fold, which is the PR's headline win, is what opens the path. (On nullable numeric columns the same leak already existed pre-PR, and the archive tier already had it via icebergDataSource; the cache tier is what regresses here.)

Not hypothetical for shipped datasets: eleven shipped ColumnSpecs are type: 'TIMESTAMP', nullable: true, including logs.timestamp, logs.observedTimestamp, traces.startTimestamp and traces.endTimestamp (otel/src/datasets.js:26-27,51-52,77-78) plus the context-graph first_seen / created_at / resolved_at / committed_at columns. Any traces or logs query with an upper day bound now over-returns. ai_gateway_messages.message_created_at - the column the 11.4s-vs-7.3s measurement is about - is nullable: false, which is exactly why the motivating case looks clean.

Fix, verified against matchFilter in the worktree: convertBinary must guard relational operators against null, e.g. emit {$and: [{col: {$ne: null}}, {col: {$lte: v}}]}. $ne: null routes through equals(null, null, false) and correctly excludes NULL while keeping every non-null value:

{at:null}         bare $lte: true  | guarded: false
{at:2026-08-11}   bare $lte: true  | guarded: true
{at:2026-08-13}   bare $lte: false | guarded: false

2. minor - the two new end-to-end tests do not discriminate

Re-running the new test file with parquet-pushdown.js swapped back to the deleted 207aaf1 implementation: the two unit tests discriminate correctly (folds typed literals and only unwraps truthiness-preserving casts both fail, as do the four bigint-to-number tests). But test/core/parquet-source.test.js:223 and :232 both pass verbatim against the deleted converter, because with appliedWhere: false the engine filters and produces the same rows. They prove the fold does not corrupt results, which is their stated purpose, but nothing in the suite asserts the predicate is actually pushed down - which is the entire point of the change. A future icebird bump that quietly regressed staticLiteral back to undefined would keep the suite green and silently cost the 4s again.

Fix: assert appliedWhere in the timestamp test, e.g. assert.equal(src.scan({ where: whereOf("... at >= TIMESTAMP '2026-08-11T00:00:00Z'") }).appliedWhere, true). One line, and it is the property LLP 0212 §consequences claims.

3. minor - the timestamp fixture is nullable: false

TIMESTAMP_COLUMNS (test/core/parquet-source.test.js:66-69) declares { name: 'at', type: 'TIMESTAMP', nullable: false } - precisely the shape that hides finding 1. Fix: make at nullable, add a NULL row, and assert the day-window query excludes it. That test fails today and passes once finding 1 is fixed upstream, so it is the natural regression test to land alongside the bump.

4. minor - icebird's foldCast diverges from squirreling for CAST(<timestamp> AS TEXT)

foldCast returns { value: String(val) } for TEXT/STRING/VARCHAR. squirreling special-cases objects: if (typeof val === 'object') return stringify(val) (evaluate.js:704, where stringify is JSON.stringify). For a Date-valued inner literal they disagree:

WHERE s = CAST(TIMESTAMP '2026-08-11T00:00:00Z' AS TEXT)
  pushed: {"s":{"$eq":"Tue Aug 11 2026 00:00:00 GMT+0000 (Coordinated Universal Time)"}}
  engine:                 "\"2026-08-11T00:00:00.000Z\""

Same class as finding 1 - a filter not equivalent to the predicate, with appliedWhere set - but the query shape is obscure enough not to block on. foldCast's own docblock promises to "stay in lockstep with the engine", so it is worth an upstream one-liner: return undefined (or mirror stringify) when val is an object.

5. minor - WHERE col = NULL pushes down as {col: {$eq: null}} and returns the NULL rows

Pushdown returns [2], engine-only returns []. Identical at 207aaf1 and at this head, so the PR does not introduce it - but the PR's premise is "theirs is correct, ours is not", and this survives the swap unexamined. A literal NULL operand should yield undefined so the engine applies three-valued logic. Worth an upstream issue alongside finding 1.

6. nit - the LLP number is load-bearing in three files here

PRs #716, #720 and #721 each add a different llp/0212-*.decision.md; git will merge all three silently and the duplicate-number test only fires after the second lands. If this one renumbers, three files move: the doc filename and title, the @ref LLP 0212 at parquet-pushdown.js:34, and two test comments at parquet-source.test.js:112 and :149. Nothing else in the repo references 0212. Flagged so whoever sequences the three knows the cost.

Also checked, clean

  • Dependency floor - enforced, not merely asserted. package.json pins "hyparquet": "1.28.1" exactly (no range), and installed icebird@0.8.19 pins 1.28.1 exactly too, so npm resolves a single deduped copy. parquet-source.js imports bare hyparquet, which is the root exact pin, and an exact pin survives a consumer installing hypaware as a dependency. The one nested copy on disk is hypvector/node_modules/hyparquet@1.26.2, which never sees this filter. The claimed 1.28.1 behaviour is real: matchesIn routes to equals(value, target, strict) and equals(a, b, false) falls to a == b, so 5n == 5 holds. Adding hyparquet to the existing overrides block would be belt-and-braces against a future icebird loosening its pin, but is not needed today.
  • The three claimed icebird behaviours - all verified in source. staticLiteral/foldCast/castTimestamp really do constant-fold TIMESTAMP '...' through the cast node, and foldCast faithfully mirrors squirreling for every primitive case (INTEGER truncates, BIGINT via BigInt(Math.trunc()), TIMESTAMP requires the same date-prefix regex), with unparseable dates returning undefined - the object case is finding 4. TRUTHINESS_PRESERVING_CASTS excludes exactly the four types squirreling's isCastType adds (TEXT, STRING, VARCHAR, TIMESTAMP), so the gate is exhaustive rather than a guess. hashParquetValue does require typeof value === 'number' for FLOAT/DOUBLE/INT32 and returns undefined for a bigint, while INT64 accepts a safe-integer number - so dropping coerceBigInt really does restore bloom pruning on non-INT64 numerics at no cost on INT64.
  • Feature-by-feature diff of the deleted converter - nothing lost. Every node shape the old file handled is handled by icebird's: NOT, IS NULL / IS NOT NULL, AND/OR with the same all-or-nothing and De Morgan treatment, all eight comparison spellings with the same neg/flip tables, LIKE to undefined, and in valuelist. icebird is a strict superset on operands and strictly narrower only where it should be (the gated cast unwrap, which is the bug fix and fails safe to engine filtering).
  • appliedWhere - the all-or-nothing property holds structurally. icebird cannot return a partial filter: AND and OR both bail to undefined unless both children convert, in valuelist bails unless every value folds, and no path drops a conjunct. union-source.js:105 correctly ANDs the flag across partitions. The failure mode in finding 1 is not partiality: it is a complete filter whose per-row semantics differ from SQL's on NULL.
  • Deep-import stability - sound. icebird's exports publishes "./src/*.js" with matching types/, files includes types, the symbol is really exported, and the shipped .d.ts is a real signature rather than any. Precedent is well past "established": about 20 deep icebird/src/* imports across six cache modules. build:types emits a clean re-export and the public surface is unchanged.
  • The bigint-to-number test move - faithful, not a weakening. Same shapes, same numeric values, reason stated in a comment, and nil runtime consequence under filterStrict: false. Confirmed end to end that id IN (1, 3) against a bigint-decoded INT64 column returns [1,3] on 1.28.1.
  • LLP 0212. Header well-formed and matching house style; every factual claim checks out against the tree (the 188-vs-33 line count, the if (!left || !right) quote, the deleted cast line, filterStrict: false in both places, the hashParquetValue claim). On the 0098 question: 0212 does not claim to extend 0098 and correctly should not - 0098's single mention of whereToParquetFilter describes icebird's converter inside scanColumn, not the kernel's, so 0212 settles an adjacent question and 0098 needs no forward-ref. No other LLP references parquet-pushdown.js or coerceBigInt.
  • Conventions. No semicolons, no U+2014 anywhere including the LLP prose, no @typedef, no inline import types.

@philcunliffe philcunliffe added the neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human label Aug 12, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

What neutral was doing. Round 1 review of this PR at head be9917c, on the own-PR ladder (adopted via neutral:adopt). The PR is MERGEABLE with all 9 checks green.

Why it cannot proceed. The review found a blocker that neutral cannot fix in this repo: adopting icebird's converter introduces a wrong-rows path on nullable TIMESTAMP columns, and the fix belongs upstream in icebird.

In short: icebird's convertBinary emits a bare relational operator; hyparquet's matchFilter evaluates it with raw JS comparison, and null <= <Date> is true in JS. parquet-source.js:51 sets appliedWhere = Boolean(filter), so the engine never re-filters and the NULL rows are returned. At 207aaf1 the deleted converter returned undefined for a TIMESTAMP '...' operand, so the predicate fell back to the engine and answered correctly - the typed-literal fold, this PR's headline win, is exactly what makes the bug reachable on the cache tier. Eleven shipped ColumnSpecs are nullable TIMESTAMP, including logs.timestamp, logs.observedTimestamp, traces.startTimestamp and traces.endTimestamp. Any traces or logs query with an upper day bound over-returns. ai_gateway_messages.message_created_at is nullable: false, which is why the motivating measurement looks clean. Full reproduction and the measured row sets are in the review record above.

Neutral did not push anything. Two of the minor findings (the fixture is nullable: false, and the two end-to-end tests pass verbatim against the deleted converter) are the regression coverage for this blocker, and they cannot go green until the upstream fix lands, so changing the branch now would only churn it.

What it needs from you - a routing decision:

  1. Fix upstream and bump (the reviewer's recommendation). Guard relational operators against null in icebird's convertBinary, e.g. emit {$and: [{col: {$ne: null}}, {col: {$lte: v}}]}. $ne: null routes through equals(null, null, false) and excludes NULL while keeping every non-null value - verified against matchFilter. Then bump the icebird pin here and land findings 2 and 3 as the regression test. This is the only option that leaves traces and logs correct.
  2. Scope it explicitly and merge anyway. Record in LLP 0212 that null-versus-relational-operator equivalence is a known open gap inherited from icebird, and file it upstream. This trades a documented perf win for an undocumented wrong-rows path on traces and logs - which is the trade LLP 0212's own context section argues against, so neutral is not taking it unilaterally.
  3. Narrow the pushdown here. Wrap whereToParquetFilter so relational operators on a nullable column return undefined, keeping the fold for non-null columns. Recovers the ai_gateway_messages win (that column is nullable: false) without the correctness risk, at the cost of the re-export being no longer a pure re-export.

Two further upstream items worth filing alongside whichever you pick, neither introduced by this PR: foldCast diverges from squirreling for CAST(<timestamp> AS TEXT) (finding 4), and WHERE col = NULL pushes down as {$eq: null} and returns the NULL rows instead of applying three-valued logic (finding 5).

Also pending on this PR, independent of the blocker: three open PRs (#716, #720, #721) each add a different llp/0212-*.decision.md. Git will merge all three silently and the duplicate-number test only fires after the second lands. If this one renumbers it costs three files: the doc, the @ref at parquet-pushdown.js:34, and two test comments.

How to unstick. Reply with a comment on this PR (or push to the branch). Neutral monitors this thread and will re-engage with your guidance on its next tick.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Correction to the stuck report, from evidence found reviewing #724. The blocker stands, but its framing was incomplete in a way that changes which option you probably want.

The NULL leak is not introduced by icebird. The identical hazard is already in the kernel's own whereToParquetFilter for <, <= and !=, and it is live on master today for s3 format: 'parquet' datasets. Measured on a nullable ts with rows {1:100, 2:null, 3:300, 4:null, 5:500}:

WHERE ts <= 300  ->  ids 1,2,3,4   (SQL: 1,3)
WHERE ts <  300  ->  ids 1,2,4     (SQL: 1)
WHERE ts != 300  ->  ids 1,2,4,5   (SQL: 1,5)

Filed as #728.

What this PR changes is only which predicates reach the leak: icebird's typed-literal fold makes it newly reachable for TIMESTAMP '...' bounds on the cache tier, because the kernel converter returned undefined for those operands and fell back to the engine. So the review's finding 1 is still accurate about this PR making a specific path newly wrong.

Why that matters for your decision: option 2 in the stuck report (scope it and merge anyway) now reads worse, since it would document a gap the repo already has unfixed elsewhere. Option 1 (fix upstream in icebird, bump the pin) reads better than it did, because a single null-guard on relational operators closes both the pre-existing kernel leak and this PR's newly-reachable case in one move - which is an argument for landing this PR with the upstream fix rather than narrowing or reverting it.

The PR stays neutral:stuck and held; this comment only sharpens the choice.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

The null-guard now exists in-repo: PR #730 (Fixes #728). It fixes the pre-existing leak in the kernel's own converter, which is the half that is live on master.

Two things from that work bear directly on the decision here:

1. More operators leak than either report listed. > and >= leak too, on a negative bound (neg > -400 returned 4 of 5 rows instead of 2), and so does NOT IN. IN leaks when the list itself holds a NULL. Whatever fix lands here has to cover all of them, not just <, <=, !=.

2. The guard shape I suggested in the stuck report was the wrong one. I proposed {$and: [{col: {$ne: null}}, {col: {$lte: v}}]}. It is correct, but it keeps the pre-fix un-pruned read. canSkipRowGroup refuses statistics skipping for any condition a NULL could satisfy, so a bare relational operator was already forfeiting row-group and page pruning on every chunk holding a NULL. Measured on a 5-group file where only group 0 can match:

bare        {ts:{$lte:500n}}          (pre-fix)     reads=5  bytes=64791
compact     {ts:{$ne:null,$lte:500n}} (PR #730)     reads=1  bytes=12933
$and-wrapped [{ts:{$ne:null}},{ts:{$lte:500n}}]     reads=5  bytes=64791

The compact form (guard as a second key in the same condition object) is correct and 5x cheaper; the $and form silently gives up pruning. $ne is the one operator that must use $and, because its guard key collides with its own, and there the cost measures as zero.

What this means for this PR. The same guard must move upstream into icebird's whereToParquetFilter, in the compact shape, before this PR can land - otherwise merging it re-introduces the wrong-answer path that #730 just closed, and in a form that also loses pruning if the shape is copied from my original suggestion.

PR #730 touches src/core/query/parquet-pushdown.js, the file this PR deletes, so the two will conflict. Sequencing is yours: land #730 first for the immediate fix and rebase this one on the upstream icebird change when it exists, or hold #730 and do it all upstream in one move. This PR stays neutral:stuck and held either way.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Third correction to the porting guidance, from PR #730's review. Porting the null-guard upstream is not sufficient on its own - and applying it alone would make icebird strictly worse than it is today.

icebird@0.8.19/src/sql/whereFilter.js is a near-verbatim twin of the kernel converter: same unguarded leaf emission, same $nor at line 71, same LIKE decline.

NOT (a OR b) compiles to $nor, which matchFilter implements as !some(...) - a two-valued complement. SQL's NOT (a OR b) is three-valued: an all-UNKNOWN row is excluded, but hyparquet reports "no child matched, therefore true". Measured on PR #730 before its own fix:

predicate                       SQL     unguarded   guard-only
NOT (ts > 300 OR ts < 100)      [1,3]   [1,3]       [1,2,3,4]
NOT (ts >= 300 OR ts <= 100)    []      []          [2,4]
NOT (ts < 300 OR ts > 300)      [3]     [3]         [2,3,4]

Note the middle column: these three were correct while unguarded, because a bare {ts: {$lt: 100n}} accidentally matched the NULL row (null < 100n coerces to 0 < 100), which flipped $nor to exclude it. The coercion bug was cancelling itself out. Adding the guard removes the accident and the NULL row escapes through $nor.

So a guard-only port converts three currently-correct queries into wrong-row queries.

The upstream change needs all three:

  1. the $ne: null guard on relational and inequality leaves,
  2. De Morgan instead of $nor, so negation reaches the leaves where the guards live (NOT (a OR b) becomes NOT a AND NOT b),
  3. the compact {col: {$ne: null, <op>: v}} shape, not $and, or pruning is silently lost.

De Morgan is also a pruning win independently: canSkipRowGroup and filterPageRanges bail out unconditionally on $nor, whereas $and prunes if any branch prunes.

PR #730 has all three landed and reviewed, in src/core/query/parquet-pushdown.js - the file this PR deletes. It is the reference implementation to port. This PR stays neutral:stuck and held.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Merge-order warning, from PR #730's triage. #730 (the in-repo null-guard fix) is now held and approved. Its triage checked icebird 0.8.15's src/sql/whereFilter.js directly and confirmed it is the pre-#728 shape: bare relational operators, $nor for negated OR at line 58, unguarded $nin at line 153.

So this PR, as written, would reintroduce every NULL leak #730 fixes, plus the NOT (a OR b) wrongness that #730's own round 1 caught and corrected. Measured on an independent 71-predicate battery against a Kleene-3VL evaluator: #730's head is 0/71 wrong, master's converter is 39/71 wrong, and icebird's is master's shape.

The good news is that merge order is protective, not a trap. test/core/parquet-source.test.js imports src/core/query/parquet-pushdown.js directly, so this PR's deletion of that file breaks loudly rather than silently. Once the import is re-pointed, the end-to-end 3VL cases are shape-agnostic and will gate whatever converter replaces it. A silent revert would require deleting those tests too.

Two honest exits when you unstick this:

  1. Upstream the three changes into icebird first (the $ne: null guard, De Morgan instead of $nor, and the compact {col: {$ne: null, <op>: v}} shape), publish, bump the pin, then land this.
  2. Keep the local converter and shrink this PR's scope to whatever else it was buying.

This PR stays neutral:stuck and held either way.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Fourth correction to the porting guidance, and this one is material to whether the PR should proceed at all. PR #743's triage probed the pinned icebird converter directly and found it is worse than every earlier note here assumed.

I previously told you icebird needs three things ported (the $ne: null guard, De Morgan instead of $nor, the compact shape). That list was incomplete, and one earlier claim was wrong.

Icebird does not decline NULL comparisons. whereFilter.js:63 declines only on value === undefined; a NULL literal survives staticLiteral and converts. Probed against the pinned 0.8.20:

SQL icebird pushes rows SQL says
ts = NULL {ts: {$eq: null}} the NULL rows none
NOT (ts = NULL) {ts: {$ne: null}} the non-NULL rows none

and icebergDataSource claims appliedWhere: true for both, so the engine never re-filters. It answers a NULL-literal comparison with IS NULL semantics - actively wrong, not merely unguarded.

So the full port list is now four items, three of which this repo has already fixed in its own copy:

  1. the $ne: null guard on relational and inequality leaves (Guard pushed-down parquet filters against NULL rows (#728) #730),
  2. De Morgan instead of $nor, so negation reaches the leaves where the guards live (Guard pushed-down parquet filters against NULL rows (#728) #730),
  3. the compact {col: {$ne: null, <op>: v}} shape rather than $and, or pruning is silently lost (Guard pushed-down parquet filters against NULL rows (#728) #730),
  4. never-match for always-UNKNOWN predicates, and declining the rest (Push never-match for always-UNKNOWN predicates instead of declining to a two-valued engine (#734) #743) - the item this correction adds.

Adopting icebird's converter as-written would regress all four.

The cache path's independent exposure is filed as #744, since it exists on master today regardless of what happens to this PR. Whoever unsticks this should read that first: it characterizes the gap between the two converters precisely, and it changes the arithmetic of option 1 (port upstream, then bump) versus option 2 (keep the local converter and shrink this PR's scope) in favour of the latter, unless someone is prepared to land four separate fixes upstream first.

This PR stays neutral:stuck and held.

…al fold

icebird's converter still disagrees with SQL on NULLs (11 of 24 predicates
in a three-valued battery), so adopting it wholesale would undo #730 and
#743. Take the one thing it had that we lacked instead: constant-folding of
typed literals, plus the truthiness gate on boolean-position casts. Folded
bounds carry the same $ne: null guard plain bounds do.

Renumber the LLP to 0219; 0212 is taken.
squirreling 0.15.3 gives WHERE Kleene NULL semantics and icebird 0.8.22
pushes filters that agree with it, so the kernel converter is no longer
ahead on correctness. Delete it for the re-export, bump hyparquet to
1.28.2 (bare relational bounds reject null cells there), and move the
shape assertions to icebird's shapes. Row-set and appliedWhere tests are
unchanged and stay green either way.
@platypii platypii removed the neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human label Aug 13, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

unstuck

neutral:stuck removed on human instruction. The head moved
be9917c0b8583899b05ad64494163e8c86660d43 to
aea733b341f8af7f712f322a7573bf3b84014286 after the stuck report was written,
which is the LLP 0027 unstick predicate; the rung now returns review at the new
head. MERGEABLE / CLEAN, 9 checks green.

The question the stuck report was waiting on is answered. It asked for a
four-item port list before this repo's converter could be deleted: the $ne: null
guard, De Morgan instead of $nor, the compact guard shape, and never-match for
always-UNKNOWN predicates. I checked the pinned icebird@0.8.22 source rather than
taking the pin as evidence, and the semantics are there, distributed across the
three packages this PR bumps together:

  • De Morgan, not $nor - whereFilter.js convertBinary's OR branch pushes
    the negation into the children, with the reasoning spelled out: hyparquet
    evaluates $nor as a two-valued complement, so a row UNKNOWN for every disjunct
    would match, and $and prunes on row-group statistics where $nor never can.
  • Never-match for always-UNKNOWN NOT IN - col NOT IN (..., NULL) folds to
    {$in: []}.
  • The $ne / $nin null guard - guardNull wraps $ne, and convertInValues
    guards $nin the same way.
  • The inequality guards moved into hyparquet - the comment records that
    hyparquet >= 1.28.2 agrees with SQL on null cells for every operator except
    $ne, which is why only $ne needs a guard here.
  • NULL-literal comparisons take the other valid strategy. This repo returns a
    never-match {$in: []}; icebird declines (if (value === null) return undefined) and lets the engine answer, which is correct only because
    squirreling >= 0.15.3 is three-valued. That is a real dependency, not a
    coincidence, and it is why the three pins have to move together.

icebird@0.8.22's own package.json requires exactly hyparquet 1.28.2 and
squirreling 0.15.3, matching this PR's bumps (master is on 0.8.15 / 1.26.2 /
0.15.0).

One thing to sequence before merging

PR #751 (fix for #744, triaged ship, held for merge) added
withSqlCorrectWhere, which wraps the iceberg cache source and calls this
repo's
whereToParquetFilter to get a SQL-correct filter, because
icebergDataSource used icebird's converter internally and claimed
appliedWhere: true over it. This PR turns that function into icebird's
converter.

That is not the failure the #751 review warned about - it warned that #721 landing
before icebird took the NULL fixes would make the wrapper a no-op and return the
bug, and 0.8.22 has taken them. But the two PRs have never been tested against each
other, and both are open right now:

Neither ordering looks unsafe, but the second one changes a test's meaning, so it
is worth picking deliberately. Neutral is running the review of this head now and
will report on the new corpus in test/core/parquet-source.test.js (+157/-71).

@philcunliffe

Copy link
Copy Markdown
Contributor Author

neutral review - round 1

Head reviewed: aea733b341f8af7f712f322a7573bf3b84014286. MERGEABLE / CLEAN, 9
checks green. Reviewed in a detached worktree; nothing was written to the branch.

Correction to my unstick note above, before the record. I wrote that master is
on icebird 0.8.15 / hyparquet 1.26.2 / squirreling 0.15.0. That is wrong: master
is on 0.8.20 / 1.28.1 / 0.15.2, so this PR moves each by one or two patch
releases, not three. I got it by grepping the package.json in my read-only main
checkout, which has been pinned at the session's starting commit and never fetched
into, instead of reading origin/master. The reviewer caught it and re-derived the
real surface: the bump touches four source files across the three packages, and
I have confirmed the pins independently. My "three versions behind on all three"
framing overstated the risk of this bump considerably.

The rest of the unstick note holds: icebird@0.8.22 does carry the port, and the
NULL-literal decline does depend on squirreling >= 0.15.3 being three-valued. The
review establishes that dependency measurably rather than by reading.

The headline is finding 1, and it needs a human decision before either this PR or
#751 merges.
The reviewer ran #751's own 42-predicate parity corpus against the
iceberg cache tier at this head, through the real dataSourceForTable seam, with
no #751 wrapper present: 42/42 correct on the row path and 42/42 on the
filtered-aggregate path. So this bump closes #744 on its own, and #751's
withSqlCorrectWhere becomes a redundant second application of the identical filter
by the identical evaluator, on the cache tier's hot path, which is the per-row
materialization LLP 0098 exists to prevent. Two of #751's assertions do not merely
stop discriminating, they fail.

#751 is currently held for a human to merge. I am cross-posting this there.


All checks complete, worktree clean. Here is the review record.


VERDICT: findings — 1 major (cross-PR sequencing), 3 minor, 1 nit. No blocker. The converter swap itself is correct, and I could not make it return a wrong row on any path I traced.

Correction to the briefing I was given: master is on icebird 0.8.20 / hyparquet 1.28.1 / squirreling 0.15.2, not 0.8.15 / 1.26.2 / 0.15.0. The bump is one or two patch releases each, and it touches exactly four source files across all three packages (see §Also checked). The "three versions behind on all three" framing overstates the surface by a lot.


Findings

1. major — llp/0222-one-pushdown-converter.decision.md and test/core/iceberg-source-parity.test.js (PR #751): two assertions in #751 go red once this lands, and the wrapper it adds becomes dead weight

Scope: this is a sequencing finding about the #721 + #751 pair, not a defect in #721 in isolation. #721 alone is green.

I verified, at this head with no #751 wrapper, that the iceberg cache tier is already SQL-correct. I ran #751's own 42-predicate parity corpus through the real dataSourceForTable seam (src/core/cache/iceberg/store.js:557icebergDataSource), on both the row path and the filtered-aggregate scanColumn path:

=== iceberg cache tier, NO #751 wrapper, icebird 0.8.22 ===
all 42 correct
=== cache tier filtered aggregates (scanColumn) ===
all 42 correct

So LLP 0222 §consequences' claim that "#744 is closed by the same bump that lands this" holds, measured. withSqlCorrectWhere therefore has nothing left to repair: it converts with icebird's converter, forwards the same predicate to icebird for pruning, then re-applies the identical filter with the identical evaluator (matchFilter, filterStrict: false) over rows that already passed it. Not harmful to correctness — harmful to the thing LLP 0098 exists to protect: rowMatches materializes every predicate column per row, and the cross-column scanColumn branch rebuilds the column stream from a row scan in 1024-value batches. That is the per-row materialization 0098 removed.

Two assertions in test/core/iceberg-source-parity.test.js fail outright, not merely stop discriminating. In the cache column stream is NULL-correct and still claims the predicate:

const sameColumn = await drainColumn(scanColumn({
  column: 'ts', where: whereOf('SELECT ts FROM t WHERE NOT (ts = NULL)'),
}))
assert.equal(sameColumn.appliedWhere, true)   // becomes false
assert.deepEqual(sameColumn.values, [])       // becomes [100, null, 300, null, 500]

Verified at this head: whereToParquetFilter(NOT (ts = NULL))DECLINED. planWhere returns undefined, so withSqlCorrectWhere takes its declined() branch and streams the raw column. A direct scanColumn call has no engine above it to re-filter, so both assertions break.

Separately, #751's bounded case flips classification exactly as the unstick note predicted. Verified: neg > CAST(-400 AS BIGINT) now folds to {neg: {$gt: -400n}} instead of declining, so the corpus loses its only exercise of the declined-CAST path — and the case's 12-line explanatory comment (test/core/iceberg-source-parity.test.js:126-137) plus the corpus docblock (:46-62) become factually wrong, as do the three section comments asserting icebird pushes {ts: {$eq: null}}, bare unguarded bounds, and $nor. All three were true of 0.8.20 and are false of 0.8.22.

Exact fix, and what a human should sequence: land #721 first, then close #751 as obsoleted rather than rebasing it — its premise ("icebird's converter is wrong three ways") is retired by the bump, and the 42-case parity corpus is worth keeping only if it is re-pointed at the unwrapped dataSourceForTable (drop withSqlCorrectWhere, drop src/core/query/iceberg-source.js, drop the bounded option and its subset branch, keep every expected row set verbatim — I ran exactly that shape above and it is 42/42 green). If instead #751 must land first for schedule reasons, #721's rebase must delete withSqlCorrectWhere and its test file in the same commit, because leaving it in ships a redundant filter application on the cache tier's hot path. LLP 0221 and LLP 0222 both currently claim to settle #744 by different mechanisms; whichever lands second needs a Superseded-by: on the other.

2. minor — llp/0222-one-pushdown-converter.decision.md:12,19,31,92,95: "cache tier" and "archive tier" are swapped throughout

Line 19 reads:

src/core/query/parquet-pushdown.js (the cache tier, via parquet-source.js) and icebird/src/sql/whereFilter.js (the archive tier, via icebergDataSource)

That is backwards. parquetDataSource has exactly one non-test caller in the repo — hypaware-core/plugins-workspace/s3/src/query-dataset.js:90, the S3 format: 'parquet' remote path. The local intrinsic cache is Iceberg: src/core/cache/storage.jssrc/core/cache/iceberg/store.js:557icebergDataSource. So the converter this PR deletes served the archive/S3 tier, and icebird's served the cache tier.

The inversion propagates into load-bearing sentences. Line 31 ("every timestamp-bounded query scanned the cache tier unpruned") attributes the motivating 11.4s-vs-7.3s measurement to the tier that already had the typed-literal fold at 0.8.20 — the fold this PR gains is gained on the S3 parquet path. Line 92 ("Timestamp-bounded predicates prune the cache tier") has the same problem. Line 95 calls #744 "the archive tier's NULL wrongness", but issue #744 is titled "Cache-path queries answer NULL-literal comparisons with IS NULL semantics" and its body names store.js:557 explicitly.

This matters because 0222 is Accepted-on-landing and is the doc a future agent reads to learn which subsystem each converter served.

Fix: swap the two labels at lines 12, 19, 31, 92 and 95. Line 19 becomes "(the archive tier, via parquet-source.js)" / "(the cache tier, via icebergDataSource)"; line 31 "scanned the archive tier unpruned"; line 92 "prune the archive tier"; line 95 "Issue #744 (the cache tier's NULL wrongness)". Line 94's "The cache tier and archive tier answer the same predicate with the same rows" is symmetric and needs no change. Mechanical, and permitted on an Accepted doc under CLAUDE.md's "typos, broken links" carve-out.

3. minor — test/core/parquet-source.test.js: LLP 0222 names NOT (col LIKE 'a%') as the #734 closure, and no test asserts it

LLP 0222 §consequences line 97:

Issue #734 is closed outright: NOT (col LIKE 'a%') and every other negation of a declined subtree is now answered correctly by the engine.

The assertion that used to name this shape was deleted with the pushes never-match only where the shape proves it test (old line 236: assert.equal(whereToParquetFilter(whereOf("... NOT (name LIKE 'a%')")), undefined), whose comment said it "stays SQL-wrong until the engine speaks three-valued logic"). Nothing replaced it, at either the unit or the row level.

The corpus's only nearby case is ["NOT (label LIKE 'a%') AND ts IS NOT NULL", [3, 5]] (:415), which was chosen to pass under the two-valued engine — the IS NOT NULL conjunct masks the leaked NULL rows, so its expectation is identical before and after and it cannot discriminate. I measured the bare shape myself: master returns [2,3,4,5], this head returns [3,5]. That is the single largest user-visible behaviour change in the PR after the timestamp fold, and it is unasserted.

(The property is not unguarded — the NOT (ts = NULL) family in the issue #734 end-to-end test does gate the 3VL engine, since a two-valued engine returns all 5 rows there. But that gates 3VL via a declined-NULL-literal route, not via a declined-LIKE subtree, and it is the LIKE shape the LLP names.)

Fix: one line in the existing case list at test/core/parquet-source.test.js:410-421:

["NOT (label LIKE 'a%')", [3, 5]],

I verified it passes at this head.

4. nit — test/core/parquet-source.test.js: two decline boundary cases dropped with no replacement

Deleting pushes never-match only where the shape proves it also dropped NOT (NULL = 1)undefined (literal-vs-literal, no column to key on) and name || NULLundefined (a value expression, not a predicate). Both were boundary cases proving the NULL-literal branch declines for the right reason rather than by accident. Neither has an end-to-end equivalent. I verified both still behave correctly ([] in each case, through the engine).

Fix: add both to the declines NULL-literal comparisons to the engine test at :250-266, alongside the id + NULL case that survived the move.

5. nit — PR title says (LLP 0212); the doc is LLP 0222

Commit 0c6af6d renumbered correctly and the in-tree references all moved (parquet-pushdown.js:24, both test comments). The PR title and body still say 0212, which on master is now session-opt-out-is-a-cli-verb.decision.md — a live, unrelated Accepted doc. Fix: retitle the PR to (LLP 0222).


Decline versus never-match, end to end

Established: equivalent, on every path a decline can reach. The two strategies differ only in cost, and only for a degenerate predicate class.

The engine that takes over is genuinely three-valued. I diffed squirreling 0.15.2 → 0.15.3 from the published tarballs. Two files, and the change is exactly the Kleene conversion:

  • expression/binary.js: AND/OR get explicit Kleene tables (NULL AND FALSE → false, NULL AND TRUE → null), and a comparison with a null operand now returns null rather than false, with the reason recorded ("invisible to a WHERE … but not to a NOT above it").
  • expression/evaluate.js: unary NOT becomes val == null ? null : !val; AND/OR short-circuits gated on left != null; in valuelist and in subquery track sawNull and return null rather than false.

Every where-carrying scan in the repo returns to that engine. I enumerated all .scan( / scanColumn( call sites in src/ and hypaware-core/plugins-workspace/: sql.js:158,179 (pass-through), visibility.js:138, storage.js:390, union-source.js:70,107,144, ai-gateway/src/dataset.js:181,205, and store.js:508 — the last of which passes no where at all. There is no consumer that reads rows with a predicate and does its own filtering off appliedWhere.

No caller reads a decline as "no filter needed". parquet-source.js:51 is appliedWhere = Boolean(filter). icebird's icebergDataSource.js:97 and :233 are both where !== undefined && filter !== undefined — honest on both the row and column paths, so the cache tier's decline is reported, not swallowed. union-source.js:111 ANDs across partitions; its appliedWhere: true at :122 is the no-where branch only. scan-column.js:24's appliedWhere: !options.where is the legacy-source shim and is conservative in the right direction. The #744 bug class does not return by a new route.

Measured, not just reasoned. I ran a 50-predicate battery over a nullable INT64/STRING fixture through the full parquetDataSource + squirreling path, comparing against SQL three-valued truth computed by hand — including every shape whose unit assertion this PR deleted:

=== row battery ===        all 50 correct
=== aggregate / scanColumn path ===   7/7 correct
=== union path ===                    6/6 correct
=== appliedWhere for declined predicates ===
ts = NULL              -> appliedWhere=false
NOT (label LIKE 'a%')  -> appliedWhere=false
ts <= 300              -> appliedWhere=true

The aggregate row includes SELECT count(*) WHERE ts = NULL → 0 and SELECT count(*) WHERE NOT (ts = NULL) → 0, which are the shapes that would expose a decline reaching a non-re-filtering aggregate path. And the 42-case cache-tier run in finding 1 covers icebergDataSource's own scan and scanColumn.

The pruning cost of declining, quantified: it does not matter. On a 500-row / 10-row-group file:

predicate rows bytes read
ts = NULL (declined) 0 4440
ts != NULL (declined) 0 4440
NOT (ts = NULL) (declined) 0 4440
ts NOT IN (1, NULL) ($in: []) 0 0
ts IN (NULL) ($in: []) 0 0
no filter 500 2323

So the decline does cost a full read of the predicate columns where the never-match cost nothing. But it applies to exactly one family — a literal NULL written into a WHERE — which is a degenerate, near-always-a-bug predicate that returns zero rows by construction. Every non-degenerate never-match survives: NOT IN (…, NULL) and IN (NULL) still fold to {$in: []} and still prune every row group on statistics alone. I judge the trade fine and would not hold the PR for it.

Dropping the $ne: null guards costs no pruning either. This was the worry the earlier neutral notes raised (compact guard vs $and vs bare). On hyparquet 1.28.2 all three shapes are byte-identical:

bare    {ts:{$lte:49}}          (icebird)  rows=42 bytes=438
compact {ts:{$ne:null,$lte:49}} (kernel)   rows=42 bytes=438
$and    [{$ne:null},{$lte:49}]             rows=42 bytes=438

and for $nin, where icebird uses $and and the kernel used the compact form, also identical (both 1953 bytes, both 300 correct rows; bare leaks to 350). The compact-shape argument was specific to 1.28.1's null-coercing comparators and is retired by the floor.


Corpus diff, case by case

Headline: the behavioural corpus was not weakened at all. I extracted every [predicate, expected] tuple from the old file (c483c1a) and the new one and diffed them sorted:

old=66 new=66
=== removed from corpus ===   (empty)
=== added to corpus ===       (empty)

All 66 end-to-end cases across issue #728, comparison against a NULL literal, issue #734, and predicates that are not always-UNKNOWN are byte-identical, expectations included. Not one expected row set was edited to match new behaviour. That is the strongest possible answer to "was the corpus bent to fit". Everything that changed is a shape assertion at the unit level, and the row-level truth those shapes must produce is pinned independently and unchanged.

Unit assertions whose expectation changed (7 tests, all shape-only, all justified and each independently confirmed by the unchanged row corpus):

assertion old new verdict
id = 3 {$eq: 3n} {$eq: 3} ok — coerceBigInt dropped; filterStrict:false routes $eq/$in/$nin through equals(), and I confirmed hashParquetValue (hyparquet/src/bloom.js:118) requires typeof value === 'number' for FLOAT/DOUBLE/INT32 and accepts either for INT64, so this is a bloom-pruning gain
id > 3, id <= 3, 3 < id, 3 >= id {$ne:null, $gt:3n} {$gt: 3} ok — guard moved into hyparquet 1.28.2; verified by the filter.js diff and by the pruning measurements above
NOT (id = 1) $and + $ne guard same, number literal ok — $ne is the one operator that keeps its guard
NOT (id = 1 OR id = 2) De Morgan $and same ok — icebird 0.8.22 carries De Morgan with the reasoning intact; 0.8.21 still had $nor at line 75, which I confirmed from the tarball
id NOT IN (1,2) {$ne:null, $nin:[1n,2n]} {$and:[{$ne:null},{$nin:[1,2]}]} ok — measured identical pruning
id IN (1, NULL) {$ne:null, $in:[1n]} {$in: [1]} ok — matchesInequals(null, 1, false) is false, so the guard was redundant
id = NULL family (8 cases) {$in: []} undefined ok — the strategy change, verified equivalent above

Unit assertions dropped. Six, from the deleted pushes never-match only where the shape proves it and the retired handles predicates whose SQL result is always UNKNOWN:

  • NOT NOT (id = NULL)acceptable: NOT NOT (ts = NULL) survives in the row corpus (:379).
  • name LIKE NULLacceptable: survives at :384.
  • NOT (name LIKE NULL)acceptable: survives at :385.
  • NOT (id = 3) → guarded $neacceptable: NOT (id = 1) in handles AND / OR / NOT is the same shape.
  • id = NULL OR id = 3acceptable: moved into the decline test with the correct new expectation (undefined, since the declined conjunct collapses the whole tree), and the row-level ['ts = NULL OR ts = 300', [3]] is unchanged.
  • NOT (NULL = 1) and name || NULLfinding 4 (nit): no replacement at either level. Verified correct by probe.
  • NOT (name LIKE 'a%')finding 3 (minor): no replacement at either level, and it is the shape LLP 0222 names.

Tests added (5), all strengthening:

  • folds typed literals (TIMESTAMP casts) — 3 assertions including TIMESTAMP 'not-a-day'undefined, which pins foldCast's fail-safe.
  • only unwraps truthiness-preserving casts — pins the CAST(a = 1 AS TEXT) gate that is the other correctness fix icebird brings.
  • timestamp day bounds filter correctly through the pushed-down scan — end to end, over a nullable TIMESTAMP fixture with a NULL row, expecting [2,3]. This directly discharges minor findings 1 and 3 from the previous review round.
  • a folded timestamp bound is actually pushed downassert.equal(scan.appliedWhere, true). This discharges the previous round's finding 2 (the "does not discriminate" one) exactly as it asked.
  • a timestamp bound matching no rows returns none (and one matching all returns all)4, not 5 on the all-matching bound, so the NULL row's exclusion is asserted at both extremes.

The TIMESTAMP_COLUMNS fixture is now { name: 'at', type: 'TIMESTAMP', nullable: true } with a NULL row, with the comment naming the eleven shipped nullable-TIMESTAMP ColumnSpecs. That was the previous round's minor finding 3 and it is properly fixed.


Also checked, clean

  • The 23 surviving lines of parquet-pushdown.js. A pure re-export: export { whereToParquetFilter } from 'icebird/src/sql/whereFilter.js', no re-wrap, no argument or return massaging. build:types emits a clean one-line re-export to types/core/query/parquet-pushdown.d.ts. src/core/query/index.js:9 still re-exports it, so the hypaware/core/query public surface is unchanged. icebird's exports map publishes "./src/*.js" with "types": "./types/*.d.ts", files includes types, and types/sql/whereFilter.d.ts is a real signature (ExprNode → ParquetQueryFilter | undefined), not any. The deep-import route is the same one ~20 existing cache modules already take.
  • @ref hygiene. The two @ref LLP 0098 [constrained-by] annotations died with the code they annotated; nothing dangles. Grepped the whole tree for the deleted helpers (coerceBigInt, guardNulls, convertInValues, extractColumnAndValue, mapOperator) — the only surviving mentions are the module header comment and LLP 0222's #no-bigint-coercion section, both deliberate. The new @ref LLP 0222 [implements] is attached with no blank line to the export, carries no anchor (so nothing to resolve), and the LLP exists. The four other @ref LLP 0098 sites in the repo all point at wrapper duties untouched by this PR and still hold. test/core/llp-ref-hygiene.test.js — which validates anchor resolution, duplicate LLP numbers, and em dashes inside annotations — is green.
  • LLP 0222 number, and its claims. 0222 is free on origin/master and across all 47 remote branches; I swept every branch's llp/ tree for 021[9]/022[0-9] and found 0219 (master), 0220, 0221 (Pin cross-backend query parity and icebird 0.8.22's pushdown contract (tests only) #751), 0222 (this), 0223, each claimed once. Header shape matches house style. Verified against the tree: the #hyparquet-floor claim (confirmed by the 1.28.1→1.28.2 filter.js diff, which is literally four lines adding value !== null && value !== undefined && to $gt/$gte/$lt/$lte); #no-bigint-coercion's three sub-claims (matchesInequals in 1.28.1 and 1.28.2, bloom hashing type gates in bloom.js:118-160); "the same exact pin, resolving to a single deduped copy" (one top-level hyparquet@1.28.2, matching icebird 0.8.22's own exact "hyparquet": "1.28.2"; the only other copy is hypvector/node_modules/hyparquet@1.26.2, which never sees this filter); every §Decision step-2 semantic (De Morgan, bare flipped operators, $ne/$nin guards, NOT IN with NULL → {$in: []}, NULL members dropped from a plain IN, NULL literals declined) read out of the installed 0.8.22 source; and §consequences' three guardrail claims (row sets end to end, appliedWhere asserted, bytes measured) all present in the test file. The "icebird@0.8.21 wrong on 11 of 24" claim I could not reproduce exactly, but I confirmed 0.8.21 is the pre-fix shape ($nor at line 75, guardNull written against a two-valued engine), which makes it credible. The production 11.4s/7.3s measurement is unverifiable from here.
  • LLP 0098 is not rewritten. Zero diff. 0222 cites it as Related and correctly does not claim to extend it — 0098's one mention of whereToParquetFilter already describes icebird's converter inside scanColumn, and 0098's "an unconvertible predicate … leaves appliedWhere: false and the engine re-filters" is exactly the contract the declining strategy leans on, now backed by a genuinely three-valued engine. No forward-ref needed.
  • The dependency bump is surgical, and fully accounted for. diff -rq of each package's src/ tree against the previously pinned version: icebird 0.8.20→0.8.22 changes one file (src/sql/whereFilter.js); hyparquet 1.28.1→1.28.2 changes one (src/filter.js, the four-line null guard shown above); squirreling 0.15.2→0.15.3 changes two (expression/binary.js, expression/evaluate.js, the Kleene conversion). Nothing else moved in any of the three. The only behaviour change beyond the NULL work is squirreling's 3VL reaching non-WHERE positions — a projected comparison (SELECT a < b) now yields null instead of false, and HAVING/CASE/JOIN ON see null where they saw false (falsy either way, so no row-set change). I grepped every SQL string in src/ and src/core/query/overview.js: no shipped query projects a comparison, so this is unobservable in the product today. Worth knowing; not worth a finding.
  • Conventions. No U+2014 anywhere in the four changed files (checked with a real codepoint scan, not a shell glob). No semicolons in the changed JS. No @typedef, no inline import('...') types, no .d.ts type-import specifiers. The two @import lines in the test file are root-anchored .js paths as required.
  • What I actually ran, on a fresh npm install in the worktree at aea733b3: npm test (4029 pass / 0 fail / 1 skipped), npm run typecheck (clean), npm run build:types (clean), node --test test/core/parquet-source.test.js (28/28), npm run smoke -- local_parquet_export (ok), npm run smoke -- cache_lifecycle_maintenance (ok), plus four purpose-built probes (50-predicate 3VL battery across row/aggregate/union paths; 42-case cache-tier parity through dataSourceForTable; byte-counting pruning measurements for decline-vs-never-match and for all three guard shapes; and a direct conversion probe for the Pin cross-backend query parity and icebird 0.8.22's pushdown contract (tests only) #751-relevant predicates). Scratch files removed; git status clean.

@philcunliffe philcunliffe changed the title Delete our duplicate WHERE-pushdown converter, use icebird's (LLP 0212) Delete our duplicate WHERE-pushdown converter, use icebird's (LLP 0222) Aug 13, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

neutral triage - ship

Review budget exhausted with findings open. Triage judged each residual a true
blocker or a preference. All five are preferences. This PR can merge. Deferred
to #756.

A human asked for this PR to be unstuck and moved forward, so re-sticking it needed
a genuinely production-level reason. There isn't one, and I checked rather than
deferred: the four-item port list the old stuck report demanded is satisfied by the
pinned dependencies (verified from icebird 0.8.22's source, not from the pin), the
converter swap returns no wrong row on any path traced, and the behavioural corpus
was not weakened (all 66 end-to-end cases byte-identical, expectations included).

Re-verified at head aea733b before deciding: a fresh npm install resolves
exactly icebird 0.8.22 / hyparquet 1.28.2 / squirreling 0.15.3; npm test 4029
pass / 0 fail / 1 skipped; npm run typecheck clean; smokes local_parquet_export
and cache_lifecycle_maintenance green.

The one finding that needed real thought: sequencing with #751

Triage established the worst case concretely rather than reasoning about it in the
abstract. gh pr view --json files shows #721 and #751 share zero files, so if
a human merges both without reading, git merges both cleanly. What ships is:

Correctness survives every path in the merged state: when icebird's converter
declines, the wrapper reports appliedWhere: false and the now-three-valued engine
re-filters correctly; when it converts, the filter is applied twice, wasteful but
right. So the worst case is a loud coordination cost, not silently wrong query
results, the hazard is cross-posted on both threads, and #751 is itself held for a
human, so a human is necessarily at the decision point.

Recommended order, verified: land #721 first, then close #751 as obsoleted. Its
42-case parity corpus was run at this head with no wrapper present and is 42/42
correct on both the row and filtered-aggregate paths, so the wrapper has nothing
left to repair. The corpus is worth salvaging re-pointed at the unwrapped
dataSourceForTable (drop withSqlCorrectWhere, src/core/query/iceberg-source.js
and the bounded subset branch, keep every expected row set verbatim), which was
verified 42/42 green in exactly that shape. If #751 must land first, #721's rebase
must delete the wrapper and its test file in the same commit. Whichever LLP loses
the overlapping #744-closure claim (0221 or 0222) needs a Superseded-by:.

One residual fixed here rather than deferred

The title said (LLP 0212) while the doc is LLP 0222, and on master 0212 is a
live unrelated Accepted doc, so a merge commit would have cited the wrong design
record. I retitled the PR. Commit 0c6af6d had already renumbered every in-tree
reference; only the title lagged.

The other three

All on #756: LLP 0222 has cache tier and archive tier swapped at five lines
(the deleted converter served the archive/S3 path, icebird's served the cache), an
unasserted NOT (col LIKE 'a%') shape that the LLP names as the #734 closure and
that nothing currently pins (one line, verified passing), and two decline boundary
cases dropped with no replacement.

@platypii
platypii merged commit 8c08185 into master Aug 14, 2026
9 checks passed
@platypii
platypii deleted the fix/one-pushdown-converter branch August 14, 2026 00:16
philcunliffe pushed a commit that referenced this pull request Aug 14, 2026
…LLP 0222)

PR #721 bumped icebird 0.8.20 to 0.8.22, hyparquet 1.28.1 to 1.28.2 and
squirreling 0.15.2 to 0.15.3, and replaced this repo's WHERE converter with
icebird's. The NULL work converged upstream, so issue #744 is closed by that
bump and `withSqlCorrectWhere` has no bug left to work around: it would only
re-apply icebird's own filter to rows that already passed it, which is the
per-row materialization LLP 0098 exists to prevent.

Deleted: `src/core/query/iceberg-source.js`, its export in
`src/core/query/index.js`, the `store.js` and s3 `query-dataset.js` wiring,
LLP 0221, and the LLP 0098 forward-ref. LLP 0222 records where converter
ownership now lives.

Kept, as the durable value: the cross-backend parity corpus, rebuilt through
`dataSourceForTable` directly. Every expected row set is SQL's three-valued
answer written down by hand, so the suite fails on a shared regression as
well as a divergent one. Against the pre-#721 stack (icebird 0.8.20,
hyparquet 1.28.1, squirreling 0.15.2) five of its six tests fail.

The wrapper-specific tests changed meaning rather than dying: `appliedWhere`
honesty and LIMIT/OFFSET now pin icebird 0.8.22's own contract, which is what
LLP 0222 makes the whole stack depend on, asserted shape by shape against the
parquet tier so a converter change in a dependency cannot diverge silently.

The CAST subset chain tightened to full equality, measured rather than
assumed. `neg > CAST(-400 AS BIGINT)`, `NOT (neg > CAST(-400 AS BIGINT))`,
`NOT (neg >= CAST(-300 AS BIGINT))` and `NOT (neg > CAST(-400 AS BIGINT) OR
neg > CAST(-600 AS BIGINT))` all give SQL == cache == parquet, so the
`bounded` option, the `isSubset` helper and the subset branch are gone rather
than left as an assertion that cannot fail.

Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe pushed a commit that referenced this pull request Aug 14, 2026
…g, s3 comment

- Body: pre-#721 corpus divergence is 29, not 27 (round 1's number for the
  41-case corpus, carried forward after it grew to 47 with the CAST section)
- Body: the CAST subset chain pushes a bare bound, guarded only for
  != / NOT IN; the rest are SQL-correct on null cells because of the
  hyparquet >= 1.28.2 floor, not because of a guard
- test/plugins/s3-query-dataset.test.js: the two CAST cases pin row
  correctness and the hyparquet-floor tripwire on the remote tier, not the
  cast fold (verified: removing the fold still passes 7/0)

Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe added a commit that referenced this pull request Aug 14, 2026
… (tests only) (#751)

* Cache-path queries answer NULL-literal comparisons with IS NULL semantics (#744)

Wrap every icebergDataSource so the rows it yields are judged by this
repo's WHERE converter instead of icebird's, which converts a NULL-literal
comparison to IS NULL semantics, pushes unguarded inequalities, and
complements a negated OR two-valued - all three claimed as appliedWhere,
so the engine never re-filtered and the wrong answer was final.

The predicate is still forwarded to icebird as a pruning-only hint (its
filter is always a superset of SQL's answer), so no file or row-group
pruning is lost.

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

* Scope LLP 0221's parity claim to owned predicates, cover CAST and the s3 iceberg branch (#744)

Review round 1 on PR #751 found the Consequences section overclaiming
cross-backend parity unconditionally: for a predicate the kernel
converter declines (a CAST/typed-literal operand), icebird's own
converter still folds it, so the cache path can return fewer rows than
the parquet path (safe direction, not the equality the doc claimed).
Scope bullet 1 to predicates the kernel converter owns and rewrite
bullet 3 to state the declined-predicate relationship is bounded
SQL ⊆ cache ⊆ parquet, backed by a new CAST case in
iceberg-source-parity.test.js that asserts the subset chain instead of
strict equality. Same fix applied to the PR body's own overreaching
#734 bullet.

Also closes the s3 iceberg branch's test coverage gap: query-dataset.js
wraps its icebergDataSource in withSqlCorrectWhere the same way the
local cache does, but had no test proving the remote branch is
NULL-correct. Added a round-trip test that writes a real table through
a real BlobStore and exercises the same NULL-literal predicates.

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

* Drop the cache WHERE wrapper #721 obsoleted, keep its parity corpus (LLP 0222)

PR #721 bumped icebird 0.8.20 to 0.8.22, hyparquet 1.28.1 to 1.28.2 and
squirreling 0.15.2 to 0.15.3, and replaced this repo's WHERE converter with
icebird's. The NULL work converged upstream, so issue #744 is closed by that
bump and `withSqlCorrectWhere` has no bug left to work around: it would only
re-apply icebird's own filter to rows that already passed it, which is the
per-row materialization LLP 0098 exists to prevent.

Deleted: `src/core/query/iceberg-source.js`, its export in
`src/core/query/index.js`, the `store.js` and s3 `query-dataset.js` wiring,
LLP 0221, and the LLP 0098 forward-ref. LLP 0222 records where converter
ownership now lives.

Kept, as the durable value: the cross-backend parity corpus, rebuilt through
`dataSourceForTable` directly. Every expected row set is SQL's three-valued
answer written down by hand, so the suite fails on a shared regression as
well as a divergent one. Against the pre-#721 stack (icebird 0.8.20,
hyparquet 1.28.1, squirreling 0.15.2) five of its six tests fail.

The wrapper-specific tests changed meaning rather than dying: `appliedWhere`
honesty and LIMIT/OFFSET now pin icebird 0.8.22's own contract, which is what
LLP 0222 makes the whole stack depend on, asserted shape by shape against the
parquet tier so a converter change in a dependency cannot diverge silently.

The CAST subset chain tightened to full equality, measured rather than
assumed. `neg > CAST(-400 AS BIGINT)`, `NOT (neg > CAST(-400 AS BIGINT))`,
`NOT (neg >= CAST(-300 AS BIGINT))` and `NOT (neg > CAST(-400 AS BIGINT) OR
neg > CAST(-600 AS BIGINT))` all give SQL == cache == parquet, so the
`bounded` option, the `isSubset` helper and the subset branch are gone rather
than left as an assertion that cannot fail.

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

* Fix PR #751 review nits: re-measured corpus count, guard-split wording, s3 comment

- Body: pre-#721 corpus divergence is 29, not 27 (round 1's number for the
  41-case corpus, carried forward after it grew to 47 with the CAST section)
- Body: the CAST subset chain pushes a bare bound, guarded only for
  != / NOT IN; the rest are SQL-correct on null cells because of the
  hyparquet >= 1.28.2 floor, not because of a guard
- test/plugins/s3-query-dataset.test.js: the two CAST cases pin row
  correctness and the hyparquet-floor tripwire on the remote tier, not the
  cast fold (verified: removing the fold still passes 7/0)

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

---------

Co-authored-by: test <test@example.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: test <test@test.com>
philcunliffe added a commit that referenced this pull request Aug 14, 2026
* Fix LLP 0222 tier swap, add #734/decline regression cases (#756)

Three deferred triage findings from PR #721 (LLP 0222):

- llp/0222-one-pushdown-converter.decision.md had "cache tier" and
  "archive tier" swapped at the lines that name which converter fed
  which tier. parquetDataSource (parquet-source.js, the deleted
  kernel converter) has its one non-test caller in the S3/archive
  plugin; the local intrinsic cache reaches icebird's converter via
  cache/iceberg/store.js. Fixed the five load-bearing sentences this
  propagated into; left the symmetric "cache tier and archive tier
  convert predicates identically" sentence as is.
- Added the bare `NOT (label LIKE 'a%')` case LLP 0222 names as the
  #734 closure to the always-UNKNOWN regression list.
- Restored the `NOT (NULL = 1)` and `name || NULL` decline boundary
  cases dropped with the old unit test.

* Fix WHERE-decline comment to name the guard that actually fires (#756)

---------

Co-authored-by: test <test@test.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:adopt Foreign PR adopted into neutral's reconcile scope neutral:adopted Adoption completion record: merged while carrying neutral:adopt (LLP 0031)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants