Skip to content

Fix LLP 0222 tier swap, add #734/decline regression cases (#756) - #763

Merged
philcunliffe merged 2 commits into
masterfrom
fix/issue-756
Aug 14, 2026
Merged

Fix LLP 0222 tier swap, add #734/decline regression cases (#756)#763
philcunliffe merged 2 commits into
masterfrom
fix/issue-756

Conversation

@philcunliffe

Copy link
Copy Markdown
Contributor

Summary

Closes the three remaining items in #756 (item 4, the PR #721 retitle, was already done). All four items trace back to PR #721 / LLP 0222, which is now merged (8c08185).

  • LLP 0222 tier swap: llp/0222-one-pushdown-converter.decision.md attributed parquetDataSource (fed by src/core/query/parquet-source.js, the converter Delete our duplicate WHERE-pushdown converter, use icebird's (LLP 0222) #721 deleted) to the cache tier and icebird's converter to the archive tier — backwards. Confirmed by tracing callers: parquetDataSource's one non-test caller is hypaware-core/plugins-workspace/s3/src/query-dataset.js (the archive/S3 tier); the local intrinsic cache reaches icebird's converter via src/core/cache/storage.jssrc/core/cache/iceberg/store.js (dataSourceForTableicebergDataSource). Fixed the five load-bearing sentences at lines 19-20, 31, 92, and 95. Line 12 ("The cache tier and the archive tier convert predicates identically") names both tiers without attributing either to a specific converter, so it needed no change, same as the already-correct line 94. This is a mechanical editorial correction to an Accepted doc under CLAUDE.md's typo/broken-link carve-out — it doesn't change what the doc decided (one converter, owned by icebird).
  • #734 regression tripwire: added ["NOT (label LIKE 'a%')", [3, 5]] to the always-UNKNOWN case list in test/core/parquet-source.test.js. Verified it passes now, and read through icebird/src/sql/whereFilter.js (op === 'LIKE' always declines, negate or not) and squirreling's applyBinaryOp/evaluate.js (a null-operand comparison is null/UNKNOWN, and unary NOT keeps null as null rather than JS ! flipping it) to confirm the SQL-correct answer is [3, 5], not [2, 3, 4, 5].
  • Decline boundary cases: restored NOT (NULL = 1) (literal vs. literal, no column to key a filter on) and name || NULL (a value expression, not a predicate) to the declines NULL-literal comparisons to the engine test, alongside the surviving id + NULL case. Confirmed both still return undefined against the current (icebird, re-exported) converter.

Test plan

  • npm install (fresh)
  • npm test (4029 pass / 0 fail)
  • npm run typecheck
  • node --test test/core/parquet-source.test.js
  • node --test test/core/llp-ref-hygiene.test.js
  • npm run smoke -- local_parquet_export
  • npm run smoke -- cache_lifecycle_maintenance

🤖 Generated with Claude Code

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.
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

neutral review - round 1

Head reviewed: ff6ac6bd10d31282c55c808181be694b778fb7c0. MERGEABLE, all 9 checks
SUCCESS. Reviewed in a detached worktree; nothing was written to the branch.

One nit. The doc correction is right, and the reviewer established that the hard
way rather than by reading the PR body.

Two things stand out:

  • It proved the new test case discriminates by execution, not argument. The
    fixer could not revert the dependency bump, so it argued from source. The reviewer
    built the pre-Delete our duplicate WHERE-pushdown converter, use icebird's (LLP 0222) #721 stack in a scratch directory (hyparquet 1.28.1, squirreling
    0.15.2, plus the deleted converter and data source recovered from
    git show 8c08185^:...) and replayed the exact fixture: all seven pre-existing
    cases unchanged, and NOT (label LIKE ) the only one that flips, [2,3,4,5]
    on the old stack versus [3,5] at this head. It then derived [3,5] by hand from
    the fixture rather than reading it off the code.
  • Its account of why the editorial carve-out applies is stronger than the PR body).
    Because the old labelling was uniformly swapped, the doc was internally
    self-consistent, which is why nothing read obviously backwards; the one place it
    broke out and contradicted the external record was line 95 calling Cache-path queries answer NULL-literal comparisons with IS NULL semantics #744 "the
    archive tier NULL wrongness" when Cache-path queries answer NULL-literal comparisons with IS NULL semantics #744 is titled "Cache-path queries...". The
    argument is invariant under exchanging the two names because every inference is
    drawn between two converters named by path.

It also confirms the author was right to leave line 12 alone, and notes #756 named
that line in its header while its own prescribed fix did not touch it. The author
resolved that inconsistency correctly.


Cleanup done, tree clean. Here is the review record.


VERDICT: findings

1. nit — test/core/parquet-source.test.js:267 — the comment names a decline reason the converter never reaches for this input

The restored case reads:

// A value expression (concatenation), not a predicate.assert.equal(whereToParquetFilter(whereOf('SELECT * FROM t WHERE name || NULL')),undefined)

The assertion is correct and the expected value is right, but the stated reason is not the branch that fires. In icebird's convertBinary (node_modules/icebird/src/sql/whereFilter.js:86-93) the order is:

const{ column, value, flipped }=extractColumnAndValue(left,right)if(!column||value===undefined)returnundefinedif(value===null)returnundefined// fires for `name || NULL`constmongoOp=mapOperator(op,flipped,negate)if(!mongoOp)returnundefined// the "not a predicate" branch

extractColumnAndValue ignores op entirely, so for name || NULL it returns {column: 'name', value: null} and the NULL-literal check short-circuits before mapOperator is ever consulted. I confirmed this by probe: name || 'x' (identical shape, non-NULL literal) also declines, and it can only decline at mapOperator; name || NULL and id + NULL both decline one line earlier. So this case declines for exactly the same reason as the id + NULL case directly above it, and cannot distinguish "declines because it is a value expression" from "declines because of the NULL literal".

The reason is a faithful carryover from the deleted test (git show 8c08185^:test/core/parquet-source.test.js:229-233, "The branch is for predicates, not values"), where it was accurate: the old kernel converter gated on op kind first (if (!isComparisonOp(op) && op !== 'LIKE') return undefined, old parquet-pushdown.js:119). icebird reversed that ordering. Issue #756 restated the old rationale verbatim, so the author inherited it rather than introducing it.

Why it matters: this is a boundary case whose whole purpose (per #756) is to prove the NULL-literal branch declines "for the right reason rather than by accident". As written it documents a reason the code does not exercise, which is the failure mode the case exists to prevent.

Exact fix, either of:

  • Replace the comment at :267 with one that matches the operative branch, for example // Same NULL-literal branch as the arithmetic case above: the operator is never consulted, since a NULL literal opposite a bare column declines first.; or
  • Keep the comment and add the case that actually pins it, immediately after :268:
// ...and a value expression declines on the operator even without a NULL.assert.equal(whereToParquetFilter(whereOf("SELECT * FROM t WHERE name || 'x'")),undefined)

I verified that added assertion passes at this head.

Nothing here blocks merge. The doc correction is right, and the NOT (label LIKE 'a%') case discriminates.

Tier attribution, re-derived

I traced both converters from definition to caller without relying on the PR body, the issue, or the #721 review.

The deleted converter, src/core/query/parquet-pushdown.js. Whole-tree grep (excluding node_modules) for whereToParquetFilter gives exactly three non-test consumers: src/core/query/parquet-source.js:7 (the import), src/core/query/parquet-source.js:50 (the only call site), and src/core/query/index.js:9 (a public re-export from hypaware/core/query). It reaches rows only through parquetDataSource (parquet-source.js:40). Grep for parquetDataSource gives one non-test caller in the entire tree: hypaware-core/plugins-workspace/s3/src/query-dataset.js:90, inside createDataSource, on the source.format !== 'iceberg' branch, reading .parquet objects listed off an S3 prefix. That is the remote export store, not the local cache.

icebird's converter, icebird/src/sql/whereFilter.js. It is reached through icebergDataSource. Grep gives four call sites: src/core/cache/iceberg/store.js:499 (scanRowsFromTable) and :557 (dataSourceForTable), plus hypaware-core/plugins-workspace/s3/src/query-dataset.js:125 (the S3 format: 'iceberg' branch). dataSourceForTable is wrapped and re-exported by src/core/cache/storage.js:369, which is the seam every intrinsic-cache query goes through (17 smoke flows and context-graph/src/datasets.js:166 call storage.dataSourceForTable). The local intrinsic cache is Iceberg-backed end to end; nothing in src/core/cache/ touches parquetDataSource.

Vocabulary check. "archive" is not defined in CONTEXT.md or llp/0000, so I checked corpus usage. LLP 0022:10 ("Lay out exported Iceberg tables for an archive's job, not the cache's"), LLP 0014:52-57 and its example config ("archive": { "writer": "@hypaware/format-iceberg", "destination": "@hypaware/s3" }), and LLP 0056:77 all use "archive" for the exported remote store and "cache" for the local intrinsic store. The correction matches that usage.

Independent cross-check from issue #744. Its title is "Cache-path queries answer NULL-literal comparisons with IS NULL semantics", its body says "the iceberg cache path (every intrinsic dataset...)" and names src/core/cache/iceberg/store.js:557 explicitly, and it opens with "The parquet-file path is SQL-correct on always-UNKNOWN predicates as of #743". That independently pins icebird's converter to the cache tier and the kernel's to the parquet/archive path, which is what the PR asserts.

Verdict on every line of the doc that names a tier (all eight occurrences of "tier"):

linetext after the PRverdict
12"The cache tier and the archive tier convert predicates identically"correct as-is; the author was right to leave it. It is symmetric under the swap: naming both tiers with no converter attached to either means the sentence has the same meaning before and after. It is also true at this head, since both tiers now run icebird's function (the S3 parquet branch via the parquet-pushdown.js re-export, the S3 iceberg branch and the local cache via icebergDataSource). Same category as line 94, which #756 itself exempted. Note that #756 lists line 12 in its header sentence but its own prescribed fix does not touch it; the author resolved that inconsistency the right way.
19-20"(the archive tier, via parquet-source.js)" / "(the cache tier, via icebergDataSource)"correct, and the load-bearing one. Matches the call-graph trace above exactly.
21continuation of 20correct
31"scanned the archive tier unpruned"correct. The declining converter was the kernel's copy, and the kernel's copy served only parquetDataSource, so the tier left unpruned is necessarily the archive one, whatever host the stopwatch ran on.
92"Timestamp-bounded predicates prune the archive tier"correct, and now the mirror of line 31
94"The cache tier and archive tier answer the same predicate with the same rows"correct as-is, symmetric, unchanged
95"Issue #744 (the cache tier's NULL wrongness)"correct, and confirmed against #744's own title and body
99"both tiers get them"correct, symmetric

End-to-end consistency, and the sentences that only made sense under the old labelling. The old doc was uniformly swapped and therefore internally self-consistent, which is why nothing read obviously backwards. There was one place where it broke out and contradicted the external record: old line 95 called #744 "the archive tier's NULL wrongness" while #744 is titled "Cache-path queries...". After the fix the doc is consistent in both directions, and one internal cross-check now closes that did not before: lines 39-42 say the NULL disagreements were fixed in "the kernel's copy" and "icebird retained" them, and line 47 says icebird@0.8.21 was wrong on 11 of 24. With icebird correctly labelled the cache tier, line 95's "the cache tier's NULL wrongness" follows directly from lines 39-47. I re-read all 108 lines: no other sentence names or implies a tier, and nothing in the sections the PR did not touch (## Decision, #hyparquet-floor, #no-bigint-coercion) depends on the labelling at all. It is not half-corrected.

On the editorial carve-out. The claim holds, and for a stronger reason than the PR body gives. Because the old labelling was uniformly swapped, the doc's entire argument is invariant under exchanging the two names: every inference the doc draws is drawn between the two converters (parquet-pushdown.js vs icebird/src/sql/whereFilter.js), which are named by path throughout and are never ambiguous. The tier words are labels hung on those paths, and relabelling them changes only which real subsystem a reader maps the name onto. That is the same defect class as a link pointing at the wrong page. Structurally: none of the five corrected lines is in ## Decision; they sit in ## Context (background) and ## Consequences (predicted effects). What LLP 0222 settled, that the kernel keeps no converter and re-exports icebird's under a bottom-up three-release bump, is stated at lines 10-11 and 51-70, is untouched by this PR, and is true under either labelling. No Superseded-by/Extended-by is warranted, and none was added. Correct call.

Also checked, clean

  • The new NOT (label LIKE 'a%') case discriminates, proven by execution rather than argument. I built the pre-Delete our duplicate WHERE-pushdown converter, use icebird's (LLP 0222) #721 stack in a scratch directory: npm install hyparquet@1.28.1 squirreling@0.15.2 hyparquet-writer@0.16.6 hyparquet-compressors@1.1.1, plus the kernel's own converter and data source recovered from git show 8c08185^:src/core/query/parquet-pushdown.js and ...:src/core/query/parquet-source.js, and the rowsToColumnSources helper at the same rev. I then replayed the test's exact NULLABLE_ROWS fixture and the full eight-case list through it. All seven pre-existing cases return their expected row sets unchanged on the old stack; the new case is the only one that flips: NOT (label LIKE 'a%') returns [2,3,4,5] on the pre-Delete our duplicate WHERE-pushdown converter, use icebird's (LLP 0222) #721 stack and [3,5] at this head. That confirms both halves of the author's argument empirically, including that NOT (label LIKE 'a%') AND ts IS NOT NULL genuinely cannot discriminate (its ts IS NOT NULL conjunct excludes rows 2 and 4 either way).
  • [3,5] is SQL-correct, derived by hand from the fixture, not read off the code.NULLABLE_ROWS has label = 'a', NULL, 'c', NULL, 'e' for ids 1 to 5. Row 1: 'a' LIKE 'a%' is TRUE, so NOT is FALSE, excluded. Rows 2 and 4: NULL LIKE 'a%' is UNKNOWN, NOT UNKNOWN is UNKNOWN, excluded by WHERE. Rows 3 and 5: FALSE, so NOT is TRUE, included. [3,5]. The two-valued answer [2,3,4,5] is what a JS ! over a falsy NULL comparison produces, which is exactly what I measured on the old stack.
  • The author's source-level reasoning, independently verified.icebird/src/sql/whereFilter.js:83 is if (op === 'LIKE') return undefined, sitting in convertBinary after the AND/OR cases, so it is reached with negate already folded in by convertExpr and declines whether or not a NOT is above it. I dumped the AST for NOT (label LIKE 'a%') and confirmed it is unary NOT over binary LIKE, taking exactly that path. squirreling/src/expression/evaluate.js:172 is if (node.op === 'NOT') return val == null ? null : !val, preserving UNKNOWN. The deleted converter's docstring at git show 8c08185^:src/core/query/parquet-pushdown.js:13 does name this shape (NOT (col LIKE 'a%') over a nullable column, issue Declined pushdowns fall back to squirreling's two-valued WHERE and return SQL-wrong rows #734), and its old test at :234-237 carried the matching assertion with the comment "stays SQL-wrong until the engine speaks three-valued logic". Every claim checks out.
  • NOT (NULL = 1) declines for the reason its comment states. AST is unary NOT over binary = with two literal operands. extractColumnAndValue requires an identifier on one side, finds none, and returns column: undefined, so if (!column || value === undefined) return undefined fires. Genuinely "no column to key a filter on", exactly as the comment says, and not an incidental decline. NOT (NULL = NULL) behaves the same.
  • Nothing from Follow-up: deferred triage findings from PR #721 (LLP 0222) #756 was dropped. Item 1: the four sentences Follow-up: deferred triage findings from PR #721 (LLP 0222) #756's prescribed fix names (line 19, which spans two lines for the two labels, plus 31, 92, 95) are all changed, and line 12 is correctly left alone as analysed above. Item 2: added at :426, positioned after the NOT (label LIKE 'a%') AND ts IS NOT NULL case exactly as asked. Item 3: both NOT (NULL = 1) and name || NULL added to the declines NULL-literal comparisons to the engine test at :266 and :268, alongside the surviving id + NULL. Item 4: already done, and confirmed at the tree, since 8c08185's commit subject reads "Delete our duplicate WHERE-pushdown converter, use icebird's (LLP 0222) (Delete our duplicate WHERE-pushdown converter, use icebird's (LLP 0222) #721)". No fifth item exists.
  • Conventions. A real codepoint scan of both changed files (not a shell glob) finds zero U+2014 and zero U+2013. No semicolons in the added JS. The added case matches the surrounding idiom exactly: same ["predicate", [ids]] tuple shape, same double-quoted outer string so the inner 'a%' needs no escaping, trailing comma consistent with the list. The two added assert.equal(whereToParquetFilter(whereOf(...)), undefined) lines match the seven around them. No @typedef, no inline import('...') types introduced. No @ref was added or invalidated: the file's one annotation (@ref LLP 0098 [tests] at :295) covers a different test and still holds.
  • Scope. The diff is exactly the two files and +11/-5 claimed. No dependency, lockfile, or source change rides along, so the behaviour under test is unchanged by this PR and the added cases are pure assertions over the merged Delete our duplicate WHERE-pushdown converter, use icebird's (LLP 0222) #721 head.
  • What I ran, on a fresh npm install in the review worktree at ff6ac6b (resolving icebird 0.8.22 / hyparquet 1.28.2 / squirreling 0.15.3 / hyparquet-writer 0.16.6): npm test (4029 pass, 0 fail, 1 skipped, matching the PR's stated numbers), npm run typecheck (clean), node --test test/core/parquet-source.test.js (28/28), node --test test/core/llp-ref-hygiene.test.js (11/11), npm run smoke -- local_parquet_export (ok), npm run smoke -- cache_lifecycle_maintenance (ok), plus three purpose-built probes: an AST-and-filter dump for the five decline shapes, a branch-discrimination probe pairing each value expression with its non-NULL twin, and the pre-Delete our duplicate WHERE-pushdown converter, use icebird's (LLP 0222) #721 stack replay described above. All scratch files removed; git status is clean and the worktree is still detached at ff6ac6b. I did not touch /work/hypaware, and did not push, commit, or edit anything on GitHub.
  • One process note, not a finding. PR Fix LLP 0222 tier swap, add #734/decline regression cases (#756) #763 is still in DRAFT state. It cannot merge until marked ready for review, regardless of this record.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

neutral review - round 2 (final)

Head reviewed: f647d557b5e8e9e72bec9ad520a9711c18616b12. All 9 checks SUCCESS. Reviewed in a detached worktree;
nothing was written to the branch.

VERDICT: clean. Round 1's finding is fixed and the reviewer re-derived every
guard verdict itself, by instrumenting all four early returns in icebird's
convertBinary and driving real parsed ASTs through both the instrumented copy and
the untouched module, asserting the two agree. It then proved the new case pins what
it claims by mutation: dropping the NULL guard flips only id = NULL, adding
|| to COMP_OPS alone flips nothing (the switch still falls through), and adding
both the operator and a switch arm flips name || 'x' and nothing else.


Cleanup done, worktree clean at f647d55, probe directory removed, nothing else under the scratchpad touched. Here is the review record.


VERDICT: clean

No findings. Nothing is ship-blocking; nothing else is raised at any severity.

Round-1 finding, re-derived

Fixed. The comment now names the branch that actually fires, and the added case pins the branch it claims.

I re-derived all three guard verdicts myself rather than taking the fixer's account. Method: copied node_modules/icebird/src/sql/whereFilter.js (icebird 0.8.22, resolved by a fresh npm install in the review worktree) into a scratch directory, instrumented all four early returns in convertBinary (LIKE, !column || value === undefined, value === null, !mongoOp) with a trace, re-exported extractColumnAndValue and mapOperator, and drove real parseSql ASTs through both the instrumented copy and the untouched module, asserting the two agree on every input. Results:

predicateASTextractColumnAndValueguard that fires
id + NULLbinary op +, identifier / literal null{column:'id', value:null}value === null (whereFilter.js:90)
name || NULLbinary op ||, identifier / literal null{column:'name', value:null}value === null (:90)
name || 'x'binary op ||, identifier / literal 'x'{column:'name', value:'x'}!mongoOp (:93)
NOT (NULL = 1)unary NOT over binary =, two literals{column:undefined, value:undefined}!column || value === undefined (:86)
id = NULL (control)identifier / literal null{column:'id', value:null}value === null (:90)

That matches the fixer's report exactly, and it matches round 1's diagnosis: name || NULL declines at the same guard as id + NULL, one line before mapOperator is reached.

The new comment's wording (test/core/parquet-source.test.js:267-269): "Same NULL-literal branch as the arithmetic case above: the operator is never consulted, since a NULL literal opposite a bare column declines first." Every clause holds. "The arithmetic case above" is id + NULL at :263, which does take the same guard. "A NULL literal opposite a bare column" is precisely what extractColumnAndValue matched: left.type === 'identifier' plus a staticLiteral right returning {value: null} (whereFilter.js:122-131, :143-151). "The operator is never consulted" is true of the operator-kind decision: mapOperator('||', false, false) returns undefined if called, and it never is. I considered the pedantic reading that opis compared against AND/OR/LIKE at :65, :71, :83 before the extract, so "never consulted" is not literally total; in context, set against the very next line's "declines on the operator", the referent is unambiguous, and the comment does not misdescribe the branch. Not raising it.

Does name || 'x' genuinely pin the operator branch? Yes, and I established it two ways. Directly: the trace shows it clears :86 and :90 (column name, value 'x', neither undefined nor null) and dies at !mongoOp, so no other guard can be claiming it. By mutation, three mutants of the real module replayed against the whole five-case group:

  • drop if (value === null) return undefined: only id = NULL flips (to {id:{$eq:null}}). id + NULL, name || NULL, name || 'x' all still decline. So the NULL guard is pinned by id = NULL and friends, not by the two value-expression cases, which is exactly what the corrected comment now says.
  • add '||' to COMP_OPS: nothing flips, because mapOperator's switch still falls through to return undefined (:231-241). The decline is still !mongoOp, i.e. still the operator branch, so the case keeps testing what it claims.
  • add '||' to COMP_OPSand a switch arm mapping it: name || 'x' is the only case in the group that flips ({name:{$concatEq:'x'}}), so it fails loudly. No other assertion in the test moves.

On durability: the one way it could silently start testing something else is a change to extractColumnAndValue that made it reject non-comparison operators or non-identifier concat operands, in which case the case would decline at :86 instead and the undefined assertion would still pass. That is inherent to every decline assertion in this test (they all assert undefined), not specific to this one, and the comment is what carries the intent. Not a finding.

Also checked, clean

  • Scope of round 2.git diff ff6ac6b..f647d55 is one file, +5/-1: the replaced comment at :267-269 and the new comment plus assertion at :271-272. llp/0222-one-pushdown-converter.decision.md has zero diff lines between ff6ac6b and f647d55; the NOT (label LIKE 'a%') case is untouched (zero matching hunks); nothing else moved.
  • The LLP edit, re-read at this head.git diff 8c08185..f647d55 -- llp/0222... is still exactly the five corrected lines round 1 verified: the parquet-pushdown.js / icebird label pair at 19-20, "scanned the archive tier unpruned" at 31, "prune the archive tier" at 92, and "Cache-path queries answer NULL-literal comparisons with IS NULL semantics #744 (the cache tier's NULL wrongness)" at 95. Line 12 and line 94 are untouched, as round 1 concluded they should be. No Superseded-by/Extended-by added, correctly.
  • Whole-PR scope.git diff --stat 8c08185..f647d55 is two files, +15/-5. No dependency, lockfile, or source change rides along.
  • Test-file hygiene, considered and judged fine. The test is named "declines NULL-literal comparisons to the engine" and now holds three cases that decline for non-NULL-literal reasons (NOT (NULL = 1) at :266, name || 'x' at :272, plus name || NULL which shares the NULL branch). They are contrast cases: their whole job is to delimit which inputs reach the NULL-literal guard and which are intercepted before or after it, and each carries a comment saying explicitly which branch it lands on ("for lack of a column rather than for the NULL-literal reason above", "same NULL-literal branch", "declines on the operator even without a NULL"). Moving name || 'x' to whereToParquetFilter returns undefined for non-convertible predicates (:243, where name LIKE 'a%' lives) would separate it from the case it exists to contrast and destroy its value. Grouping is right; not a finding.
  • Conventions. Codepoint scan (not a shell glob) of both changed files: zero U+2014, zero U+2013, zero non-ASCII characters at all. No semicolons in added code (the file's single line ending in ; is :302, pre-existing prose inside a comment, not in this diff). The ... in the new comment is three ASCII dots. The new assertion uses a double-quoted outer string so the inner 'x' needs no escaping, matching :244 and :232; comments wrap at 74-76 columns like their neighbours; no trailing whitespace anywhere in the file. No @ref added, invalidated, or affected (the file's one annotation at :299 covers a different test).
  • What I ran, on a fresh npm install in the detached worktree at f647d55 (resolving icebird 0.8.22 / hyparquet 1.28.2 / squirreling 0.15.3 / hyparquet-writer 0.16.6): npm test (4029 pass, 0 fail, 1 skipped, exit 0), npm run typecheck (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 the instrumented-guard probe and the three-mutant analysis described above. I did not run package_bin_boot or walkthrough_picker_to_first_query (red on master for package_bin_boot is red on master: the --help banner changed and the smoke still pins the old copy #758/walkthrough_picker_to_first_query is red on master, and no smoke runs in CI #750). I did not touch /work/hypaware, and did not push, commit, or edit anything on GitHub. Scratch probe directory deleted; git status in the worktree is clean and it is still detached at f647d55.
  • Process note, not a finding. Round 1 recorded that PR Fix LLP 0222 tier swap, add #734/decline regression cases (#756) #763 was in DRAFT. Worth re-confirming its state at merge time, since a draft cannot merge regardless of this record.

@philcunliffe
philcunliffe marked this pull request as ready for review August 14, 2026 01:27
@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 14, 2026
@philcunliffe
philcunliffe merged commit e957530 into masterAug 14, 2026
9 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-756 branch August 14, 2026 17:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approvedneutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@philcunliffe