Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 84 additions & 4 deletions graphify/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,25 @@ def _score_query(
or label_tokens.startswith(joined)
):
score += _PREFIX_MATCH_BONUS * 10 * joined_w

# Sub-phrase tier: for queries with 3+ terms, check contiguous 2-gram and 3-gram
# sub-phrases against the label. A node matching "incidence algebra" (a 2-gram
# from the query) ranks above a node matching just "incidence" alone.
# At most one phrase bonus per node — longest match wins.
if n_terms >= 3:
phrase_hit = False
for width in (3, 2): # prefer longer phrases
if phrase_hit:
break
for start in range(n_terms - width + 1):
phrase = " ".join(norm_terms[start:start + width])
# Check label_tokens to ensure word boundaries and avoid partial matches
# (e.g. "get set" in "target settings" should not match).
if f" {phrase} " in f" {label_tokens} ":
phrase_w = max(idf.get(t, 1.0) for t in norm_terms[start:start + width])
score += _PREFIX_MATCH_BONUS * width * phrase_w
phrase_hit = True
break # breaks inner loop; phrase_hit breaks outer
# Term coverage (#1602): scale the per-term exact/prefix tiers by the
# squared fraction of query terms the node's LABEL matches, so a lone
# generic word that happens to equal a short label (query term "home"
Expand Down Expand Up @@ -834,19 +853,51 @@ def _seed_label_key(nid: str) -> str:
or _strip_diacritics(data.get("label") or "").lower()) or nid

top_score = scored[0][0]
top_nid = scored[0][1]
seeds: list[str] = []
seen_labels: set[str] = set()

# First, collect gap-based seeds. We gather all that pass the gap threshold
# so we can apply the community coherence filter if they exceed max_k.
gap_candidates: list[str] = []
for score, nid in scored:
if len(seeds) >= max_k:
break
if seeds and score < top_score * gap_ratio:
if gap_candidates and score < top_score * gap_ratio:
break
key = _seed_label_key(nid)
if key in seen_labels:
continue
seen_labels.add(key)
gap_candidates.append(nid)

# Community coherence filter: identify the dominant community among gap candidates.
# A community is dominant if it has >= 2 candidates and strictly more than the runner-up.
majority_cid = None
if G is not None:
community_counts: dict[int, int] = {}
for nid in gap_candidates:
cid = G.nodes[nid].get("community")
if cid is not None:
community_counts[cid] = community_counts.get(cid, 0) + 1
if community_counts:
sorted_comms = sorted(community_counts.items(), key=lambda kv: kv[1], reverse=True)
if sorted_comms[0][1] >= 2 and (len(sorted_comms) == 1 or sorted_comms[0][1] > sorted_comms[1][1]):
majority_cid = sorted_comms[0][0]

# If a majority community exists, filter out minority candidates (except seed #1 / top_nid)
# and cap to max_k.
if majority_cid is not None:
gap_candidates = [
nid for nid in gap_candidates
if nid == top_nid or G.nodes[nid].get("community") is None or G.nodes[nid].get("community") == majority_cid
]

# Apply max_k limit to the gap candidates and seed them
for nid in gap_candidates[:max_k]:
seeds.append(nid)

# Rebuild seen_labels to accurately reflect the truncated seeds
seen_labels = {_seed_label_key(s) for s in seeds}

if G is not None and best_seed_by_term:
# Guarantee one seed per distinct query term that has any match at all,
# so an incidental exact match on one term cannot starve matches on
Expand All @@ -866,12 +917,20 @@ def _seed_label_key(nid: str) -> str:
ordered_terms += sorted(t for t in best_seed_by_term if t not in set(ordered_terms))
for term in ordered_terms:
best_nid = best_seed_by_term[term]
# Cross-domain guard (#3810): if a dominant community was established among
# the query's best candidates, do not re-add an incidental single-token match
# from an unrelated community.
if majority_cid is not None:
best_cid = G.nodes[best_nid].get("community")
if best_cid is not None and best_cid != majority_cid:
continue
# Honor the same per-label cap so the per-term guarantee can't
# reintroduce a second copy of an already-seeded generic label.
key = _seed_label_key(best_nid)
if best_nid not in seeds and key not in seen_labels:
seen_labels.add(key)
seeds.append(best_nid)

return seeds


Expand Down Expand Up @@ -1145,9 +1204,30 @@ def _adj(n):
dist[nb] = hop
nxt.append(nb)
frontier = nxt
# Compute nodes on inter-seed shortest paths (evidence nodes).
# Use an undirected view over the induced subgraph of `nodes` to:
# 1) match the BFS hop distance above which traverses both edge directions,
# 2) avoid NetworkXNoPath when paths are bidirectional in a DiGraph,
# 3) bound the path search to the extracted subgraph instead of the full graph.
evidence: set[str] = set()
if len(seed_hits) >= 2:
sub_undirected = G.subgraph(nodes).to_undirected(as_view=True)
for i in range(len(seed_hits)):
for j in range(i + 1, min(i + 3, len(seed_hits))): # cap pairs to avoid O(n²) blowup
try:
path_nodes = nx.shortest_path(sub_undirected, seed_hits[i], seed_hits[j])
evidence.update(path_nodes)
except (nx.NetworkXNoPath, nx.NodeNotFound):
pass

ordered = seed_hits + sorted(
nodes - seed_set,
key=lambda n: (dist.get(n, 1 << 30), -G.degree(n), str(n)),
key=lambda n: (
0 if n in evidence else 1, # evidence nodes first
dist.get(n, 1 << 30),
-G.degree(n),
str(n),
),
)
for nid in ordered:
d = G.nodes[nid]
Expand Down
169 changes: 169 additions & 0 deletions tests/test_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -1905,3 +1905,172 @@ def test_dotted_python_symbol_same_file_tie_is_refused_by_path_endpoint():
G.add_node("m", label=".run()", source_file="pkg/mod.py", source_location="L9")
nid, _, err = _resolve_path_endpoint(G, "pkg.mod.run")
assert nid is None and err.startswith("Ambiguous")


# --- MCP Retrieval Quality (#3810) ---

def test_score_query_phrase_bonus_prevents_single_token_hijack():
G = nx.Graph()
G.add_node("n1", label="Incidence", community=0, source_file="physics.py")
G.add_node("n2", label="Incidence Algebra", community=1, source_file="math.py")
G.add_node("n3", label="Algebraic Geometry", community=2, source_file="math.py")

scored = _score_query(G, ["incidence", "algebra", "derivations", "poset"], collect_per_term_seeds=False).ranked
scores = {nid: s for s, nid in scored}

# n2 ("Incidence Algebra") should score higher than n1 ("Incidence") because it matches a 2-gram phrase
assert scores["n2"] > scores["n1"]


def test_score_query_phrase_bonus_off_for_short_queries():
G = nx.Graph()
G.add_node("n1", label="extract", community=0)
G.add_node("n2", label="extract_all", community=0)

# Single term query should be unaffected (no phrase logic fires)
scored1 = _score_query(G, ["extract"], collect_per_term_seeds=False).ranked

# Check that scores still rank n1 correctly
scores = {nid: s for s, nid in scored1}
assert scores["n1"] > scores.get("n2", 0)


def test_pick_seeds_community_coherence_favors_majority():
G = nx.Graph()
# 4 seeds: 3 in comm 1, 1 in comm 2
G.add_node("n1", community=1)
G.add_node("n2", community=1)
G.add_node("n3", community=1)
G.add_node("n4", community=2)

# Mock scored output where n4 has highest score initially
scored = [(10.0, "n4"), (9.0, "n1"), (8.0, "n2"), (7.0, "n3")]

# We pass max_k=2 to _pick_seeds. Seed #1 (n4) must be retained, and the
# second slot is filled by the dominant community (community 1).
seeds = _pick_seeds(scored, gap_ratio=0.5, max_k=2, G=G, best_seed_by_term=None)

assert len(seeds) == 2
assert seeds[0] == "n4" # top-scored seed is always retained
assert seeds[1] == "n1" # majority community candidate takes next slot


def test_pick_seeds_no_change_when_under_max_k():
G = nx.Graph()
G.add_node("n1", community=1)
G.add_node("n2", community=2)

scored = [(10.0, "n1"), (9.0, "n2")]

# Under max_k (3), community logic doesn't fire.
seeds = _pick_seeds(scored, gap_ratio=0.5, max_k=3, G=G, best_seed_by_term={})
assert seeds == ["n1", "n2"]


def test_subgraph_to_text_evidence_nodes_survive_truncation():
G = nx.Graph()
# 2 seeds connected by a 3-hop path
seeds = ["seed1", "seed2"]
G.add_node("seed1")
G.add_node("path1")
G.add_node("path2")
G.add_node("seed2")
G.add_edge("seed1", "path1")
G.add_edge("path1", "path2")
G.add_edge("path2", "seed2")

# A random high degree node not on path
G.add_node("hub")
for i in range(10):
n = f"leaf{i}"
G.add_node(n)
G.add_edge("hub", n)
G.add_edge("seed1", "hub") # connects to graph

nodes = set(G.nodes)
edges = list(G.edges)

text = _subgraph_to_text(G, nodes, edges, token_budget=100, seeds=seeds)
# token_budget=100 -> char_budget=300. This is small enough to truncate leaves.
# path1 and path2 should appear because they are evidence (shortest path).
assert "seed1" in text
assert "seed2" in text
assert "path1" in text
assert "path2" in text


def test_subgraph_to_text_evidence_works_on_directed_graph():
"""Shortest path evidence should successfully find connecting paths on directed graphs."""
G = nx.DiGraph()
seeds = ["seed1", "seed2"]
G.add_node("seed1")
G.add_node("path1")
G.add_node("seed2")
# Edges oriented away from path1: seed1 <- path1 -> seed2
G.add_edge("path1", "seed1")
G.add_edge("path1", "seed2")

G.add_node("hub")
for i in range(10):
n = f"leaf{i}"
G.add_node(n)
G.add_edge("hub", n)
G.add_edge("seed1", "hub")

nodes = set(G.nodes)
edges = list(G.edges)

text = _subgraph_to_text(G, nodes, edges, token_budget=80, seeds=seeds)
assert "seed1" in text
assert "seed2" in text
assert "path1" in text


def test_query_graph_text_multi_term_resolves_to_target_community():
G = nx.Graph()
G.add_node("n1", label="Incidence", community=0, source_file="physics.py", source_location="L1")
G.add_node("n2", label="Incidence Algebra", community=1, source_file="math.py", source_location="L1")
G.add_node("n3", label="Derivations", community=1, source_file="math.py", source_location="L1")
G.add_edge("n2", "n3")

text = _query_graph_text(
G=G,
question="incidence algebra derivations",
)

# n2 should be the primary seed, pulling in n3. n1 (physics.py) must be absent.
assert "Incidence Algebra" in text
assert "Derivations" in text
assert "physics.py" not in text
assert "NODE Incidence [" not in text


def test_query_graph_text_excludes_cross_domain_seed_with_real_clustering():
"""End-to-end test using real community detection (cluster()) ensuring
cross-domain guaranteed seeds from unrelated modules are excluded (#3810)."""
from graphify.cluster import cluster
G = nx.Graph()
# Math cluster: densely connected
G.add_node("alg", label="Incidence Algebra", source_file="math/algebra.py", source_location="L1")
G.add_node("der", label="Derivations", source_file="math/derivations.py", source_location="L1")
G.add_node("pos", label="Poset", source_file="math/poset.py", source_location="L1")
G.add_edge("alg", "der")
G.add_edge("der", "pos")
G.add_edge("alg", "pos")

# Physics cluster: separate component
G.add_node("inc", label="Incidence", source_file="physics/optics.py", source_location="L1")
G.add_node("opt", label="Optics", source_file="physics/optics.py", source_location="L10")
G.add_edge("inc", "opt")

# Run real community detection
communities = cluster(G)
for cid, members in communities.items():
for m in members:
G.nodes[m]["community"] = cid

text = _query_graph_text(G, "incidence algebra derivations poset")
assert "Incidence Algebra" in text
assert "Derivations" in text
assert "physics" not in text
assert "Optics" not in text
Loading