Repository navigation
fix(gfql): pin sum/avg-over-BOOLEAN return types across engines (#1820 verdict) - #1982
Merged
Merged
Conversation
…ss engines Implements the owner's 2026-07-28 verdict on #1820: adopt sum()/avg() over BOOLEAN as a documented extension, with three amendments. 1. min/max over BOOLEAN are stated and pinned as the standard boolean ORDERING false < true returning NULL over zero non-null values -- not as an AND/OR fold, which agrees on populated input but predicts the conventional empty identities (true/false) where every engine answers NULL. 2. sum -> 0 over zero rows is described as Cypher conformance (SQL returns NULL), not a compromise. 3. The dtypes are pinned, not just the values -- the gap that was still live. sum(BOOLEAN) -> INTEGER (int64) avg(BOOLEAN) -> FLOAT (float64) min(BOOLEAN) -> BOOLEAN (ordering false < true; NULL over zero non-null) max(BOOLEAN) -> BOOLEAN (ordering false < true; NULL over zero non-null) count(BOOLEAN) -> INTEGER (int64) Polars answered sum(BOOLEAN) and every count() with UInt32, the all-null substitution answered sum with an Int32 literal, and cuDF answered count(DISTINCT ...) with int32 -- same values, four different return types. Exercising the cuDF arm (unverified at the time of the verdict) also turned up a wrong VALUE: cuDF's grouped sum answered a group with no non-null values with NULL, where Cypher says 0 and pandas/polars already said 0. Enforced at all seven aggregate implementations: the pandas/cuDF row pipeline, the native polars row pipeline, and the OLAP fast path's fused polars lane, value_counts count plan, eager polars twin and pandas/cuDF twin. Non-boolean aggregates are unaffected: polars already sums every other numeric input to Int64/Float64/Duration, and FLOAT/DURATION sums are explicitly excluded from the widening. count() is now INTEGER on polars for every input type, which is a deliberate widening -- it matches pandas/cuDF and Cypher. Fixes #1820 Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AjbKuKheqDu78oapRT5AYm
…contract # Conflicts: # CHANGELOG.md
This was referenced Aug 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the verdict the owner issued on 2026-07-28 in #1820 — "adopt the rule, with three amendments — measured, not assumed" — including all three amendments. This is not a re-litigation of the design question; it is the work the verdict specifies.
sum()/avg()overBOOLEANis adopted as a documented extension. Cypher rejects it ("expected Float, Integer or Duration but was Boolean"); GFQL serves it. It is a strict superset — it accepts only input Cypher rejects outright, so no Cypher-valid query changes meaning.Fixes #1820
The three amendments
1.
min/maxare an ORDERING, not an AND/OR fold.min == AND/max == ORis a derivation fromfalse < true, not a definition, and a logical fold gets the empty case backwards: the conventional identity of AND over zero elements istrueand of OR over zero elements isfalse, while every engine answers NULL. Stated and pinned as "standard boolean orderingfalse < true, NULL when there are no non-null values" — the same orderORDER BYalready gives booleans.2.
sum -> 0over zero rows is conformance, not a compromise. Cypher'ssum()returns 0 where SQL's returns NULL, and Cypher'savg()returns null. Described that way in both the module contract and the docs.3. Dtypes are pinned, not just values — the gap that was still live.
Before / after, measured on this box
Input
pd.array([...], dtype="boolean"), whole-table and grouped (identical); dtypes read natively (ato_pandas()round-trip turns a cuDFbool-with-nulls column intoobjectand fabricates a divergence).[T,F,T]2:Int64 0.667:Float64 F:boolean T:boolean 3:Int642:UInt320.667:Float64 F:Boolean T:Boolean 3:UInt322:Int64 … 3:Int64✅2:int64 0.667:float64 F:bool T:bool 3:int64[T,null,F]1:UInt32…2:UInt321:Int64 … 2:Int64✅[null,null]0:Int64 NULL:Float64 NULL:boolean NULL:boolean 0:Int640:UInt32…0:UInt320:Int64 … 0:Int64✅NULL:int64 NULL:float64 NULL:bool NULL:bool 0:int640:int64✅[](0 rows)0:int64 NULL NULL NULL 0:int64Two findings beyond the reported polars
UInt32, both from arms the verdict flagged as unverified or unexamined:sumanswers a group with no non-null values withNULL, where Cypher says 0 and pandas/polars already said 0 — onBOOLEAN,Int64andfloat64alike. This is the verdict's[null, null] -> sum 0row, and it is exactly what "cuDF was not probed" was hiding.count(DISTINCT ...)withint32, where pandas answersint64.Plus two polars ones the sweep turned up: the all-null substitution emitted a bare
pl.lit(0), which isInt32, and the OLAP fast path'svalue_countscount plan — a fourth count formulation — was left atUInt32after itsgroup_bysibling was conformed, silently making two lanes that document themselves as interchangeable type-different.Scoping: what widened, and the evidence
sumis boolean-scoped by construction, not by a guard. Measured on polars 1.42:sumoverInt8/Int64/UInt8already returnsInt64, overFloat64returnsFloat64, overDurationreturnsDuration.Booleanis the only input whose sum is not already the contract type, so thesumhalf of the cast can only fire on a boolean column.polars_agg_result_castdeclinesFloat64andDurationexplicitly, and a test pins those declines — widening a FLOAT or DURATION sum toInt64would be a silent wrong answer.countDID widen, deliberately. Polars answerscount()withUInt32for every input dtype; pandas and cuDF answerint64for every input dtype; Cypher declarescountINTEGER. Fixing it only for boolean input would have left the same divergence one query away and made the contract arbitrary. Evidence that the widening is correct and expected: the failure-set diff below shows the only tests it moved were thevalue_countslane's own schema-equality pins, which compare that lane against a locally-builtpl.len()twin — and those pass once the twin is built the way production now builds it.Sites
Seven aggregate implementations, all conformed off the one contract module (
agg_types.py):value_countslow-cardinality count planReceipts
Anti-vacuity — 30 RED at the merge-base. The new/strengthened pins were run at
e4ad11c6bin a throwaway worktree: 30 failed, 323 passed, 0 errors. Green with the fix (353 passed).Per-site mutation — 17 sites, 0 vacuous. Each site was reverted to its pre-fix form and the pin file re-run:
count(*)sum(col)count(*)count(col)count(col)sum(col)sum(col)count_distinctcount(*)count(col)value_countscount planThree sites mutated to zero on the first pass and the pins were strengthened rather than accepted: the two all-null literals (their tests compared values only), and the fused lane's
pl.len()(thevalue_countsplan wins for a purecount(*), so reaching it needs acount(*)sharing its RETURN with another aggregate). An eighth candidate site — an integer widen on the fast path's pandascount— could not be made to bite because cuDF's groupedcountis alreadyint64, so it was removed rather than kept as a vacuous no-op.Failure SET comparison —
graphistry/tests/compute, merge-basee4ad11c6bvs this branch, same box, same interpreter:value_countstwin update (7 before it, all in that one file, all the schema-equality pins described above)Guards — run inside the worktree:
bin/ci_comment_density_guard.pyrc=0 (no growth; 2 files now below baseline),./bin/lint.shrc=0,./bin/typecheck.shrc=0 (mypy clean, 333 files).Engine arms actually exercised
cudf_polarsis not installed on this box; all 32 skips carry the named reasonpolars-gpu lane requires cudf_polars (RAPIDS 26.02+ image). The lane needs a re-run on the RAPIDS 26.02 image.The cuDF arm is the one the verdict called out as unverified; it is now exercised for real, and it is where the wrong-value bug came from.
Known gap left OPEN, deliberately
The 0-row ungrouped identity row carries no type evidence, so
avg/min/maxover an empty result land on pandasobject/ polarsNullrather than their contract dtypes. The values are correct on every engine (sum0,avg/min/maxNULL,count0) and are pinned; the dtypes are not, and the test says so in place rather than pinning today's behaviour. This is not boolean-specific —avgover an empty INTEGER column loses its dtype identically — and closing it means plumbing the aggregate's contract dtype through the identity-row fill. Registered on #1665 and #1664 rather than folded into this PR.Same for cuDF's 0-row grouped schema, which types every aggregate column with the input dtype.
Registered
Commented on #1665 (per-engine capability/semantics matrix) and #1664 (openCypher conformance tracker) so the extension reads as a deliberate, typed divergence rather than an oversight.
🤖 Generated with Claude Code
https://claude.ai/code/session_01AjbKuKheqDu78oapRT5AYm