Skip to content

fix(gfql): gate polars varlen node aliases by hop distance (refs #1741)#1747

Merged
lmeyerov merged 5 commits into
masterfrom
fix/1741-chain-alias-hop-gate
Jul 20, 2026
Merged

fix(gfql): gate polars varlen node aliases by hop distance (refs #1741)#1747
lmeyerov merged 5 commits into
masterfrom
fix/1741-chain-alias-hop-gate

Conversation

@lmeyerov

Copy link
Copy Markdown
Contributor

Stacked on #1746 — review that one first; this PR's diff is only the chain half.

What this fixes

#1741: on the polars engine, MATCH (a {id: 'p0'})-[*1..2]-(b) RETURN b returned {p0, p1, p2} where pandas returns {p1, p2}. Silently wrong rows, not an error. After this PR both engines return {p1, p2}.

Root cause (corrected — the original write-up was wrong)

The first diagnosis said "polars lacks pandas' backtrack avoidance". Direct A/B refutes that: both engines return identical node sets and identical final _nodes; only the alias flag diverges.

The actual pandas rule is a hop-distance gate (compute/chain.py ~456-501): a node named after an edge op is aliased only when min_hop <= hop <= max_hop. The seed's hop label is null under an undirected walk, so it fails the gate and loses the alias. polars had no hop labels at all, so it could not run the gate — #1746 adds the labels, this PR adds the gate.

Implementation

  • Auto-inject label_node_hops whenever an edge op is variable-length or output-sliced, mirroring ast.py:621-625 needs_auto_labels.
  • Gate the alias by semi-joining against the [min_hop, max_hop] window in _apply_node_names.
  • The hop distance travels with the wavefront, so it lands on the named node step's own frame, not the preceding edge step's. The gate reads it there. (This cost a debugging round — worth knowing if you touch it.)
  • Also corrects the label rule under return_as_wave_front — the mode the chain always hops in. visited_nodes starts empty there, but pandas labels a backtracked-to seed null in both modes, so the undirected label-seen set is now pre-seeded with the seeds unconditionally.

Verification

  • 4892 passed across graphistry/tests/compute/gfql + test_compute_hops.py + test_compute_chain.py.
  • The 59 failures in that selection are byte-identical to origin/master's on the same environment (temporal / cudf / viz) — I ran the same selection on a detached master worktree and diffed the failure lists: zero new failures.
  • 8 new tests pin the gate: pandas-parity across four varlen patterns, the backtracked seed explicitly, a negative case proving fixed-length hops are not gated, and a no-leak check on the injected column.

Residual gap (kept on #1741)

min_hops > 1 chains get no gate — requesting labels there would turn a currently-native chain into an NIE, since those labels come from the layered backward walk (hop.py:676-778), which is not yet ported.

Follow-up

Once this lands, #1742 closes unmerged — it was the interim honest-NIE decline for exactly this shape, and a raise is the wrong end state (per review feedback). Its test flips from "raises" to "matches the pandas oracle".

🤖 Generated with Claude Code

@lmeyerov

Copy link
Copy Markdown
Contributor Author

Cross-engine validation on dgx-spark — 0 regressions, 70/98 mismatches fixed

4-engine differential sweep against pandas as oracle, 144 cases (4 hand graphs + 12 random graphs × 9 varlen/fixed patterns), run in-container on dgx-spark under safe_run.sh (RAPIDS 26.02, RMM 40GB, floor 20GB). Same box, same session, A/B against clean master c355660a.

build pandas cudf polars polars-gpu
master c355660a 144/144 144/144 79 ok / 49 wrong / 16 NIE 79 / 49 / 16
+ #1746 + #1747 144/144 144/144 114 ok / 14 wrong / 16 NIE 114 / 14 / 16

Set-diff of the two mismatch sets: 0 regressions, 70 fixed. polars and polars-gpu behave identically throughout, and cudf/pandas are untouched — confirming the change is confined to the polars lane.

The 28 residual mismatches (14 per polars engine) are all one shape, forward -[*2..3]->, where min_hops > 1 declines the hop label and so leaves the alias ungated. That is pre-existing on master — it shows up in the baseline column too — so this PR does not introduce it. Filed separately as #1748.

Raw JSON + the sweep script are committed in the campaign evidence set.

lmeyerov added a commit that referenced this pull request Jul 20, 2026
…+ label-col collision (#1741/#1746)

Two correctness fixes to the native polars label_node_hops path, moved down from the
stacked chain PR so #1746 is right standalone:

1. Undirected seed pre-seed (was in #1747). Without pre-seeding label_seen_nodes with
   the seeds, an undirected walk in wave-front mode (visited starts empty) re-labels a
   seed it backtracks into, and a seed filtered out of the frontier by source_node_match
   also gets re-labeled. pandas excludes all original seeds from undirected labeling.
   Differential vs pandas over direction x hops x return_as_wave_front x source_node_match
   x destination_node_match x 10 graphs: was 456/24, now 480/480.

2. Label column-name collision parity. A requested label_node_hops name that already
   exists on the node table is now redirected to `<name>_1` (matching pandas'
   resolve_label_col) instead of the polars left-join auto-suffix `<name>_right` /
   DuplicateError.

Amplifies TestHopLabelsDifferential with the seed-filter / wave-front / multi-seed axes
that were previously unvaried (exactly the diverging cases), plus a collision test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
@lmeyerov
lmeyerov force-pushed the fix/1741-chain-alias-hop-gate branch from c2add5d to 3f0fa21 Compare July 20, 2026 15:38
@lmeyerov lmeyerov changed the title fix(gfql): gate polars varlen node aliases by hop distance (closes #1741) fix(gfql): gate polars varlen node aliases by hop distance (refs #1741) Jul 20, 2026
@lmeyerov

Copy link
Copy Markdown
Contributor Author

Rebased to chain-only + collision-hardened

Two changes since the review:

  1. Moved the hop-label correctness fix down to the base PR feat(gfql): native polars label_node_hops on the plain BFS (#1741 groundwork) #1746. The reviewer here noted this PR also carried a hop_eager.py pre-seed change; that (plus a label-column collision parity fix) now lives in feat(gfql): native polars label_node_hops on the plain BFS (#1741 groundwork) #1746, where the label feature is introduced. This PR's diff is now chain.py + its tests only — verified: git diff vs the feat(gfql): native polars label_node_hops on the plain BFS (#1741 groundwork) #1746 head touches exactly those two files.

  2. Finding 2 (collision-safe column name) fixed. __gfql_chain_node_hop__ is no longer a raw literal fed to the hop as label_node_hops; it is resolved once per chain against the user's node columns via generate_safe_column_name, so a user column of that exact name can't be clobbered (which would otherwise crash the int/str compare in the gate). New test test_user_column_named_like_the_auto_label_is_not_clobbered.

  3. Finding 1 (retitle + pin the gap). closes #1741refs #1741. The forward/reverse min_hops>1 alias remains ungated (silently-wrong, pre-existing on master) — filed as polars chain: forward/reverse varlen with min_hops>1 returns nodes outside the hop window (silent wrong) #1748 and pinned here with strict xfail tests (test_fwd_rev_min_hops_gt_one_alias_matches_pandas_KNOWN_GAP) so it flips to a hard CI failure the moment polars chain: forward/reverse varlen with min_hops>1 returns nodes outside the hop window (silent wrong) #1748 is fixed.

Full polars chain suite: 1000 passed, 2 xfailed, ruff clean. Cross-engine A/B (all 4 engines, dgx-spark) re-run below.

lmeyerov added a commit that referenced this pull request Jul 20, 2026
…ong (#1748)

Adapts the retired #1742 decline pattern to the one shape #1747's hop-gate can't yet
cover: a node named after a forward/reverse variable-length edge with min_hops>1. Those
labels come from pandas' layered backward walk (not ported), so the alias runs ungated
and tags nodes outside the [min_hop, max_hop] window — silently wrong rows. Now an honest
NotImplementedError pointing at #1748.

The decline is precise (differential vs pandas, 15 graphs x fwd/rev x named/unnamed):
30/30 named shapes NIE, 30/30 unnamed shapes run and match pandas, 0 silent-wrong,
0 over-decline. With this the whole #1741 stack leaves ZERO silent-wrong shape on the
polars chain. xfail pins flipped to positive assert-raises tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
@lmeyerov

Copy link
Copy Markdown
Contributor Author

Now leaves ZERO silent-wrong shapes (adapted the closed #1742's mechanism for #1748)

Rather than discard the closed #1742 (which declined the undirected-varlen-alias shape), its decline pattern is adapted here to the one shape this PR's hop-gate can't yet cover: a node named after a forward/reverse min_hops>1 edge. Those labels come from pandas' layered backward walk (not ported), so the alias would run ungated and tag nodes outside the [min_hop, max_hop] window — previously silently-wrong (#1748). It now raises an honest NotImplementedError.

Precise, not blunt — differential vs pandas (15 graphs × fwd/rev × named/unnamed):

  • 30/30 named shapes → decline (NIE)
  • 30/30 unnamed shapes (no alias to mis-gate) → run natively and match pandas
  • 0 silent-wrong, 0 over-decline

4-engine cross-engine sweep on dgx-spark, before → after this commit:

pandas cudf polars polars-gpu
#1747 hop-gate only 144/144 144/144 114 / 14 wrong / 16 NIE 114 / 14 / 16
+ #1748 decline 144/144 144/144 112 / 0 wrong / 32 NIE 112 / 0 / 32

The xfail pins flipped to positive assert-raises tests. Net: the whole #1741 stack is now silent-wrong-free on the polars chain — every unsupported shape is an honest error, every supported shape matches pandas. Raw: xeng-1748.json in pyg-bench #91.

# generate_safe_column_name (see _chain_traversal_polars), so a user column literally named
# `__gfql_chain_node_hop__` can't be clobbered (would otherwise crash on the int/str compare in
# the gate). This base string is only the seed + the fallback for callers that don't resolve.
_AUTO_NODE_HOP = "__gfql_chain_node_hop__"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extern to a symbols.py?

lmeyerov and others added 4 commits July 20, 2026 10:46
A node named after a variable-length edge (`MATCH (a)-[*1..2]-(b)`) must carry its
alias only when its hop distance falls inside the edge's [min_hop, max_hop] window
(pandas chain.py:456-501). The polars chain had no distance to gate on, so it aliased
a backtracked-to seed pandas leaves unaliased — silently wrong RETURN rows.

Auto-injects the hop-distance label (name resolved against the user's node columns via
generate_safe_column_name, so a user column literally named __gfql_chain_node_hop__ is
never clobbered — a clash would crash the int/str compare) and applies pandas' alias
window in _apply_node_names.

The underlying hop-label correctness (undirected seed pre-seed) now lives in the base
PR #1746. Retitled closes->refs #1741: forward/reverse min_hops>1 aliases still run
ungated (a whole family of silently-wrong shapes) tracked as #1748 and pinned here with
strict xfail tests so the gap is visible in CI and flips to failure the moment it lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
…ong (#1748)

Adapts the retired #1742 decline pattern to the one shape #1747's hop-gate can't yet
cover: a node named after a forward/reverse variable-length edge with min_hops>1. Those
labels come from pandas' layered backward walk (not ported), so the alias runs ungated
and tags nodes outside the [min_hop, max_hop] window — silently wrong rows. Now an honest
NotImplementedError pointing at #1748.

The decline is precise (differential vs pandas, 15 graphs x fwd/rev x named/unnamed):
30/30 named shapes NIE, 30/30 unnamed shapes run and match pandas, 0 silent-wrong,
0 over-decline. With this the whole #1741 stack leaves ZERO silent-wrong shape on the
polars chain. xfail pins flipped to positive assert-raises tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
…ecise types (review c2,c3)

Establishes graphistry/compute/gfql/lazy/engine/polars/reserved_columns.py — a per-engine
registry of the __gfql_*__ internal column-name bases, referencing the repo-wide reserved
prefix/suffix from identifiers.py. _AUTO_NODE_HOP now sources CHAIN_NODE_HOP from it.
Types made precise: _auto_node_hop_col(op: ASTObject), _alias_hop_bounds(op: ASTEdge),
_exec wavefront params Optional[Any]. (Follow-up: migrate remaining inline literals.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
…e + #1748 decline)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
@lmeyerov
lmeyerov force-pushed the fix/1741-chain-alias-hop-gate branch from 196f69d to 7605dbc Compare July 20, 2026 17:47
…baseline

New file was 100% covered but absent from ci-polars-py3.12.json, which the per-file
floor audit requires (fails un-baselined targets). Floor 90 (constants, always 100%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
@lmeyerov
lmeyerov changed the base branch from fix/1741-polars-hop-labels to master July 20, 2026 18:23
@lmeyerov
lmeyerov merged commit cc00151 into master Jul 20, 2026
55 checks passed
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