Skip to content

GFQL Cypher: whole-entity endpoint projection (RETURN b) collapses openCypher bag multiplicity #1994

Description

@lmeyerov

MATCH (a)-->(b) RETURN b — a whole-entity projection of one endpoint over a relationship pattern — answers a deduplicated node set, not the openCypher bag. Every property spelling of the same projection is already correct, so the engine disagrees with itself:

fixture: nodes 1..5, edges (1,2) (1,3) (2,3) (3,4)

MATCH (a)-->(b) RETURN b.id AS x   -> 4 rows [2, 3, 3, 4]   correct
MATCH (a)-->(b) RETURN a, b        -> 4 rows                correct
MATCH (a)-->(b) RETURN b           -> 3 rows [2, 3, 4]      WRONG (node 3 is bound twice)
MATCH (a)-->(b) RETURN b, b.id AS x-> 3 rows                WRONG

No warning; the query runs and returns a plausible answer. Pinned as test_row_multiplicity_semantics.py::test_whole_row_endpoint_projection_multiplicity_residual (strict xfail, pandas + polars), so it cannot regress silently.

Root cause

_forces_relationship_multiplicity_projection_bindings (graphistry/compute/gfql/cypher/lowering.py) exits early on a bare-alias projected item, and _lower_projection_chain independently vetoes the binding-row lane on plan.whole_row_output_names. The projection therefore lowers to rows(table="nodes", source="b"), whose frame is the per-alias node table — a set. RETURN a, b escapes because _binding_row_aliases_for_multi_alias_whole_row_node_projection admits multi-alias whole-row projections to binding rows; the guard there is len(whole_row_refs) <= 1, so exactly one whole-row ref is refused.

Why the obvious fix is not the fix — measured

Routing a single whole-row output over a relationship pattern to rows(binding_ops=...) is a 3-line change in _lower_projection_chain and it does produce the correct bag on pandas and polars:

MATCH (a)-->(b) RETURN b -> 4 rows [2, 3, 3, 4]
MATCH (a)-->(b) RETURN a -> 4 rows [1, 1, 2, 3]
MATCH (a)     RETURN a   -> 5 rows  (unchanged, no relationship)

It also converts the sibling defect in #1935 item 1 (MATCH (a:P)-->(c) WITH a AS p OPTIONAL MATCH ..., 3 rows vs openCypher 5) from a wrong answer into a typed decline on all three engines, because the prefix then genuinely carries duplicate p rows and the anti-join reports that it cannot tell matched from unmatched rows apart.

But graphistry/tests/compute/gfql goes from green to 60+ failures, because the binding-rows lane does not support whole-entity rendering everywhere the node-set lane does:

  • test_engine_polars_row_pipeline.py — MATCH (n)-[e]->(m) RETURN m and friends: the polars projector raises NotImplementedError: does not yet natively render this cypher result projection (whole-entity RETURN over float/temporal/nested/multi-entity columns)
  • test_engine_polars_with_match_reentry.py — ~18 parity cells across the WITH→MATCH reentry lane
  • test_seeded_typed_hop_fastpath.py::TestCypherSeededTypedHopPolars — the RETURN p fast path no longer sees its lowering
  • test_optional_match_semantics.py — 4 polars whole-row OPTIONAL arms
  • test_reentry_carry_seed_restriction.py, test_reentry_caller_graph_immutability.py, test_alias_scoping_semantics.py, test_lowering.py, test_binder.py

So the honest sequencing is: teach the polars whole-entity projector and the reentry lanes to render off binding rows first, then flip the lane. Flipping first would trade a wrong answer for a large capability regression on polars.

Not a candidate for a narrow typed decline either

A runtime "detect the collapse and decline" guard would have to fire on every RETURN <alias> over a relationship whose endpoint is bound more than once — which is the common case on any non-tree graph, i.e. it would decline most whole-entity endpoint projections rather than a corner. That is the same capability regression as above, just spelled as an error.

Also affected

Same root cause as #1935 item 1 (the whole-row WITH a AS p carry dedups the prefix before re-entry, which is why the duplicate_carried_node_rows decline is unreachable there).

🤖 Generated with Claude Code
https://claude.ai/code/session_01AjbKuKheqDu78oapRT5AYm

Activity

  1. lmeyerov commented on Aug 29, 2026

    @lmeyerov
    ContributorAuthor

    Closing as completed by merged PR #2000.

    Acceptance receipt on current master a2b1b44662161ed886665ddf92f7b14fd53b1bda:

    • Fixed-length, non-DISTINCT whole-node endpoint projections now preserve the relationship-match bag: destination, source, aliased, two-hop, parallel-edge, sibling-property, multi-entity, all-field, ordered, and post-WHERE shapes are pinned.
    • The former strict residual is now a positive four-row oracle: MATCH (a)-->(b) RETURN b returns b.id = [2, 3, 3, 4].
    • Polars binding-row whole-entity rendering has exact pandas row/column parity.
    • The seeded typed-hop fast path engages where intended, equals the full-path answer, and declines its unsupported boundaries without changing results.
    • DISTINCT, relationship-free, variable-length, and WITH-to-MATCH reentry controls remain deliberately unchanged; they are separate semantic contracts, not hidden GFQL Cypher: whole-entity endpoint projection (RETURN b) collapses openCypher bag multiplicity #1994 residuals.

    Independent integrated validation: 71 focused tests passed and 15 unavailable GPU-engine cells skipped as expected. The #2000 receipt covers cuDF/NVRTC/Polars-GPU canaries, and its hosted rollup was 76 passed with zero bad or pending checks. Ruff passed all 14 Python files in the merged diff; source mypy passed all 8 production modules; the exact added-line audit found no prohibited typing or dynamic-attribute escapes. CHANGELOG documents the fixed scope and exclusions.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions