diff --git a/graphify/serve.py b/graphify/serve.py index a9ecd3540..6c7be478e 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -989,11 +989,25 @@ def _dfs(G: nx.Graph, start_nodes: list[str], depth: int) -> tuple[set[str], lis return visited, edges_seen -def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_budget: int = 2000, *, seeds: list[str] | None = None) -> str: +def _subgraph_to_text( + G: nx.Graph, + nodes: set[str], + edges: list[tuple], + token_budget: int = 2000, + *, + seeds: list[str] | None = None, + scores: dict[str, float] | None = None, +) -> str: """Render subgraph as text, cutting at token_budget (approx 3 chars/token). seeds: exact-match nodes rendered first before the degree-sorted expansion, so the queried symbol always appears at the top of the output. + scores: per-node relevance to the question (the `_score_query` ranking the + query path already computed). Within one hop layer, a node that scored + against the query renders before a higher-degree node that scored zero, so + a tight budget cuts the incidental hub rather than the answer. Nodes absent + from the map score 0.0; an empty or missing map leaves the hop/degree order + unchanged. """ char_budget = token_budget * 3 lines = [] @@ -1025,9 +1039,13 @@ def _adj(n): dist[nb] = hop nxt.append(nb) frontier = nxt + # Hop distance stays the primary key (#BUG2); query relevance decides + # within a layer so a term match outranks an unrelated hub; degree then + # str(n) keep the tail deterministic and byte-identical when no score is set. + score_of = scores or {} ordered = seed_hits + sorted( nodes - seed_set, - key=lambda n: (dist.get(n, 1 << 30), -G.degree(n), str(n)), + key=lambda n: (dist.get(n, 1 << 30), -score_of.get(n, 0.0), -G.degree(n), str(n)), ) for nid in ordered: d = G.nodes[nid] @@ -1253,7 +1271,13 @@ def _query_graph_text( # Pass the seeds so the queried symbol renders first and survives truncation # (#BUG2): a branch merge had silently dropped this argument, leaving the # seed-first ordering as dead code. - return header + _subgraph_to_text(traversal_graph, nodes, edges, token_budget, seeds=start_nodes) + # `qs.ranked` already holds every node's relevance to the question; hand it + # to the renderer so the budget cut is relevance-aware instead of dropping a + # term-matching node behind a same-layer hub. + scores = {nid: score for score, nid in qs.ranked} + return header + _subgraph_to_text( + traversal_graph, nodes, edges, token_budget, seeds=start_nodes, scores=scores + ) def _find_node_tiers( diff --git a/tests/test_serve.py b/tests/test_serve.py index 87e71f821..6c7f8e703 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -1735,3 +1735,65 @@ def test_resolve_single_node_shared_by_get_node_and_get_neighbors(): nid, err = _resolve_single_node(G, "nonexistent") assert nid is None assert "No node matching" in err + + +# --- relevance-aware ordering under the budget --- + +def _hub_vs_match_graph(): + """Seed S with two depth-1 neighbors: `RetryTimeout` (degree 1, matches the + query term "timeout") and `Logger` (a hub wired to many leaves, matches + nothing). Both sit in the same hop layer, so today's (hop, -degree) sort + puts the hub first and a tight budget cuts the node that actually answers + the question.""" + G = nx.Graph() + G.add_node("s", label="CompanySpacingGate", source_file="gate.py") + G.add_node("match", label="RetryTimeout", source_file="retry.py") + G.add_node("hub", label="Logger", source_file="log.py") + G.add_edge("s", "match", relation="calls", confidence="EXTRACTED") + G.add_edge("s", "hub", relation="calls", confidence="EXTRACTED") + for i in range(8): + G.add_node(f"leaf{i}", label=f"Leaf{i}", source_file="leaf.py") + G.add_edge("hub", f"leaf{i}", relation="calls", confidence="EXTRACTED") + return G + + +def test_subgraph_to_text_query_match_outranks_hub_in_same_hop_layer(): + """Within one hop layer, a node that scored against the query must render + before a higher-degree node that scored zero, so a tight budget cuts the + incidental hub, not the answer.""" + G = _hub_vs_match_graph() + text = _subgraph_to_text( + G, set(G.nodes), list(G.edges()), token_budget=40, + seeds=["s"], scores={"match": 5.0}, + ) + node_lines = [l for l in text.splitlines() if l.startswith("NODE ")] + assert "CompanySpacingGate" in node_lines[0], "seed still renders first" + assert "RetryTimeout" in node_lines[1], f"query match must beat the hub: {node_lines}" + + +def test_subgraph_to_text_without_scores_keeps_degree_order(): + """No scores (or all-zero scores) must leave the existing hop/degree + ordering byte-identical — the hub still wins its layer.""" + G = _hub_vs_match_graph() + kwargs = dict(token_budget=2000, seeds=["s"]) + baseline = _subgraph_to_text(G, set(G.nodes), list(G.edges()), **kwargs) + node_lines = [l for l in baseline.splitlines() if l.startswith("NODE ")] + assert "Logger" in node_lines[1] + assert _subgraph_to_text(G, set(G.nodes), list(G.edges()), scores={}, **kwargs) == baseline + assert _subgraph_to_text(G, set(G.nodes), list(G.edges()), scores={"match": 0.0}, **kwargs) == baseline + + +def test_query_graph_text_threads_relevance_scores_into_rendering(): + """End to end: `query` already scores every node against the question; + that ranking must reach the renderer so a non-seed node matching a query + term survives a tight budget ahead of an unrelated hub.""" + G = _hub_vs_match_graph() + # `TimeoutPolicy` takes the per-term seat for "timeout"; `RetryTimeout` + # still matches the term but is NOT a seed — the case this fix is about. + G.add_node("policy", label="TimeoutPolicy", source_file="policy.py") + G.add_edge("s", "policy", relation="calls", confidence="EXTRACTED") + text = _query_graph_text(G, "CompanySpacingGate timeout", mode="bfs", depth=1, token_budget=60) + labels = [l.split(" [", 1)[0] for l in text.splitlines() if l.startswith("NODE ")] + assert "NODE RetryTimeout" in labels, f"non-seed query match was cut: {labels}" + if "NODE Logger" in labels: + assert labels.index("NODE RetryTimeout") < labels.index("NODE Logger"), labels