Skip to content

fix(gfql): chain result contract: binding-column aliases decline, duplicate ids answer once, no internal columns (#2050, #2051, #2067) - #2056

Merged
lmeyerov merged 7 commits into
masterfrom
fix/gfql-2050-binding-alias-validation
Sep 7, 2026
Merged

fix(gfql): chain result contract: binding-column aliases decline, duplicate ids answer once, no internal columns (#2050, #2051, #2067)#2056
lmeyerov merged 7 commits into
masterfrom
fix/gfql-2050-binding-alias-validation

Conversation

@lmeyerov

@lmeyerov lmeyerov commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Three related contract fixes on the chain result, folded into one PR at the owner's request (formerly #2056 + #2060 + #2067). Base master; first of the three-PR stack (this → #2054#2062), then #2017 last.

  1. Binding-column aliases are a typed decline on native chains too (gfql: an alias named like the source, destination or edge-id binding column is not rejected (silent clobber on pandas/cuDF, raw polars SchemaError) #2050): an edge alias named like src/dst/edge-id or a node alias named like the node id raises the same GFQLValidationError the Cypher route raises. TO-side test_chain.py pins re-pinned as typed declines (contract change stated here).
  2. Polars plain single-hop branch returns one row per node id (gfql polars: an unnamed untyped single-hop chain keeps duplicate node rows that pandas, cuDF and polars' own multi-hop collapse #2051): the skip-combine branch deduplicates like the full path; strict xfails flipped. The cuDF 26.02 duplicate-id hop pin passes on this head on the real GPU (gfql cuDF 26.02 (real GPU): chain default route and policy-forced full path disagree on prune_to_endpoints hops and duplicate node ids; tests use the removed cudf.DataFrame.from_pandas #2043 dedup half; marker dropped).
  3. The shadow-restore column exists only inside a Cypher pipeline: cypher_pipeline() (context flag) marks a compiled Cypher execution; only then does the polars chain keep a shadowed user column under __gfql_shadow_restore__<alias>__ for its row pipeline. On gfql([...]) / chain([...]) the marker shadows the column outright, like pandas and cuDF. Nothing is stripped from results (a blanket strip was tried and rejected: it broke pipelines composed from successive gfql calls and frame-returning ops).

Receipts on this head are posted below (CI, local, GPU on the stack top). Earlier receipts for each part are on the closed PRs #2060 and #2067.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QztW7jYsDd66e8rb8pJNQA

@lmeyerov

lmeyerov commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

READY (157a55c, stacked on #2055) — CI 60/60 non-skipped check-runs success (7 path-skipped lanes, same filter gap as #2055). tck-gfql run locally from its current main (66fee10) against this head: exit 0 — 4143 passed / 6 skipped / 689 xfailed (baseline), 2964 supported scenarios, direct-Cypher non-validation debt 0. Local suites: 2372 passed / 24 skipped with the cuDF lane; guards OK; mypy = master; ruff clean. Contract change (TO-side binding aliases now a typed decline instead of served-with-overwrite) is stated in the description for your call.

@lmeyerov

lmeyerov commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

GPU receipt (dgx-spark, graphistry/test-rapids-official:26.02-gfql-polars, cudf 26.02.01 / polars 1.35.2, safe_run, TEST_CUDF=1; suites test_chain.py, test_hop.py, test_chain_alias_column_collision.py, tests/compute/gfql/**) on the restacked stack head a54ac4b (#2065, containing #2055 rework, #2056, #2054, #2061, #2062): 13386 passed / 95 failed / 38 xfailed / 4 xpassed. 93 of the failures are master 86de0f5's polars-gpu fused-lane set (#2064, fixed separately in #2066). The other 2 are strict XPASS of the #2043 prune_to_endpoints markers on cuDF 26.02: the prune shapes now agree fast-vs-full on this stack (the #2062 closure fix removed the divergence), so #2062 drops that marker in a follow-up commit (tree otherwise identical; #2065 restacked).

@lmeyerov

lmeyerov commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

CI receipt at f04fe11 (restacked on the #2055 rework): 60 check-runs success, 7 path-skipped as whole jobs; tck-gfql, test-polars ×6, test-gfql-core, gfql-benchmarks and the guards green. GPU receipt on the stack head above.

Base automatically changed from fix/gfql-2049-multihop-alias-collision to master September 6, 2026 13:00
lmeyerov and others added 4 commits September 6, 2026 08:32
…ne on native chains too (#2050)

The Cypher route already rejected an edge alias equal to the source or destination
binding (E108); native chains kept an "overwrite parity" where the marker silently
replaced the binding column on pandas and cuDF (the seed's edges vanished, or the edge
ids became True) and polars raised a raw SchemaError. The same validator now runs for
native chains and also covers the edge-ID binding, so every engine declines before
execution, like the node-ID collision.

Pins: the collision matrix's binding-column shapes flip from strict expected failure to
green on every engine; the TO-side shapes test_chain.py pinned as served-with-overwrite
are re-pinned as the typed decline on both routes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QztW7jYsDd66e8rb8pJNQA
…e id (#2051)

The unnamed, untyped single-hop chain shape is served by the polars chain's skip-combine
branch, which built its node frame with a semi-join against the node table and so kept
a node table's duplicate rows for the same id, where the full polars chain, pandas and
cuDF collapse them. Root cause confirmed by route-off amplification: with the branch
forced to decline, the #2051 strict expected failures pass. The branch now applies the
same one-row-per-id rule as the full chain's node materialization.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QztW7jYsDd66e8rb8pJNQA
(cherry picked from commit 4fa87f3)
…real-GPU receipt), marker dropped

(cherry picked from commit 347d975)
…line, never on op-list results

The polars chain kept a shadowed user column under
__gfql_shadow_restore__<alias>__ so the Cypher row pipeline could read the
value back; that internal column also reached op-list results. Now a
compiled Cypher execution marks itself (cypher_pipeline() context) and only
then does the chain keep the column; on gfql([...]) / chain([...]) the marker
shadows the column outright, as on pandas and cuDF. Nothing is stripped from
results: user-defined columns (the name= marker, requested hop labels, a
caller's own __gfql_-prefixed column) and pipelines composed from successive
gfql calls are untouched. Pins: no internal column on pandas/cuDF/polars for
the shadow shape with the marker and payload intact; Cypher still reads the
shadowed user value on both engines; user hop labels and a user-prefixed
column survive; the deprecated chain() surface is clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QztW7jYsDd66e8rb8pJNQA
(cherry picked from commit 0b610b0)
@lmeyerov
lmeyerov force-pushed the fix/gfql-2050-binding-alias-validation branch from 11ad4f4 to 6d2c654 Compare September 6, 2026 15:33
@lmeyerov lmeyerov changed the title fix(gfql): edge aliases named like binding columns are a typed decline on native chains (#2050) fix(gfql): chain result contract: binding-column aliases decline, duplicate ids answer once, no internal columns (#2050, #2051, #2067) Sep 6, 2026
@lmeyerov

lmeyerov commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

GPU receipt (dgx-spark, graphistry/test-rapids-official:26.02-gfql-polars, cudf 26.02.01 / polars 1.35.2, safe_run, TEST_CUDF=1; suites test_chain.py, test_hop.py, test_chain_alias_column_collision.py, tests/compute/gfql/**) on the folded stack top 500265e (#2062, containing #2056 and #2054): 13532 passed / 7 failed / 37 xfailed / 4 xpassed, versus master 86de0f5's 13227 / 93 in the same image. Every one of the 7 is inside master's set (5 polars-gpu strictness cases and the routing-contract / indexed-bypass pins tracked on #2064 and #1803); zero branch-specific failures, and the fused-lane fix folded from #2066 clears the other 86.

@lmeyerov

lmeyerov commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

CI receipt at 5681875 (folded: #2050 decline + #2051 dedup + Cypher-only shadow restore; rebased onto master 60bb843): 77 check-runs, all success. Local on the stack top: broad CPU + cuDF 12939 pass / 0 fail; tck-gfql 4143 pass / 689 xfail (debt unchanged); GPU receipt above.

@lmeyerov

lmeyerov commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Contract resolution (owner thread, 2026-09-06): op lists vs Cypher for an alias named like a binding column

One rule on both surfaces, taken from openCypher: scope shadows, projection declines duplicate output names. (MATCH (src)-[e]->(dst) is legal in Neo4j; RETURN src, e.src AS src is "multiple result columns with the same name".) Binding columns are structure, not data: the binding stays on the original column values everywhere.

  • Op lists (this PR): projection is implicit and total, the structural columns are always projected, and name= is by definition an output column. An alias equal to a same-frame binding column (node alias == node-id; edge alias == source/destination/edge-id) is therefore a duplicate output column, and the typed decline (E108) is the Cypher-consistent answer. This PR declines only those same-frame cases; cross-frame aliases such as n(name="src") are unaffected and work.
    • Rejected: "structure wins, marker silently not emitted" (Cypher never drops a result column silently; downstream .src reads endpoints instead of a mask) and "marker wins, endpoints rebound to a generated name" (renames the user's binding unasked; the corruption shape master had).
  • Cypher: RETURN projects entities, so RETURN src never collides. The existing Cypher-route E108 fires at the scoping layer, which is the wrong layer. That is fixed separately by a lowering rename: GFQL Cypher: binding-named variables (e.g. MATCH (src)-[e]->(dst)) should scope, not decline (E108) #2069. Not part of this PR.
  • Data columns (e.g. a pipeline that recomputes score each phase) are untouched by the decline and shadow by design on both surfaces: op lists = dataframe assignment (later write wins on every engine, fix(gfql): one op-list alias-shadowing contract on every engine (#2049, #2039 polars edge side) #2055), Cypher = WITH rebinding across scopes.

Dynamic pipelines that generate alias names can pre-check against g._node, g._source, g._destination, g._edge, or catch E108.

@lmeyerov
lmeyerov force-pushed the fix/gfql-2050-binding-alias-validation branch from 0cd9d65 to 2a645a3 Compare September 7, 2026 01:42
@lmeyerov

lmeyerov commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Owner question: does the decline break TCK, q1–q9, or SNB? Checked, not reasoned.

  • TCK: per-route ledger 4143/4143 on this head and on master. No change.
  • q1–q9: GFQL runs bind s/d and id/node_id; aliases are computed names (numPersons, outDegree, …). The (src:/(dst: variables are in the Memgraph runner only. Unaffected.
  • SNB queries: variables p, m, x, n, f. Unaffected.
  • SNB sentinel: one point would go red. native-hop-alias-collision aliased the node op as id on a graph bound on id, which is exactly the same-frame binding collision this PR declines. Its stated intent is the alias-vs-data-column shadow shape, so the alias moves to label__Message in pyg-bench Dev/gremlin valuemap #250 (keys and bounds unchanged).

Three dgx sentinel runs with the corrected shape, gate vs the 86de0f5 baseline:

run timing bounds collision point hash
this PR, 0cd9d65, r1 polars/node-only-props 2.22 > 1.5×1.41; polars/node-only-entity 4.00 > 1.5×2.33 differs (expected: shape changed)
this PR, 0cd9d65, r2 all pass differs (expected)
master 60bb843, control polars/node-only-entity 3.85 > 1.5×2.33 differs (expected)

The polars node-only misses reproduce on master itself, so they are sentinel variance around a fast baseline run, not this PR. Baseline swap after this merges (pyg-bench #250).

Gap found by the same repro, fixed here (2a645a3)

g.chain() (deprecated surface) bypassed the decline and died with a raw pandas ValueError: The column label 'id' is not unique / polars DuplicateError. The check now lives in chain.py and runs on both gfql() and chain(); pins are parametrized over the two surfaces plus a cross-frame negative pin (n(name="s") on the node frame: served, marker present). Targeted suites 212/0 incl. cuDF; lint, mypy (matches master's list), comment-density and type-hygiene guards clean; CI re-fired on 2a645a3.

…chain() surface

The typed E108 decline for an alias named like a same-frame binding column lived only on the gfql() dispatch; g.chain() still reached the engines and died with a raw pandas ValueError or polars DuplicateError. The helper moves to chain.py and runs on both surfaces; pins cover both surfaces (decline) and the cross-frame case (served, marker present).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QztW7jYsDd66e8rb8pJNQA
@lmeyerov
lmeyerov force-pushed the fix/gfql-2050-binding-alias-validation branch from 2a645a3 to 5775f76 Compare September 7, 2026 01:51
@lmeyerov

lmeyerov commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Receipts for head 5775f76

Stack restacked on this head: #2054 b5ba132 (own product delta byte-identical before and after the rebase; helper kept out of the moved block) and #2062 c2443da (own delta identical), both pushed with CI re-fired.

@lmeyerov

lmeyerov commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

CI on 5775f76: 77/77 green (check-run histogram via the API). All receipts for this head are now on the PR: CI, real-GPU lane, broad CPU+cuDF suites, targeted pins, sentinel runs (pyg-bench #250).

@lmeyerov

lmeyerov commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Benchmarks pass with this PR, not after it. pyg-bench #250 now carries the corrected sentinel shape, a new gate baseline taken at this head (5775f76), and a per-engine drift tolerance derived from four back-to-back master runs. Gate at 5775f76 vs that baseline: ok, on all 18 points. Master fails that gate on exactly one field, the polars collision point's output hash, which is this PR removing the internal shadow-restore column from op-list output. TCK, q1–q9 and the SNB query arms are unaffected (checked above). #250 admin-merges on my side once its smoke CI is green.

@lmeyerov

lmeyerov commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

pyg-bench #250 merged (main 56873798e): sentinel shape + baseline at this head + per-engine drift tolerance are on main. The benchmark side of this change is closed.

…, binding names are rejected

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QztW7jYsDd66e8rb8pJNQA
@lmeyerov

lmeyerov commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Docs-only commit 32ea472: the spec's Named Results section now states the marker rule in two sentences (the marker replaces a same-named column on the same frame; a binding-column name is rejected). No product or test change, so the GPU lane, broad-suite and sentinel receipts at 5775f76 stand; CI re-fired on 32ea472.

@lmeyerov

lmeyerov commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

CI on 32ea472: 77/77 green. Head is final.

@lmeyerov
lmeyerov merged commit f45469c into master Sep 7, 2026
78 checks passed
@lmeyerov
lmeyerov deleted the fix/gfql-2050-binding-alias-validation branch September 7, 2026 06:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant