Skip to content

GFQL hop: min_hops prune backward walk drops qualifying branches ending below max_hops (pandas+cuDF; polars chain mirror reproduces it) #1944

Description

@lmeyerov

Found during the #1798/#1918-F2/#1940 close-out (PR pending): the min-hop prune's backward walk in graphistry/compute/hop.py drops qualifying branches that end below max_hops, on pandas AND cuDF, and the polars chain min_hops mirror reproduces the same behavior — so a paired two-lane fix is required.

Repro (pandas, master)

import pandas as pd, graphistry
nodes = pd.DataFrame({"id": ["a","b","c","d","e"], "kind": ["x","y","y","z","x"], "score": [1,2,3,4,5]})
edges = pd.DataFrame({"s": ["a","b","c","d","a","c"], "d": ["b","c","d","e","c","a"]})
g = graphistry.nodes(nodes, "id").edges(edges, "s", "d")
sel = g._nodes[g._nodes["id"] == "c"]
out = g.hop(nodes=sel, min_hops=2, max_hops=3, direction="forward", return_as_wave_front=True, engine="pandas")
# nodes {a,b,c}; edges {(a,b),(b,c),(c,a)}
# ORACLE: branch c->d->e ENDS at hop 2, inside [min=2, max=3] -> d, e, (c,d), (d,e) must be retained

The walk c->d->e reaches min_hops=2, so per the documented contract ("min_hops/max_hops: inclusive traversal bounds", "Prune dead-end branches that do not reach min_hops") the whole branch qualifies — but it is silently dropped.

Root cause

The backward retention walk (hop.py, min-hop prune block) seeds current_targets with ALL goal nodes only at the TOP hop level, then narrows targets to the previous level's sources. A goal reached at a level strictly below max_edge_hop is never a target when its own level is processed, so its terminating edge — and everything feeding it exclusively — is pruned.

Verified fix shape (reverted out of the close-out PR for parity reasons)

An edge traversed at a level >= min_hops ends a qualifying walk ITSELF and is retained outright; only sub-min levels need to feed a retained longer walk:

level_is_goal = hop_level >= resolved_min_hops
reaching_edges = hop_edges if level_is_goal else hop_edges[hop_edges[dest].isin(current_targets)]

Measured on the close-out branch: this returns the hand-oracle (nodes a1,b2,c2,d1,e2; all 6 edges) and the full hop battery (boundary matrix, semantics pins, kernel contracts, endpoint closure) stays green — but it flips ~29 cells of test_engine_polars_chain.py min_hops fuzz parity plus test_engine_polars_hop.py::test_polars_hop_min_hops_labeled_policy_unit_parity, because the polars chain mirror (hop_eager.py:_min_hops_labeled_node_output and the polars min-hops walk) reproduces today's under-retention. The two sides must land together (or the parity cells be re-oracled in the same PR).

Also decide the seed-row question for the widened membership: under the #1918-F2 contract an unlabeled min_hops>=2 hop excludes the unlabeled seed row; a seed re-reached at >= min_hops (e.g. c via c->a->c) keeps its real row.

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

Activity

  1. added a commit that references this issue on Aug 19, 2026
    0542a72
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