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
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: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_chainindependently vetoes the binding-row lane onplan.whole_row_output_names. The projection therefore lowers torows(table="nodes", source="b"), whose frame is the per-alias node table — a set.RETURN a, bescapes because_binding_row_aliases_for_multi_alias_whole_row_node_projectionadmits multi-alias whole-row projections to binding rows; the guard there islen(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_chainand it does produce the correct bag on pandas and polars: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 duplicateprows and the anti-join reports that it cannot tell matched from unmatched rows apart.But
graphistry/tests/compute/gfqlgoes 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 mand friends: the polars projector raisesNotImplementedError: 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 lanetest_seeded_typed_hop_fastpath.py::TestCypherSeededTypedHopPolars— theRETURN pfast path no longer sees its loweringtest_optional_match_semantics.py— 4 polars whole-row OPTIONAL armstest_reentry_carry_seed_restriction.py,test_reentry_caller_graph_immutability.py,test_alias_scoping_semantics.py,test_lowering.py,test_binder.pySo 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 pcarry dedups the prefix before re-entry, which is why theduplicate_carried_node_rowsdecline is unreachable there).🤖 Generated with Claude Code
https://claude.ai/code/session_01AjbKuKheqDu78oapRT5AYm