diff --git a/graphify/build.py b/graphify/build.py index 92c05d2f..518bad83 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -763,6 +763,49 @@ def graph_has_legacy_ids(nodes: list, root: str | Path | None = None, sample: in return False +def legacy_id_collisions(nodes: list, root: str | Path | None = None) -> int: + """How many pre-#1504 node IDs are AMBIGUOUS — one id claimed by more than one + source file. This is the harm the new scheme exists to prevent; a legacy id + that no other file claims resolves to exactly the node it always did. + + `graph_has_legacy_ids` answers "was this graph built by the old extractor", + which is true of any graph predating the change and stays true no matter how + harmless the old ids turn out to be. Read-only consumers were printing a + rebuild nudge on that answer alone, on every single query. Measured on a + 51k-node graph: 591 file nodes carried legacy ids and **none** of them + collided, so every query paid a warning for a hazard that had not occurred and + recommended a full re-extraction that would have changed nothing (#RANK1). + + Callers should nudge on THIS being non-zero and stay quiet otherwise. + """ + from graphify.extractors.base import _file_stem + _r = str(root) if root is not None else None + claims: dict[str, set[str]] = {} + for node in nodes: + if not isinstance(node, dict): + continue + if str(node.get("source_location") or "") != "L1": + continue + if _has_global_id(node): + continue + nid = node.get("id") + sf = node.get("source_file") + if not nid or not isinstance(nid, str) or not sf: + continue + sf_norm = _norm_source_file(str(sf), _r) or str(sf) + rel = Path(sf_norm) + if _is_abs(sf_norm) or not rel.name: + continue + new_stem = make_id(_file_stem(rel)) + if not new_stem: + continue + norm = _normalize_id(nid) + if norm == new_stem or norm.startswith(new_stem + "_"): + continue # already path-qualified + claims.setdefault(norm, set()).add(str(sf)) + return sum(1 for files in claims.values() if len(files) > 1) + + def _doc_twin_remap(nodes: list) -> dict[str, str]: """Map a markdown quick-scan's bare doc node ```` to the semantic ``_doc`` node for the SAME file (#1799). diff --git a/graphify/cli.py b/graphify/cli.py index efede012..b3cf4864 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -1283,12 +1283,19 @@ def dispatch_command(cmd: str) -> None: except TypeError: G = json_graph.node_link_graph(_raw) try: - from graphify.build import graph_has_legacy_ids as _legacy - if _legacy(_raw.get("nodes", [])): + # Nudge only when a legacy id is genuinely AMBIGUOUS (claimed by + # more than one file). Warning on "this graph predates #1504" + # alone fired on every query of every older graph, including the + # measured 51k-node case where 591 legacy ids collided zero times + # (#RANK1). + from graphify.build import legacy_id_collisions as _legacy_collisions + _collisions = _legacy_collisions(_raw.get("nodes", [])) + if _collisions: print( - "[graphify] note: this graph uses the pre-#1504 node-ID scheme; " - "rebuild with `graphify extract --force` to get path-qualified IDs " - "(fixes same-name-file collisions).", + f"[graphify] note: {_collisions} node ID(s) in this graph are " + "claimed by more than one file (pre-#1504 scheme); those nodes " + "may answer for the wrong file. Rebuild with " + "`graphify extract --force` for path-qualified IDs.", file=sys.stderr, ) except Exception: @@ -2220,11 +2227,18 @@ def dispatch_command(cmd: str) -> None: if have_label: changed += 1 if changed: + # Clustering re-runs on every rebuild, so a corpus that changed at all + # produces a different community set — this is the normal case, not a + # fault. The affected names have ALREADY been refreshed deterministically + # from each community's hub by the lines above, so the old wording + # ("Run `graphify label`") named a required repair for work that was + # already done, on every single run (#RANK1). Report what happened and + # say plainly that the LLM pass is optional. print( - f"[graphify] community set changed since labeling " - f"({len(existing_labels)} saved labels, {len(communities)} communities now; " - f"renamed {changed} community(ies) by their hub). " - f"Run `graphify label` to refresh names with the LLM.", + f"[graphify] renamed {changed} of {len(communities)} communities " + f"after re-clustering ({len(existing_labels)} saved labels); new " + f"names come from each community's hub. Optional: `graphify label` " + f"regenerates them with the LLM.", file=sys.stderr, ) elif no_label and not force_relabel: diff --git a/graphify/serve.py b/graphify/serve.py index 8b6fc7ce..b3b9e245 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -7,14 +7,14 @@ import sys from array import array from collections import OrderedDict -from pathlib import Path +from pathlib import Path, PurePosixPath import threading from typing import NamedTuple import networkx as nx from networkx.readwrite import json_graph from graphify.security import sanitize_label, check_graph_file_size_cap from graphify.build import edge_data, edge_datas -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: import jieba as _jieba # type: ignore[import-untyped] @@ -51,11 +51,14 @@ def _load_graph(graph_path: str) -> nx.Graph: _logical_directed = bool(data.get("directed", False)) data = {**data, "directed": True} try: - from graphify.build import graph_has_legacy_ids as _legacy - if _legacy(data.get("nodes", [])): + from graphify.build import legacy_id_collisions as _legacy_collisions + _collisions = _legacy_collisions(data.get("nodes", [])) + if _collisions: print( - "[graphify] note: this graph uses the pre-#1504 node-ID scheme; " - "rebuild with `graphify extract --force` for path-qualified IDs.", + f"[graphify] note: {_collisions} node ID(s) in this graph are " + "claimed by more than one file (pre-#1504 scheme); those nodes " + "may answer for the wrong file. Rebuild with " + "`graphify extract --force` for path-qualified IDs.", file=sys.stderr, ) except Exception: @@ -193,6 +196,60 @@ def _search_tokens(text: str) -> list[str]: return re.findall(r"[^\W_]+", _strip_diacritics(str(text)).lower()) +# Identifier sub-word splitter. `_search_tokens` splits on punctuation and `_`, +# which leaves `handleTeamConfigWrite` as ONE token — so a natural-language query +# saying "team config write" could only ever reach it through the substring tier, +# worth 1/1000th of an exact match. Measured on a 51k-node TypeScript graph, a +# node matching four of six query terms scored 15 while a bare `worker` variable +# matching one term scored 126 (#RANK1). camelCase, PascalCase and SCREAMING_SNAKE +# are the dominant identifier conventions in most of the languages graphify +# extracts, so an identifier's parts have to be matchable as words. +# +# [A-Z]+(?![a-z]) a run of capitals not starting a capitalised word (HTTP in +# HTTPServer, and the whole of SCREAMING_SNAKE parts) +# [A-Z][a-z0-9]* a capitalised word (Team, Config) +# [a-z0-9]+ a lowercase run (handle, lang) +_CAMEL_SPLIT_RE = re.compile(r"[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+") + + +def _subword_tokens(text: str) -> list[str]: + """Lower-cased identifier parts of `text`, splitting camelCase and PascalCase + as well as the punctuation/underscore boundaries `_search_tokens` handles. + + `handleTeamConfigWrite` -> [handle, team, config, write]; + `PLATFORM_SOURCE_LANG` -> [platform, source, lang]; + `HTTPServer` -> [http, server]. + + Case-sensitive by necessity, so it takes the RAW text — passing an already + lower-cased `norm_label` would silently yield one token per punctuation run + and quietly restore the behaviour this exists to fix. + """ + out: list[str] = [] + for chunk in re.split(r"[^0-9A-Za-z]+", _strip_diacritics(str(text))): + if not chunk: + continue + out.extend(m.group(0).lower() for m in _CAMEL_SPLIT_RE.finditer(chunk)) + return out + + +def _fold_plural(token: str) -> str: + """Fold a trailing plural `s` so query and label agree on number. + + A question is written in prose ("which route validates team config writes") + and an identifier is written in the singular (`validate`, `write`), so without + this the two never meet and the node loses a tier it earned. + + Deliberately a fold, not a stemmer: it is applied to BOTH sides, so its job is + to be consistent rather than linguistically right. `status` -> `statu` is + wrong as English and harmless here, because the label folds to `statu` too. + Words ending `ss` (class, address) and short words (its, has) are left alone, + where dropping the `s` would collide with unrelated terms. + """ + if len(token) > 3 and token.endswith("s") and not token.endswith("ss"): + return token[:-1] + return token + + def _has_chinese(text: str) -> bool: return any("一" <= ch <= "鿿" for ch in text) @@ -295,6 +352,14 @@ def _query_terms(question: str) -> list[str]: _PREFIX_MATCH_BONUS = 100.0 _SUBSTRING_MATCH_BONUS = 1.0 _SOURCE_MATCH_BONUS = 0.5 +# A query term that equals a whole PART of an identifier (`team` in +# `handleTeamConfigWrite`). Sits between the prefix and substring tiers: the node +# genuinely names the concept, which a bare substring hit ("cat" in "catalogue") +# does not, but it names only part of itself, which a prefix match does. Like the +# exact and prefix tiers it counts toward term coverage, so a node explaining +# three of a question's six words is ranked as explaining half the question +# (#RANK1). +_SUBWORD_MATCH_BONUS = 60.0 # The extraction spec stores the WHY of a concept as a `rationale` attribute # on the node, not as a node of its own, so for a "why does X …" question that # prose is often the only place the question's words occur (#2293). Score it @@ -304,6 +369,70 @@ def _query_terms(question: str) -> list[str]: # an exact-label tier it did not earn. _RATIONALE_MATCH_BONUS = 0.75 +# --- Relevance shaping: a support file never outranks what it supports -------- +# +# The scorer's tiers rank a node by how well its text matches the query, and a +# test file is often the *better* lexical match: `LanguagesCard.test.tsx` holds a +# bare `LANGUAGES` constant that exact-matches a query term, while the component +# it exercises spells the same idea as `SectionLanguagesCard()` and only +# prefix-matches. Ranked on text alone the test wins, and an agent reading the +# answer top-down opens the test instead of the implementation (#RANK1). +# +# The weights are multiplicative on the final score rather than a hard filter, +# so a test stays findable when it is genuinely the answer ("which test covers +# X") — it just cannot outrank a source file that matched as well. +_TEST_RELEVANCE_WEIGHT = 0.30 +_SUPPORT_RELEVANCE_WEIGHT = 0.50 + +# Whole path segments marking generated, vendored or fixture material. Matched +# segment-wise (never as a raw substring) for the same reason `_is_test_path` +# is: "distribution/" must not match on "dist". +_SUPPORT_DIR_SEGMENTS = frozenset({ + "fixtures", "fixture", "mocks", "__mocks__", "testdata", "test-data", + "snapshots", "__snapshots__", "node_modules", "vendor", "third_party", + "dist", "build", "generated", "__generated__", "coverage", ".next", + # Archived material describes what WAS decided. A superseded proposal is + # prose, so it matches more of a question's words than the code it describes + # ever will, and on the graph measured for #RANK1 five archived specs + # outranked the live route the question was about. + "archive", "archived", +}) + +# Configuration / manifest / lockfile suffixes. These describe a project rather +# than implement it, so they answer "how is X configured" and almost never +# "where is X implemented" — which is what a natural-language query asks. +_SUPPORT_SUFFIXES = frozenset({ + ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".lock", + ".snap", ".properties", ".editorconfig", +}) + + +def _relevance_weight(source_file: str) -> float: + """Multiplier applied to a node's query score, from the kind of file it is in. + + 1.0 for ordinary source, `_TEST_RELEVANCE_WEIGHT` for a test/spec file, + `_SUPPORT_RELEVANCE_WEIGHT` for fixtures, generated output, vendored code and + configuration manifests. Test classification reuses `paths._is_test_path` so + extraction, call resolution and ranking agree on what a test is rather than + keeping three drifting definitions. + + An empty path scores 1.0: an unknown file is not evidence of a support file, + and penalising it would demote every node an extractor left unattributed. + """ + if not source_file: + return 1.0 + norm = str(source_file).replace("\\", "/") + if _is_test_path(norm): + return _TEST_RELEVANCE_WEIGHT + pure = PurePosixPath(norm) + for segment in pure.parts: + if segment.lower() in _SUPPORT_DIR_SEGMENTS: + return _SUPPORT_RELEVANCE_WEIGHT + if pure.suffix.lower() in _SUPPORT_SUFFIXES: + return _SUPPORT_RELEVANCE_WEIGHT + return 1.0 + + def _compute_idf(G: nx.Graph, terms: list[str]) -> dict[str, float]: """IDF weights for query terms, cached in G.graph['_idf_cache']. @@ -543,7 +672,16 @@ def _score_query( # back to the whole graph when the index isn't selective. The result is # identical either way — the per-node scoring below is unchanged and a # non-candidate node always scores 0. (IDF above stays a whole-graph statistic.) - candidate_ids = _trigram_candidates(G, norm_terms + ([joined] if joined else [])) + # Folded (singular) form of each query term, for the sub-word tier. Both the + # query side and the label side fold, so they meet in the middle. + folded_terms = [_fold_plural(t) for t in norm_terms] + # The folded forms join the trigram needles: without them a node whose label + # contains `write` is not even a candidate for the query term `writes`, and + # the sub-word tier could never fire on it. + _needles = list(dict.fromkeys( + norm_terms + folded_terms + ([joined] if joined else []) + )) + candidate_ids = _trigram_candidates(G, _needles) node_iter = ( G.nodes(data=True) if candidate_ids is None else ((nid, G.nodes[nid]) for nid in candidate_ids) @@ -567,7 +705,14 @@ def _score_query( # sides makes "uoce: dehumidifier driver" match query "uoce dehumidifier # driver". label_tokens = " ".join(_search_tokens(data.get("label") or "")) + # Identifier parts of the RAW label (case matters to the camelCase split), + # folded to singular so `writes` reaches `...Write`. + subwords = {_fold_plural(w) for w in _subword_tokens(data.get("label") or "")} source = (data.get("source_file") or "").lower() + # Kind-of-file multiplier (#RANK1), applied to the finished score below + # and to the per-term singleton so a test file cannot win a guaranteed + # seed slot away from the source file it exercises. + weight = _relevance_weight(source) rationale = _node_rationale_text(data) # `nid_lower` is needed both by the full-query tier (`if joined`) and by # the per-token singleton tier (joined-singlet exact-match check). When @@ -621,6 +766,9 @@ def _score_query( elif norm_label.startswith(t) or bare_label.startswith(t): tier_value = _PREFIX_MATCH_BONUS * w matched += 1 + elif _fold_plural(t) in subwords: + tier_value = _SUBWORD_MATCH_BONUS * w + matched += 1 elif t in norm_label: substr_value = _SUBSTRING_MATCH_BONUS * w score += substr_value @@ -652,7 +800,8 @@ def _score_query( singleton = _PREFIX_MATCH_BONUS * 10 * w else: singleton = 0.0 - singleton += tier_value + substr_value + source_value + rationale_value + singleton = (singleton + tier_value + substr_value + + source_value + rationale_value) * weight if singleton > 0: # Tie-break key mirrors the legacy sort+max(degree): # (-singleton, -degree, label_len, nid) — the minimum @@ -664,6 +813,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 @@ -675,6 +825,211 @@ def _score_query( return _QueryScores(ranked=scored, best_seed_by_term=best_seed_by_term) +# --- The answer block: why a node matched, and what is on that line ---------- +# +# `_score_query` has always ranked nodes, but only `_pick_seeds` ever read the +# ranking: the renderer ordered by graph topology (seeds, then hop distance, then +# degree) and printed no score at all. Every line therefore carried identical +# apparent weight, so a reader could not tell the node that answered the question +# from the neighbourhood it was found in, and the ranking was invisible rather +# than absent (#RANK1). These helpers surface it. + +_ANSWER_BLOCK_MAX = 5 +# How many nodes that matched none of the query's terms may follow the matched +# set. Traversal context is worth showing — it says what the answer connects to — +# but it must not be what a raised budget buys. Measured at `--budget 20000`: +# 122 matched nodes and 266 with rel=0. +_CONTEXT_TAIL_MAX = 15 +# Longest source line worth echoing. A minified bundle or a generated data file +# has "lines" of tens of kilobytes, and one of them would swamp the whole answer. +_SNIPPET_MAX_LINE = 200 +_SNIPPET_MAX_BYTES = 256 * 1024 + + +def _matched_terms(data: dict, norm_terms: list[str]) -> set[str]: + """The query terms this node matched at any tier, label or path or rationale. + + Shares its predicates with `_match_reason` through the same helper walk, so + the confidence line and the per-node explanation can never disagree. + """ + return {t for t, _tier in _term_tiers(data, norm_terms)} + + +def _term_tiers(data: dict, norm_terms: list[str]) -> list[tuple[str, str]]: + """(term, tier) for every query term this node matches, strongest tier only. + + Tier names mirror `_score_query`'s precedence exactly — exact, prefix, + subword, substring, path, rationale — so an explanation can never claim a + match the score did not credit. + """ + norm_label = data.get("norm_label") or _strip_diacritics(data.get("label") or "").lower() + bare_label = norm_label.rstrip("()") + label_tokens = " ".join(_search_tokens(data.get("label") or "")) + subwords = {_fold_plural(w) for w in _subword_tokens(data.get("label") or "")} + source = (data.get("source_file") or "").lower() + rationale = _node_rationale_text(data) + out: list[tuple[str, str]] = [] + for t in norm_terms: + if t in (norm_label, bare_label, label_tokens): + out.append((t, "exact")) + elif norm_label.startswith(t) or bare_label.startswith(t) or label_tokens.startswith(t): + out.append((t, "prefix")) + elif _fold_plural(t) in subwords: + out.append((t, "subword")) + elif t in norm_label: + out.append((t, "substring")) + elif t in source: + out.append((t, "path")) + elif rationale and t in rationale: + out.append((t, "rationale")) + return out + + +def _match_reason(data: dict, nid: str, norm_terms: list[str]) -> str: + """One short phrase saying WHY this node is in the answer. + + Reports the strongest tier each query term reached, in the same precedence + `_score_query` scores them by, so the explanation cannot claim a match the + score did not credit. Terms that matched nothing are omitted rather than + listed as misses — the reader wants the evidence for the hit, and a five-term + query would otherwise spend most of the line on absences. The confidence line + above the block reports the misses once, for the answer as a whole. + """ + phrasing = { + "exact": "name is", + "prefix": "name starts with", + "subword": "name has part", + "substring": "name contains", + "path": "path has", + "rationale": "rationale mentions", + } + grouped: dict[str, list[str]] = {} + for term, tier in _term_tiers(data, norm_terms): + grouped.setdefault(tier, []).append(term) + parts = [f"{phrasing[tier]} " + ", ".join(grouped[tier]) + for tier in ("exact", "prefix", "subword", "substring", "path", "rationale") + if tier in grouped] + if not parts: + # Reached only for a node pulled in by traversal rather than by text — + # it has no query match of its own, and saying so is the honest answer. + return "reached by traversal from a matching node" + return "; ".join(parts) + + +def _source_snippet(source_file: str, source_location: str, root: Path | None = None) -> str: + """The single source line a node points at, trimmed — or "" when unavailable. + + The reader's next move after any query result is to open the file at the + line, so putting that line in the answer removes a round trip. Best-effort + by design: a query may run anywhere, against a graph built elsewhere, so a + missing, unreadable, binary or moved file yields "" and the answer renders + exactly as it did before. It never raises, and it never becomes the reason a + query fails. + + Reads only up to the target line, and refuses a file over + `_SNIPPET_MAX_BYTES`, so a query cannot be made slow by a large file. + """ + if not source_file or not source_location: + return "" + m = re.match(r"^L(\d+)", str(source_location).strip()) + if not m: + return "" + want = int(m.group(1)) + if want < 1: + return "" + try: + path = (root or Path.cwd()) / str(source_file) + if not path.is_file() or path.stat().st_size > _SNIPPET_MAX_BYTES: + return "" + with path.open("r", encoding="utf-8", errors="replace") as fh: + for i, line in enumerate(fh, 1): + if i == want: + text = line.strip() + if len(text) > _SNIPPET_MAX_LINE: + text = text[:_SNIPPET_MAX_LINE - 1] + "\u2026" + return text + if i > want: + break + except (OSError, ValueError, UnicodeError): + return "" + return "" + + +def _answer_block( + G: nx.Graph, + ranked_in_view: list[tuple[float, str]], + norm_terms: list[str], + *, + limit: int = _ANSWER_BLOCK_MAX, + root: Path | None = None, +) -> tuple[str, list[str]]: + """Render the leading ANSWER section and return it with the ids it named. + + `ranked_in_view` is the query ranking restricted to nodes the traversal + actually returned, best first. Scores are shown relative to the top hit + (100 = best): the raw score is an IDF-weighted tier sum whose magnitude means + nothing across two different queries, while "this one scored 12 against a + best of 100" is directly actionable — it is the difference between an answer + and a near-miss. + + Returns ("", []) when nothing matched the query text, so a pure-traversal + result keeps its old shape instead of growing an empty heading. + """ + if not ranked_in_view: + return "", [] + top = ranked_in_view[0][0] or 1.0 + shown = ranked_in_view[:limit] + lines = [ + f"ANSWER \u2014 top {len(shown)} of " + f"{len(ranked_in_view)} nodes that matched your query, best first:" + ] + # Confidence, stated rather than implied. A lexical graph can only find a node + # whose text shares words with the question; when the question's vocabulary and + # the corpus's do not overlap, the ranking still produces a confidently-ordered + # list of near-misses, and the reader cannot tell that from a hit. Measured on + # the #RANK1 corpus: "where does the chrome string fallback chain terminate" + # returns a plausible five-node answer in which no node matches more than one + # of the five query terms, and the file that actually answers it shares NO word + # with the question. Naming the terms nothing matched is the actionable part — + # it says which way the vocabulary gap runs, and tells a caller to search by + # another route rather than trust the top line. + if len(norm_terms) >= 3: + best_cover = max( + (len(_matched_terms(G.nodes[nid], norm_terms)) for _sc, nid in shown), + default=0, + ) + if best_cover * 2 < len(norm_terms): + covered = set() + for _sc, nid in shown: + covered |= _matched_terms(G.nodes[nid], norm_terms) + missed = [t for t in norm_terms if t not in covered] + miss_note = ( + f"; nothing in this graph matched {', '.join(repr(t) for t in missed)}" + if missed else "" + ) + lines.append( + f" [LOW CONFIDENCE: the best node matches only {best_cover} of your " + f"{len(norm_terms)} query terms{miss_note}. These are leads, not an " + f"answer \u2014 if none is right, the wording of the question and the " + f"wording in the code may simply not overlap.]" + ) + chosen: list[str] = [] + for rank, (score, nid) in enumerate(shown, 1): + d = G.nodes[nid] + label = sanitize_label(str(d.get("label", nid))) + src = sanitize_label(str(d.get("source_file", ""))) + loc = sanitize_label(str(d.get("source_location", ""))) + rel = round(100.0 * score / top) + where = f"{src}:{loc}" if src and loc else (src or "?") + lines.append(f" {rank}. {label} [rel={rel}] {where}") + lines.append(f" why: {sanitize_label(_match_reason(d, nid, norm_terms))}") + snippet = _source_snippet(str(d.get("source_file", "")), str(d.get("source_location", "")), root) + if snippet: + lines.append(f" line: {sanitize_label(snippet)}") + chosen.append(nid) + return "\n".join(lines) + "\n\n", chosen + + def _pick_scored_endpoint(G: nx.Graph, scored: list[tuple[float, str]], query: str) -> str: """Pick a path endpoint from a _score_nodes result, preferring full-token matches. @@ -1025,11 +1380,41 @@ 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, + relevance: dict[str, float] | None = None, + pinned: list[str] | None = None, + context_cap: int = _CONTEXT_TAIL_MAX, +) -> 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. + + relevance: query score per node, from `_score_query`. It is the PRIMARY sort + key — a reader scanning top-down should meet the nodes that answer the + question first, wherever the traversal reached them — with hop distance + demoted to the tie-break that keeps a seed's own neighbourhood together + among equally-relevant nodes. Printed on each NODE line as a share of the + best hit. Omitted (or empty) restores the previous purely topological order, + which is what non-query callers still want. + + #BUG2's guarantee (the queried symbol is always in the answer) is preserved + by `pinned` and `seeds` below rather than by hop order. + + context_cap: how many nodes that matched NOTHING may follow the matched set. + The budget used to buy filler — at `--budget 20000` on the measured graph one + query rendered 122 matched nodes and **266** with `rel=0`, which is the + "more noise, same problem" a bigger budget produced. Capping the tail makes a + raised budget buy more ANSWER instead. + + pinned: nodes that must survive the budget cut whatever their score — + the entries the ANSWER block already named. Rendered before `seeds`. """ char_budget = token_budget * 3 lines = [] @@ -1061,10 +1446,38 @@ 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)), + # Query relevance first, then the hop/degree tie-breaks. With no relevance + # map every key starts at the same 0.0 and the order is exactly the previous + # topological one, so non-query callers are unaffected. + rel = relevance or {} + head = list(dict.fromkeys( + [n for n in (pinned or []) if n in nodes] + seed_hits + )) + head_set = set(head) + ordered = head + sorted( + nodes - head_set, + key=lambda n: (-rel.get(n, 0.0), dist.get(n, 1 << 30), -G.degree(n), str(n)), ) + # Trim the unmatched tail (see `context_cap`). Only when there is a ranking to + # trim by: with no relevance map every node is "unmatched" and the cap would + # silently truncate a non-query caller's subgraph. + context_dropped = 0 + if rel: + kept: list[str] = [] + context_kept = 0 + for n in ordered: + if n in head_set or rel.get(n, 0.0) > 0: + kept.append(n) + elif context_kept < context_cap: + kept.append(n) + context_kept += 1 + else: + context_dropped += 1 + ordered = kept + # Edges are rendered only between nodes that survived, so a trimmed node + # cannot leave a dangling half-edge in the output. + nodes = set(ordered) + top_rel = max(rel.values(), default=0.0) or 1.0 for nid in ordered: d = G.nodes[nid] # Every LLM-derived field passes through sanitize_label before being @@ -1080,12 +1493,26 @@ def _adj(n): status = sanitize_label(str(entry.get("status", ""))) if status: learning_suffix = f" learning={status}{':stale' if entry.get('stale') else ''}" + # `rel=` is the node's query score as a percentage of the best hit, so a + # reader can see where the signal stops without counting lines. Absent + # when there is no ranking to report (a non-query caller), keeping that + # output byte-identical. + rel_suffix = "" + if rel: + _score = rel.get(nid, 0.0) + _pct = 100.0 * _score / top_rel + # Floor a real match at 1 so `rel=0` means exactly one thing: this + # node matched none of your terms. Rounding a weak-but-real match down + # to 0 made it indistinguishable from pure traversal context, and + # contradicted the trimming note, which says rel=0 nodes are the ones + # dropped. + rel_suffix = f" rel={max(1, round(_pct)) if _score > 0 else 0}" line = ( f"NODE {sanitize_label(d.get('label', nid))} " f"[src={sanitize_label(str(d.get('source_file', '')))} " f"loc={sanitize_label(str(d.get('source_location', '')))} " f"community={sanitize_label(str(d.get('community_name') or d.get('community', '')))}" - f"{learning_suffix}]" + f"{rel_suffix}{learning_suffix}]" ) lines.append(line) for u, v in edges: @@ -1123,6 +1550,14 @@ def _adj(n): f"{sanitize_label(G.nodes[tgt].get('label', tgt))}{at_suffix}" ) lines.append(line) + # Never drop nodes silently: a reader who cannot see that context was trimmed + # reads a short answer as a small neighbourhood. Stated on its own line so it + # survives the budget cut below, which only ever trims the END of `output`. + context_note = ( + f"\n[i] {context_dropped} further node(s) matched none of your query terms " + f"and are not shown (context cap {context_cap}; raise --budget for more " + f"ANSWER, not more context)." if context_dropped else "" + ) output = "\n".join(lines) if len(output) > char_budget: cut_at = output[:char_budget].rfind("\n") @@ -1131,9 +1566,9 @@ def _adj(n): # inside the seed block, extend the cut to cover it. The symbol the # question named must always be in the answer (#BUG2). Seeds are bounded # (_pick_seeds max_k + one per term), so the overshoot is a few lines. - if seed_hits: - seed_block_end = sum(len(lines[i]) + 1 for i in range(len(seed_hits))) - 1 - cut_at = max(cut_at, min(seed_block_end, len(output))) + if head: + head_block_end = sum(len(lines[i]) + 1 for i in range(len(head))) - 1 + cut_at = max(cut_at, min(head_block_end, len(output))) total_nodes = sum(1 for l in lines if l.startswith("NODE ")) shown_nodes = output[:cut_at].count("\nNODE ") + (1 if output.startswith("NODE ") else 0) cut_count = total_nodes - shown_nodes @@ -1165,22 +1600,53 @@ def _adj(n): f"answer — raising --budget further will not shrink it. Narrow " f"with context_filter=['call'] or use get_node for a specific " f"symbol to reduce size instead.\n\n" - ) + output + ) + output + context_note # Prominent notice at the TOP so a truncated answer can never be mistaken # for a complete one — silence used to read as absence (#BUG2). The # notice + end marker sit OUTSIDE char_budget by design (two bounded # wrapper lines, like the existing end marker). - output = ( - f"[!] TRUNCATED: showing {shown_nodes} of {total_nodes} nodes " - f"(~{token_budget}-token budget). The answer may be among the " - f"{cut_count} cut nodes — raise the token budget (CLI: --budget) or " - f"narrow the query (e.g. context_filter=['call'], or get_node for a " - f"specific symbol).\n\n" - + output[:cut_at] - + f"\n... (truncated — {cut_count} more nodes cut by ~{token_budget}-token budget." - f" Narrow with context_filter=['call'] or use get_node for a specific symbol)" - ) - return output + # Say whether the cut could have taken the answer, instead of asserting + # that it might have (#RANK1). Nodes are now ordered by query relevance, + # so the cut falls on the tail: when every cut node scored zero against + # the query, the visible part IS the complete set of matches and the old + # unconditional "the answer may be among the cut nodes" was false. It + # cost a reader a --budget retry that could only return more of the same + # unmatched traversal context. + cut_ids = ordered[shown_nodes:] if shown_nodes <= len(ordered) else [] + matched_cut = [n for n in cut_ids if rel.get(n, 0.0) > 0] + if rel and not matched_cut: + head_notice = ( + f"[i] Showing {shown_nodes} of {total_nodes} nodes " + f"(~{token_budget}-token budget). Every node that matched your " + f"query is shown above, best first; the {cut_count} cut nodes are " + f"traversal context that matched none of your terms (rel=0). " + f"Raising --budget returns more context, not a better answer." + ) + tail_notice = ( + f"\n... ({cut_count} more traversal-context nodes cut, none matching " + f"the query)" + ) + else: + best_cut = max((rel.get(n, 0.0) for n in matched_cut), default=0.0) + top_line = ( + f" Best cut node scores rel={round(100.0 * best_cut / top_rel)}" + f" against the top hit." if matched_cut else "" + ) + head_notice = ( + f"[!] TRUNCATED: showing {shown_nodes} of {total_nodes} nodes " + f"(~{token_budget}-token budget). {len(matched_cut) or cut_count} of " + f"the {cut_count} cut nodes matched your query.{top_line} Raise the " + f"token budget (CLI: --budget) or narrow the query (e.g. " + f"context_filter=['call'], or get_node for a specific symbol)." + ) + tail_notice = ( + f"\n... (truncated — {cut_count} more nodes cut by ~{token_budget}-token" + f" budget. Narrow with context_filter=['call'] or use get_node for a" + f" specific symbol)" + ) + output = head_notice + "\n\n" + output[:cut_at] + tail_notice + return output + context_note + return output + context_note def _cut_lines_to_budget(lines: list[str], token_budget: int, narrow_hint: str) -> str: @@ -1329,10 +1795,31 @@ def _query_graph_text( header_parts.append(f"Context: {', '.join(resolved_filters)} ({filter_source})") header_parts.append(f"{len(nodes)} nodes found") header = " | ".join(header_parts) + "\n\n" + # The ranking `_score_query` already produced, restricted to what the + # traversal returned. Until #RANK1 this was computed, used to pick seeds, and + # then discarded — the renderer ordered by graph topology and printed no + # score, so every line looked equally relevant and the reader had no way to + # separate the answer from its neighbourhood. + relevance = {nid: sc for sc, nid in qs.ranked if nid in nodes} + ranked_in_view = sorted( + ((sc, nid) for nid, sc in relevance.items()), + key=lambda pair: (-pair[0], len(G.nodes[pair[1]].get("label") or pair[1]), pair[1]), + ) + norm_terms = list(dict.fromkeys(tok for t in terms for tok in _search_tokens(t))) + # Root for snippet reads: the graph lives in `/graphify-out/graph.json`, + # so its grandparent is the tree the `source_file` paths are relative to. + # Falling back to the CWD matches how every other relative path here resolves. + snippet_root = Path(graph_path).resolve().parent.parent if graph_path else None + answer, pinned = _answer_block( + G, ranked_in_view, norm_terms, root=snippet_root + ) # 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 + answer + _subgraph_to_text( + traversal_graph, nodes, edges, token_budget, + seeds=start_nodes, relevance=relevance, pinned=pinned, + ) def _find_node_tiers( diff --git a/graphify/watch.py b/graphify/watch.py index 58028eae..896c3200 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -1976,11 +1976,13 @@ def _failed(f: str) -> bool: from graphify.cluster import label_communities_by_hub labels.update(label_communities_by_hub(G, missing)) if stale: + # Same reasoning as the `cli.py` message: the rename already happened + # above, so this reports it rather than demanding a repair (#RANK1). print( - f"[graphify watch] community set changed since labeling " - f"({len(raw)} saved labels, {len(communities)} communities now; " - f"renamed {len(stale)} community(ies) by their hub). " - f"Run `graphify label` to refresh names with the LLM.", + f"[graphify watch] renamed {len(stale)} of {len(communities)} " + f"communities after re-clustering ({len(raw)} saved labels); new names " + f"come from each community's hub. Optional: `graphify label` " + f"regenerates them with the LLM.", file=sys.stderr, ) questions = suggest_questions(G, communities, labels) diff --git a/tests/test_query_ranking_is_visible.py b/tests/test_query_ranking_is_visible.py new file mode 100644 index 00000000..3381769d --- /dev/null +++ b/tests/test_query_ranking_is_visible.py @@ -0,0 +1,492 @@ +"""The query answer is ranked, scored, explained — and honest about a miss (#RANK1). + +Before this, `_score_query` ranked every node and only `_pick_seeds` ever read the +ranking: the renderer ordered by graph topology and printed no score, so a caller +saw a flat list of equally-weighted lines with the answer somewhere inside it. The +tests here pin the four properties that changed — a leading ANSWER block, a +relevance figure on every node line, support files ranked under the source they +support, and a truncation notice that only cries loss when something relevant was +actually lost. +""" +import networkx as nx +import pytest + +from graphify.build import graph_has_legacy_ids, legacy_id_collisions +from graphify.serve import ( + _ANSWER_BLOCK_MAX, + _answer_block, + _fold_plural, + _match_reason, + _matched_terms, + _query_graph_text, + _relevance_weight, + _score_nodes, + _source_snippet, + _subgraph_to_text, + _subword_tokens, +) + + +# -------------------------------------------------------------------------- +# file-kind weighting +# -------------------------------------------------------------------------- + +@pytest.mark.parametrize("path,expected", [ + ("src/lib/SectionLanguagesCard.tsx", 1.0), + ("src/lib/LanguagesCard.test.tsx", 0.30), + ("src/lib/langLadder.spec.ts", 0.30), + ("pkg/thing_test.go", 0.30), + ("tests/helpers.py", 0.30), + ("apps/builder-e2e/project.json", 0.50), + ("a/__mocks__/client.ts", 0.50), + ("openspec/changes/archive/2026-05-10-admin-ui/tasks.md", 0.50), + ("node_modules/left-pad/index.js", 0.50), + ("", 1.0), +]) +def test_relevance_weight_classifies_by_file_kind(path, expected): + assert _relevance_weight(path) == pytest.approx(expected) + + +@pytest.mark.parametrize("path", [ + "src/latest/thing.ts", # "latest" is not "test" + "src/contest.py", # substring, not a segment + "src/greatest/x.py", + "lib/distribution/pack.ts", # "distribution" is not "dist" +]) +def test_relevance_weight_never_matches_a_substring(path): + assert _relevance_weight(path) == 1.0 + + +def test_a_test_file_does_not_outrank_the_source_it_tests(): + """The single clearest symptom: `*.test.tsx` above the component it exercises. + + Same label, same tier, so the two nodes scored identically and the winner was + decided by node id — the test file whenever its id happened to sort first. + """ + G = nx.Graph() + G.add_node("a_test", label="LanguagesCard", source_file="ui/LanguagesCard.test.tsx", + source_location="L15", community=0) + G.add_node("z_src", label="LanguagesCard", source_file="ui/LanguagesCard.tsx", + source_location="L4", community=0) + G.add_edge("a_test", "z_src", relation="imports") + assert [nid for _score, nid in _score_nodes(G, ["languagescard"])] == ["z_src", "a_test"] + + +def test_the_rendered_answer_leads_with_the_source_not_the_test(): + """End to end, because the inversion a reader actually saw was in the render: + seeds printed in seed order, so a test-file node that won one term's seed slot + took the top line whatever the combined ranking said.""" + G = nx.Graph() + G.add_node("a_test", label="LanguagesCard", source_file="ui/LanguagesCard.test.tsx", + source_location="L15", community=0) + G.add_node("z_src", label="LanguagesCard", source_file="ui/LanguagesCard.tsx", + source_location="L4", community=0) + G.add_edge("a_test", "z_src", relation="imports") + out = _query_graph_text(G, "languagescard", depth=2, token_budget=2000) + first_node_line = next(l for l in out.splitlines() if l.startswith("NODE ")) + assert "ui/LanguagesCard.tsx" in first_node_line + assert ".test.tsx" not in first_node_line + + +# -------------------------------------------------------------------------- +# identifier sub-words +# -------------------------------------------------------------------------- + +@pytest.mark.parametrize("text,expected", [ + ("handleTeamConfigWrite", ["handle", "team", "config", "write"]), + ("SectionLanguagesCard", ["section", "languages", "card"]), + ("PLATFORM_SOURCE_LANG", ["platform", "source", "lang"]), + ("HTTPServer", ["http", "server"]), + ("matchWriteRoute.ts", ["match", "write", "route", "ts"]), + ("plain", ["plain"]), + ("", []), +]) +def test_subword_tokens_splits_identifier_conventions(text, expected): + assert _subword_tokens(text) == expected + + +@pytest.mark.parametrize("word,folded", [ + ("writes", "write"), ("languages", "language"), ("validates", "validate"), + ("class", "class"), ("address", "address"), # -ss is left alone + ("its", "its"), ("has", "has"), # too short to fold + ("config", "config"), +]) +def test_fold_plural_is_a_consistent_fold_not_a_stemmer(word, folded): + assert _fold_plural(word) == folded + + +def test_an_identifier_naming_three_query_words_beats_a_one_word_collision(): + """The measured inversion: a bare `worker` variable scored 126 and + `handleTeamConfigWrite` — which names three of the six terms — scored 15.""" + G = nx.Graph() + G.add_node("noise", label="worker", source_file="app/sw/main.ts", + source_location="L50", community=0) + G.add_node("real", label="handleTeamConfigWrite()", + source_file="worker/src/routes/handleTeamConfigWrite.ts", + source_location="L109", community=0) + G.add_edge("noise", "real", relation="calls") + terms = ["worker", "route", "validates", "team", "config", "writes"] + order = [nid for _score, nid in _score_nodes(G, terms)] + assert order[0] == "real" + + +def test_a_single_identifier_lookup_still_wins_outright(): + """The sub-word tier must not cost the exact-match dominance identifier + lookups depend on.""" + G = nx.Graph() + G.add_node("exact", label="langLadder()", source_file="lib/langLadder.ts", + source_location="L45", community=0) + G.add_node("part", label="langLadderCache", source_file="lib/cache.ts", + source_location="L3", community=0) + G.add_edge("exact", "part", relation="calls") + assert _score_nodes(G, ["langLadder"])[0][1] == "exact" + + +# -------------------------------------------------------------------------- +# the ANSWER block +# -------------------------------------------------------------------------- + +def _answer_graph() -> nx.Graph: + G = nx.Graph() + G.add_node("card", label="SectionLanguagesCard()", + source_file="builder/feature-content/SectionLanguagesCard.tsx", + source_location="L32", community=0) + G.add_node("test", label="LANGUAGES", source_file="builder/ui/LanguagesCard.test.tsx", + source_location="L15", community=0) + G.add_node("far", label="unrelatedHelper", source_file="other/helper.ts", + source_location="L2", community=1) + G.add_edge("card", "test", relation="imports") + G.add_edge("card", "far", relation="calls") + return G + + +def test_query_output_leads_with_a_ranked_answer_block(): + G = _answer_graph() + out = _query_graph_text(G, "languages card section", depth=2, token_budget=2000) + assert "ANSWER" in out + answer, _, nodes = out.partition("NODE ") + assert "SectionLanguagesCard()" in answer + # the answer names the file and the line, so the reader's next move is an open + assert "builder/feature-content/SectionLanguagesCard.tsx:L32" in answer + # ...and says why + assert "why:" in answer + # the ANSWER block precedes every raw NODE line + assert answer.index("ANSWER") < len(answer) + assert nodes + + +def test_every_node_line_carries_its_relevance(): + G = _answer_graph() + out = _query_graph_text(G, "languages card section", depth=2, token_budget=2000) + node_lines = [l for l in out.splitlines() if l.startswith("NODE ")] + assert node_lines and all("rel=" in l for l in node_lines) + # the best hit is 100 and it comes first + assert "rel=100" in node_lines[0] + + +def test_relevance_orders_nodes_within_a_hop_layer(): + """Hop distance stays the primary key (#BUG2 keeps the answer's neighbourhood); + relevance decides inside a layer, and the ANSWER block above is the + relevance-only view. Every node here is one hop from the seed, so the whole + list is a single layer and must descend.""" + G = _answer_graph() + out = _query_graph_text(G, "languages card section", depth=2, token_budget=2000) + rels = [int(l.split("rel=")[1].split()[0].rstrip("]")) + for l in out.splitlines() if l.startswith("NODE ") and "rel=" in l] + assert rels == sorted(rels, reverse=True) + + +def test_relevance_outranks_hop_distance(): + """Relevance is the PRIMARY key. A weak match two hops out still leads a node + one hop away that matched nothing — a reader scanning top-down must meet the + nodes that answer the question first, wherever the traversal reached them.""" + G = nx.Graph() + G.add_node("seed", label="alphaGate", source_file="src/alphaGate.ts", + source_location="L1", community=0) + # six strong hop-1 matches, enough to fill the answer block so the weak match + # below is ordered on merit rather than pinned into it + for i in range(6): + G.add_node(f"hit{i}", label=f"alphaGateHandler{i}", + source_file=f"src/alphaGateHandler{i}.ts", source_location="L1", + community=0) + G.add_edge("seed", f"hit{i}", relation="calls") + G.add_node("near", label="unrelatedNeighbour", source_file="src/near.ts", + source_location="L1", community=0) + G.add_edge("seed", "near", relation="calls") + G.add_node("far", label="betaAlphaGateGamma", source_file="src/far.ts", + source_location="L1", community=0) + G.add_edge("near", "far", relation="calls") + + out = _query_graph_text(G, "alphaGate", depth=2, token_budget=8000) + lines = [l for l in out.splitlines() if l.startswith("NODE ")] + assert "src/far.ts" not in out.split("ANSWER")[1].split("NODE ")[0], "must be ordered, not pinned" + near_at = next(i for i, l in enumerate(lines) if "src/near.ts" in l) + far_at = next(i for i, l in enumerate(lines) if "src/far.ts" in l) + assert far_at < near_at, "a hop-2 match must lead a hop-0/1 non-match" + + +def test_hop_distance_breaks_ties_between_equally_relevant_nodes(): + """Hop is the tie-break, so a seed's own neighbourhood still holds together + among nodes the query cannot separate.""" + G = nx.Graph() + G.add_node("seed", label="alpha", source_file="src/alpha.ts", + source_location="L1", community=0) + G.add_node("near", label="zzzNear", source_file="src/near.ts", + source_location="L1", community=0) + G.add_node("far", label="aaaFar", source_file="src/far.ts", + source_location="L1", community=0) + G.add_edge("seed", "near", relation="calls") + G.add_edge("near", "far", relation="calls") + out = _query_graph_text(G, "alpha", depth=2, token_budget=4000) + lines = [l for l in out.splitlines() if l.startswith("NODE ")] + near_at = next(i for i, l in enumerate(lines) if "src/near.ts" in l) + far_at = next(i for i, l in enumerate(lines) if "src/far.ts" in l) + # both score 0; `aaaFar` sorts first alphabetically, so only hop can order them + assert near_at < far_at + + +def test_the_answer_block_nodes_are_pinned_whatever_their_distance(): + G = nx.Graph() + G.add_node("seed", label="alpha", source_file="src/alpha.ts", + source_location="L1", community=0) + G.add_node("near", label="unrelatedNeighbour", source_file="src/near.ts", + source_location="L1", community=0) + G.add_node("far", label="betaAlphaGamma", source_file="src/far.ts", + source_location="L1", community=0) + G.add_edge("seed", "near", relation="calls") + G.add_edge("near", "far", relation="calls") + out = _query_graph_text(G, "alpha", depth=2, token_budget=2000) + lines = [l for l in out.splitlines() if l.startswith("NODE ")] + assert "src/far.ts" in lines[1] + assert "src/near.ts" in lines[2] + + +# -------------------------------------------------------------------------- +# the budget buys answer, not filler +# -------------------------------------------------------------------------- + +def _hub_with_context(n_context: int) -> nx.Graph: + G = nx.Graph() + G.add_node("seed", label="alphaGate", source_file="src/alphaGate.ts", + source_location="L1", community=0) + for i in range(n_context): + G.add_node(f"n{i}", label=f"unrelated{i:03d}", + source_file=f"src/u{i:03d}.ts", source_location="L1", community=0) + G.add_edge("seed", f"n{i}", relation="calls") + return G + + +def test_rel_zero_means_matched_nothing_and_nothing_else(): + """A weak-but-real match used to round down to rel=0, making it + indistinguishable from pure traversal context — and contradicting the + trimming note, which says rel=0 nodes are exactly the ones dropped. + `weak` matches only through its path, worth 0.5x a term against the top + hit's several thousand, so it rounds to zero and must be floored at 1.""" + G = nx.Graph() + G.add_node("top", label="alphaGateHandler", + source_file="src/alphaGateHandler.ts", source_location="L1", community=0) + G.add_node("weak", label="zzz", source_file="src/alpha/zzz.ts", + source_location="L1", community=0) + G.add_node("none", label="unrelated", source_file="src/other/none.ts", + source_location="L1", community=0) + G.add_edge("top", "weak", relation="calls") + G.add_edge("top", "none", relation="calls") + + out = _query_graph_text(G, "alpha gate handler", depth=2, token_budget=8000) + by_file = {} + for line in out.splitlines(): + if line.startswith("NODE ") and "rel=" in line: + src = line.split("src=", 1)[1].split(" ", 1)[0] + by_file[src] = int(line.split("rel=", 1)[1].split()[0].rstrip("]")) + assert by_file["src/other/none.ts"] == 0, "a true non-match must read rel=0" + assert by_file["src/alpha/zzz.ts"] >= 1, "a real match must never read rel=0" + assert by_file["src/alphaGateHandler.ts"] == 100 + + +def test_a_raised_budget_does_not_buy_unmatched_context(): + """The measured complaint: at --budget 20000 one query rendered 122 matched + nodes and 266 with rel=0. A bigger budget bought filler.""" + G = _hub_with_context(200) + out = _query_graph_text(G, "alphaGate", depth=2, token_budget=50000) + zero = [l for l in out.splitlines() if l.startswith("NODE ") and "rel=0" in l] + assert len(zero) <= 15 + + +def test_trimmed_context_is_announced_never_dropped_silently(): + G = _hub_with_context(200) + out = _query_graph_text(G, "alphaGate", depth=2, token_budget=50000) + assert "matched none of your query terms and are not shown" in out + assert "185 further node(s)" in out + + +def test_trimming_leaves_no_dangling_edge(): + G = _hub_with_context(60) + out = _query_graph_text(G, "alphaGate", depth=2, token_budget=50000) + shown = {l.split("NODE ", 1)[1].split(" [")[0] + for l in out.splitlines() if l.startswith("NODE ")} + for line in out.splitlines(): + if not line.startswith("EDGE "): + continue + body = line[5:] + src = body.split(" --", 1)[0] + tgt = body.split("]--> ", 1)[1].split(" at=")[0] + assert src in shown and tgt in shown, line + + +def test_the_context_cap_leaves_non_query_callers_alone(): + """With no relevance map every node is 'unmatched'; capping there would + silently truncate a subgraph `path`/`explain` asked for in full.""" + G = _hub_with_context(60) + out = _subgraph_to_text(G, set(G.nodes()), list(G.edges()), 50000) + assert len([l for l in out.splitlines() if l.startswith("NODE ")]) == 61 + assert "are not shown" not in out + + +def test_a_non_query_caller_keeps_the_unscored_node_line(): + """`_subgraph_to_text` with no ranking is byte-identical to its old output.""" + G = _answer_graph() + nodes = {"card", "test"} + out = _subgraph_to_text(G, nodes, [("card", "test")], 2000) + assert "rel=" not in out + + +def test_the_reason_never_claims_a_tier_the_score_did_not_credit(): + data = {"label": "handleTeamConfigWrite()", "norm_label": "handleteamconfigwrite()", + "source_file": "worker/src/routes/handleTeamConfigWrite.ts"} + terms = ["worker", "route", "validates", "team", "config", "writes"] + reason = _match_reason(data, "n", terms) + assert "name has part team, config, writes" in reason + assert "path has worker, route" in reason + # `validates` matches nothing here and must not be claimed + assert "validates" not in reason + assert _matched_terms(data, terms) == {"worker", "route", "team", "config", "writes"} + + +def test_low_confidence_is_stated_when_the_vocabulary_does_not_overlap(): + """A lexical graph cannot find a node that shares no word with the question; + it can say so rather than present five near-misses as an answer.""" + G = nx.Graph() + G.add_node("a", label="terminate()", source_file="scripts/seed.mjs", + source_location="L166", community=0) + G.add_node("b", label="fallback", source_file="lib/source.ts", + source_location="L28", community=0) + G.add_edge("a", "b", relation="calls") + out = _query_graph_text(G, "chrome string fallback chain terminate", + depth=2, token_budget=2000) + assert "LOW CONFIDENCE" in out + # names the words nothing in the corpus matched, which is the actionable part + assert "'chrome'" in out and "'string'" in out and "'chain'" in out + + +def test_low_confidence_stays_quiet_on_a_good_answer(): + G = _answer_graph() + out = _query_graph_text(G, "languages card section", depth=2, token_budget=2000) + assert "LOW CONFIDENCE" not in out + + +def test_answer_block_is_empty_when_nothing_matched_the_text(): + G = _answer_graph() + assert _answer_block(G, [], ["nothing"]) == ("", []) + + +# -------------------------------------------------------------------------- +# honest truncation +# -------------------------------------------------------------------------- + +def _wide_graph(n: int) -> nx.Graph: + G = nx.Graph() + G.add_node("hub", label="teamConfigWriter", source_file="src/teamConfigWriter.ts", + source_location="L1", community=0) + for i in range(n): + G.add_node(f"f{i}", label=f"unrelatedThing{i}", + source_file=f"src/unrelated{i}.ts", source_location="L1", community=0) + G.add_edge("hub", f"f{i}", relation="calls") + return G + + +def test_truncation_does_not_claim_loss_when_only_context_was_cut(): + """The old banner always read "The answer may be among the N cut nodes". With + the list ordered by relevance the cut falls on the tail, so when every cut + node scored zero that sentence was false and cost the reader a --budget retry.""" + G = _wide_graph(400) + out = _query_graph_text(G, "team config writer", depth=2, token_budget=300) + assert "TRUNCATED" not in out + assert "matched none of your terms" in out + assert "Raising --budget returns more context, not a better answer." in out + + +def test_truncation_still_warns_when_a_matching_node_was_cut(): + G = nx.Graph() + for i in range(200): + G.add_node(f"m{i}", label=f"teamConfigWriter{i}", + source_file=f"src/teamConfigWriter{i}.ts", source_location="L1", + community=0) + if i: + G.add_edge("m0", f"m{i}", relation="calls") + out = _query_graph_text(G, "team config writer", depth=2, token_budget=300) + assert "TRUNCATED" in out + assert "matched your query" in out + + +def test_the_named_answer_survives_the_budget_cut(): + G = _wide_graph(400) + out = _query_graph_text(G, "team config writer", depth=2, token_budget=300) + body = out.split("ANSWER")[1] + assert "teamConfigWriter" in body.split("NODE ")[0] + assert "NODE teamConfigWriter" in out + + +# -------------------------------------------------------------------------- +# snippets +# -------------------------------------------------------------------------- + +def test_source_snippet_reads_the_line_the_node_points_at(tmp_path): + (tmp_path / "a.ts").write_text("one\ntwo\nexport const three = 3;\n") + assert _source_snippet("a.ts", "L3", tmp_path) == "export const three = 3;" + + +@pytest.mark.parametrize("src,loc", [ + ("missing.ts", "L3"), # no such file + ("a.ts", "L99"), # past the end + ("a.ts", "nonsense"), # unparseable location + ("a.ts", ""), # no location + ("", "L1"), # no file +]) +def test_source_snippet_is_silent_rather_than_fatal(tmp_path, src, loc): + (tmp_path / "a.ts").write_text("one\n") + assert _source_snippet(src, loc, tmp_path) == "" + + +def test_source_snippet_truncates_a_generated_megaline(tmp_path): + (tmp_path / "big.js").write_text("x" * 5000 + "\n") + out = _source_snippet("big.js", "L1", tmp_path) + assert 0 < len(out) <= 200 + + +# -------------------------------------------------------------------------- +# the legacy-id nudge only fires on real harm +# -------------------------------------------------------------------------- + +def test_legacy_ids_that_collide_are_reported(): + nodes = [ + {"id": "spec", "source_file": "a/spec.md", "source_location": "L1"}, + {"id": "spec", "source_file": "b/spec.md", "source_location": "L1"}, + ] + assert graph_has_legacy_ids(nodes, root=".") is True + assert legacy_id_collisions(nodes, root=".") == 1 + + +def test_legacy_ids_that_collide_with_nothing_are_not_reported(): + """Measured on a 51k-node graph: 591 legacy ids, zero collisions — and a + rebuild nudge on every single query.""" + nodes = [ + {"id": "alpha_spec", "source_file": "a/alpha/spec.md", "source_location": "L1"}, + {"id": "beta_spec", "source_file": "b/beta/spec.md", "source_location": "L1"}, + ] + assert graph_has_legacy_ids(nodes, root=".") is True + assert legacy_id_collisions(nodes, root=".") == 0 + + +def test_path_qualified_ids_are_never_collisions(): + nodes = [{"id": "a_alpha_spec", "source_file": "a/alpha/spec.md", "source_location": "L1"}] + assert legacy_id_collisions(nodes, root=".") == 0