Skip to content

Commit 962f690

Browse files
lmeyerovclaude
andcommitted
tests(gfql): scaling pin — an undirected 2-hop costs a bounded multiple of two plain joins
Ratio pin, not a wall-clock threshold: on a heavy-tailed 1.5M-edge graph from 50 hub seeds, the 2-hop chain must cost < 30x two plain frame joins that traverse the same edges. The 0.59.0 tree (per-edge Python loop, #2023) measures 47-52x on pandas and polars; the engine-native rule 20x / 6x. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WwMmVFo44ADiRRj5cxh7i1
1 parent fd1a0fb commit 962f690

2 files changed

Lines changed: 89 additions & 0 deletions

File tree

bin/test-polars.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ POLARS_TEST_FILES=(
8383
# latency contract: the polars params (fast-path served pins + ratio pins) only run here
8484
graphistry/tests/compute/gfql/test_gfql_latency_contract.py
8585
graphistry/tests/compute/gfql/test_seed_rediscovery_2023.py
86+
graphistry/tests/compute/gfql/test_hop_scaling_pin.py
8687
# #1882/#1913-f4/#1879 crash-family pins: the polars params (filter helpers on polars
8788
# frames, polars prune_self_edges, nodes-only typed-decline advice) only run here
8889
graphistry/tests/compute/gfql/test_crash_family_1882_1879.py
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Scaling pin for undirected multi-hop (#2023 / #2026 family).
2+
3+
Wall-clock thresholds are flaky in CI, so this pins a RATIO: an undirected 2-hop chain
4+
from hub seeds must cost no more than a fixed multiple of two plain frame joins that
5+
traverse the same edges. A per-edge interpreter loop (the #2023 defect) blew this ratio
6+
to ~50x; the engine-native rule keeps it under ~20x. The graph is small enough for
7+
the core lanes; polars runs when installed.
8+
"""
9+
import time
10+
11+
import numpy as np
12+
import pandas as pd
13+
import pytest
14+
15+
import graphistry
16+
from graphistry.compute.ast import e_undirected, n
17+
from graphistry.compute.predicates.is_in import IsIn
18+
19+
#: Measured on the same graph: the 0.59.0 tree (per-edge loop, #2023) 47-52x on both engines;
20+
#: the engine-native rule 20x (pandas) / 6x (polars). 30 sits between the two populations.
21+
MAX_RATIO = 30.0
22+
N_NODES = 200_000
23+
N_EDGES = 1_500_000
24+
SEEDS = 50
25+
26+
27+
def _graph():
28+
# heavy-tailed degrees (hubs), so a 2-hop from the top seeds touches most edges,
29+
# which is the shape that exposed #2023 on LiveJournal
30+
rng = np.random.default_rng(2023)
31+
src = (rng.zipf(1.3, N_EDGES) - 1) % N_NODES
32+
dst = rng.integers(0, N_NODES, N_EDGES)
33+
edges = pd.DataFrame({"s": src, "d": dst})
34+
degree = pd.concat([edges["s"], edges["d"]]).value_counts()
35+
nodes = pd.DataFrame({"id": degree.index, "degree": degree.values})
36+
seeds = degree.index[:SEEDS].tolist()
37+
return edges, nodes, seeds
38+
39+
40+
def _baseline_two_joins(edges: pd.DataFrame, seeds) -> int:
41+
"""Two undirected frontier expansions as plain merges: the work floor for a 2-hop."""
42+
both = pd.concat([edges, edges.rename(columns={"s": "d", "d": "s"})], ignore_index=True)
43+
frontier = pd.DataFrame({"s": seeds})
44+
seen = set(seeds)
45+
for _ in range(2):
46+
nxt = both.merge(frontier, on="s")["d"].drop_duplicates()
47+
seen.update(nxt.tolist())
48+
frontier = pd.DataFrame({"s": nxt})
49+
return len(seen)
50+
51+
52+
def _best_of(fn, runs=2):
53+
best = float("inf")
54+
for _ in range(runs):
55+
t0 = time.perf_counter()
56+
fn()
57+
best = min(best, time.perf_counter() - t0)
58+
return best
59+
60+
61+
try:
62+
import polars # noqa: F401
63+
HAS_POLARS = True
64+
except ImportError: # pragma: no cover
65+
HAS_POLARS = False
66+
67+
68+
@pytest.mark.parametrize("engine", [
69+
"pandas",
70+
pytest.param("polars", marks=pytest.mark.skipif(not HAS_POLARS, reason="polars not installed")),
71+
])
72+
def test_undirected_two_hop_costs_a_bounded_multiple_of_two_joins(engine):
73+
edges, nodes, seeds = _graph()
74+
if engine == "polars":
75+
import polars as pl
76+
g = graphistry.edges(pl.from_pandas(edges), "s", "d").nodes(pl.from_pandas(nodes), "id")
77+
else:
78+
g = graphistry.edges(edges, "s", "d").nodes(nodes, "id")
79+
query = [n({"id": IsIn(options=seeds)}), e_undirected(to_fixed_point=False, hops=2), n()]
80+
g.gfql(query, engine=engine) # warm
81+
hop_s = _best_of(lambda: g.gfql(query, engine=engine))
82+
base_s = _best_of(lambda: _baseline_two_joins(edges, seeds))
83+
ratio = hop_s / base_s
84+
print(f"[scaling-pin] {engine}: 2-hop {hop_s * 1000:.0f} ms, "
85+
f"two joins {base_s * 1000:.0f} ms, ratio {ratio:.1f}x")
86+
assert ratio < MAX_RATIO, (
87+
f"{engine}: 2-hop took {hop_s * 1000:.0f} ms = {ratio:.0f}x two plain joins "
88+
f"({base_s * 1000:.0f} ms); a per-edge interpreter loop is back (see #2023)")

0 commit comments

Comments
 (0)