Files
graphify-labs__graphify/graphify/benchmark.py
T
nauman73 e5f263ba98 fix(windows): unblock pipeline on Windows consoles + missing __main__ guards (#788)
Three independent Windows compatibility fixes shipped together because they
all surface during the same first /graphify run on Windows.

graphify/benchmark.py
  print_benchmark() unconditionally printed U+2500 (box-drawing) and U+2192
  (rightwards arrow), which UnicodeEncodeError'd on stdouts that can't encode
  them — most notably the legacy Windows console at cp1252. New _safe()
  helper falls back to ASCII when the active stdout encoding can't carry the
  glyph; _hr() uses it. Two regression tests cover both paths and prove
  print_benchmark survives a cp1252-strict stream.

graphify/extract.py
  ProcessPoolExecutor on Windows uses spawn, so worker subprocesses
  re-import the calling __main__. When the caller is `python -c "..."` or a
  script without an `if __name__ == "__main__":` guard, the workers
  recursively spawn themselves and the pool dies. The user-visible failure
  was a 290-line traceback ending in BrokenProcessPool, hiding the actual
  cause. _extract_parallel now catches BrokenProcessPool, prints a one-line
  warning that names the __main__-guard idiom, and returns False so the
  public extract() routes to the existing _extract_sequential fallback. Two
  tests cover the parallel-returns-False contract and the sequential
  fallback wiring.

graphify/skill-windows.md
  Every `python -c "..."` block (30 in total) is replaced with a
  Write+run+delete pattern using PowerShell's literal here-string @'...'@.
  The old form was a quote-escaping minefield: any double-quote inside the
  Python source had to be backslash-escaped for the shell, and PowerShell's
  parser ate them inconsistently — failing on f-strings like
  `f'AST: {len(result["nodes"])} nodes'`. The new form passes Python source
  to disk literally, so what the model writes is what Python sees. The AST
  step's script template now includes an explicit `if __name__ == "__main__":`
  guard so multi-core extraction works even before the runtime fallback above
  kicks in. All 31 resulting heredoc blocks parse cleanly under
  `ast.parse`.

Co-authored-by: Nauman Hameed <Nauman.Hameed@enghouse.com>
2026-05-09 12:59:38 +01:00

151 lines
5.3 KiB
Python

"""Token-reduction benchmark - measures how much context graphify saves vs naive full-corpus approach."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import networkx as nx
from networkx.readwrite import json_graph
_CHARS_PER_TOKEN = 4 # standard approximation
def _safe(unicode_char: str, ascii_fallback: str) -> str:
"""Return unicode_char if stdout can encode it, else ascii_fallback.
Windows consoles often default to cp1252 which cannot encode box-drawing
or arrow glyphs; printing them raises UnicodeEncodeError mid-output.
"""
encoding = getattr(sys.stdout, "encoding", None) or ""
try:
unicode_char.encode(encoding)
return unicode_char
except (UnicodeEncodeError, LookupError):
return ascii_fallback
def _hr(width: int = 50) -> str:
"""Horizontal rule that survives non-UTF-8 stdout (e.g. Windows cp1252 console)."""
return _safe("─", "-") * width
def _estimate_tokens(text: str) -> int:
return max(1, len(text) // _CHARS_PER_TOKEN)
def _query_subgraph_tokens(G: nx.Graph, question: str, depth: int = 3) -> int:
"""Run BFS from best-matching nodes and return estimated tokens in the subgraph context."""
terms = [t.lower() for t in question.split() if len(t) > 2]
scored = []
for nid, data in G.nodes(data=True):
label = data.get("label", "").lower()
score = sum(1 for t in terms if t in label)
if score > 0:
scored.append((score, nid))
scored.sort(reverse=True)
start_nodes = [nid for _, nid in scored[:3]]
if not start_nodes:
return 0
visited: set[str] = set(start_nodes)
frontier = set(start_nodes)
edges_seen: list[tuple] = []
for _ in range(depth):
next_frontier: set[str] = set()
for n in frontier:
for neighbor in G.neighbors(n):
if neighbor not in visited:
next_frontier.add(neighbor)
edges_seen.append((n, neighbor))
visited.update(next_frontier)
frontier = next_frontier
lines = []
for nid in visited:
d = G.nodes[nid]
lines.append(f"NODE {d.get('label', nid)} src={d.get('source_file', '')} loc={d.get('source_location', '')}")
for u, v in edges_seen:
if u in visited and v in visited:
d = G.edges[u, v]
lines.append(f"EDGE {G.nodes[u].get('label', u)} --{d.get('relation', '')}--> {G.nodes[v].get('label', v)}")
return _estimate_tokens("\n".join(lines))
_SAMPLE_QUESTIONS = [
"how does authentication work",
"what is the main entry point",
"how are errors handled",
"what connects the data layer to the api",
"what are the core abstractions",
]
def run_benchmark(
graph_path: str = "graphify-out/graph.json",
corpus_words: int | None = None,
questions: list[str] | None = None,
) -> dict:
"""Measure token reduction: corpus tokens vs graphify query tokens.
Args:
graph_path: path to the built graph
corpus_words: total word count from detect() output; if None, estimated from graph
questions: list of questions to benchmark; defaults to _SAMPLE_QUESTIONS
Returns dict with: corpus_tokens, avg_query_tokens, reduction_ratio, per_question
"""
data = json.loads(Path(graph_path).read_text(encoding="utf-8"))
try:
G = json_graph.node_link_graph(data, edges="links")
except TypeError:
G = json_graph.node_link_graph(data)
if corpus_words is None:
# Rough estimate: each node label is ~3 words, plus source context
corpus_words = G.number_of_nodes() * 50
corpus_tokens = corpus_words * 100 // 75 # words → tokens (100 words ≈ 133 tokens)
qs = questions or _SAMPLE_QUESTIONS
per_question = []
for q in qs:
qt = _query_subgraph_tokens(G, q)
if qt > 0:
per_question.append({"question": q, "query_tokens": qt, "reduction": round(corpus_tokens / qt, 1)})
if not per_question:
return {"error": "No matching nodes found for sample questions. Build the graph first."}
avg_query_tokens = sum(p["query_tokens"] for p in per_question) // len(per_question)
reduction_ratio = round(corpus_tokens / avg_query_tokens, 1) if avg_query_tokens > 0 else 0
return {
"corpus_tokens": corpus_tokens,
"corpus_words": corpus_words,
"nodes": G.number_of_nodes(),
"edges": G.number_of_edges(),
"avg_query_tokens": avg_query_tokens,
"reduction_ratio": reduction_ratio,
"per_question": per_question,
}
def print_benchmark(result: dict) -> None:
"""Print a human-readable benchmark report."""
if "error" in result:
print(f"Benchmark error: {result['error']}")
return
print(f"\ngraphify token reduction benchmark")
print(_hr(50))
arrow = _safe("→", "->")
print(f" Corpus: {result['corpus_words']:,} words {arrow} ~{result['corpus_tokens']:,} tokens (naive)")
print(f" Graph: {result['nodes']:,} nodes, {result['edges']:,} edges")
print(f" Avg query cost: ~{result['avg_query_tokens']:,} tokens")
print(f" Reduction: {result['reduction_ratio']}x fewer tokens per query")
print(f"\n Per question:")
for p in result["per_question"]:
print(f" [{p['reduction']}x] {p['question'][:55]}")
print()