mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-18 13:27:06 +00:00
c0875ccae9
PR #1918 replaced _pick_seeds(terms=) with best_seed_by_term= from _score_query. Update the #1900 German-stopword seed test to the new single-traversal API and add the missing trailing newline in the (manual, non-collected) query-scoring benchmark. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
281 lines
11 KiB
Python
281 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Non-CI microbenchmark: single-pass scoring vs legacy per-term rescoring.
|
|
|
|
Verifies the single-pass refactor eliminates the T+1 graph-scoring passes a
|
|
T-term query used to run, without changing the result. Reports median/min
|
|
latency for:
|
|
|
|
* legacy — one combined `_score_nodes(G, terms)` call PLUS one
|
|
`_score_nodes(G, [token])` call per distinct query token
|
|
(the old `_pick_seeds(terms=...)` per-term guarantee loop).
|
|
* optimized — one `_score_query(G, terms, collect_per_term_seeds=True)`
|
|
call, with the per-token best computed inline in the same
|
|
traversal. `_pick_seeds` then consumes `best_seed_by_term`
|
|
directly — no rescoring.
|
|
|
|
Equality is asserted (ranked, best_seed_by_term, seeds) before timing. The
|
|
optimized path's traversal count is invariant in the number of query terms.
|
|
|
|
Run it manually; do NOT wire this into CI (wall-clock assertions are flaky):
|
|
|
|
uv run python tests/bench_query_scoring.py \\
|
|
--nodes 100000 --term-counts 3,10 --repeats 5
|
|
|
|
uv run python tests/bench_query_scoring.py \\
|
|
--graph graphify-out/graph.json \\
|
|
--query "what calls extract" --query "symbol resolution" \\
|
|
--repeats 10
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import random
|
|
import statistics
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import networkx as nx
|
|
|
|
from graphify.serve import (
|
|
_get_trigram_index,
|
|
_pick_seeds,
|
|
_query_terms,
|
|
_score_nodes,
|
|
_score_query,
|
|
_search_tokens,
|
|
)
|
|
|
|
|
|
SYLLABLES = [
|
|
"foo", "bar", "baz", "get", "set", "run", "user", "name", "path",
|
|
"build", "report", "extract", "router", "config", "service",
|
|
"handler", "token", "auth", "rate", "limit", "widget", "model",
|
|
]
|
|
|
|
QUERIES_BY_TERM_COUNT: dict[int, list[str]] = {
|
|
1: ["foo"],
|
|
2: ["foo", "bar"],
|
|
3: ["router", "service", "handler"],
|
|
5: ["get", "user", "run", "name", "path"],
|
|
10: ["extract", "build", "report", "router", "config",
|
|
"service", "token", "rate", "limit", "widget"],
|
|
}
|
|
|
|
|
|
def _build_random_graph(n: int, *, seed: int) -> nx.DiGraph:
|
|
"""Reproducible broad-match DiGraph: short constructed labels + edge noise.
|
|
|
|
Labels draw from a small syllable pool so tokens collide across nodes,
|
|
forcing the trigram prefilter to be selective and exercising score ties
|
|
on common tokens. Edge noise provides degree variance so the legacy
|
|
tie-break (`max(tied, key=degree)`) is actually exercised against the
|
|
new `(-singleton, -degree, label_len, nid)` key tuple."""
|
|
rng = random.Random(seed)
|
|
G: nx.DiGraph = nx.DiGraph()
|
|
for i in range(n):
|
|
label = "_".join(rng.sample(SYLLABLES, rng.randint(1, 3)))
|
|
G.add_node(f"n{i}", label=label, source_file=f"src/{label[:8]}.py")
|
|
for _ in range(n * 2):
|
|
a, b = rng.randrange(n), rng.randrange(n)
|
|
if a != b:
|
|
G.add_edge(f"n{a}", f"n{b}", relation="calls", confidence="EXTRACTED")
|
|
return G
|
|
|
|
|
|
def _load_real_graph(path: str) -> nx.Graph:
|
|
"""Light wrapper that just builds a NetworkX graph from a real
|
|
`graphify-out/graph.json`, skipping the size cap and work-memory overlay
|
|
that `serve._load_graph` enforces (the bench is read-only)."""
|
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
if "links" not in data and "edges" in data:
|
|
data = dict(data, links=data["edges"])
|
|
data = {**data, "directed": True}
|
|
try:
|
|
return nx.readwrite.json_graph.node_link_graph(data, edges="links")
|
|
except TypeError:
|
|
return nx.readwrite.json_graph.node_link_graph(data)
|
|
|
|
|
|
def _legacy_score_and_pick(G, terms):
|
|
"""Recreates the pre-refactor flow: combined scoring plus one
|
|
`_score_nodes([token])` call per distinct query token, with the legacy
|
|
tie-break (`max(tied, key=degree)` over a (-score, label_len, nid)-sorted
|
|
list) to derive `best_seed_by_term`. Returns the same triple as the
|
|
optimized path so they can be compared for equality."""
|
|
ranked = _score_nodes(G, terms)
|
|
norm_terms = sorted({tok for t in terms for tok in _search_tokens(t)})
|
|
best_seed_by_term: dict[str, str] = {}
|
|
for term in norm_terms:
|
|
term_scored = _score_nodes(G, [term])
|
|
if not term_scored:
|
|
continue
|
|
best_score = term_scored[0][0]
|
|
tied = [nid for s, nid in term_scored if s == best_score]
|
|
best_nid = max(tied, key=lambda n: G.degree(n)) if len(tied) > 1 else term_scored[0][1]
|
|
best_seed_by_term[term] = best_nid
|
|
seeds = _pick_seeds(ranked, G=G, best_seed_by_term=best_seed_by_term)
|
|
return ranked, best_seed_by_term, seeds
|
|
|
|
|
|
def _optimized_score_and_pick(G, terms):
|
|
qs = _score_query(G, terms, collect_per_term_seeds=True)
|
|
seeds = _pick_seeds(qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term)
|
|
return qs.ranked, qs.best_seed_by_term, seeds
|
|
|
|
|
|
def _warm_caches(G, terms):
|
|
"""Pre-populate the trigram index and IDF cache so the first timed
|
|
iteration doesn't pay the amortized build cost on either path. Both
|
|
legacy and optimized share these caches via the graph object, so warming
|
|
once is fair to both."""
|
|
_get_trigram_index(G)
|
|
# Touch the idf cache for the combined terms and every per-token singleton,
|
|
# matching exactly the calls the legacy path will make.
|
|
_score_nodes(G, terms)
|
|
for term in {tok for t in terms for tok in _search_tokens(t)}:
|
|
_score_nodes(G, [term])
|
|
|
|
|
|
def _bench(fn, *, repeats: int) -> list[float]:
|
|
# One uncounted warm-up — `_warm_caches` already populated caches, but
|
|
# this also amortizes any per-call code-path setup unique to `fn`.
|
|
fn()
|
|
times: list[float] = []
|
|
for _ in range(repeats):
|
|
t0 = time.perf_counter()
|
|
fn()
|
|
times.append(time.perf_counter() - t0)
|
|
return times
|
|
|
|
|
|
def _verify_equality(G, terms) -> tuple[int, int]:
|
|
leg_rank, leg_best, leg_seeds = _legacy_score_and_pick(G, terms)
|
|
opt_rank, opt_best, opt_seeds = _optimized_score_and_pick(G, terms)
|
|
assert leg_rank == opt_rank, (
|
|
f"ranked diverged for terms={terms}: legacy[:5]={leg_rank[:5]} opt[:5]={opt_rank[:5]}"
|
|
)
|
|
assert leg_best == opt_best, (
|
|
f"best_seed_by_term diverged for terms={terms}: legacy={leg_best} opt={opt_best}"
|
|
)
|
|
assert leg_seeds == opt_seeds, (
|
|
f"seeds diverged for terms={terms}: legacy={leg_seeds} opt={opt_seeds}"
|
|
)
|
|
return len(leg_rank), len(leg_seeds)
|
|
|
|
|
|
def _row(label: str, n_nodes: int, n_terms: int, times: list[float],
|
|
traversal_count: int, n_ranked: int, n_seeds: int) -> str:
|
|
med = statistics.median(times) * 1000
|
|
mn = min(times) * 1000
|
|
return (f"{label:<10} | n={n_nodes:<7} | terms={n_terms:<3} | "
|
|
f"median={med:7.2f}ms | min={mn:7.2f}ms | "
|
|
f"passes={traversal_count:<3} | ranked={n_ranked:<6} seeds={n_seeds}")
|
|
|
|
|
|
def _legacy_traversal_count(terms) -> int:
|
|
# 1 combined pass + one per-token singleton pass.
|
|
return 1 + len({tok for t in terms for tok in _search_tokens(t)})
|
|
|
|
|
|
def _run_scenario(G, terms, *, repeats: int) -> tuple[float, float]:
|
|
_warm_caches(G, terms)
|
|
n_ranked, n_seeds = _verify_equality(G, terms)
|
|
|
|
legacy_times = _bench(lambda: _legacy_score_and_pick(G, terms), repeats=repeats)
|
|
opt_times = _bench(lambda: _optimized_score_and_pick(G, terms), repeats=repeats)
|
|
|
|
n_nodes = G.number_of_nodes()
|
|
n_terms = len(set(tok for t in terms for tok in _search_tokens(t)))
|
|
print(_row("legacy", n_nodes, n_terms, legacy_times,
|
|
_legacy_traversal_count(terms), n_ranked, n_seeds))
|
|
print(_row("optimized", n_nodes, n_terms, opt_times,
|
|
1, n_ranked, n_seeds))
|
|
|
|
med_legacy = statistics.median(legacy_times)
|
|
med_opt = statistics.median(opt_times)
|
|
speedup = med_legacy / med_opt if med_opt > 0 else float("inf")
|
|
print(f"speedup | median: {speedup:.2f}x | "
|
|
f"min: {min(legacy_times) / min(opt_times):.2f}x")
|
|
return med_legacy, med_opt
|
|
|
|
|
|
def _resolve_scenarios(args) -> list[list[str]]:
|
|
if args.graph:
|
|
# Real-graph mode: each --query is a natural-language sentence, tokenized
|
|
# using the same helper the production path uses.
|
|
sentences = args.query or ["what calls extract"]
|
|
scenarios = [_query_terms(s) for s in sentences]
|
|
# Dedupe identical token sets (multiple --query args may tokenize the same).
|
|
seen: list[list[str]] = []
|
|
for q in scenarios:
|
|
if q not in seen:
|
|
seen.append(q)
|
|
return seen
|
|
term_counts = [int(s.strip()) for s in args.term_counts.split(",") if s.strip()]
|
|
scenarios: list[list[str]] = []
|
|
for tc in term_counts:
|
|
if tc in QUERIES_BY_TERM_COUNT:
|
|
scenarios.append(QUERIES_BY_TERM_COUNT[tc])
|
|
elif tc > 0:
|
|
scenarios.append([SYLLABLES[i % len(SYLLABLES)] for i in range(tc)])
|
|
return scenarios
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
p = argparse.ArgumentParser(
|
|
description="Microbenchmark single-pass query scoring vs legacy per-term rescoring.",
|
|
)
|
|
p.add_argument("--nodes", type=int, default=100_000,
|
|
help="node count for the synthetic benchmark graph (default: 100000)")
|
|
p.add_argument("--seed", type=int, default=20260714, help="RNG seed for the synthetic graph")
|
|
p.add_argument("--term-counts", default="3,10",
|
|
help="comma-separated list of term counts to benchmark (synthetic mode)")
|
|
p.add_argument("--repeats", type=int, default=5,
|
|
help="timed iterations per scenario (after one warm-up)")
|
|
p.add_argument("--graph", default=None,
|
|
help="optional path to a real graphify-out/graph.json; overrides --nodes")
|
|
p.add_argument("--query", action="append", default=None,
|
|
help="natural-language query sentence (real-graph mode; repeat for multiple)")
|
|
args = p.parse_args(argv)
|
|
|
|
if args.graph:
|
|
print(f"loading real graph: {args.graph} ...", file=sys.stderr)
|
|
t0 = time.perf_counter()
|
|
G = _load_real_graph(args.graph)
|
|
print(f" loaded in {time.perf_counter() - t0:.2f}s", file=sys.stderr)
|
|
else:
|
|
print(f"building synthetic graph: n={args.nodes} seed={args.seed} ...",
|
|
file=sys.stderr)
|
|
t0 = time.perf_counter()
|
|
G = _build_random_graph(args.nodes, seed=args.seed)
|
|
print(f" built in {time.perf_counter() - t0:.2f}s", file=sys.stderr)
|
|
|
|
print()
|
|
print(f"graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
|
|
scenarios = _resolve_scenarios(args)
|
|
print(f"scenarios: {len(scenarios)} | repeats per scenario: {args.repeats}")
|
|
print("-" * 110)
|
|
|
|
summaries = []
|
|
for terms in scenarios:
|
|
print()
|
|
med_legacy, med_opt = _run_scenario(G, terms, repeats=args.repeats)
|
|
summaries.append((terms, med_legacy, med_opt))
|
|
|
|
print()
|
|
print("-" * 110)
|
|
print("summary:")
|
|
for terms, med_legacy, med_opt in summaries:
|
|
speedup = med_legacy / med_opt if med_opt > 0 else float("inf")
|
|
print(f" terms={len(set(tok for t in terms for tok in _search_tokens(t))):>3} | "
|
|
f"median legacy={med_legacy*1000:>8.2f}ms | "
|
|
f"median optimized={med_opt*1000:>8.2f}ms | "
|
|
f"speedup={speedup:>5.2f}x")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|