Skip to content

GFQL: conform to openCypher/SQL semantics — track, fix & document violations (3VL nulls, …) #1664

Description

@lmeyerov

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 cypher WHERE n.col <> x over 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' own WHERE NOT n.col = x path. Per openCypher/SQL 3VL, null <> x is null → not a match → row excluded. Fixed in NE.__call__ (mask nulls); now all four engines agree. (commit 39e278a5)

A 4-engine null-audit harness (pandas/cudf/polars/polars-gpu × filter_dict + cypher WHERE, 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)

Deliverables

  1. A systematic conformance audit (generalize the 4-engine null-audit harness across the predicate/surface/engine matrix).
  2. Fix each violation toward openCypher/SQL semantics (pandas + cuDF + polars + polars-gpu), or record an explicit, justified deviation.
  3. User-facing docs: a GFQL "null / three-valued logic & openCypher semantics" section so the behavior is specified, not implied.

Related: #1663 (cuDF cross-engine divergences — the dtype item above is finding #3).

Activity

  1. lmeyerov commented on Jun 30, 2026

    @lmeyerov
    ContributorAuthor

    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 (commit 4ef8248d). Reference example.
    • ✅ membership / IN on NULL (n({col:[.., None]})) — cuDF kept the null; fixed in filter_by_dict (commit 3a2579d4). 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 h on an int64 val=[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 literal 5/2 case on polars (so the intent is int-division), but the column case falls through to true-division on every engine (row/pipeline.py:1086; polars row_pipeline.py:481-482 only declines literal/literal). High blast radius (any int-column division). Behavior change to fix.

    2. ORDER BY ... DESC places NULLs last; openCypher places them first.
    ORDER BY a.val DESC with 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:4369 uses na_position='last' regardless of direction; polars order_by passes a single unconditional nulls_last=True, which also breaks mixed multi-key ORDER 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 give 7.0 (NaN treated as null), polars/polars-gpu give NaN (correct). Similarly (0.0/0.0) IS NULL → pandas/cuDF true, openCypher false. 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

    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 … LIMIT row 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 Fixed note + docs, per this issue's charter.

  2. lmeyerov commented on Aug 20, 2026

    @lmeyerov
    ContributorAuthor

    Conformance entry: sum/avg over BOOLEAN is 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 over BOOLEAN: 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:

    1. sum() returns 0 over zero rows (SQL's SUM returns NULL). Neo4j "Considerations": "sum(null) returns 0". All engines matched — except cuDF, see below.
    2. avg() returns null over zero rows. Neo4j: "avg(null) returns null". All engines matched.
    3. min/max are an ORDERING (false < true), returning NULL over zero non-null values — the same order ORDER BY already gives booleans. Deliberately not written as min == 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 sum answered a group with no non-null values with NULL, where openCypher says 0 and pandas/polars already said 0.

    Not boolean-specific — it hit BOOLEAN, Int64 and float64 alike. It matches the pattern this issue already documents for ne() and IN: "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's 0 at the aggregate output.

    Also closed: the return-type half of conformance

    The spec declares count() as INTEGER; polars answered UInt32 for every input type, and cuDF answered count(DISTINCT ...) with int32. 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-gpu is NOT covered — cudf_polars is 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/max over an empty result land on pandas object / polars Null rather than their contract dtypes. Values are spec-correct and pinned; dtypes are not. Not boolean-specific — avg over 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions