mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-13 10:57:13 +00:00
a316590adc
Common terms like 'error'/'exception' were stealing BFS seed slots from rare identifiers like 'FooBarService', burning the token budget on noise. - _compute_idf: weights query terms by inverse document frequency, cached on G.graph so cost is paid once per graph load not per query - _score_nodes: multiplies each tier bonus by IDF weight - _pick_seeds: replaces fixed top-3 with gap-ratio selection — stops adding seeds when score drops below 20% of the top match - _subgraph_to_text: truncation hint now tells Claude to narrow with context_filter or use get_node instead of just saying 'truncated' Fixes #897 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
369 lines
11 KiB
Python
369 lines
11 KiB
Python
"""Tests for serve.py - MCP graph query helpers (no mcp package required)."""
|
|
import json
|
|
import pytest
|
|
import networkx as nx
|
|
from networkx.readwrite import json_graph
|
|
|
|
from graphify.serve import (
|
|
_communities_from_graph,
|
|
_score_nodes,
|
|
_compute_idf,
|
|
_pick_seeds,
|
|
_bfs,
|
|
_dfs,
|
|
_filter_graph_by_context,
|
|
_infer_context_filters,
|
|
_query_graph_text,
|
|
_resolve_context_filters,
|
|
_subgraph_to_text,
|
|
_load_graph,
|
|
)
|
|
|
|
|
|
def _make_graph() -> nx.Graph:
|
|
G = nx.Graph()
|
|
G.add_node("n1", label="extract", source_file="extract.py", source_location="L10", community=0)
|
|
G.add_node("n2", label="cluster", source_file="cluster.py", source_location="L5", community=0)
|
|
G.add_node("n3", label="build", source_file="build.py", source_location="L1", community=1)
|
|
G.add_node("n4", label="report", source_file="report.py", source_location="L1", community=1)
|
|
G.add_node("n5", label="isolated", source_file="other.py", source_location="L1", community=2)
|
|
G.add_edge("n1", "n2", relation="calls", confidence="INFERRED", context="call")
|
|
G.add_edge("n2", "n3", relation="imports", confidence="EXTRACTED", context="import")
|
|
G.add_edge("n3", "n4", relation="uses", confidence="EXTRACTED")
|
|
return G
|
|
|
|
|
|
# --- _communities_from_graph ---
|
|
|
|
def test_communities_from_graph_basic():
|
|
G = _make_graph()
|
|
communities = _communities_from_graph(G)
|
|
assert 0 in communities
|
|
assert 1 in communities
|
|
assert "n1" in communities[0]
|
|
assert "n2" in communities[0]
|
|
assert "n3" in communities[1]
|
|
|
|
def test_communities_from_graph_no_community_attr():
|
|
G = nx.Graph()
|
|
G.add_node("a", label="foo") # no community attr
|
|
communities = _communities_from_graph(G)
|
|
assert communities == {}
|
|
|
|
def test_communities_from_graph_isolated():
|
|
G = _make_graph()
|
|
communities = _communities_from_graph(G)
|
|
assert 2 in communities
|
|
assert "n5" in communities[2]
|
|
|
|
|
|
# --- _score_nodes ---
|
|
|
|
def test_score_nodes_exact_label_match():
|
|
G = _make_graph()
|
|
scored = _score_nodes(G, ["extract"])
|
|
nids = [nid for _, nid in scored]
|
|
assert "n1" in nids
|
|
assert scored[0][1] == "n1" # highest score first
|
|
|
|
def test_score_nodes_no_match():
|
|
G = _make_graph()
|
|
scored = _score_nodes(G, ["xyzzy"])
|
|
assert scored == []
|
|
|
|
def test_score_nodes_source_file_partial():
|
|
G = _make_graph()
|
|
# "cluster.py" contains "cluster" - should score 0.5 for source match
|
|
scored = _score_nodes(G, ["cluster"])
|
|
nids = [nid for _, nid in scored]
|
|
assert "n2" in nids
|
|
|
|
|
|
def test_infer_context_filters_for_calls_question():
|
|
assert _infer_context_filters("who calls extract") == ["call"]
|
|
|
|
|
|
def test_resolve_context_filters_explicit_overrides_heuristic():
|
|
filters, source = _resolve_context_filters("who calls extract", ["field"])
|
|
assert filters == ["field"]
|
|
assert source == "explicit"
|
|
|
|
|
|
# --- _bfs ---
|
|
|
|
def test_bfs_depth_1():
|
|
G = _make_graph()
|
|
visited, edges = _bfs(G, ["n1"], depth=1)
|
|
assert "n1" in visited
|
|
assert "n2" in visited # direct neighbor
|
|
assert "n3" not in visited # 2 hops away
|
|
|
|
def test_bfs_depth_2():
|
|
G = _make_graph()
|
|
visited, edges = _bfs(G, ["n1"], depth=2)
|
|
assert "n3" in visited # n1 -> n2 -> n3
|
|
|
|
def test_bfs_disconnected():
|
|
G = _make_graph()
|
|
visited, edges = _bfs(G, ["n5"], depth=3)
|
|
assert visited == {"n5"} # isolated node
|
|
|
|
def test_bfs_returns_edges():
|
|
G = _make_graph()
|
|
visited, edges = _bfs(G, ["n1"], depth=1)
|
|
assert len(edges) >= 1
|
|
assert any(u == "n1" or v == "n1" for u, v in edges)
|
|
|
|
|
|
def test_filter_graph_by_context_limits_traversal():
|
|
G = _make_graph()
|
|
filtered = _filter_graph_by_context(G, ["call"])
|
|
visited, edges = _bfs(filtered, ["n1"], depth=2)
|
|
assert "n2" in visited
|
|
assert "n3" not in visited
|
|
assert edges == [("n1", "n2")]
|
|
|
|
|
|
# --- _dfs ---
|
|
|
|
def test_dfs_depth_1():
|
|
G = _make_graph()
|
|
visited, edges = _dfs(G, ["n1"], depth=1)
|
|
assert "n1" in visited
|
|
assert "n2" in visited
|
|
assert "n3" not in visited
|
|
|
|
def test_dfs_full_chain():
|
|
G = _make_graph()
|
|
visited, edges = _dfs(G, ["n1"], depth=5)
|
|
assert {"n1", "n2", "n3", "n4"}.issubset(visited)
|
|
|
|
|
|
# --- _subgraph_to_text ---
|
|
|
|
def test_subgraph_to_text_contains_labels():
|
|
G = _make_graph()
|
|
text = _subgraph_to_text(G, {"n1", "n2"}, [("n1", "n2")])
|
|
assert "extract" in text
|
|
assert "cluster" in text
|
|
|
|
def test_subgraph_to_text_truncates():
|
|
G = _make_graph()
|
|
# Very small budget forces truncation
|
|
text = _subgraph_to_text(G, {"n1", "n2", "n3", "n4"}, [("n1", "n2")], token_budget=1)
|
|
assert "truncated" in text
|
|
|
|
def test_subgraph_to_text_edge_included():
|
|
G = _make_graph()
|
|
text = _subgraph_to_text(G, {"n1", "n2"}, [("n1", "n2")])
|
|
assert "EDGE" in text
|
|
assert "calls" in text
|
|
|
|
|
|
def test_subgraph_to_text_includes_edge_context():
|
|
G = _make_graph()
|
|
text = _subgraph_to_text(G, {"n1", "n2"}, [("n1", "n2")])
|
|
assert "context=call" in text
|
|
|
|
|
|
def test_query_graph_text_explicit_context_filter_changes_traversal():
|
|
G = _make_graph()
|
|
text = _query_graph_text(G, "extract", mode="bfs", depth=2, token_budget=2000, context_filters=["call"])
|
|
assert "Context: call (explicit)" in text
|
|
assert "cluster" in text
|
|
assert "build" not in text
|
|
|
|
|
|
def test_query_graph_text_heuristic_context_filter_changes_traversal():
|
|
G = _make_graph()
|
|
text = _query_graph_text(G, "who calls extract", mode="bfs", depth=2, token_budget=2000)
|
|
assert "Context: call (heuristic)" in text
|
|
assert "cluster" in text
|
|
assert "build" not in text
|
|
|
|
|
|
# --- _load_graph ---
|
|
|
|
def test_load_graph_roundtrip(tmp_path):
|
|
G = _make_graph()
|
|
data = json_graph.node_link_data(G, edges="links")
|
|
p = tmp_path / "graph.json"
|
|
p.write_text(json.dumps(data))
|
|
G2 = _load_graph(str(p))
|
|
assert G2.number_of_nodes() == G.number_of_nodes()
|
|
assert G2.number_of_edges() == G.number_of_edges()
|
|
|
|
def test_load_graph_missing_file(tmp_path):
|
|
graphify_dir = tmp_path / "graphify-out"
|
|
graphify_dir.mkdir()
|
|
with pytest.raises(SystemExit):
|
|
_load_graph(str(graphify_dir / "nonexistent.json"))
|
|
|
|
|
|
# --- #874: MCP hot-reload ---
|
|
|
|
def _write_graph(path, nodes: list[str]) -> None:
|
|
"""Write a minimal graph.json with the given node IDs."""
|
|
G = nx.DiGraph()
|
|
for n in nodes:
|
|
G.add_node(n, label=n, community=0)
|
|
data = json_graph.node_link_data(G, edges="links")
|
|
path.write_text(json.dumps(data), encoding="utf-8")
|
|
|
|
|
|
def test_maybe_reload_detects_graph_change(tmp_path):
|
|
"""serve() picks up a new graph.json written after startup (#874)."""
|
|
import time
|
|
from unittest.mock import patch
|
|
|
|
out = tmp_path / "graphify-out"
|
|
out.mkdir()
|
|
graph_path = out / "graph.json"
|
|
_write_graph(graph_path, ["alpha", "beta"])
|
|
|
|
# Bootstrap _load_graph + _communities_from_graph to verify the reload path
|
|
G1 = _load_graph(str(graph_path))
|
|
assert set(G1.nodes()) == {"alpha", "beta"}
|
|
|
|
# Simulate file changing (bump mtime by touching)
|
|
time.sleep(0.01)
|
|
_write_graph(graph_path, ["alpha", "beta", "gamma"])
|
|
|
|
G2 = _load_graph(str(graph_path))
|
|
assert "gamma" in G2.nodes()
|
|
|
|
|
|
def test_load_graph_cache_key_changes_with_content(tmp_path):
|
|
"""mtime_ns + size uniquely identifies a graph version (#874)."""
|
|
import time
|
|
|
|
out = tmp_path / "graphify-out"
|
|
out.mkdir()
|
|
graph_path = out / "graph.json"
|
|
_write_graph(graph_path, ["a"])
|
|
|
|
s1 = graph_path.stat()
|
|
key1 = (s1.st_mtime_ns, s1.st_size)
|
|
|
|
time.sleep(0.01)
|
|
_write_graph(graph_path, ["a", "b"])
|
|
|
|
s2 = graph_path.stat()
|
|
key2 = (s2.st_mtime_ns, s2.st_size)
|
|
|
|
assert key1 != key2, "stat key must change when file content changes"
|
|
|
|
|
|
# --- IDF weighting tests (#897) ---
|
|
|
|
def _make_noisy_graph() -> nx.Graph:
|
|
"""20 error-handler nodes + 1 rare identifier: FooBarService."""
|
|
G = nx.Graph()
|
|
for i in range(20):
|
|
G.add_node(f"err{i}", label=f"error_handler_{i}", source_file=f"err{i}.py", community=0)
|
|
if i > 0:
|
|
G.add_edge(f"err{i-1}", f"err{i}", relation="calls", confidence="EXTRACTED")
|
|
G.add_node("fbs", label="FooBarService", source_file="service.py", community=1)
|
|
G.add_node("fbs_dep", label="ServiceClient", source_file="client.py", community=1)
|
|
G.add_edge("fbs", "fbs_dep", relation="uses", confidence="EXTRACTED")
|
|
return G
|
|
|
|
|
|
def test_idf_downweights_common_terms():
|
|
"""'error' matches 20 nodes, 'foobarservice' matches 1 — IDF should make
|
|
FooBarService rank first despite error's higher raw frequency."""
|
|
G = _make_noisy_graph()
|
|
scored = _score_nodes(G, ["foobarservice", "error"])
|
|
assert scored, "should have results"
|
|
assert scored[0][1] == "fbs", (
|
|
f"FooBarService should rank first, got {scored[0][1]}"
|
|
)
|
|
|
|
|
|
def test_idf_cached_on_graph():
|
|
"""IDF results are stored in G.graph so repeated queries don't recompute."""
|
|
G = _make_graph()
|
|
_score_nodes(G, ["extract"])
|
|
assert "_idf_cache" in G.graph
|
|
assert "extract" in G.graph["_idf_cache"]
|
|
|
|
|
|
def test_idf_new_graph_starts_fresh():
|
|
"""Two separate graph instances must not share an IDF cache."""
|
|
G1 = _make_graph()
|
|
G2 = _make_graph()
|
|
_score_nodes(G1, ["extract"])
|
|
assert "_idf_cache" not in G2.graph
|
|
|
|
|
|
def test_idf_rare_term_gets_high_weight():
|
|
"""A term matching only 1 of N nodes should get IDF > 1."""
|
|
import math
|
|
G = _make_graph() # 5 nodes
|
|
idf = _compute_idf(G, ["extract"])
|
|
# extract matches only n1: IDF = log(1 + 5/2) ≈ 1.25
|
|
assert idf["extract"] > 1.0
|
|
|
|
|
|
def test_idf_common_term_gets_low_weight():
|
|
"""A term matching most nodes should get IDF < 1."""
|
|
import math
|
|
G = nx.Graph()
|
|
# 'handle' in every node label
|
|
for i in range(20):
|
|
G.add_node(f"n{i}", label=f"handle_{i}", source_file=f"f{i}.py")
|
|
idf = _compute_idf(G, ["handle"])
|
|
assert idf["handle"] < 1.0
|
|
|
|
|
|
# --- _pick_seeds tests (#897) ---
|
|
|
|
def test_pick_seeds_dominant_identifier_gives_one_seed():
|
|
"""FooBarService at 1000 vs error nodes at 1.0 → only 1 seed chosen."""
|
|
scored = [(1000.0, "fbs"), (1.0, "err1"), (0.9, "err2")]
|
|
seeds = _pick_seeds(scored)
|
|
assert seeds == ["fbs"]
|
|
|
|
|
|
def test_pick_seeds_close_scores_keeps_multiple():
|
|
"""When all scores are within 20% of the top, keep up to 3 seeds."""
|
|
scored = [(10.0, "a"), (9.0, "b"), (8.5, "c")]
|
|
seeds = _pick_seeds(scored)
|
|
assert len(seeds) == 3
|
|
|
|
|
|
def test_pick_seeds_empty():
|
|
assert _pick_seeds([]) == []
|
|
|
|
|
|
def test_pick_seeds_single():
|
|
assert _pick_seeds([(5.0, "x")]) == ["x"]
|
|
|
|
|
|
def test_pick_seeds_respects_max_k():
|
|
"""Never return more than max_k seeds even when all scores are close."""
|
|
scored = [(10.0, f"n{i}") for i in range(10)]
|
|
seeds = _pick_seeds(scored, max_k=3)
|
|
assert len(seeds) == 3
|
|
|
|
|
|
# --- actionable truncation hint (#897) ---
|
|
|
|
def test_subgraph_to_text_truncation_hint_is_actionable():
|
|
"""Truncation message must tell Claude what to do, not just say truncated."""
|
|
G = _make_graph()
|
|
text = _subgraph_to_text(G, {"n1", "n2", "n3", "n4"}, [("n1", "n2")], token_budget=1)
|
|
assert "truncated" in text
|
|
assert "get_node" in text or "context_filter" in text
|
|
|
|
|
|
# --- integration: identifier + noise query seeds from identifier (#897) ---
|
|
|
|
def test_query_seeds_from_identifier_not_noise():
|
|
"""'FooBarService error handling' should expand from FooBarService,
|
|
not from error-handler nodes, so ServiceClient appears in results."""
|
|
G = _make_noisy_graph()
|
|
text = _query_graph_text(G, "FooBarService error handling", mode="bfs", depth=2)
|
|
assert "FooBarService" in text
|
|
assert "ServiceClient" in text
|