perf(gfql): constant-fold literal-only sub-expressions at plan time (generalizes the toLower residual fix) - #1800
Conversation
…l as written)
`_residual_polars_expr` recognized only the two-sided `toLower(a.col) =
toLower('lit')` shape. The Cypher renderer emits `toLower(a.col) = 'lit'`
verbatim -- there is no constant folding of `toLower('lit')` -- so the equally
idiomatic one-sided spelling failed to translate. A SINGLE untranslatable
residual declines the whole fused single-collect two-star plan, so one such
predicate dropped the query onto the eager per-op-collect path AND onto the
where_rows chain evaluator for every alias.
Semantics are the evaluator's, established by measurement before writing code:
`toLower(x)` folds the COLUMN, and a bare literal is compared AS WRITTEN, so
`toLower(x) = 'MALE'` matches zero rows on both the pandas and the polars
evaluators. The two-sided arm keeps folding the literal; the one-sided arm folds
nothing in Python. Lowercasing the one-sided RHS would be a silent wrong answer
that passes every lowercase-literal benchmark query, so mixed-case parity is
pinned directly against the general path.
Every existing decline guard applies unchanged to the new shape: escaped
literals (\uXXXX), non-string and Categorical columns, alias mismatch, absent
column. Newly qualifying is exactly one residual string shape,
`(tolower(<alias>.<col>) = '<literal>')`; reversed operand order, a column RHS,
toUpper/lower/upper, other operators, NOT-wrapping and compound predicates all
still decline.
Measured on the real graph-benchmark dataset (dgx-spark GB10, polars, perf lock
held, ABBA/BAAB, 9 runs/slot, values identical in every slot). One-sided form,
20k: q5 8.15/8.31 -> 4.17/4.61 ms, q6 12.55/13.06 -> 6.29/4.92, q7 9.40/10.39 ->
5.87/5.91. 100k: q5 18.06/17.38 -> 15.30/12.30, q6 25.54/21.35 -> 20.93/13.88,
q7 19.47/16.87 -> 11.47/11.16. Non-overlapping slot ranges in all twelve cells.
The two-sided form was measured as a control in the same session and TIES at
both scales (overlapping ranges), so no published benchmark cell moves.
Locked in by a structural probe -- the fused lane must serve exactly once for a
one-sided residual query -- not by a scaling ladder: the removed cost is a
per-op constant, so a growth-ratio gate would police the wrong direction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB
bf1e30f to
d044686
Compare
…Lower spelling
Owner review of the previous commit: "looks conceptually right... except it is
special casing toLower, vs all constant-foldable methods (eg, non-rand,
non-network, ...)". Correct, and this is that design.
The previous commit taught `_residual_polars_expr` a SECOND SPELLING -- it matched
`tolower(a.c) = tolower('lit')` and additionally `tolower(a.c) = 'lit'`. The
general form is CONSTANT FOLDING: evaluate any pure, deterministic sub-expression
over literal arguments at plan time, so `toLower('Fine Dining')` becomes
`'fine dining'` before the matcher ever sees it. The two-sided form then collapses
INTO the one-sided form, the translator needs ONE canonical shape (it is now
narrower than master's), and every other foldable function is handled without its
own case.
- NEW `graphistry/compute/gfql/expr_const_fold.py`: a bottom-up fold over the row
expression AST, hooked into `_row_expr_arg` -- the single funnel where an
ExprNode becomes the canonical predicate text that the row evaluators and the
fast-path translator both consume, and which already hosts one folding pass
(literal integer division).
- `_RESIDUAL_TOLOWER_EQ` -> `_RESIDUAL_CASE_FN_EQ`: one shape,
`(<case fn>(alias.col) = '<literal>')`, literal used AS WRITTEN, and now
uniform over toLower/lower/toUpper/upper instead of toLower alone.
THE FOLDABILITY CRITERION (stated, not a list). A call folds iff (P) pure and
deterministic, (A) argument-closed after bottom-up folding, (E) engine-invariant on
those argument VALUES, and (T) total on them. (E) is load-bearing: pandas>=3's
Arrow-backed `str` uses SIMPLE case mappings where polars and Python use FULL ones
(#1802), so `toUpper(n.name) = 'STRASSE'` already answers differently on the two
engines. The region where Python, Rust (polars), Arrow (pandas>=3), libcudf and
Java (the Cypher reference) provably agree is ASCII -- string folds are gated on it
and DECLINE outside it. That is a deliberate narrowing: master's two-sided fast
path compared a polars-lowercased COLUMN against a Python-lowercased LITERAL, an
unproven Rust-vs-Python case-table assumption a non-ASCII literal now declines out
of instead of guessing at.
The classification is enforced as a PARTITION of GFQL's whole Cypher function
surface, so a newly added function cannot silently default to either side; a folder
that raises is a decline, never a plan-time crash; and the pass never synthesizes a
null literal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
…s gate
Both gaps were found by mutation testing, not by inspection:
M14 moved folding BEFORE `_rewrite_cypher_integer_division_ast` and SURVIVED.
It is not an equivalent mutant -- it changes answers. That rewrite wraps a
division in `toInteger(...)` only when both operands are already integer
literals, so folding first makes `size('abcd') / 2` into `4 / 2` in time for it
to fire, and the expression starts truncating. Nothing pinned the order; now
four cases do.
M18 removed the alias gate from the case-function branch of
`_residual_polars_expr` and SURVIVED: the decline list had an alias-mismatch case
for the scalar branch only. Three added, one per case-function spelling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
…pher surface guard `cypher-frontend-surface-guard` forbids any growth in `lowering.py`. The hook is now one import plus one call (the explanatory comment moved to the module that owns the logic), so the file grows by 5 lines and the guard baseline moves 9255 -> 9260. All of the pass lives in the new module, which is the point of the design: the funnel gains a call, not a feature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
A `$param` is substituted before the fold runs, so `toLower($p)` folds the PARAMETER VALUE into the plan. That is only safe because `_compile_string_query` keys its cache on the params as well as the query text -- the classic constant-folding-plus-plan-cache bug is a plan folded for one parameter value being reused for another. Pinned at both levels: the rendered text per parameter value (including a non-ASCII parameter, which declines exactly like a written non-ASCII literal), and end to end on one graph object across four engines with three parameter values and a repeat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
Only CHANGELOG conflicted, and both entries are kept -- #1823's fused single-hop grouped aggregate and this PR's constant folding are disjoint lanes (#1823 serves q1-q4, this serves q5-q7), and gfql_fast_paths.py auto-merged with no overlap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
…, not an answer pin
I wrote that running the fold before the integer-division rewrite 'changes
answers'. I had verified the rendered TEXT changed (`(4 / 2)` -> `toInteger((4 /
2))`) and inferred the rest. MEASURED instead: build the reordered tree and diff
the ANSWERS over nine division shapes -- `size(...)/int`, `/float`, `/size(...)`,
negated, substring-derived -- and they are IDENTICAL, because the row evaluators
already floor-divide two integer VALUES (`6 / 4` answers 1 with or without the
wrapper, on master and here).
So the order test pins the ORDER and the PLAN TEXT, and claims no wrong answer.
Worth keeping: the wrapper is load-bearing wherever the evaluator would not
integer-divide on its own, and a plan-time pass must not silently move which
expressions receive it. The docstring now says exactly that, including the
pre-existing inconsistency it uncovered (`size(n.s) / 4` = 1.5 vs
`size('abcdef') / 4` = 1, on master too).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
Measurement provenance and raw evidence (for the reviewer)Everything below was run on
Three things in the body are deliberately less favourable than the previous revision's claim, and are the parts worth reviewing hardest:
One self-correction is committed rather than dropped: |
| "toupper": _fold_to_upper, | ||
| "upper": _fold_to_upper, | ||
| "size": _fold_size, | ||
| "substring": _fold_substring, |
The set is too small by at least three — and this PR's own
|
| declined | stated reason | accurate? |
|---|---|---|
sqrt, floor, ceil, ceiling, round, tofloat |
engine-pinned kernel / tie rules | contract-forced — they return FLOAT, unrepresentable. The kernel prose isn't the binding reason. |
toboolean, coalesce |
(E) decline / (T) null | contract-forced (bool, null) |
abs, sign |
"engine-pinned numeric kernel/result dtype" | half wrong: abs(-5) = 5 exactly on every engine, and int is representable. The result dtype half is right — a Literal(int) can't carry the pinned Int64/Float64 cast. Correct argument bundled with a wrong one. |
tostring |
cuDF/pandas float→string divergence | over-broad by input type: tostring(42) → "42" is unambiguous |
Suggestion
The criterion is sound and the partition test is the right structural guarantee — this is about uniform application, not design.
Cheapest honest fix: extend the per-argument decline already proven by substring to reverse, tail, and head (ASCII, non-empty), and restate the numeric declines in terms of the output contract rather than engine kernels — the contract argument is the one that actually holds, and it's stronger.
Widening the contract to floats/bools would admit sqrt/floor/ceil/round/toboolean as well, but that's a larger separate question and I wouldn't hold this PR for it.
Follow-up: the FOLDABLE / NON_FOLDABLE split conflates four unrelated situations — only one of which the criterion decidesThe reviewer's read is right, and the mechanics confirm it. Two facts first: 1. 2. Argument shape is handled once, generically, by the driver: for arg in folded.args:
if not isinstance(arg, Literal):
return folded # (A) not argument-closedSo What the 40 entries actually are
So of 40 entries, ~18 are vacuous or unreachable, 10 are "aggregates are aggregates", and 12 carry actual decisions. On the f(literal) / f(literal expr) / f(col) axisThat axis is the right one, and it collapses:
The table reads as though it answers a 40-way question. It answers a 12-way one. Suggested restructure (documentation only — no behaviour change)Split the map by what's actually being said:
Keep the partition test — it's the right guarantee — but partition against the union of (1)–(3) plus an explicit "structural" bucket, so adding And per the previous comment, restating the (E) numeric declines in terms of the output contract ( |
|
Follow-up on the taxonomy, now with the parse/eval evidence rather than a reading of the prose. Short version: the two lists are not a partition of behavior, and 1.
|
|
Concrete proposal, following up on the taxonomy comment above: replace the foldable/non-foldable claim with a positive and a negative test per characterization. The current completeness test proves the 46 names are covered; it cannot prove any reason is true. Tests can. One trap to design around first, because the obvious version reproduces the defect exactly: # VACUOUS — passes for all 40 names no matter what reason is attached, because
# nothing outside FOLDABLE_FUNCTIONS can fold in the first place.
assert fold_constants(parse_expr(q)) == parse_expr(q)The test has to assert the mechanism the reason names, so a wrong reason fails:
Net effect: the taxonomy collapses to something smaller and true.
On the positive side, each
Happy to push this as a commit on the branch if you'd like it done rather than described. |
|
Sharpening the previous comment to the right bar, per review: every disqualifier in Applying that bar found more than the if not isinstance(result, (str, int)) or isinstance(result, bool):
return folded— already decides most of the 7 of the 11 Of the 4 that return a guard-passing type, two have no constructible witness: Full tally against the bar:
23 of 40 entries are declined by a guard whatever the list says, 5 are wrong, 9 are real, 2 are unproven. Suggested shape. Keep the 9 aggregates as an explicit deny-set with a witness test each ( That keeps the completeness property the current test provides while making every surviving claim falsifiable. |
…is not a criterion
The decline taxonomy was 40 free-text reasons that NOTHING read. `fold_constants`'
only name-keyed gate is `table.get(folded.name.lower()) is None`; `NON_FOLDABLE_REASONS`
appeared in `__all__`, one comment, and its own definition. A table that no code
consults cannot be wrong, which is exactly the problem -- and the obvious test for it is
vacuous: `fold_constants(parse_expr(q)) == parse_expr(q)` passes for all 40 names
trivially, because nothing outside FOLDABLE_FUNCTIONS can fold at all.
The bar applied here: each disqualifier must have a violating existence proof -- a
concrete expression where folding would change the answer. Every name is now filed under
the MECHANISM that stops it, and each mechanism's test asserts THAT mechanism.
WITNESSED:
* DENIED_AGGREGATE (9) -- the only load-bearing deny-set. `count(1)` is argument-closed
and int-valued, so it passes every structural guard the driver has; the witness is that
it answers 1 over a one-row match and 12 over a twelve-row one.
* DENIED_NOT_ARGUMENT_CLOSED (14) -- witness is the parsed node of the shape the lowering
emits: `keys(n)` etc. carry an Identifier arg, and the quantifiers parse to a
QuantifierExpr and never reach the name lookup at all.
* DENIED_RESULT_TYPE (9) -- witness is the ENGINE's own value for the literal-only call:
sqrt/floor/ceil/ceiling/round/toFloat -> float, toBoolean and the simple-CASE marker ->
bool, range -> list. FoldedValue is str|int, so a perfect folder could not fold these.
`round` is the sharp case: its neo4j-tie/JDK-6430675 reasoning is real and belongs to
the row kernel, but does no work here -- round(1.5) is 2.0 and the guard rejects it
before any tie rule matters.
NO WITNESS, AND SAID SO:
* DENIED_BY_POLICY -- abs(-3)=3, sign(-3)=-1, coalesce(1,2)=1 are guard-passing and
identical to a plain Python fold, so folding could not change an answer. Declined as a
PERF call (the gain on literal-only arithmetic is nil), not a correctness one. A test
pins the ABSENCE of a witness, so it fails if an engine ever stops agreeing.
* DENIED_UNVERIFIED -- toString/toInteger are the only entries where the claimed
divergence could yield a witness. I could not exhibit one: pandas and polars agree with
Python on every literal-only spelling I could construct, and the claim is specifically
about cuDF float->string formatting, which no CI lane here runs. Reason restated as
UNVERIFIED. The test asserts agreement on every engine it CAN reach, so a GPU lane
either produces the witness or exposes these as policy declines.
MISCLASSIFIED, NOW FOLDED: head/tail/reverse. On GFQL's surface they are STRING ops
(row/dispatch.py: `.str.get(0)` / `.str.slice(start=1)` / `.str[::-1]`, and
eval_sequence_fn_scalar checks isinstance(value, str) FIRST for reverse). Their (A)
reason described only the LIST overload, which the per-call argument guard already
declines. head('abc')='a', tail('abc')='bc', reverse('abc')='cba' -- all guard-passing.
They now fold under the same ASCII gate as the case folds; head('') declines because the
runtime answers null there and NULL POLICY forbids synthesizing one. `range` moved from
(A) to DENIED_RESULT_TYPE, where the witness (a list) is real. The test that found all
four asserts they ARE argument-closed, and is kept as a regression pin.
Each foldable entry now gets three tests: folds to a PINNED literal, non-literal spelling
untouched, and -- the one that protects the change -- folded and unfolded plans answer
identically on the same data, per engine. `size` and `substring` get their own parity
cases instead of only appearing nested. ONE ASYMMETRY DISCLOSED: on polars the unfolded
spelling of head/tail/reverse over a literal has no native row-op lowering and raises,
while the folded spelling runs natively -- there the pass WIDENS coverage rather than
merely renaming a predicate, and the test records that instead of hiding it.
The completeness/partition test is kept -- it proves coverage, not meaning -- extended to
the six buckets (9+9+14+9+3+2 = 46 = the whole surface). One new structural test replaces
the reason table by proving the invariant it gestured at: hand the driver a registry
containing a declined name and it folds, so the registry lookup is the ONLY name-keyed
gate. Also pinned, because the grammar is looser than the language: `keys('x')` parses
and IS argument-closed, so the (A) claim is about the emitted shape, not the grammar.
Local: ruff clean, mypy clean on the three files (4 pre-existing errors in
Engine.py/degrees.py from local polars drift, untouched). Polars-lane cost of the new
parity cases measured at well under a second.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
Stacked on #1800 so the shared CHANGELOG stops conflicting on every landing. Ten files touched by both branches 3-way merged with zero conflict markers; two (test_exec_context_scoping.py, test_varlen_bounded_engine_parity_1787.py) were added on BOTH sides from master with byte-identical content and taken as-is. CHANGELOG hand-resolved: a textual 3-way conflicted and the add-only fallback does not apply here, because this branch REWRITES one of its own entries (the unbacked 288-cell figure) rather than only adding. Built as the parent file plus this branch's two ### Fixed entries, asserting each appears exactly once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
Stacked on #1800 so the shared CHANGELOG stops conflicting on every landing. Files touched by both branches were 3-way merged with zero conflict markers; two (test_exec_context_scoping.py, test_varlen_bounded_engine_parity_1787.py) arrived on BOTH sides from master byte-identical and were taken as-is. Supersedes an earlier attempt whose tree was WRONG: the overlay step tested membership with a shell glob against a newline-separated list, so it never matched and re-added every already-merged file from the child ref, clobbering the merge. That silently reverted #1828's low-cardinality count gate out of gfql_fast_paths.py (_LOWCARD_COUNT_MAX_GROUPS: 4 occurrences -> 0) and dropped five CHANGELOG lines. Caught by checking the merged file for the parent's symbols instead of trusting rc=0. Membership is now an exact grep -qxF. CHANGELOG hand-resolved: a textual 3-way conflicted and the add-only fallback does not apply, because this branch REWRITES one of its own entries (the unbacked 288-cell figure). Built as the parent file plus this branch's two ### Fixed entries, each asserted to appear exactly once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
pyg-bench verdict: the fold buys no time on the board. Its value is polars native-coverage widening.Re-measured under pyg-bench's own harness after merge (dgx-spark, all three perf locks held, box verified quiet, slots The board writes q5/q6/q7 one-sided, so no fold fires there. Isolating the pass at 20k:
So the board's q5/q6/q7 improvement belongs to the matcher widening ( On the Structural lock-in (no timing gate): Also corrected above: the body's decline table listed |
…giene baseline The per-file ratchet in bin/ci_type_hygiene_baseline.json is a SNAPSHOT, so it goes stale whenever a PR other than the one that owns it touches a baselined file. #1830 captured its baseline on a branch whose merge-base is b6181d3 -- before #1800, #1799 and #1816 landed -- and each of those three added cast() calls to a file #1830 had already pinned. All four were green on their own bases; the merged combination was not. The baseline is UNCHANGED. The findings are removed instead: - 3 of the 7 were never typing.cast. The guard matches any call named `cast`, including the attribute form, so pl.Expr.cast -- a polars RUNTIME dtype conversion -- counts as a typing finding. Those carry the documented `# hygiene-ok: explicit-cast` escape hatch with a reason. - The other 4 are real and are gone by DECLARATION rather than by call-site assertion. _two_hop_cached_equal_domain_degree_counts declares `counts: Tuple[DataFrameT, DataFrameT]` once, collapsing four casts into one localized `# type: ignore[assignment]` on the polars arm. _apply_connected_optional_match declares `seed_ids: SeriesT` / `node_ids: SeriesT`, since selecting one column off a frame is a Series on every engine. All three files now sit at or below baseline (18/18, 132/133, 41/42). Typing-only: typing.cast is the identity function at runtime, so every removal is provably value-preserving. The one restructured line binds an unchanged pl.Expr list to a name so the per-line suppression fits the 127-column limit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
What changed, and why it is not the previous design
Review of the previous revision: "looks conceptually right... except it is special casing toLower, vs all constant-foldable methods (eg, non-rand, non-network, ...)." That is right, and this is that design.
The previous revision taught
_residual_polars_expra second spelling — it matchedtolower(a.c) = tolower('lit')and additionallytolower(a.c) = 'lit'. The general form is constant folding: evaluate any pure, deterministic sub-expression over literal arguments at plan time, sotoLower('Fine Dining')becomes'fine dining'before the matcher ever sees it. The two-sided form then collapses into the one-sided form, the translator needs one canonical shape, and every other foldable function is handled without its own case.Net effect on the matcher: it is narrower than master's, not wider — and it now covers
toLower/lower/toUpper/upperuniformly instead oftoLoweralone.Two files of behaviour change plus one new module:
graphistry/compute/gfql/expr_const_fold.py— the fold pass and the classification.cypher/lowering.py— one call, in_row_expr_arg: the single funnel where anExprNodebecomes the canonical predicate text that the row evaluators and the fast-path translator both consume, and which already hosted one folding pass (literal integer division).gfql_fast_paths.py—_RESIDUAL_TOLOWER_EQ→_RESIDUAL_CASE_FN_EQ.The foldability criterion
A
FunctionCallfolds to aLiteraliff all four hold:rand,randomUUID,timestamp,now,datefail here permanently.Literal. Folding is bottom-up, so a nested call that folds first satisfies this for its parent:toLower(substring('ABCDEF', 0, 3))→toLower('ABC')→'abc'.ListLiteral/MapLiteralare deliberately notLiteral: the pass synthesizes scalar literals only.Anything failing any of the four is left untouched, so a decline is always behaviour-preserving: the text is unchanged and every downstream consumer behaves as it did.
(E) is where the trap is, and how it is resolved
Issue #1802 is live on master: pandas>=3 defaults to an Arrow-backed
strdtype whoseutf8_lower/utf8_upperare simple per-codepoint case mappings, while polars' (and Python's) are full mappings. Against the same data:So folding
toUpper('straße')in Python computes the full mapping — which is what polars would produce and pandas would not. Three resolutions were available; this PR takes the first, and here is why.str, Rust'sstr(polars), Arrow'sutf8_*kernels (pandas>=3), libcudf and Java'sString.toLowerCase(the Cypher reference) all agree is the ASCII block, where case mapping is a fixed 26-character bijection fixed by the Unicode standard's invariant range and independent of every implementation's Unicode table version.str.isascii()decides it exactly — no sampling, no table lookup. Every string fold is gated on it.This is a deliberate narrowing, and it is a correctness improvement, not only a decline. Master's two-sided fast path compared a polars-lowercased COLUMN against a Python-lowercased LITERAL — an unproven Rust-vs-Python case-table equality that master's own docstring flagged ("a divergence would need a Rust-vs-Python Unicode table drift"). A non-ASCII two-sided literal now declines out of that assumption instead of guessing at it. The cost is a slower query on an exotic shape; the answer is unchanged, and that is asserted (
test_non_ascii_two_sided_declines_the_lane_but_not_the_answer).The classification (a partition, not a list)
Foldable —
tolower,lower,toupper,upper,size,substring. All gated on ASCII string arguments;substringadditionally requires non-negativeint(notbool) indices and an in-range slice.Not foldable, each recorded next to the function name with the criterion it fails:
abssqrtsignfloorceilceilingroundtointegertofloattobooleantostringcoalescekeyslabelstypepropertiesnodesrelationshipsrangeheadtailreverserow/dispatch.pyimplements them as.str.get(0)/.str.slice(start=1)/.str[::-1]), so they fold under the same ASCII gate as the case folds. Their LIST overloads parse to aListLiteralargument, which the per-call argument guard declines on its own. This row was stale against the branch head; the shipped code folds nine functions, these three included.__node_keys____edge_keys____node_entity____edge_entity____cypher_case_eq__anyallnonesinglecountcount_distinctsumminmaxavgmeancollectcollect_distincttest_every_surface_function_is_classified_exactly_onceassertsFOLDABLE ∪ DECLINED == GFQL_ALLOWED_FUNCTIONS ∪ GFQL_AGGREGATION_FUNCTIONSand that the two are disjoint — a new function cannot silently default to either side, andrand/randomUUID/timestamp/now/dateare pinned as absent from the surface today so that adding one forces a classification rather than an accident.Null literals, folds that raise, nesting
nullliteral and never folds a call whose arguments include one. Substitutingnullchanges which branch of the evaluators' three-valued logic runs downstream.toLower(null)is left for the runtime, which already answers it. (test_pass_never_synthesizes_nullasserts it of every registered folder.)registry=parameter rather than by monkeypatching module state.substring('abc', 99)is''in Python, an error in Neo4j, clamped in polars — so it declines rather than picking one.toLower(substring('ABCDEF', 0, 3))→'abc'; a partial fold is visible (toLower(size('abc'))→tolower(3), outer declines)._rewrite_cypher_integer_division_ast. Running it first would turnsize('abcd') / 2into4 / 2in time for that rewrite to fire, and the expression would start truncating — a different answer. Found by mutation, not by inspection (M14 below).Evidence
Differential: folding changes no answer
test_const_fold_engine_parity.pyruns every case twice on the same graph and the same engine — pass live vs pass replaced by the identity — and requires equal answers. 23 cases × 4 engines = 92 paired comparisons, 0 divergences, 0 skipped. Engagement is instrumented per case: each declares whether the pass should fire and a spy asserts it, so an "identical answers" pass cannot come from a rewrite that never ran; the non-ASCII cases are the negative control (folds_expected=False).Randomised sweep, same property, wider: randomised sweep over 120 query shapes x 8 graphs x 4 engines x 2 arms = 7,680 executions -> 3,840 paired comparisons, 0 mismatches. The shapes put foldable calls in WHERE, RETURN, nested inside each other, and under
<>/STARTS WITH/CONTAINS/ENDS WITH/IN/NOT/OR/AND/coalesce, over graphs carrying ASCII, non-ASCII and null string values. 3,456 of the 3,840 cells are ones where the pass actually rewrote something (so the clean result is not a pass that never ran), and 320 cells error identically on both arms (errors are preserved too, not swallowed). An earlier 3-graph run: 1,440 comparisons, 0 mismatches.Cross-engine
ASCII cases: all available engines must agree (15 cases). Non-ASCII cases deliberately do not assert cross-engine agreement — that is #1802, and asserting it would fail for reasons unrelated to this PR; instead
test_non_ascii_case_divergence_is_pre_existing_not_introducedasserts the delta: the same cross-engine picture with folding on and off, which stays green whichever way #1802 is eventually fixed.Parameters and the compiled-plan cache
A
$paramis substituted before this pass runs, sotoLower($p)folds the parameter value into the plan — a genuine widening, and exactly the shape where constant folding classically goes wrong. It is safe only because_compile_string_querykeys its cache on(language, query, params, node_dtypes), not on the query text alone. Pinned at both levels: the rendered text per parameter value (including a non-ASCII parameter, which declines like a written non-ASCII literal), and end-to-end on one graph object across four engines with three parameter values plus a repeat, so a text-only cache key would fail it.Cypher surface guard
cypher-frontend-surface-guardforbids any growth inlowering.py. The hook is one import plus one call — the explanatory comment lives in the module that owns the logic — so the file grows by 5 lines and the baseline moves9255 → 9260. Regenerated withbin/ci_cypher_surface_guard.py --write-baseline, which is what the guard's own failure message instructs. The compiled-surface field/property counts are unchanged.Suite
1537e467bin/test-polars.sh(dgx GB10,--gpus all, cudf 26.02.01 / polars 1.35.2 / cudf_polars)After merging current master (#1823 + #1817; only CHANGELOG conflicted,
gfql_fast_paths.pyauto-merged with no overlap) the same lane on the merged tree gives 1 failed, 3456 passed, 3 skipped, 1 xfailed — same single pre-existing failure.Failure sets byte-identical on both lanes (
diffof the sortedFAILEDlines is empty); the pre-existing failures are a dask import and six cuDF-environment cases, present on master in the same image.bin/lint.shclean;bin/typecheck.sh— mypy 2.3.0, no issues in 326 source files.GPU receipt, per engine,
test_folding_is_answer_preserving: pandas 23 PASSED, polars 23 PASSED, cudf 23 PASSED, polars-gpu 23 PASSED, 0 SKIPPED.Note on environments: the dgx image is pandas 2.3.3, so the #1802 divergence itself is only visible in CI's py3.12+ lanes (pandas 3.0.3). The tests were written to assert deltas rather than absolute cross-engine values precisely so they are meaningful in both.
Mutation
18 mutations, each breaking exactly one guarantee; the suite must go red. Run in the dgx image over the three affected test modules.
substring's in-range guardFoldedValuecontract guarddistinctguard''toUpperfolder uses.lower()len(text)→len(text[:])— planted equivalent, control17/18 detected; the one survivor is the planted equivalent. Two mutations survived the first pass; both were real test gaps, and one of the two turned out to be a narrower finding than I first wrote — both reported rather than buried:
_rewrite_cypher_integer_division_astmakessize('abcd') / 2into4 / 2in time for that rewrite to fire, so the text becomestoInteger((4 / 2)). I wrote that this "changes answers" having verified only the text. Then I built the reordered tree and diffed the answers over nine division shapes (size(...)/int,/float,/size(...), negated, substring-derived): identical, because the row evaluators already floor-divide two integer values —6 / 4answers1with or without the wrapper, on master and here. So the four added cases pin the order and the plan text, and claim no wrong answer. Kept anyway: the wrapper is load-bearing wherever the evaluator would not integer-divide on its own, and a plan-time pass must not silently move which expressions receive it. Correction committed rather than quietly dropped (fdd987f).Uncovered on the way, pre-existing on master: GFQL answers
size(n.s) / 4as1.5andsize('abcdef') / 4as1— an internal inconsistency with openCypher (wheresizereturns an Integer, so both are integer division). Present on master, unchanged here, and out of scope for a constant-folding PR.Benchmark lane
Provenance: dgx-spark GB10, image
graphistry/test-rapids-official:26.02-gfql-polars(polars 1.35.2, cuDF 26.02.01),--gpus all --network none, both of the box's perf locks held for the whole experiment (queued behind another agent's lane and waited —LOCK1 07:29,LOCK2 07:44, released 08:04; host load 2.7). Query text loaded from the board's ownbenchmarks/graphbench/matched_q1_q9/gb_queries.py, md56e7ae268a5a41742587fcb87854b6e27, echoed in every lane header — never hand-written. Kuzu 0.11.3 re-run in the same interleaved session. Arms differ in exactly the PR's runtime files:gfql_fast_paths.py,cypher/lowering.py, plus the new module (diff -rqoutput in the log). Arm A = master1537e467; the verdict rule (overlapping per-slot ranges ⇒ TIE, never a win) lives inboard.pyand predates this session.Engagement, on the board's own text —
twostar= the fused single-collect two-star lane:--mutate twostar(positive control)Master prints the exact declining residuals (
(tolower(i.interest) = 'fine dining')|i,(tolower(p.gender) = 'male')|p,(tolower(i.interest) = 'tennis')|i, …); this branch declines none. Same at 20k and 100k. Forcing the lane to decline moves the clock back (20k q5 4.20→8.36, q6 6.48→9.43, q7 6.06→10.02), so the lane is load-bearing rather than merely counted.The fold itself does NOT fire on the board queries, and that is the honest reading: the board writes q5/q6/q7 ONE-SIDED already, so
const-fold rewrites/run = 0.00there — measured, and reported as the negative control it is. Folding is what lets the matcher carry a single canonical shape (and is what serves the two-sided spelling users also write): on the mechanically-derived two-sided variants of the same three queries it fires 2 / 2 / 1 times, exactly the number oftoLowerpredicates each has.Plan-time cost of the pass (compile only, 5 warmup + 200 timed, quiet box under the lock), master → branch: q5 0.6973 → 0.7035, q6 1.0641 → 1.0780, q7 1.0282 → 1.0548 ms per compile — +6 to +27 µs, inside the run-to-run spread, and compiles are cached per
(query, params, node_dtypes), so a repeated query pays it once. The pass is not a runtime cost at all:fold_constantsis called 0.00 times per timed run in the engagement probe, because the plan is already compiled.Baseline reconciliation — master arm vs the published board, 20k:
The pandas control reproduces the published board on all nine cells (+1.0%…+6.2%, median +2.1%), so tree/data/query/harness match it. Kuzu was re-run in-session anyway, so no transfer model is used.
Verdicts, 20k, two independent interleaved lanes (
RUNS=7, 6 slots/arm; andRUNS=21, 6 slots/arm), per-slot medians:q6 is NOT claimed as a preserved flip, and that is a correction to what the previous revision reported. It reads WIN 1.33× in the main lane (non-overlapping) and TIE 1.15× in the tightening lane, where five of six branch slots (5.41 5.56 6.86 7.10 7.24) sit below Kuzu's minimum (7.77) and the sixth is an outlier at 11.17 that grazes. Under the campaign's own rule — a WIN must replicate across the independent tightening run or it is downgraded — q6 scores TIE this session. What does replicate, in every lane and at both scales, is that the loss is gone and the magnitude is the one the previous revision measured: master 12.06 → branch 6.98 ms (−42%) at 20k, 22.06 → 15.84 (−28%) at 100k.
q5 and q7 must be read as "loss narrowed to near-parity", not parity: the ranges overlap (hence TIE by the rule) but the medians still favour Kuzu — q5 5.11 vs 5.88, q7 5.04 vs 5.79 at 20k. The overlap rule cuts both ways and is applied both ways here.
100k (
RUNS=13): q5 LOSE 1.46× → TIE, q7 LOSE 1.81× → TIE, q6 TIE → TIE (Kuzu is bimodal there:[15.92 16.01 16.78 23.57 23.89 24.12]); q1/q2/q3/q8/q9 wins and q4's tie are unchanged. Branch improves q5 −27%, q6 −28%, q7 −38% at 100k.Correctness across every slot:
VALUE/ROW IDENTITY: 0 mismatches across all arms, all slots, and vs Kuzu, at both scales, both lanes. All nine pandas cells tie between arms (|Δ| ≤ 2.2%) — a built-in null control confirming the change is polars-only. The six queries the lane does not serve show fully overlapping arm ranges (q8's +8.9% median in one lane is inside a[1.46 … 3.35]vs[1.90 … 2.36]overlap on a ~2 ms cell).Typing
_residual_polars_expr'sschematightenedMapping[str, Any]→NodeDtypes; its dtype helpers takeDType. The new module has noAny, nocast, nogetattr/setattr, no barelist/dict: folder values areobjectso each folder must narrow explicitly,FoldedValue = Union[str, int]is the only thing the pass will substitute, and_plain_int/_ascii_strare narrowing helpers returningOptional[...]rather thanboolpredicates that mypy cannot use.Base: merged current master (
7d0d0fc2, i.e. after #1823 and #1817). The benchmark arm A was master1537e467, i.e. before #1823 landed; #1823 serves q1–q4 and this PR serves q5–q7, and the engagement counters show the two lanes are disjoint (neither is ever called on the other's queries), so the q5/q6/q7 verdicts above are unaffected — but they are a two-arm comparison against1537e467, not a combined-board claim.🤖 Generated with Claude Code
https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx