Skip to content

perf(gfql): constant-fold literal-only sub-expressions at plan time (generalizes the toLower residual fix) - #1800

Merged
lmeyerov merged 11 commits into
masterfrom
perf/gfql-fused-tolower-one-sided
Jul 28, 2026
Merged

perf(gfql): constant-fold literal-only sub-expressions at plan time (generalizes the toLower residual fix)#1800
lmeyerov merged 11 commits into
masterfrom
perf/gfql-fused-tolower-one-sided

Conversation

@lmeyerov

@lmeyerov lmeyerov commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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_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, 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/upper uniformly instead of toLower alone.

master   _RESIDUAL_TOLOWER_EQ  (tolower(a.c) = tolower('lit'))          one fn, two-sided only
prev rev _RESIDUAL_TOLOWER_EQ  (tolower(a.c) = (tolower('lit')|'lit'))  one fn, two spellings
this PR  _RESIDUAL_CASE_FN_EQ  (<case fn>(a.c) = 'lit')                 four fns, ONE spelling

Two files of behaviour change plus one new module:

  • new 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 an ExprNode becomes 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 FunctionCall folds to a Literal iff all four hold:

criterion
(P) Pure and deterministic — value is a total function of its arguments: no clock, RNG, locale, timezone, environment, filesystem, network, graph context, or dependence on the row set. rand, randomUUID, timestamp, now, date fail here permanently.
(A) Argument-closed — every argument is already a 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/MapLiteral are deliberately not Literal: the pass synthesizes scalar literals only.
(E) Engine-invariant on these argument values — the Python-computed result must be provably identical to what every supported engine computes for the same literal expression, provable from the values, not from a spot check.
(T) Total on these arguments — evaluation neither raises nor lands where implementations are known to disagree. A folder that raises is caught and treated as a decline.

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 str dtype whose utf8_lower/utf8_upper are simple per-codepoint case mappings, while polars' (and Python's) are full mappings. Against the same data:

MATCH (n) WHERE toUpper(n.name) = 'STRASSE' RETURN n.id
  engine='pandas' -> [9]        engine='polars' -> [8, 9]

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.

  1. Fold only where every engine provably agrees. ← chosen. The region where Python's str, Rust's str (polars), Arrow's utf8_* kernels (pandas>=3), libcudf and Java's String.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.
  2. Fold per-engine using that engine's own semantics — rejected: the fold runs at lowering, which is engine-agnostic by construction, and making it engine-aware would push engine identity up into the planner for a rewrite whose entire value is that it happens once.
  3. Fix GFQL: pandas vs polars silently disagree on toUpper/toLower under pandas>=3 (simple vs full Unicode case mapping) #1802 first, then fold unconditionally — out of reach here, and correctly so: the fix needs special-casing/final-sigma handling on a hot path, which is why GFQL: pandas vs polars silently disagree on toUpper/toLower under pandas>=3 (simple vs full Unicode case mapping) #1802 was filed rather than patched in passing.

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)

Foldabletolower, lower, toupper, upper, size, substring. All gated on ASCII string arguments; substring additionally requires non-negative int (not bool) indices and an in-range slice.

Not foldable, each recorded next to the function name with the criterion it fails:

group functions fails
numeric kernels abs sqrt sign floor ceil ceiling round tointeger tofloat (E) each engine implements a deliberately non-default kernel to match neo4j (ties toward +inf at precision 0, HALF_UP above, −0.0 normalization, Float64/Int64 result casts, the p>308 identity window, explicit NaN masking). A fold would have to re-derive every one exactly, and the argument values do not establish agreement. Perf value on literal-only arithmetic is nil.
toboolean (E) the evaluators decline on unrecognized tokens; a fold cannot reproduce a decline
tostring (E) float→string formatting diverges between cuDF and pandas (worked around by a host round-trip)
coalesce (T) can yield null; the pass never substitutes a null literal
graph context / non-scalar keys labels type properties nodes relationships range (A) operate on entities or lists; there is no literal form
head tail reverse CORRECTED — these DO fold. On GFQL's surface they are STRING operations (row/dispatch.py implements 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 a ListLiteral argument, 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.
internal markers __node_keys__ __edge_keys__ __node_entity__ __edge_entity__ __cypher_case_eq__ (A)/(P)
quantifiers any all none single (A) bind a variable over a source
aggregates count count_distinct sum min max avg mean collect collect_distinct (P) value depends on the row set, not the arguments

test_every_surface_function_is_classified_exactly_once asserts FOLDABLE ∪ DECLINED == GFQL_ALLOWED_FUNCTIONS ∪ GFQL_AGGREGATION_FUNCTIONS and that the two are disjoint — a new function cannot silently default to either side, and rand/randomUUID/timestamp/now/date are pinned as absent from the surface today so that adding one forces a classification rather than an accident.

Null literals, folds that raise, nesting

  • Null: the pass never synthesizes a null literal and never folds a call whose arguments include one. Substituting null changes 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_null asserts it of every registered folder.)
  • Raises: any exception from a folder is caught by the driver and turned into a decline, so a fold can never convert a runtime error into a plan-time crash nor swallow one that should surface — the node is returned untouched and the runtime produces exactly what it produced before. Proven with a folder that raises, injected through the registry= parameter rather than by monkeypatching module state.
  • Out of range: substring('abc', 99) is '' in Python, an error in Neo4j, clamped in polars — so it declines rather than picking one.
  • Nesting: bottom-up, so toLower(substring('ABCDEF', 0, 3))'abc'; a partial fold is visible (toLower(size('abc'))tolower(3), outer declines).
  • Pass order is load-bearing and pinned: folding runs after _rewrite_cypher_integer_division_ast. Running it first would turn size('abcd') / 2 into 4 / 2 in 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.py runs 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_introduced asserts 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 $param is substituted before this pass runs, so toLower($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_query keys 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-guard forbids any growth in lowering.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 moves 9255 → 9260. Regenerated with bin/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

lane master 1537e467 this branch
bin/test-polars.sh (dgx GB10, --gpus all, cudf 26.02.01 / polars 1.35.2 / cudf_polars) 1 failed, 2935 passed, 3 skipped, 1 xfailed 1 failed, 3076 passed, 3 skipped, 1 xfailed
CI's GFQL-core file list (same image) 7 failed, 6735 passed, 35 skipped, 16 xfailed 7 failed, 7005 passed, 35 skipped, 16 xfailed

After merging current master (#1823 + #1817; only CHANGELOG conflicted, gfql_fast_paths.py auto-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 (diff of the sorted FAILED lines is empty); the pre-existing failures are a dask import and six cuDF-environment cases, present on master in the same image. bin/lint.sh clean; 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.

# mutation verdict
M1 remove the hook — the pass never runs DETECTED (62 failed)
M2 case-fold the one-sided RHS in the translator (the classic wrong rule) DETECTED (3 failed, all mixed-case)
M3 remove the ASCII gate DETECTED (53 failed)
M4 remove substring's in-range guard DETECTED (3 failed)
M5 remove the FoldedValue contract guard DETECTED (4 failed)
M6 stop catching a folder that raises DETECTED (1 failed)
M7 restore the two-sided arm in the matcher DETECTED (2 failed)
M8 remove the distinct guard DETECTED (1 failed)
M9 drop one function from the classification DETECTED (1 failed)
M10 fold a null literal to '' DETECTED (4 failed)
M11 toUpper folder uses .lower() DETECTED (11 failed)
M12 invert the case function in the fast path DETECTED (12 failed)
M13 len(text)len(text[:])planted equivalent, control SURVIVED (expected)
M14 run the fold before the integer-division rewrite DETECTED (4 failed) — survived the first pass; see below
M15 remove the escaped-literal guard DETECTED (1 failed)
M16 fold only at the top level (no recursion) DETECTED (68 failed)
M17 remove the string-dtype gate in the fast path DETECTED (2 failed)
M18 remove the alias gate in the case-function branch DETECTED (3 failed) — survived the first pass; see below

17/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:

  • M14 changes the plan TEXT but — measured — not the answers, and I initially claimed otherwise. Folding before _rewrite_cypher_integer_division_ast makes size('abcd') / 2 into 4 / 2 in time for that rewrite to fire, so the text becomes toInteger((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 values6 / 4 answers 1 with 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) / 4 as 1.5 and size('abcdef') / 4 as 1 — an internal inconsistency with openCypher (where size returns an Integer, so both are integer division). Present on master, unchanged here, and out of scope for a constant-folding PR.
  • M18 removed the alias gate from the case-function branch and survived: the decline list carried an alias-mismatch case for the scalar branch only. Three added, one per case-function spelling.

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 own benchmarks/graphbench/matched_q1_q9/gb_queries.py, md5 6e7ae268a5a41742587fcb87854b6e27, 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 -rq output in the log). Arm A = master 1537e467; the verdict rule (overlapping per-slot ranges ⇒ TIE, never a win) lives in board.py and predates this session.

Engagement, on the board's own texttwostar = the fused single-collect two-star lane:

q5 q6 q7 q1 q2 q3 q4 q8 q9
master, served/called 0.00 / 1.00 0.00 / 1.00 0.00 / 1.00 never called
this branch 1.00 / 1.00 1.00 / 1.00 1.00 / 1.00 never called
branch, --mutate twostar (positive control) 0.00 / 1.00 0.00 / 1.00 0.00 / 1.00

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.00 there — 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 of toLower predicates 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_constants is 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:

q1 q2 q3 q4 q5 q6 q7 q8 q9
pandas (single-threaded control) +6.0% +6.2% +2.1% +1.0% +1.5% +1.9% +3.9% +2.3% +1.3%
polars +10.9% +11.2% +2.8% +12.0% −2.8% −4.4% +8.8% +6.0% +11.2%
Kuzu −1.6% +3.1% −15.5% +10.7% +2.2% +0.2% −3.9% −5.9% +0.9%

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; and RUNS=21, 6 slots/arm), per-slot medians:

q lane A (master) lane A (branch) tight (master) tight (branch) verdict
q5 LOSE 1.91× TIE LOSE 1.91× TIE LOSE → TIE (replicates)
q6 LOSE 1.19× WIN 1.33× LOSE 1.50× TIE 1.15× LOSE → TIE (see below)
q7 LOSE 2.04× TIE LOSE 2.13× LOSE 1.15× loss narrowed ~2.1× → ~1.15×
q1 q2 q3 q4 q8 q9 arm ranges overlap, no cell moves

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's schema tightened Mapping[str, Any]NodeDtypes; its dtype helpers take DType. The new module has no Any, no cast, no getattr/setattr, no bare list/dict: folder values are object so each folder must narrow explicitly, FoldedValue = Union[str, int] is the only thing the pass will substitute, and _plain_int/_ascii_str are narrowing helpers returning Optional[...] rather than bool predicates that mypy cannot use.


Base: merged current master (7d0d0fc2, i.e. after #1823 and #1817). The benchmark arm A was master 1537e467, 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 against 1537e467, not a combined-board claim.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx

…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
lmeyerov and others added 6 commits July 28, 2026 00:16
…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
@lmeyerov lmeyerov changed the title perf(gfql): translate the one-sided toLower residual natively (literal as written) perf(gfql): constant-fold literal-only sub-expressions at plan time (generalizes the toLower residual fix) Jul 28, 2026
lmeyerov and others added 2 commits July 28, 2026 00:52
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
@lmeyerov

Copy link
Copy Markdown
Contributor Author

Measurement provenance and raw evidence (for the reviewer)

Everything below was run on dgx-spark in graphistry/test-rapids-official:26.02-gfql-polars (polars 1.35.2, cuDF 26.02.01, pandas 2.3.3), --gpus all. Nothing was measured on a laptop.

  • The two perf locks were both held for the whole experiment, and I waited for them. Another agent's lane was in flight when this was queued; the run blocked on flock from 07:29 (lock 1) until 07:44 (lock 2) and ran to 08:04 on a box at load 2.7. Logged in the lane header.
  • Query text is the board's own matched_q1_q9/gb_queries.py, md5 6e7ae268a5a41742587fcb87854b6e27, echoed in every lane header, and the file's toLower lines are printed in the engagement log so the ONE-SIDED spelling is visible in the receipt rather than asserted.
  • Verdict rule predates the run: board.py (from the previous closing experiment) — overlapping per-slot ranges ⇒ TIE, never a win.
  • Arms: A = master 1537e467, B = this branch's product tree. diff -rq over graphistry/ = exactly the PR's runtime files plus _version.py; master's gfql_fast_paths.py md5 a55996d642432a7be5373965de3ecd2e matches the value recorded for master in the previous session's provenance note.

Three things in the body are deliberately less favourable than the previous revision's claim, and are the parts worth reviewing hardest:

  1. q6 is scored TIE, not WIN. It reads WIN 1.33× in the main lane and TIE 1.15× in the tightening lane (one of six branch slots is an outlier that grazes Kuzu's range). Under the replication rule that is a TIE. The loss is gone in every lane and the magnitude reproduces (−42% at 20k), but the flip is not claimed.
  2. q5 and q7 are "loss narrowed to near-parity", not parity — the ranges overlap, but the medians still favour Kuzu.
  3. The fold does not fire on the board's queries at all (rewrites/run = 0.00), because the board already writes the one-sided form. The board delta comes from the matcher accepting the canonical shape; the folding is what makes that a narrowing of the matcher rather than a second special case, and it is what serves the two-sided spelling (2/2/1 rewrites on the derived two-sided variants).

One self-correction is committed rather than dropped: fdd987f retracts a claim I made in an earlier commit message that the M14 mutation "changes answers". It changes the plan text; the answers are identical over nine division shapes, measured on a purpose-built reordered tree.

"toupper": _fold_to_upper,
"upper": _fold_to_upper,
"size": _fold_size,
"substring": _fold_substring,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

more?

@lmeyerov

Copy link
Copy Markdown
Contributor Author

The set is too small by at least three — and this PR's own substring folder proves it

Checked against the real surface in language_defs.py rather than the summary. No function is missing from the partition — GFQL's function surface is small (no trim/replace/left/right/split), and FOLDABLE_FUNCTIONS ∪ NON_FOLDABLE_REASONS genuinely covers it. But three declines are over-broad.

1. reverse — the stated reason is factually wrong for GFQL

"reverse": "(A) sequence op; list literals parse to ListLiteral, not Literal",

GFQL's reverse is a string op, not a list op:

# graphistry/compute/gfql/row/dispatch.py:34
"reverse": lambda series_value: series_value.str[::-1],

# eval_sequence_fn_scalar — the string branch is checked FIRST
if fn_name == "reverse":
    if isinstance(value, str):
        return value[::-1]

A string literal is a Literal, so (A) is satisfied. It's pure (P), total on strings (T — ''[::-1] is '', no raise), and engine-invariant on ASCII (E) by exactly the argument that licenses substring. reverse('abc') meets all four criteria.

2. head / tail — same story, minus one edge case

Both are string ops too (.str.get(0), .str.slice(start=1)). tail is total on strings. head('') returns None (value[0] if len(value) > 0 else None), which the null policy correctly refuses — but that's an argument-level decline, not a function-level one.

3. …and this PR already has the machinery for that distinction

From _fold_substring's own docstring:

out-of-range slices DECLINE (criterion T): Python yields '', Neo4j raises, polars clamps

So substring folds in-range arguments and declines out-of-range ones — per argument, not per function. Declining head/tail/reverse wholesale is inconsistent with the design this module already applies one function over. That's the substantive point: not "a function was missed", but the per-argument decline mechanism isn't being used where it applies.

The structural observation: the binding constraint is the output contract, not purity

fold_constants guards:

if not isinstance(result, (str, int)) or isinstance(result, bool):
    return folded

No float, no bool, no null, no list. That single choice — not engine variance — is what excludes most of the surface, and the reasons table partly mis-attributes it:

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.

@lmeyerov

Copy link
Copy Markdown
Contributor Author

Follow-up: the FOLDABLE / NON_FOLDABLE split conflates four unrelated situations — only one of which the criterion decides

The reviewer's read is right, and the mechanics confirm it. Two facts first:

1. NON_FOLDABLE_REASONS is never consulted by the driver. fold_constants only does table.get(name) against FOLDABLE_FUNCTIONS. Safety comes entirely from the allowlist; the deny-map is documentation plus a partition test. That's a fine design — but it means every entry has to earn its place as documentation, and right now most don't.

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-closed

So f(<column ref>) never folds — for every function equally. That is not a property of keys or labels; it's a property of the call site.

What the 40 entries actually are

group n what it really means
(A) 18 mostly vacuous — the driver's isinstance(arg, Literal) check already covers them, identically to every other function. Four of them (any/all/none/single) are structurally unreachable: quantifiers parse to QuantifierExpr, never a FunctionCall, so they can't reach the fold table at all. And three (head/tail/reverse) are misclassified — see my previous comment; they're string ops and can take literals.
(P) 10 aggregates + one internal marker. Genuinely different: row-set dependent, so not foldable even with literal arguments. A real reason — but note the safety still comes from absence from the allowlist, not from this entry.
(E) / (T) 12 the only category where the criterion does work: pure, could-fold-with-literal-args, and we decline anyway. abs, sqrt, sign, floor, ceil, ceiling, round, tointeger, tofloat, toboolean, tostring, coalesce.

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) axis

That axis is the right one, and it collapses:

  • f(col ref) — one generic check in the driver. Never a per-function question.
  • f(literal expr) — folds bottom-up first (_rebuild_expr_node recurses before the table lookup), so it becomes f(literal). Not a separate case.
  • f(literal) — the only real question, and only ~12 functions need an answer to it.

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:

  1. _NOT_A_FUNCTION_CALL — quantifiers; can't reach the pass. One line, not four entries.
  2. _ROW_SET_DEPENDENT — aggregates; not foldable at any argument shape.
  3. _DECLINED_WITH_LITERAL_ARGS — the real list: the 12 pure functions we could fold and won't, each with its reason. This is the one worth reviewing carefully.
  4. Everything else — drop the per-function entries and state the rule once: a call with any non-literal argument is never folded; that is enforced structurally in fold_constants, not per function.

Keep the partition test — it's the right guarantee — but partition against the union of (1)–(3) plus an explicit "structural" bucket, so adding trim tomorrow still forces a decision without adding noise.

And per the previous comment, restating the (E) numeric declines in terms of the output contract (str | int only — no float, no bool, no null) makes group 3 shorter and more honest still: sqrt/floor/ceil/round/tofloat/toboolean are contract-forced, not judgement calls, which leaves abs, sign, tointeger, tostring, coalesce as the genuine ones.

@lmeyerov

Copy link
Copy Markdown
Contributor Author

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 FOLDABLE_FUNCTIONS is missing three entries.

1. NON_FOLDABLE_REASONS is never consulted

It occurs three times in the module: __all__, one comment, and its own definition. fold_constants never reads it. The only name-keyed gate is table.get(folded.name.lower()) is None. So the 40 entries are documentation pinned by a completeness test, not a decline arm — which is worth stating outright, because the (P)/(A)/(E)/(T) codes read as if they were dispatch.

2. The codes are per-NAME; the driver's declines are per-CALL

The driver declines in exactly three places: the name lookup; not isinstance(arg, Literal) for any arg; and the result guard (None / not (str, int) / bool). Only the first is a property of the name. (A) "not argument-closed" is a property of the call site, so attaching it to a name cannot be right in general — and in practice it isn't:

keys(a)                     -> FunctionCall     args=['Identifier']
nodes(p)                    -> FunctionCall     args=['Identifier']
any(x IN [1,2] WHERE x > 1) -> QuantifierExpr   (not a FunctionCall at all)
range(1, 5)                 -> FunctionCall     args=['Literal', 'Literal']
count(1)                    -> FunctionCall     args=['Literal']
reverse('abc')              -> FunctionCall     args=['Literal']
reverse([1,2,3])            -> FunctionCall     args=['ListLiteral']

The whole 18-name (A) group does no work, for three different reasons: 14 are redundant (6 entity/path ops + 4 internal markers are declined by the per-call arg guard; the 4 quantifiers never reach the name lookup because they aren't FunctionCalls); range is mislabeled — it IS argument-closed, so the real reason is the result-type guard, not (A); and 3 are simply in the wrong list.

3. head / tail / reverse are foldable

They are string ops in the live dispatch (row/dispatch.py:32-34): .str.get(0), .str.slice(start=1), .str[::-1], with eval_sequence_fn_scalar checking isinstance(value, str) first for reverse. Verified:

head('abc')    = 'a'    passes driver contract guard: True
tail('abc')    = 'bc'   passes driver contract guard: True
reverse('abc') = 'cba'  passes driver contract guard: True

The stated reason — "sequence op; list literals parse to ListLiteral, not Literal" — is true only of the list overload, which the per-call guard already declines on its own. It documents the safe case and misses the reachable one. These three belong in FOLDABLE_FUNCTIONS on the same footing as substring, and each wants a positive test on the string overload plus a negative one on the list overload.

4. Two reasons that don't survive contact

  • coalesce: "can yield null; this pass never substitutes a null literal" — the result is None guard already covers exactly that, and coalesce(1, 2) would fold correctly to 1. The entry is conservative, not required.
  • the 11 (E) numerics: the group's own closing sentence, "the perf value of folding literal-only arithmetic is nil", carries all 11 uniformly and is the honest reason. The engine-pinned-kernel argument is substantive only for round / tostring / toboolean (documented divergence) and thin for abs / sign, where the result is integer-exact. Leading with the weaker argument makes the group look like a correctness claim it doesn't need.

5. What does carve at a joint

The 9 aggregates. count(1) is argument-closed with a Literal and returns an int, so the name lookup is the only thing standing between it and a wrong answer — the sole category where the list is load-bearing rather than descriptive.

Suggested shape: keep the aggregates as an explicit deny-set (they earn it), move head/tail/reverse into FOLDABLE_FUNCTIONS, and either drop the rest of NON_FOLDABLE_REASONS or restate it as what it is — a coverage note over the function universe, with the (A) entries labeled "already declined by the per-call argument guard" rather than given a reason of their own. The completeness test is still worth keeping; it just proves coverage, not that the categories mean anything.

(Separately, for lane budget: this PR touches bin/test-polars.sh, and the py3.12 polars cell is at 442s against a 600s cap on master's latest green run.)

@lmeyerov

Copy link
Copy Markdown
Contributor Author

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:

code executable predicate outcome today
(A) not argument-closed parsed node is not a FunctionCall, or some arg is not a Literal fails for range, head, tail, reverse — all parse to FunctionCall with Literal args
(P) value depends on the row set the same argument-closed call yields different values on two different row sets passes for the 9 aggregates; nothing else in the list can pass it
(T) can yield null / raises the folder returns None or raises already what the driver's result is None guard enforces
(E) engine-pinned kernel ??? no property to assert — see below

(A) is the valuable one: it finds the four misclassifications by itself, with no reviewer needing to notice them.

(E) is the informative failure. Writing the test requires engines to actually disagree, which they do for round / tostring / toboolean but not for abs / sign — and the group's real justification is its own closing line, "the perf value of folding literal-only arithmetic is nil", which is a policy call, not a correctness property. A category that cannot be given a failing test is not a correctness claim, so (E) should demote to a documented policy line rather than sit in a list that reads as a safety argument.

Net effect: the taxonomy collapses to something smaller and true.

  • (P) survives as a real deny-set — count(1) is argument-closed and returns an int, so the name lookup genuinely is what prevents a wrong answer.
  • (A) becomes one parametrized mechanism test, which reclassifies head / tail / reverse into FOLDABLE_FUNCTIONS and re-labels range under the result-type guard.
  • (T) and the 14 redundant (A) names are already enforced by existing guards; they need a coverage note, not a reason each.
  • (E) becomes one policy sentence.

On the positive side, each FOLDABLE_FUNCTIONS entry wants three tests:

  1. folds — a literal-only call rewrites to the expected Literal (pins the value, not just that something happened);
  2. declines — the same function with a non-literal arg is returned untouched;
  3. value identity — folded plan and unfolded plan return the same result on the same data, per engine. This is the one that actually protects the change; (1) and (2) are unit checks of the driver, (3) is the safety property, and it is the test head/tail/reverse will need on the string overload with a matching negative on the list overload.

Happy to push this as a commit on the branch if you'd like it done rather than described.

@lmeyerov

Copy link
Copy Markdown
Contributor Author

Sharpening the previous comment to the right bar, per review: every disqualifier in NON_FOLDABLE_REASONS should come with a violating existence proof — a concrete expression where folding would change the answer. A disqualifier with no constructible witness is a guess, and should either move into FOLDABLE_FUNCTIONS or be restated as policy rather than correctness.

Applying that bar found more than the (A) misclassifications. The driver's own contract guard —

if not isinstance(result, (str, int)) or isinstance(result, bool):
    return folded

— already decides most of the (E) group, because these functions return float:

fn         expr                    engine scalar   driver contract guard
abs        abs(-3)                             3   ACCEPT
abs        abs(-3.5)                         3.5   DECLINE (type float)
sqrt       sqrt(4)                           2.0   DECLINE (type float)
floor      floor(1.7)                        1.0   DECLINE (type float)
ceil       ceil(1.2)                         2.0   DECLINE (type float)
ceiling    ceiling(1.2)                      2.0   DECLINE (type float)
round      round(2.5)                        3.0   DECLINE (type float)
tofloat    tofloat(1)                        1.0   DECLINE (type float)
toboolean  toboolean('true')                True   DECLINE (type bool)
size       size('abc')                         3   ACCEPT
tostring   tostring(1.0)                   '1.0'   ACCEPT
tointeger  tointeger(1.9)                      1   ACCEPT
sign       sign(-3)                           -1   ACCEPT

7 of the 11 (E) entries could not fold even with a perfect folder. round is the sharpest case: it carries the most detailed correctness argument in the file (neo4j ties toward +inf, the JDK-6430675 ulp trap in floor(x+0.5)), and none of it is load-bearing — round(2.5) returns a float and is declined structurally. Worth keeping that reasoning in the kernel's docstring where it is true and necessary; it is doing no work in this list.

Of the 4 that return a guard-passing type, two have no constructible witness: abs(-3) -> 3 and sign(-3) -> -1 are integer-exact and identical to a plain Python fold. Their stated disqualifier is unfounded. tostring / tointeger are the only entries where the claimed engine divergence could produce a witness, and the PR asserts it (cuDF vs pandas float->string formatting) rather than exhibiting one — that is the single genuinely open question in the taxonomy.

Full tally against the bar:

group n witness status
aggregates (P) 9 witness existscount(1) yields different values on two row sets while being argument-closed; the only load-bearing group
(A) entity/path/marker/quantifier 14 no witness needed — the per-call Literal guard declines them; the 4 quantifiers parse to QuantifierExpr and never reach the name lookup
(E) float/bool-returning 7 no witness possible — result-type guard declines them
range, coalesce 2 declined by the result-type / result is None guards; coalesce(1, 2) would fold correctly to 1
head, tail, reverse 3 witness of the opposite kindreverse('abc') = 'cba', guard-passing; they belong in FOLDABLE_FUNCTIONS
abs, sign 2 no witness constructible — disqualifier unfounded
tostring, tointeger 2 witness plausible, not exhibited

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 (count(1) over two row sets). Move head/tail/reverse into FOLDABLE_FUNCTIONS with the three tests from the previous comment. Exhibit a witness for tostring/tointeger or fold them. Delete the rest of the list and replace it with one test asserting the structural truth it was gesturing at — every remaining allowed function is declined by the argument guard or the result-type guard — which is checkable, cannot rot, and is the actual invariant. The (E) perf rationale ("the value of folding literal-only arithmetic is nil") stays as one policy sentence, not a per-name correctness claim.

That keeps the completeness property the current test provides while making every surviving claim falsifiable.

lmeyerov and others added 2 commits July 28, 2026 02:52
…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
lmeyerov added a commit that referenced this pull request Jul 28, 2026
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
lmeyerov added a commit that referenced this pull request Jul 28, 2026
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
@lmeyerov
lmeyerov merged commit 8535553 into master Jul 28, 2026
77 checks passed
@lmeyerov

Copy link
Copy Markdown
Contributor Author

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 A B B A B A A B, RUNS=21 WARMUP=5, per-slot medians, board query text md5 6e7ae268a5a41742587fcb87854b6e27, arms differing by exactly one file — the fold_constants call). Reported because it contradicts the natural reading of this PR, not because it confirms it.

The board writes q5/q6/q7 one-sided, so no fold fires there. Isolating the pass at 20k:

cell (q5/q6/q7) fold OFF fold ON verdict
one_sided 4.56 / 6.22 / 5.38 5.32 / 6.21 / 5.46 TIE ×3 (negative control)
two_sided_lower / _upper / via_substring 8.7–11.1 4.2–6.1 WIN −42%…−56%, non-overlapping
via_reverse, via_tail raises NotImplementedError answers COVERAGE

So the board's q5/q6/q7 improvement belongs to the matcher widening (_RESIDUAL_TOLOWER_EQ_RESIDUAL_CASE_FN_EQ), not to folding. The master-context lane shows one_sided −44/−41/−48% while two_sided_lower is a TIE vs master, because master already special-cased that one spelling.

On the head/tail/reverse expansion: it is not hygiene. The coverage matrix shows those three raising on polars over a literal on master and on fold-OFF, and answering with the pass on — they are exactly the functions with no polars row-op lowering. toLower/lower/toUpper/upper/substring/size already answered unfolded. pandas answered all ten spellings everywhere, so the widening is polars-only. 304 value-vs-board checks, 0 mismatches.

Structural lock-in (no timing gate): tests/test_gfql_const_fold_engagement.py in pyg-bench (#118, merged) asserts every foldable spelling reaches the polars residual translator as the one canonical <case fn>(alias.col) = <bare literal> shape and is SERVED. 5 pass here; 3 fail identically against master and against fold-OFF.

Also corrected above: the body's decline table listed head/tail/reverse as not foldable, which was stale against the branch head — the shipped code folds them.

lmeyerov added a commit that referenced this pull request Jul 29, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GFQL: pandas vs polars silently disagree on toUpper/toLower under pandas>=3 (simple vs full Unicode case mapping)

1 participant