Skip to content
Closed
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
93 changes: 83 additions & 10 deletions graphify/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from networkx.readwrite import json_graph
from graphify.security import sanitize_label, check_graph_file_size_cap, _CONTROL_CHAR_RE
from graphify.build import _CONFIDENCE_RANK, edge_datas, edge_evidence, has_parallel_links
from graphify.paths import default_graph_json as _default_graph_json
from graphify.paths import default_graph_json as _default_graph_json, _is_test_path

try:
with warnings.catch_warnings():
Expand Down Expand Up @@ -566,6 +566,43 @@ class _QueryScores(NamedTuple):
term_order: tuple[str, ...] = ()


# Query seeding prefers source code over tests and docs. A test named after a bug
# repeats the question's words in its own name (``UsernameValidatorsTests``), so it
# wins the exact-label tier and takes the seed slots the source it tests should
# get. Only `query` applies this weight; `path`, `explain` and `_score_nodes`
# callers score exactly as before.
_QUERY_DEMOTE_WEIGHT = 0.3
_QUERY_TEST_WORDS = frozenset({"test", "tests", "testing", "testcase", "unittest"})
_QUERY_DOC_WORDS = frozenset({"doc", "docs", "documentation", "readme"})
_QUERY_DOC_SUFFIXES = (".md", ".rst", ".txt", ".yml", ".yaml", ".toml", ".cfg", ".ini", ".json")
_QUERY_DOC_DIRS = frozenset({"doc", "docs", "example", "examples"})


def _query_path_weight(terms: list[str]):
"""Score multiplier per node for `query`: tests, docs, config files and nodes
without a source file count `_QUERY_DEMOTE_WEIGHT`, unless the question itself
asks about tests (or docs); everything else counts 1.0."""
words = {tok for t in terms for tok in _search_tokens(t)}
keep_tests = bool(words & _QUERY_TEST_WORDS)
keep_docs = bool(words & _QUERY_DOC_WORDS)

def weight(data: dict) -> float:
path = str(data.get("source_file") or "").replace("\\", "/")
parts = path.lower().split("/")
if not keep_tests and (_is_test_path(path) or "testing" in parts[:-1] or parts[-1] == "conftest.py"):
return _QUERY_DEMOTE_WEIGHT
if not keep_docs and (
not path
or data.get("file_type") in ("document", "paper")
or parts[-1].endswith(_QUERY_DOC_SUFFIXES)
or any(p in _QUERY_DOC_DIRS for p in parts[:-1])
):
return _QUERY_DEMOTE_WEIGHT
return 1.0

return weight


def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]:
"""Combined query scorer returning the existing ranked `(score, node_id)` list.

Expand All @@ -578,7 +615,7 @@ def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]:


def _score_query(
G: nx.Graph, terms: list[str], *, collect_per_term_seeds: bool
G: nx.Graph, terms: list[str], *, collect_per_term_seeds: bool, path_weight=None
) -> _QueryScores:
"""Single-pass combined scorer that optionally also records the best seed
for each normalized query token.
Expand Down Expand Up @@ -654,6 +691,10 @@ def _score_query(
# neither runs (`joined` empty AND not collecting seeds) skip the call;
# this preserves the single-query-time perf where nid_lower was lazy.
nid_lower = nid.lower() if (joined or collect_per_term_seeds) else ""
# `query` passes a per-node multiplier (tests/docs below source); it scales
# the combined score and the per-term singleton alike, so the per-term
# seed guarantee still holds but prefers a source node for each term.
weight = path_weight(data) if path_weight is not None else 1.0
score = 0.0
# Full-query tier: a multi-word query that equals (or prefixes) the whole
# label must dominate the per-token bag-of-words sums below, so `path`/
Expand Down Expand Up @@ -738,6 +779,7 @@ def _score_query(
else:
singleton = 0.0
singleton += tier_value + substr_value + source_value + rationale_value + attr_value
singleton *= weight
if singleton > 0:
# Tie-break key mirrors the legacy sort+max(degree):
# (-singleton, -degree, label_len, nid) — the minimum
Expand All @@ -749,6 +791,7 @@ def _score_query(
best_by_term[t] = (key, nid)
if tiered:
score += tiered * (matched / n_terms) ** 2
score *= weight
if score > 0:
scored.append((score, nid))
# Sort by score desc; break ties toward the shorter label so a concise exact
Expand Down Expand Up @@ -1122,11 +1165,14 @@ 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: `query`'s node scores. When given, the answer is file-first: seeds
and then the other nodes are grouped by file, best-scoring file first.
"""
char_budget = token_budget * 3
lines = []
Expand Down Expand Up @@ -1158,10 +1204,35 @@ def _adj(n):
dist[nb] = hop
nxt.append(nb)
frontier = nxt
ordered = seed_hits + sorted(
nodes - seed_set,
key=lambda n: (dist.get(n, 1 << 30), -G.degree(n), str(n)),
)
if scores:
# File-first (query only): rank files by their best-scoring node in the
# answer, then list the seeds, and after them the other nodes, file by
# file (best file first), by score, then by hop and degree as before.
# Nodes the question did not match keep the old order after every
# matched file.
file_best: dict[str, float] = {}
for n in nodes:
sc = scores.get(n, 0.0)
if sc > 0:
f = str(G.nodes[n].get("source_file") or "")
if sc > file_best.get(f, 0.0):
file_best[f] = sc
file_rank = {f: i for i, (f, _) in enumerate(sorted(file_best.items(), key=lambda kv: (-kv[1], kv[0])))}
unranked = len(file_rank)

def _order(n: str):
f = str(G.nodes[n].get("source_file") or "")
sc = scores.get(n, 0.0)
fr = file_rank.get(f, unranked) if sc > 0 else unranked
return (fr, -sc, dist.get(n, 1 << 30), -G.degree(n), str(n))

seed_hits = sorted(seed_hits, key=_order)
ordered = seed_hits + sorted(nodes - seed_set, key=_order)
else:
ordered = seed_hits + sorted(
nodes - seed_set,
key=lambda n: (dist.get(n, 1 << 30), -G.degree(n), str(n)),
)
for nid in ordered:
d = G.nodes[nid]
# Every LLM-derived field passes through sanitize_label before being
Expand Down Expand Up @@ -1472,7 +1543,7 @@ def _query_graph_text(
# — one combined + one per query token — re-walking the whole graph each
# time; on a 100k-node, three-term benchmark ~71% of scoring time was
# spent in those redundant per-term passes.
qs = _score_query(G, terms, collect_per_term_seeds=True)
qs = _score_query(G, terms, collect_per_term_seeds=True, path_weight=_query_path_weight(terms))
# Relational-intent verbs ("calls", "uses", ...) describe the relation the
# question asks about, not a symbol to seed from; drop them from the
# per-term seed GUARANTEE so an incidental verb match cannot seat a decoy
Expand All @@ -1490,7 +1561,8 @@ def _query_graph_text(
if not start_nodes:
return _QUERY_NO_MATCH
return _render_query(G, question, start_nodes, mode=mode, depth=depth, token_budget=token_budget,
context_filters=context_filters, graph_path=graph_path)
context_filters=context_filters, graph_path=graph_path,
scores={nid: sc for sc, nid in qs.ranked})


def _render_query(
Expand All @@ -1505,6 +1577,7 @@ def _render_query(
graph_path: str | None,
given: bool = False,
unresolved: list[str] | None = None,
scores: dict[str, float] | None = None,
) -> str:
resolved_filters, filter_source = _resolve_context_filters(question, context_filters)
traversal_graph = _filter_graph_by_context(_traversal_view(G), resolved_filters)
Expand Down Expand Up @@ -1533,7 +1606,7 @@ def _render_query(
# 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)
return header + _subgraph_to_text(traversal_graph, nodes, edges, token_budget, seeds=start_nodes, scores=scores)


def _resolve_path_scoped_symbol(G: nx.Graph, path_part: str, symbol_part: str) -> list[str]:
Expand Down
125 changes: 125 additions & 0 deletions tests/test_query_source_first.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""`query` prefers source code over tests and docs, and answers file-first.

A test named after a bug repeats the question's words in its own name, so on the
name tiers it ties with (or beats) the source it tests and takes the seed slots.
`query` now weights test, doc, config and source-less nodes down unless the
question asks about tests or docs. `path`, `explain` and `_score_nodes` are not
affected. The answer lists nodes file by file, best-scoring file first.
"""
from __future__ import annotations

import networkx as nx

from graphify.serve import (
_QUERY_DEMOTE_WEIGHT,
_query_graph_text,
_query_path_weight,
_query_terms,
_score_nodes,
_score_query,
_subgraph_to_text,
)


def _graph():
G = nx.Graph()
G.add_node("src", label="validate_username()", source_file="pkg/auth/validators.py", source_location="L10")
G.add_node("tst", label="validate_username()", source_file="tests/auth/test_validators.py", source_location="L5")
G.add_node("doc", label="validate_username", source_file="docs/auth.md", source_location="L1")
G.add_node("cfg", label="username", source_file="pkg/settings.yml", source_location="L1")
G.add_node("ext", label="username") # external symbol: no source file
G.add_edge("tst", "src", relation="calls", confidence="EXTRACTED")
return G


def _nodes_in_order(text: str) -> list[str]:
return [line.split(" [src=")[1].split(" ")[0] for line in text.splitlines() if line.startswith("NODE ")]


def test_source_node_outranks_its_namesake_test():
G = _graph()
terms = _query_terms("validate_username allows a trailing newline")
plain = dict((nid, sc) for sc, nid in _score_query(G, terms, collect_per_term_seeds=False).ranked)
assert plain["src"] == plain["tst"] # names alone cannot tell them apart
weighted = _score_query(G, terms, collect_per_term_seeds=True, path_weight=_query_path_weight(terms))
order = [nid for _, nid in weighted.ranked]
assert order.index("src") < order.index("tst")
assert dict((n, s) for s, n in weighted.ranked)["tst"] == plain["tst"] * _QUERY_DEMOTE_WEIGHT


def test_query_answer_leads_with_source_not_test():
text = _query_graph_text(_graph(), "validate_username allows a trailing newline", depth=1)
assert _nodes_in_order(text)[0] == "pkg/auth/validators.py"


def test_question_about_tests_keeps_test_nodes_at_full_weight():
G = _graph()
weight = _query_path_weight(_query_terms("validate_username tests fail on newline"))
assert weight(G.nodes["tst"]) == 1.0
assert weight(G.nodes["src"]) == 1.0


def test_docs_config_and_sourceless_nodes_are_weighted_down():
G = _graph()
weight = _query_path_weight(_query_terms("username validation"))
assert weight(G.nodes["doc"]) == _QUERY_DEMOTE_WEIGHT
assert weight(G.nodes["cfg"]) == _QUERY_DEMOTE_WEIGHT
assert weight(G.nodes["ext"]) == _QUERY_DEMOTE_WEIGHT
assert weight(G.nodes["src"]) == 1.0
# a question that names the docs keeps doc nodes at full weight
assert _query_path_weight(_query_terms("username docs"))(G.nodes["doc"]) == 1.0


def test_pytest_style_testing_dir_and_conftest_count_as_tests():
weight = _query_path_weight(_query_terms("collect only shortcut"))
assert weight({"source_file": "testing/test_session.py"}) == _QUERY_DEMOTE_WEIGHT
assert weight({"source_file": "testing/python/fixtures.py"}) == _QUERY_DEMOTE_WEIGHT
assert weight({"source_file": "src/conftest.py"}) == _QUERY_DEMOTE_WEIGHT
assert weight({"source_file": "src/_pytest/main.py"}) == 1.0


def test_path_and_explain_scoring_is_unchanged():
# _score_nodes (used by path/explain endpoint resolution) applies no weight.
G = _graph()
terms = _query_terms("validate_username")
assert _score_nodes(G, terms) == _score_query(G, terms, collect_per_term_seeds=False).ranked
assert dict((n, s) for s, n in _score_nodes(G, terms))["tst"] == dict((n, s) for s, n in _score_nodes(G, terms))["src"]


def test_per_term_seed_guarantee_still_seeds_a_term_only_tests_match():
# "fixture_loader" exists only in a test file: the term still gets its seed.
G = _graph()
G.add_node("fx", label="fixture_loader()", source_file="tests/helpers.py", source_location="L3")
text = _query_graph_text(G, "validate_username fixture_loader", depth=1)
start = text.split("Start: ")[1].split("|")[0]
assert "fixture_loader()" in start and "validate_username()" in start


def test_answer_is_file_first():
# The hub has the highest degree but the question does not match it; the
# matched files come first, best file first, each file's nodes together.
G = nx.Graph()
G.add_node("hub", label="registry", source_file="pkg/registry.py", source_location="L1")
G.add_node("a1", label="parse_header()", source_file="pkg/http.py", source_location="L1")
G.add_node("a2", label="header_value()", source_file="pkg/http.py", source_location="L9")
G.add_node("b1", label="parse()", source_file="pkg/util.py", source_location="L1")
for n in ("a1", "a2", "b1"):
G.add_edge("hub", n, relation="calls")
for i in range(5):
G.add_node(f"x{i}", label=f"other{i}", source_file="pkg/other.py", source_location=f"L{i}")
G.add_edge("hub", f"x{i}", relation="calls")
nodes = set(G.nodes)
scores = {"a1": 10.0, "a2": 4.0, "b1": 6.0}
order = _nodes_in_order(_subgraph_to_text(G, nodes, [], 2000, seeds=["b1"], scores=scores))
assert order[:3] == ["pkg/util.py", "pkg/http.py", "pkg/http.py"] # seed first, then best file's nodes
# without scores (path/explain/other callers) the old hop-then-degree order is kept
old = _nodes_in_order(_subgraph_to_text(G, nodes, [], 2000, seeds=["b1"]))
assert old[:2] == ["pkg/util.py", "pkg/registry.py"]


def test_seed_block_is_ordered_file_first():
G = nx.Graph()
G.add_node("s1", label="weak()", source_file="pkg/a.py", source_location="L1")
G.add_node("s2", label="strong()", source_file="pkg/b.py", source_location="L1")
text = _subgraph_to_text(G, {"s1", "s2"}, [], 2000, seeds=["s1", "s2"], scores={"s1": 1.0, "s2": 9.0})
assert _nodes_in_order(text) == ["pkg/b.py", "pkg/a.py"]
Loading