cc @lmeyerov @mj3cheun
Summary
The two GFQL Cypher WHERE execution paths disagree on the not-equals operator <> when the (existing) column value is null:
| path |
routing trigger |
null <> 'ab' |
semantics family |
filter_dict (columnar) |
flat A AND B ... chain |
TRUE → row kept |
dataframe/table-search ("values differ") |
where_rows (row expr engine) |
parens / OR / XOR / NOT / arithmetic |
NULL → row dropped |
SQL / openCypher 3-valued logic |
So these two queries — identical except for semantically-inert parentheses — return different rows when n.s has nulls:
MATCH (n) WHERE n.s <> 'ab' RETURN n -- filter_dict: null row INCLUDED
MATCH (n) WHERE (n.s <> 'ab') RETURN n -- where_rows: null row EXCLUDED
Every other operator exercised by the metamorphic characterization (=, >, IS NULL, IS NOT NULL, CONTAINS) agrees across both paths, including on null rows. Only <>/null diverges.
Provenance
Found by @mj3cheun's metamorphic WHERE-equivalence test written for #1653 (test_where_metamorphic.py, commit b7b4324 — characterized there as an xfail). This is pre-existing execution-engine behavior, orthogonal to the Earley→LALR parser switch (#1682): the parser produces identical ASTs for both paths' inputs; the divergence is entirely in how filter_dict vs where_rows evaluate <> against null. (The known, deliberate difference on absent columns — filter_dict raises, where_rows treats absent-as-null — is a separate documented boundary and not this issue.)
The actual question: which semantics is intended for the Cypher surface?
This is a semantics-intent decision, not just a bug:
- openCypher/SQL 3VL says
null <> 'ab' → null → row filtered. where_rows is spec-correct for a Cypher surface.
- GFQL
filter_dict carries dataframe/table-search semantics, where a null cell is straightforwardly "not equal to 'ab'" — arguably what a table-filtering user means, and consistent with how filter_dict behaves elsewhere in GFQL proper.
Options:
- Cypher surface gets 3VL everywhere: the cypher lowering stops routing
<> predicates to filter_dict when the column is nullable (or wraps with an IS NOT NULL conjunct), keeping filter_dict's native semantics for native GFQL users. Spec-correct, minimal blast radius.
- Align filter_dict's
<> to 3VL globally: consistent, but changes native-GFQL behavior others may rely on.
- Document the divergence and keep it (status quo, but it makes row membership depend on whether a WHERE body happens to lift — observable and surprising).
Option 1 looks right for the Cypher surface (the lift is an internal optimization and must not be observable), but this needs an intent call from @lmeyerov / @mj3cheun given filter_dict's table-semantics role in native GFQL.
Repro sketch
Fixture with a row whose s is null; run the two queries above; compare returned ids. The dropped metamorphic test (b7b4324:graphistry/tests/compute/gfql/cypher/test_where_metamorphic.py) has a complete harness including the exact xfail condition (_assert_path_equiv).
🤖 Generated with Claude Code
https://claude.ai/code/session_01W5vkD2ZCyv3bmecBYoYYQy
cc @lmeyerov @mj3cheun
Summary
The two GFQL Cypher WHERE execution paths disagree on the not-equals operator
<>when the (existing) column value is null:null <> 'ab'filter_dict(columnar)A AND B ...chainwhere_rows(row expr engine)So these two queries — identical except for semantically-inert parentheses — return different rows when
n.shas nulls:Every other operator exercised by the metamorphic characterization (
=,>,IS NULL,IS NOT NULL,CONTAINS) agrees across both paths, including on null rows. Only<>/null diverges.Provenance
Found by @mj3cheun's metamorphic WHERE-equivalence test written for #1653 (
test_where_metamorphic.py, commit b7b4324 — characterized there as an xfail). This is pre-existing execution-engine behavior, orthogonal to the Earley→LALR parser switch (#1682): the parser produces identical ASTs for both paths' inputs; the divergence is entirely in howfilter_dictvswhere_rowsevaluate<>against null. (The known, deliberate difference on absent columns — filter_dict raises, where_rows treats absent-as-null — is a separate documented boundary and not this issue.)The actual question: which semantics is intended for the Cypher surface?
This is a semantics-intent decision, not just a bug:
null <> 'ab'→null→ row filtered.where_rowsis spec-correct for a Cypher surface.filter_dictcarries dataframe/table-search semantics, where a null cell is straightforwardly "not equal to'ab'" — arguably what a table-filtering user means, and consistent with how filter_dict behaves elsewhere in GFQL proper.Options:
<>predicates to filter_dict when the column is nullable (or wraps with anIS NOT NULLconjunct), keeping filter_dict's native semantics for native GFQL users. Spec-correct, minimal blast radius.<>to 3VL globally: consistent, but changes native-GFQL behavior others may rely on.Option 1 looks right for the Cypher surface (the lift is an internal optimization and must not be observable), but this needs an intent call from @lmeyerov / @mj3cheun given filter_dict's table-semantics role in native GFQL.
Repro sketch
Fixture with a row whose
sis null; run the two queries above; compare returned ids. The dropped metamorphic test (b7b4324:graphistry/tests/compute/gfql/cypher/test_where_metamorphic.py) has a complete harness including the exact xfail condition (_assert_path_equiv).🤖 Generated with Claude Code
https://claude.ai/code/session_01W5vkD2ZCyv3bmecBYoYYQy