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
Found during the #1798/#1918-F2/#1940 close-out (PR pending): the min-hop prune's backward walk in
graphistry/compute/hop.pydrops qualifying branches that end belowmax_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)
The walk
c->d->ereachesmin_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) seedscurrent_targetswith 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 belowmax_edge_hopis 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_hopsends a qualifying walk ITSELF and is retained outright; only sub-min levels need to feed a retained longer walk: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 oftest_engine_polars_chain.pymin_hops fuzz parity plustest_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_outputand 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.cviac->a->c) keeps its real row.🤖 Generated with Claude Code
https://claude.ai/code/session_01AjbKuKheqDu78oapRT5AYm