Skip to content

fix(gfql): hop-family defects — cuDF NA-mask node drop (#1798), min_hops seed residue (#1918 F2), internal column leak (#1940) - #1945

Merged
lmeyerov merged 1 commit into
masterfrom
fix/gfql-hop-family-1798-1918-1940
Aug 19, 2026
Merged

lmeyerov merged 1 commit into
masterfrom
fix/gfql-hop-family-1798-1918-1940

Conversation

@lmeyerov

Copy link
Copy Markdown
Contributor

Fixes #1798
Fixes #1940

Pre-release fixes for the three outstanding hop-family defects, verified live at e6625ed28 with hand-computed oracles (cuDF 25.10 lanes really executed). Four surgical changes in graphistry/compute/hop.py; the rest is pins.

#1798 — cuDF drops ALL self-loop matches on seeded undirected [*1..1]

Before: MATCH (a {kind:'a'})-[*1..1]-(b) RETURN count(*) on the self-loop fixture (nodes 0..3 kind [b,a,a,b]; edges (2,2)x3, (0,3)x2, (3,1), (1,1)): hand-enumerated oracle 5 (self-loops bind once per edge row, Neo4j relationship semantics; the issue text's "9" was the stale double-counting figure); pandas 5, cuDF 1.

Root cause: the degenerate varlen window engages hop tracking; undirected seeds carry a NULL hop label, and the output-window node mask combined (hop <= max) with the endpoint rescue using cuDF's non-Kleene boolean ops: NULL | True = NULL, and cuDF's boolean indexing silently drops NULL-masked rows. Both self-loop seed node rows vanished from the node output, so the Cypher join found 1 match. pandas survived only via Kleene NA | True = True.

Fix: fillna(False) on each window comparison BEFORE combining, so no NULL ever enters the and/or chain and both engines agree.

After: pandas 5, cuDF 5; all 9 tracked/windowed undirected probe arms value-identical across engines. Four strict-xfail pins that recorded exactly this divergence XPASSed and had their markers lifted (test_hop_kernel_contracts seed-label pair, test_endpoint_closure_matrix min-hops seed cells, test_known_cross_engine_divergences::test_output_hop_window_backfills_the_source_node_row_on_cudf, test_varlen_bounded_engine_parity_1787::test_seeded_undirected_degenerate_window_agrees_with_the_oracle[cudf]) — all now run green on cuDF.

#1918 F2 residual — min_hops>=2 seed row: NaN residue on pandas, divergent on cuDF

Before: hop(nodes=[0], min_hops=2, hops=2) (unlabeled) on the attributed path 0->1->2: pandas emitted the seed row id-only (attrs NaN, int64 -> float64 upcast) via the endpoint backfill; cuDF omitted the seed row (by accident, through the same NULL-mask bug above).

Contract decision (from the hop() docstring): seeds are hop 0, and hop 0 is labeled only under label_seeds, so an UNLABELED min_hops>=2 hop excludes the seed row entirely. Scope: labeled hops keep the NULL-labeled seed stub — the chain wavefront contract (mirrored verbatim by the polars lane's _min_hops_labeled_node_output) depends on that stub, and this PR deliberately leaves the chain contract untouched, so the 400-case polars chain min_hops parity stays green. label_seeds=True keeps the seed labeled 0; a seed re-reached at hop >= min_hops keeps its real row (cycle pin unchanged).

Fix: the endpoint backfill no longer resurrects min-hop-pruned seed rows on the unlabeled arm (gated min_hop_prune_applied and label_node_hops is None and not label_seeds).

After: pandas == cuDF == nodes [1,2], attrs intact, no upcast; on the labeled arm both engines now agree too (seed stub NULL-labeled — previously cuDF dropped it AND the goal node).

Bonus fix in the same family: the min-hop prune's goal-label rebuild carried hop values under edge_hop_col while the groupby read node_hop_col (the names only coincide when both labels are internal), so min_hops=2 + label_node_hops returned NULL labels for every goal node on pandas and cuDF. Now renamed; goals the backward walk retains carry their real hop, and net node membership is unchanged in every arm (verified against the polars parity fuzz, 400 seeds x 2 suites).

#1918 status: F2 was the last open residual and this closes it — #1918 can be CLOSED. F1/F3–F8 were fixed in the earlier rounds and their pins stay green here; the deliberate residue pin test_f2_min_hops_forward_seed_is_still_attribute_less_residue is replaced by pins of the fixed contract.

Known residual kept out of scope (chain contract, cross-lane): the min-hop prune's backward walk still drops qualifying branches that end below max_hops (e.g. seed c, min=2,max=3, branch c->d->e ends at hop 2 and vanishes). Fixing it flips ~29 polars chain-parity cells because the polars mirror reproduces the same behavior, so it needs a paired change in the polars lane; follow-up issue #1944 filed with the verified fix shape (retain every edge traversed at a level >= min_hops outright).

#1940 — internal __gfqlhop__hop_0__ column leaks into user output

Before (both pandas and cuDF): min_hops>=2, label_edge_hops-only, and output_min/max_hops arms leaked the internal tracking column into user node and/or edge frames — the mid-function drops ran before backfill blocks that re-add the column.

Fix: a final sweep just before return drops internally-generated label columns (only when the corresponding label_*_hops was not requested; requested labels, including collision-suffixed ones, are untouched).

After: all 20 engine x arm sweep cells clean.

Pins + anti-vacuity

Gates

  • ruff clean; comment-density / cypher-surface / type-hygiene guards rc=0 from the worktree.
  • mypy: identical 4 pre-existing errors (polars-lane files) at base and head — no new.
  • Full graphistry/tests/compute: failure set vs master baseline is identical (105 pre-existing cells, none hop-related) — zero unintended flips; the hop battery (boundary matrix 581 cells, semantics pins, kernel contracts, 1918 pins, endpoint-closure matrix, varlen parity, legacy hops) is 100% green.
  • No new source files; both touched pin files already registered in bin/test-polars.sh.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AjbKuKheqDu78oapRT5AYm

…ops seed residue (#1918 F2), internal column leak (#1940)

Four surgical changes in hop.py, cross-engine (pandas + cuDF) value-verified:

* #1798: the output-window node mask combined NULL hop labels with cuDF's
  non-Kleene boolean ops (NULL | True = NULL) and cuDF drops NULL-masked
  rows, silently deleting NA-labeled seed/self-loop node rows that pandas
  kept. fillna(False) each window comparison BEFORE combining. Seeded
  undirected [*1..1] over self-loops: cuDF 1 -> 5 (= pandas = hand oracle).

* #1918 F2: min_hops>=2 excluded the unlabeled hop-0 seed from the node
  output, then the endpoint backfill resurrected it id-only (attrs NaN,
  int64->float64 upcast) on pandas while cuDF omitted it — divergent.
  Contract per docstring: hop 0 is labeled only under label_seeds, so the
  seed row is excluded entirely. The backfill no longer resurrects
  min-hop-pruned seeds (scoped: min_hop_prune_applied and not label_seeds).

* Same family: the min-hop prune's node-label rebuild carried hop values
  under edge_hop_col while the groupby read node_hop_col (names coincide
  only when both labels are internal); min_hops=2 + label_node_hops
  returned NULL labels. Renamed into node_hop_col.

* #1940: internal __gfqlhop__hop_0__ tracking columns leaked into user
  node/edge frames on min_hops>=2, label_edge_hops-only, and output-window
  arms (both engines). Final sweep drops internally-generated label
  columns before return; requested labels untouched.

Pins: 55 red-at-master cells (38 leak-matrix, 10 #1798-family incl. 5
lifted strict xfails, 7 F2-family), all green at head; mutation checks
re-redden each group (8/6/38/1 cells) when its fix is individually reverted. Intended
legacy flips: min_hops>=2 memberships drop the seed row
(test_compute_hops, test_hop_semantics_1918 F8, endpoint-closure matrix).

Fixes #1798. Fixes #1940. Closes out #1918 (F2 was its last residual).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AjbKuKheqDu78oapRT5AYm
@lmeyerov
lmeyerov merged commit 07c57a8 into master Aug 19, 2026
69 checks passed
@lmeyerov
lmeyerov deleted the fix/gfql-hop-family-1798-1918-1940 branch August 19, 2026 00:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant