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
30 changes: 27 additions & 3 deletions graphify/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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(
Expand Down
62 changes: 62 additions & 0 deletions tests/test_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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