Skip to content

fix(gfql): one Cypher type contract for sum()/avg() across pandas, polars and cuDF - #1819

Merged
lmeyerov merged 7 commits into
masterfrom
fix/gfql-polars-agg-string-divergence
Jul 28, 2026
Merged

fix(gfql): one Cypher type contract for sum()/avg() across pandas, polars and cuDF#1819
lmeyerov merged 7 commits into
masterfrom
fix/gfql-polars-agg-string-divergence

Conversation

@lmeyerov

@lmeyerov lmeyerov commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

The defect

Two aggregate divergences between the pandas and polars engines, wrong in opposite directions:

query pandas (before) polars (before)
avg(<string column>) GFQLTypeError silent null
sum(<string column>) 'abac' (string CONCATENATION) raw polars.exceptions.InvalidOperationError

Both are pre-existing on engine='polars'. They matter now because #1743 changes engine=auto to
resolve to polars by default, which would promote the silent null to every user who never
specifies an engine
— a typed error degrading into a quiet wrong answer, by default.

Because the two engines are wrong in opposite directions, "make polars match pandas" was not
available. The contract had to be established first.

The intended contract, and its source

graphistry/compute/gfql/agg_types.py carries the sources in its module docstring. In short:

  • avg(input) / sum(input) accept INTEGER | FLOAT | DURATION (and null) ONLY.
    Neo4j declares exactly that signature for both
    (neo4j/docs-cypher modules/ROOT/pages/functions/aggregating.adoc:
    "Returns the average of a set of INTEGER, FLOAT or DURATION values", input : INTEGER | FLOAT | DURATION),
    and enforces it at runtime. Verified live against two independent implementations:

    # Neo4j 5.26.26 (docker, cypher-shell)
    UNWIND [{s:'a'},{s:'b'}] AS r RETURN avg(r.s);
      -> AVG(...) can only handle numerical values, duration, or null.
    UNWIND [{s:'a'},{s:'b'}] AS r RETURN sum(r.s);
      -> SUM(...) can only handle numerical values, duration, or null.
    UNWIND [date('2020-01-01')] AS x RETURN sum(x);
      -> Type mismatch: expected Float, Integer or Duration but was Date
    UNWIND [null,null] AS x RETURN sum(x), avg(x);   -> 0, NULL
    UNWIND [] AS x RETURN sum(x), avg(x);            -> 0, NULL
    
    # Kuzu 0.11.3 (openCypher, embedded)
    MATCH (p:P) RETURN avg(p.s)  -> Binder exception: Function AVG did not receive correct
                                    arguments: Actual: (STRING)
    MATCH (p:P) RETURN sum(p.s)  -> Binder exception: Function SUM ... Actual: (STRING)
    MATCH (p:P) RETURN min(p.s)  -> 'a'      # min/max/count/collect take ANY
    
  • min / max / count / collect accept ANY (same doc: input : ANY; openCypher TCK
    Aggregation2 scenarios [7]–[12] cover min()/max() over strings, lists and mixed values).

  • Nulls are excluded from every aggregate; sum over an empty-or-all-null set is 0 and avg
    over one is null (Neo4j "Considerations", confirmed live above).

GFQL's own conventions agree: pandas already raised for avg(<string>), and GFQLTypeError is the
established class for this failure.

So sum(<string>) == 'abac' on pandas was itself the wrong contract, and this PR changes it.
That is a deliberate, called-out behaviour change on the pandas engine, not a side effect: string
concatenation has no meaning in Cypher, it is a silent wrong answer to a question the user asked
about numbers, and it was already inconsistent with avg(), which raised.

The divergence table (the real deliverable)

Full sweep: 8 aggregates × 9 dtypes × {grouped, whole-table} × {pandas, polars, cuDF}
288 cells. Repro harness reproduced as graphistry/tests/compute/gfql/test_aggregate_type_contract.py.

24 divergences before, 0 after. Grouped by cause:

aggregate dtype pandas (before) polars (before) cuDF (before) after (all engines)
avg string / nullable-string GFQLTypeError null GFQLTypeError GFQLTypeError E302
avg categorical GFQLTypeError null GFQLTypeError GFQLTypeError E302
avg datetime mean timestamp GFQLTypeError GFQLTypeError GFQLTypeError E302
sum string / nullable-string 'abac' raw polars.InvalidOperationError GFQLTypeError GFQLTypeError E302
sum datetime / categorical GFQLTypeError null GFQLTypeError GFQLTypeError E302
sum all-null 0 raw polars.InvalidOperationError GFQLTypeError 0
avg all-null null null GFQLTypeError null
min / max categorical GFQLTypeError ok ok ok (lexicographic)
collect, collect(DISTINCT) categorical ok ok GFQLTypeError ok
count(DISTINCT) categorical 2, 3 2, 3 'c', 'd' — a category LABEL instead of a count (and ValueError whole-table) 2, 3

Bold = silent wrong answer. Note the last row: the sweep turned up a second quiet-wrong-answer
class on cuDF that nobody was looking for.

What changed

One shared contract module, graphistry/compute/gfql/agg_types.py, holding the Cypher sources, the
sum/avg aggregate set, the two engine dtype classifiers, the all-null substitution value, and
one raiser — so the error class, ErrorCode and message cannot drift between engines. The
aggregate kernels are written three times, and all three now consult it:

  1. graphistry/compute/gfql/row/pipeline.py — pandas/cuDF row pipeline
  2. graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py — native polars row pipeline
  3. graphistry/compute/gfql_fast_paths.py — OLAP single-hop grouped fast path (both of its
    engine branches; it reimplements the aggregates, so without a guard there whether an ill-typed
    aggregate was caught depended on whether the query happened to match the fast-path shape)

Plus:

  • No raw polars.exceptions.* escapes the GFQL surface. On the pandas side execute_call
    already wraps any kernel exception as GFQLTypeError(E303); the native polars path runs before
    execute_call and skipped that wrapper entirely. Native row ops now funnel polars errors through
    the same wrapper with the same code and message shape, preserving the polars text as the cause.

  • Categoricals are decategorized once, up front, on both engines. Cypher treats a categorical as
    a string column; the host kernels did not agree with each other about it. This also dodges a
    polars 1.35.2 rust panic (categorical.rs:350 not implemented) on a grouped min/max over a
    Categorical — it surfaces as a pyo3_runtime.PanicException, which is not a polars exception, so
    no python-side wrapper can catch it. Found by the GPU lane on the RAPIDS 26.02 image.

  • Error messages name the operation and the user's output alias (the cypher lowering
    materializes the aggregate's argument into an internal __cypher_agg__ column, so naming the raw
    column would hand the user a name that appears nowhere in their query):

    [incompatible-column-type] Aggregation avg() requires numeric or duration values, but the
    argument of 'mean_s' has type String | field: __cypher_agg__ | value: 'String' | suggestion:
    Cypher restricts avg() to INTEGER/FLOAT/DURATION; use count()/collect()/min()/max() over the
    argument of 'mean_s', or cast it to a number
    

Cost — not assumed, ordered for it

The dtype verdict is O(1) (a dtype check) and runs first; the O(n) all-null scan is consulted
only for a column the dtype has already rejected, i.e. only on queries that were previously wrong
or erroring. A served numeric aggregate pays one dtype string check and nothing else.

Measured, 200k nodes / 400k edges, 12 reps, two interleaved base/head rounds:

shape base head
pandas row-pipeline group+agg 85.3 / 86.3 ms 87.7 / 85.8 ms
pandas OLAP fast path 110.9 / 108.2 ms 111.5 / 112.4 ms
polars row-pipeline group+agg 3.00 / 2.81 ms 3.01 / 3.06 ms
polars OLAP fast path 19.4 / 19.4 ms 18.5 / 18.8 ms

Round-to-round spread within each arm is ~±3%, and every delta sits inside it — this design does
not resolve anything smaller, and no smaller claim is made.

Tests

graphistry/tests/compute/gfql/test_aggregate_type_contract.py, parametrized over
pandas / polars / cudf / polars-gpu with named skips (not available_nonpandas_engines(), which
silently shrinks the parametrization when a stack is missing):

  • negative lane — what must raise, which typed error, and that the message names the operation
    and the alias; plus that no polars.exceptions.* class reaches the caller
  • positive lane — int / float / bool / duration still aggregate; min/max/count/collect
    still accept strings and categoricals; all-null → 0/null; a 0-row frame is not treated as
    all-null (that path must keep its source dtype)
  • differential matrix — the whole aggregate × dtype cross-product against the pandas oracle, on
    value and error class
  • fast-path lane — with a spy asserting the fast path is actually entered, so it cannot
    silently degrade into testing the ordinary row pipeline

Where they run: the file is under graphistry/tests/compute/, so the main lane collects it, and
it is added to bin/test-polars.sh — the native polars aggregate guard and the polars-error wrap
run in no other lane. (Note #1805, open, widens that script further.)

GPU receipt — dgx-spark GB10, --gpus all, graphistry/test-rapids-official:26.02-gfql-polars
(cudf 26.02.01 / polars 1.35.2 / cudf_polars available), via the dgx safety harness:

cudf 26.02.01 polars 1.35.2 cudf_polars True
403 passed in 9.48s

That run is what surfaced the polars 1.35.2 categorical panic (10 failures before the decategorize
fix, 0 after) — the local polars 1.42 cannot see it.

Mutation checks — 10/10 detected. Each fix reverted individually, suite re-run:

mutation outcome
polars sum/avg dtype guard removed DETECTED
pandas sum/avg dtype guard removed DETECTED
OLAP fast-path polars guard removed DETECTED
OLAP fast-path pandas guard removed DETECTED
polars all-null substitution reverted to kernel DETECTED
polars Null-dtype substitution reverted DETECTED
pandas all-null substitution reverted DETECTED
pandas categorical decategorize removed DETECTED
polars categorical decategorize removed DETECTED
raw-polars-exception wrap removed DETECTED

Two of these were not detected on the first pass — the fast-path guards and the polars-error wrap
were being covered by tests that never reached them (the fast path declines the query shape I first
wrote, and the dtype guard now prevents the one polars error the wrap was known to catch). Both were
rewritten until they fail on revert.

Verification

  • graphistry/tests/compute: 7095 passed; failure set byte-identical to origin/master (233b64c)
    — 123 pre-existing failures from missing optional deps (umap/dask/cupy), 0 new
  • bin/test-polars.sh: 0 failures not also present on the base commit
  • bin/lint.sh clean, bin/typecheck.sh clean (mypy: no issues in 326 source files)
  • No Any / cast / getattr / setattr / bare list / Dict[str, Any] introduced

Filed, not fixed

Siblings the sweep found that are out of this PR's scope, each filed with a precise repro:

Does this unblock #1743 on this axis?

Yes. The avg(<string>) silent null and the sum(<string>) raw-exception leak are gone on
engine='polars', independently of the routing change: this branch is off origin/master
(233b64c) and #1743 does not need to carry it. On the aggregate-type axis specifically, routing
engine=auto to polars no longer changes any answer — the 288-cell sweep is identical across
pandas, polars and cuDF. This says nothing about #1743's other open axes (the postload/postchain
policy-hook gap, the frame-type and dtype contract changes).

🤖 Generated with Claude Code

https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB

`avg(<string column>)` raised on pandas but returned a silent `null` on polars, and
`sum(<string column>)` returned the string CONCATENATION ('abac') on pandas while leaking a raw
`polars.exceptions.InvalidOperationError` through the GFQL surface. Wrong in OPPOSITE directions,
so "match the other engine" was not available -- the contract is pinned to Cypher instead.

openCypher/Neo4j declares `avg(input)`/`sum(input)` over `INTEGER | FLOAT | DURATION` only
(neo4j/docs-cypher functions/aggregating.adoc) and enforces it at runtime: Neo4j 5.26.26 answers
`RETURN avg(r.s)` over strings with "AVG(...) can only handle numerical values, duration, or
null." and `sum(date(...))` with "Type mismatch: expected Float, Integer or Duration but was
Date"; Kuzu 0.11.3 rejects both at bind time. min/max/count/collect take ANY (TCK Aggregation2
[7]-[12]). So a non-numeric input is a GFQLTypeError(E302) naming the aggregate and the user's
output alias, on every engine.

The aggregate kernels are written three times -- pandas/cuDF row pipeline, native polars row
pipeline, OLAP single-hop grouped fast path -- so the guard is placed at all three, over ONE
shared classifier (graphistry/compute/gfql/agg_types.py) that also carries the sources.

Deliberate contract change on pandas: `sum(<string>)` no longer concatenates.

Also brought to the contract, found by a full aggregate x dtype sweep rather than one at a time:
- sum over temporal/categorical (polars returned null, pandas raised)
- sum/avg over an all-null column -> 0 / null per Neo4j, substituted rather than delegated
  (host kernels answer 0/''/NaT/TypeError by dtype; polars raises on both str and null)
- min/max/collect/count(DISTINCT) over a CATEGORICAL, which Cypher accepts as ANY: pandas
  rejected it outright and cuDF answered count(DISTINCT) with a CATEGORY LABEL instead of a
  count. Decategorized once, up front, on both engines -- which also dodges a polars 1.35.2
  rust panic (categorical.rs "not implemented") that escapes as an uncatchable pyo3
  PanicException on the RAPIDS 26.02 image.
- native polars row ops now wrap polars errors as GFQLTypeError(E303), the same code and message
  shape execute_call already gives the pandas surface, instead of letting the third-party class
  reach the caller.

288-cell pandas x polars x cuDF sweep over 8 aggregates x 9 dtypes: 24 divergences -> 0.

Cost: the dtype verdict is an O(1) schema/dtype check and runs first; the O(n) all-null scan is
consulted only for a column the dtype has already rejected, so a served numeric aggregate pays
nothing. Measured at 200k nodes / 400k edges, two interleaved rounds, every delta inside the
~3% round-to-round spread (pandas row-pipeline 85.3/86.3 -> 87.7/85.8 ms; pandas fast path
110.9/108.2 -> 111.5/112.4; polars row-pipeline 3.00/2.81 -> 3.01/3.06; polars fast path
19.4/19.4 -> 18.5/18.8).

Tests parametrized over pandas/polars/cudf/polars-gpu, positive and negative lanes plus the
differential matrix; added to bin/test-polars.sh so the native polars guard runs in a CI lane.
GPU receipt: dgx-spark GB10, `--gpus all`, graphistry/test-rapids-official:26.02-gfql-polars
(cudf 26.02.01 / polars 1.35.2 / cudf_polars) -- 403 passed, 0 failed. Every fix mutation-checked
(10/10 detected).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB
lmeyerov and others added 4 commits July 27, 2026 21:00
…ate guard

CI py3.14 (pandas 3) caught this: a bare `pd.Series(["a"])` is `object` on pandas 2 but `str` on
pandas 3, and the classifier matched string dtypes against a FIXED SET of spellings. A missed
spelling fails OPEN -- the column is delegated to the host kernel, which is exactly the
`sum(<string>) == 'abac'` concatenation this stack removes -- so the check is now a prefix match
over `str*` / `large_string*`, and the test asserts the VERDICT across every spelling pandas
offers rather than pinning one version's dtype repr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB
The contract is enforced in code but was invisible to users. Three observable
things, in the Cypher-mapping doc because two of them are divergences from Cypher:

- sum/avg accept INTEGER|FLOAT|DURATION, plus BOOLEAN as a deliberate GFQL
  extension (Neo4j rejects BOOLEAN) -- see #1820 for the standing decision
- every other input type now RAISES, where a string column previously returned
  its concatenation on pandas and leaked a raw polars error on polars
- empty/all-null follows Cypher not SQL: sum -> 0, avg -> null

Output dtypes are deliberately NOT pinned here: sum/count over BOOLEAN still
return int64 on pandas vs UInt32 on polars, which belongs with #1820.
Conflict resolution only; no change to the PR's contract.

- CHANGELOG.md: took master's file and re-inserted this branch's two
  entries at the top of `### Fixed` under `## [Development]`.
- bin/test-polars.sh: kept master's lane rewrite (xdist + completeness
  enforcement) and re-added this branch's
  test_aggregate_type_contract.py entry.
- graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py: disjoint
  hunks (master's #1787 var-length gate vs this branch's `_agg_expr`
  type contract).
- graphistry/compute/gfql_fast_paths.py: master added the FUSED lazy
  polars lane `_single_hop_grouped_aggregate_fused_polars`, which runs
  BEFORE the eager twin this branch guards and reimplements the
  aggregates a third time. Left unresolved it would have restored the
  divergence on the fast path -- `sum(<string>)` would reach the caller
  as a raw polars.exceptions.InvalidOperationError from `.collect()`
  instead of GFQLTypeError(E302). The fused lane now DECLINES (-> the
  untouched eager twin, which owns the contract) for sum()/avg() over a
  non-numeric or all-null column, and for a Categorical/Enum property
  column, which the twin casts to String and this lane does not. No
  shape in master's fused-lane test corpus is affected: every SERVED
  shape aggregates a numeric column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
… tests actually run

The entry claimed a "288-cell pandas x polars x cuDF sweep over 8 aggregates x
9 dtypes ... zero divergences (24 before)". Nothing in the repo produces 288:
the shipped matrix is `_MATRIX_AGGS` (6: count/sum/avg/min/max/collect) x
`_MATRIX_COLS` (10: 5 numeric + 4 non-numeric + 1 all-null) x 3 engines
(polars/cudf/polars-gpu), i.e. 180 cells, each compared against the pandas
oracle on BOTH the value and the error class. 288 = 8x9x4, which matches
neither the agg list, the column list, nor the engine list, and the prose
names three engines while 4 would be needed to reach it. The "(24 before)"
baseline has no artifact behind it either, so it is dropped rather than
restated -- an unverifiable number is worse than no number.

Also: "the aggregate kernels are written three times" was true when this
branch was cut and is not now. #1823 added a fused lazy lane in front of the
OLAP single-hop grouped fast path, making four -- which is exactly why this
branch had to add a decline to that lane.

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
#1828 landed the low-cardinality count(*) gate in gfql_fast_paths.py, which is
the same file this branch adds its fused two-hop count lane to, so this needed a
real 3-way rather than the CHANGELOG-only resolution the earlier merges took.

git merge-file returns rc=0 with zero conflict markers, and the arithmetic
checks out: 2687 merged lines = 2538 (branch) + 149 (master-added), so nothing
was dropped. Structurally the two additions are disjoint -- this branch dispatches
the two-hop lane from _execute_two_hop_count_fast_path, while #1828 gates a
SINGLE-hop grouped count on _LOWCARD_COUNT_MAX_GROUPS / _MAX_INPUT_ROWS. They do
not gate on the same shape.

A clean textual merge is not on its own proof of semantic compatibility -- the
sibling PR #1819 hit exactly that trap, where a marker-free merge silently put
a new fused lane in front of a guarded twin. Flagging it here so review checks
the interaction rather than trusting rc=0; CI is the arbiter.

CHANGELOG resolved structurally: master file taken whole, this branch entry
re-inserted at the top of the same section it occupied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
@lmeyerov
lmeyerov changed the base branch from master to perf/gfql-fused-tolower-one-sided July 28, 2026 15:02
@lmeyerov
lmeyerov force-pushed the fix/gfql-polars-agg-string-divergence branch 2 times, most recently from d4ef4bf to 5ef3d0f Compare July 28, 2026 15:56
lmeyerov and others added 2 commits July 28, 2026 08:56
…ng-divergence (linear PR stack)

Stacked so the shared CHANGELOG stops conflicting on every landing: this branch
now sits on top of perf/gfql-fused-tolower-one-sided rather than beside it. Files touched by both were
3-way merged with zero conflict markers; CHANGELOG resolved structurally, with
this branch's entry re-inserted into the parent's file under the same section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
The stacking upload rewrote the tree entry as mode 100644, so the
test-polars lanes died with 'Permission denied' (exit 126) before
running a single test.
@lmeyerov
lmeyerov changed the base branch from perf/gfql-fused-tolower-one-sided to master July 28, 2026 16:23
@lmeyerov
lmeyerov merged commit 47df7d4 into master Jul 28, 2026
75 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant