Repository navigation
GFQL: conform to openCypher/SQL semantics — track, fix & document violations (3VL nulls, …) #1664
Description
Activity
Validated conformance findings (4-engine sweep on dgx-spark, pandas/cudf/polars/polars-gpu)
A systematic null/semantics sweep confirmed the following. Each was run on all four engines; "openCypher EXPECTED" is the spec answer (which may differ from the current pandas oracle).
Fixed already
- ✅
ne()/<>on NULL — 3VL (commit4ef8248d). Reference example. - ✅ membership /
INon NULL (n({col:[.., None]})) — cuDF kept the null; fixed infilter_by_dict(commit3a2579d4). All engines now exclude the null.
Confirmed violations — ALL engines diverge from openCypher (silent; the differential harness can't catch these since the engines agree with each other)
1. Integer division of integer columns returns a float (no truncation).
MATCH (a) RETURN a.val/2 AS hon an int64val=[5,7,9]→ all four engines give[2.5, 3.5, 4.5]. openCypher: integer/integer is integer division →[2,3,4]. The repo already NIEs the literal5/2case on polars (so the intent is int-division), but the column case falls through to true-division on every engine (row/pipeline.py:1086; polarsrow_pipeline.py:481-482only declines literal/literal). High blast radius (any int-column division). Behavior change to fix.2.
ORDER BY ... DESCplaces NULLs last; openCypher places them first.
ORDER BY a.val DESCwith a null → all four engines give[30,20,10,null]. openCypher orders null as larger than any value → DESC = null first[null,30,20,10]. (row/pipeline.py:4369usesna_position='last'regardless of direction; polarsorder_bypasses a single unconditionalnulls_last=True, which also breaks mixed multi-keyORDER BY a ASC, b DESC.) Behavior change to fix.3. In-query NaN is treated as NULL on pandas/cuDF; openCypher treats NaN as a value.
RETURN coalesce(0.0/0.0, 7.0)→ pandas/cuDF give7.0(NaN treated as null), polars/polars-gpu giveNaN(correct). Similarly(0.0/0.0) IS NULL→ pandas/cuDFtrue, openCypherfalse. Narrow (only in-query NaN; ingested NaN is normalized to null on the polars path so both agree there). Rooted in pandas conflating NaN and null (.isna()); a deeper fix.Confirmed cross-engine dtype divergence → tracked in #1663
- Endpoint synthesis int→float upcast generalizes GFQL: cuDF cross-engine result divergences (list-literal order, toString(float), min_hops seed hop-label, group_by Series-truthiness) #1663 finding Document API with pydoc #3 beyond hop-labels to any integer USER node-attribute column: when a hop/chain synthesizes edge-endpoint rows missing from the node table (
hop.py:981-998,chain.py:1148-1158), the NA-stub concat upcasts an int attr col tofloat64on pandas but cuDF's native nullable-int keepsint64. Confirmed:wdtypefloat64(pandas) vsint64(cuDF). Amplifies to a wrong VALUE viatoString→"100.0"vs"100".
Did NOT reproduce (predictions that don't hold — recorded so we don't chase them)
- cuDF
collect()within-group list-element order: matched pandas in tests. - cuDF
DISTINCT/DISTINCT … LIMITrow order: matched pandas in tests.
Note on the methodology
These were found by probing the openCypher SPEC, not just pandas-vs-others parity — items 1–3 are exactly the class the differential harness is blind to (all engines wrong together). The fixes for 1–2 are real behavior changes to the default engine, so they want explicit sign-off + a CHANGELOG
Fixednote + docs, per this issue's charter.- ✅
- added 8 commits that reference this issue
on Jul 1, 2026 - added a commit that references this issue
on Jul 3, 2026 Conformance entry:
sum/avgoverBOOLEANis a DOCUMENTED, TYPED deviation (not a bug)Registering this on the openCypher conformance tracker per the owner's 2026-07-28 verdict on #1820. Implemented in #1982.
This is the tracker's second disposition — "document an explicit, justified deviation" — rather than a fix-to-spec, and it is the well-behaved kind:
- Neo4j rejects it. 5.26.26:
UNWIND [true,false,true] AS x RETURN sum(x)→ "Type mismatch: expected Float, Integer or Duration but was Boolean". Kuzu 0.11.3 rejects at bind time. - GFQL serves it, on every engine, and always has.
- It is a strict SUPERSET: it only accepts input the spec rejects outright, so no spec-valid query changes meaning. The deviation cannot alter the answer to any conformant query — it can only turn an error into an answer.
- Rationale: summing an indicator column is idiomatic in the dataframe surface GFQL also serves, and rejecting it would break working user queries with no correctness argument behind the break.
Documented at
docs/source/gfql/spec/cypher_mapping.md§ Aggregate input types / Aggregates overBOOLEAN: return types.Where the PR conforms TO the spec rather than deviating from it
Three of the four sub-behaviours are pure openCypher conformance and are now pinned as such:
sum()returns0over zero rows (SQL'sSUMreturns NULL). Neo4j "Considerations": "sum(null)returns0". All engines matched — except cuDF, see below.avg()returnsnullover zero rows. Neo4j: "avg(null)returnsnull". All engines matched.min/maxare an ORDERING (false < true), returning NULL over zero non-null values — the same orderORDER BYalready gives booleans. Deliberately not written asmin == AND/max == OR: that derivation agrees on populated input but predicts the conventional empty identities (true/false) exactly where the spec and every engine say NULL. The all-null and empty-group rows are the discriminating cases and are now negative-tested on all arms.
A genuine 3VL-adjacent conformance BUG this turned up on cuDF
Squarely this tracker's business, and it is a wrong value, not a dtype nit:
cuDF's grouped
sumanswered a group with no non-null values withNULL, where openCypher says0and pandas/polars already said0.Not boolean-specific — it hit
BOOLEAN,Int64andfloat64alike. It matches the pattern this issue already documents forne()andIN: "all engines match pandas" is necessary but not sufficient, and here they did not match pandas, on a row nobody had probed. It surfaced only because the verdict required exercising the cuDF arm, which #1820 recorded as "cuDF was not probed". Fixed by repairing the kernel's null answer to the spec's0at the aggregate output.Also closed: the return-type half of conformance
The spec declares
count()as INTEGER; polars answeredUInt32for every input type, and cuDF answeredcount(DISTINCT ...)withint32. Both now return int64. A contract that pins values but not return types leaves the divergence class this tracker and #1665 exist to close.Coverage caveat
pandas / polars / cuDF (real GPU: RTX 3080 Ti, cudf 25.10) all exercised.
polars-gpuis NOT covered —cudf_polarsis absent on the box and every param skips with a named reason; it needs a run on the RAPIDS 26.02 image before this entry can be called four-arm complete.Left open (tracked, not fixed here)
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. Values are spec-correct and pinned; dtypes are not. Not boolean-specific —avgover an empty INTEGER column loses its dtype identically. Detail on #1665.Executable form:
graphistry/tests/compute/gfql/test_aggregate_type_contract.py; the rule itself:graphistry/compute/gfql/agg_types.py.- Neo4j rejects it. 5.26.26:
Summary
GFQL is a Cypher-flavored query language, but in several places it diverges from openCypher / SQL semantics — most importantly three-valued (Kleene) NULL logic. This is an umbrella issue to track, fix, and document those violations systematically.
Key principle: conformance is to the openCypher/SQL spec, not merely to the pandas engine. GFQL uses pandas as the differential-parity oracle for the other engines (cuDF, polars, polars-gpu), but the pandas engine itself can be non-conformant — so "all engines match pandas" is necessary but not sufficient. Where the spec and pandas disagree, the spec wins (we fix pandas), or we document an explicit, justified deviation.
Reference example (already fixed — the pattern to generalize)
ne()/<>on NULL:n({"col": ne(x)})and cypherWHERE n.col <> xover a NULL cell used to keep the null row on pandas (NaN != x→True), diverging from cuDF and polars (both drop it) and from pandas' ownWHERE NOT n.col = xpath. Per openCypher/SQL 3VL,null <> xisnull→ not a match → row excluded. Fixed inNE.__call__(mask nulls); now all four engines agree. (commit39e278a5)A 4-engine null-audit harness (
pandas/cudf/polars/polars-gpu×filter_dict+ cypherWHERE, with a NULL-bearing fixture) pinned the entire divergence to exactly that one predicate — that audit pattern is how we should sweep the rest.Areas to audit / track (each: confirm vs openCypher, fix or document, lock with a test)
eq/gt/lt/ge/le/between/is_in(audit shows these already drop nulls = 3VL-correct; lock with explicit cross-engine tests so they can't regress).NOTon NULL —NOT IN,NOT contains,NOT startswith/endswith, double-negation — these are the high-risk cases (negating anunknownmust stayunknown→ excluded), sincenewas the one that was wrong.contains/startswith/endswith/matchover a null cell.null + x, comparisons of computed nulls,coalesce, etc. (partly done: Kleene booleans over null literals, list/map structural equality — see prior 3VL tranches [META-split] Direct-Cypher wrong-rows tranche C: tri-valued list/map/null semantics #1407/[META-split] Direct-Cypher wrong-rows tranche A: comparator + ORDER BY semantics #1405/Cypher WHERE: static validation gap on row-boolean shapes (OR / NOT / XOR among row predicates) — emergent from #1217 Earley swap #1219).WHEREvsfilter_dictconsistency — the two surfaces must give identical results for the same logical predicate on every engine (the<>case showed they can drift).numdtype). Decide a canonical dtype contract for null-bearing outputs.5/2→2in cypher), ordering of NULLs inORDER BY(NULLs last), etc. — fold in as found.Deliverables
Related: #1663 (cuDF cross-engine divergences — the dtype item above is finding #3).