diff --git a/CHANGELOG.md b/CHANGELOG.md index 077647f13..3625e4a56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,12 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) -## 0.9.35 (unreleased) +## 0.9.36 (unreleased) + +- Fix: a `query`'s generic nouns no longer each drag their own hub into the traversal (#2507). The per-term seed guarantee (#1445) now skips a term that is a substring of the top-ranked seed's normalized label — the query's dominant match, judged by the same predicate as the scorer's weakest match tier, so that seed provably would have matched the term in scoring — because such a term is not starved, and starvation is the only thing the guarantee exists to prevent. "what code uses ChargeCustomerService to charge a customer" no longer seeds the `Customer` model on top of `ChargeCustomerService`, whose label already matches both words, so the model's member fan-out stops flooding the answer. Only the dominant match can cover a term, so a coincidental substring collision in a lower-ranked seed (`ReportService` containing "port") cannot starve a distinct term's winner; a term the dominant match does not cover still gets its guaranteed seed, a seed with no label covers nothing, and every `_pick_seeds` caller other than the natural-language query pipeline keeps the guarantee unchanged. +- Fix: a heuristically inferred context filter that discovers nothing beyond the seeds is now relaxed instead of returning a confident near-empty answer (#2507). "Who calls ChargeCustomerService?" infers a `call` filter, but a class node owns no call edges — calls land on its methods, and the class-to-member edge carries no context — so the filtered traversal could not leave the seed. The query retraverses unfiltered and says so in the header (`Context: call (heuristic; relaxed — no matches beyond seeds)`), so a fallback answer never reads as a filtered one. This also covers "callers of X", which infers the same filter since `caller`/`callers` joined the `call` context hints in 0.9.35. Both traversal modes behave identically, an explicitly requested filter is never relaxed, and a filter that reaches even one node beyond the seeds is left in force. + +## 0.9.35 (2026-08-06) - Fix: the `build_merge` #479 shrink guard is no longer effectively dead (#2497, thanks @sortakool). It read the post-replace node count, so a broken partial re-extract could silently destroy nodes without tripping the guard, and the guard was skipped entirely under `prune_sources`. The guard now diffs the on-disk baseline by node identity and refuses any loss from a source that was neither re-extracted nor pruned this run (active even under `prune_sources`, skipped only under `dedup`), and reports how many nodes a re-extract replaced. - Fix: `build_merge`/`merge_raw_extraction` `prune_sources` now prunes correctly when given absolute paths under a non-standard layout, deriving the scan root by suffix-matching stored source paths, and warns (instead of reporting "already clean") when a prune matches nothing (#2446, thanks @AI-invest). diff --git a/graphify/serve.py b/graphify/serve.py index 3b205d84f..47d89269a 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -637,6 +637,7 @@ def _pick_seeds( *, G: "nx.Graph | None" = None, best_seed_by_term: dict[str, str] | None = None, + skip_covered_terms: bool = False, ) -> list[str]: """Select BFS seed nodes, stopping when score drops too far below the top. @@ -667,6 +668,28 @@ def _pick_seeds( nodes back inside the gap window; this per-term guarantee remains load-bearing for relevant nodes matched only via substrings, whose flat scores a dampened collision can still exceed. + + `skip_covered_terms` (default off, opted into only by `_query_graph_text`) + refines that guarantee to *every term with any match is matched by the + top-ranked seed or by a seed of its own*, from *every term's own singleton + winner gets a slot*: a term that is a substring of the top-ranked seed's + normalized label — the same predicate as _score_nodes' weakest match tier, + and its label alone, so that seed provably would have matched the term in + scoring — is not starved, and starvation is the only thing the guarantee + exists to prevent. A seed with no label covers nothing. Without this skip, a + natural-language question's generic nouns each drag in their own hub: "what + code uses ChargeCustomerService to charge a customer" seeds the `Customer` + model and `.charge()` on top of `ChargeCustomerService`, whose label already + matches both terms, and the hub's member fan-out then floods the traversal + (#2507). + + Only the TOP-ranked seed may make that claim, not any picked seed. Coverage + asserts "the query's dominant match already answers this term", which is a + statement about the query's subject; a lower-ranked seed that merely happens + to contain the term's letters (`ReportService` for "port") is asserting a + coincidence, and letting it skip the guarantee starves the term's real + winner — for a term whose winner sits outside the gap window, the guarantee + is the only way in (#2507 review). """ if not scored: return [] @@ -685,6 +708,23 @@ def _seed_label_key(nid: str) -> str: return (data.get("norm_label") or _strip_diacritics(data.get("label") or "").lower()) or nid + # The `skip_covered_terms` coverage predicate needs the seed's normalized + # LABEL alone — empty when the node has none — not `_seed_label_key`. That + # key's `or nid` tail is correct for the dedup gate above (a labelless node + # still needs something unique to dedup on) but wrong for coverage: a + # `norm_label == ""` node is real (build.py's `_fold_node_aliases`: an + # alias-only node enters the graph with no label) and is still seedable + # through _score_nodes' source-file tier, so the tail would let its node-id + # path fragments declare unrelated terms covered — a match _score_nodes never + # makes, since its substring tier reads `norm_label` only. That would starve + # the term's real winner and break the very invariant this flag refines. + def _seed_norm_label(nid: str) -> str: + if G is None: + return "" + data = G.nodes[nid] + return (data.get("norm_label") + or _strip_diacritics(data.get("label") or "").lower()) + top_score = scored[0][0] seeds: list[str] = [] seen_labels: set[str] = set() @@ -716,9 +756,20 @@ def _seed_label_key(nid: str) -> str: # 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) + if best_nid in seeds or key in seen_labels: + continue + # Layered after that dedup gate, but only the TOP-ranked seed — the + # query's dominant match, always `scored[0]` and so always already + # present here — can declare a term covered. Letting any picked seed + # make that claim lets a coincidental substring collision inside an + # unrelated, lower-ranked seed's label silently starve a distinct + # term's real winner: `ReportService` contains "port", which would + # cost a corpus symbol literally named `port` the only seat it can + # get (#2507 review; the #1597 concern one layer down). + if skip_covered_terms and seeds and term in _seed_norm_label(seeds[0]): + continue + seen_labels.add(key) + seeds.append(best_nid) return seeds @@ -1141,18 +1192,45 @@ def _query_graph_text( best_seed_by_term = { t: nid for t, nid in best_seed_by_term.items() if t not in intent } - start_nodes = _pick_seeds(qs.ranked, G=G, best_seed_by_term=best_seed_by_term) + # `skip_covered_terms` is the other half of the seed hygiene: a term an + # already-picked seed's label matches is not starved, so it claims no extra + # seed and a generic noun stops dragging in its own hub (#2507). Opted into + # here only, so every other `_pick_seeds` caller keeps the legacy guarantee. + start_nodes = _pick_seeds( + qs.ranked, G=G, best_seed_by_term=best_seed_by_term, skip_covered_terms=True + ) if not start_nodes: return "No matching nodes found." resolved_filters, filter_source = _resolve_context_filters(question, context_filters) traversal_graph = _filter_graph_by_context(G, resolved_filters) - nodes, edges = _dfs(traversal_graph, start_nodes, depth) if mode == "dfs" else _bfs(traversal_graph, start_nodes, depth) + traverse = _dfs if mode == "dfs" else _bfs + nodes, edges = traverse(traversal_graph, start_nodes, depth) + # A guessed filter that reaches nothing is worse than no filter: "Who calls + # ChargeCustomerService?" infers a `call` filter, but a class node owns no + # call edges — calls land on its methods and the class->member edge carries + # `context=None` — so the filtered traversal cannot leave the seed and the + # answer comes back near-empty yet confident. Retraverse unfiltered instead, + # and say so in the header so a relaxed answer never reads as a filtered one + # (#2507). + # + # Both traversals visit every seed, so `nodes <= set(start_nodes)` means the + # traversal discovered nothing at all. That zero-expansion threshold is + # deliberate: "few nodes" would need a tuning constant, and a filter that + # found *something* is doing its job. Mode-independent by construction. + # + # Only the heuristic is second-guessed. An explicitly requested filter is an + # instruction, not a guess, and is honored even when it strands the seeds. + relaxed = filter_source == "heuristic" and nodes <= set(start_nodes) + if relaxed: + traversal_graph = G + nodes, edges = traverse(G, start_nodes, depth) header_parts = [ f"Traversal: {mode.upper()} depth={depth}", f"Start: {[G.nodes[n].get('label', n) for n in start_nodes]}", ] if resolved_filters: - header_parts.append(f"Context: {', '.join(resolved_filters)} ({filter_source})") + note = "; relaxed — no matches beyond seeds" if relaxed else "" + header_parts.append(f"Context: {', '.join(resolved_filters)} ({filter_source}{note})") header_parts.append(f"{len(nodes)} nodes found") header = " | ".join(header_parts) + "\n\n" # Pass the seeds so the queried symbol renders first and survives truncation diff --git a/pyproject.toml b/pyproject.toml index 6288b9c8a..f99f81e80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.9.35" +version = "0.9.36" description = "AI coding assistant skill (Claude Code, CodeBuddy, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Devin CLI, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph" readme = "README.md" license = "Apache-2.0" diff --git a/tests/seeding_fixtures.py b/tests/seeding_fixtures.py new file mode 100644 index 000000000..0b1f0db38 --- /dev/null +++ b/tests/seeding_fixtures.py @@ -0,0 +1,208 @@ +"""Synthetic corpus fixture for the query seeding-hygiene work (#2507). + +Shared by the three seeding changes the query pipeline now applies — +relational-intent demotion (landed in 0.9.35), the covered-term guarantee skip +and the heuristic-filter starvation fallback (both 0.9.36) — so all of them +grade the same three natural-language phrasings against one corpus instead of +re-inventing a graph each time. + +The existing `_make_callers_graph` in test_serve.py attaches the call edges +straight to the class node, which is the one wiring real extraction never +produces; this fixture reproduces the measured shape instead, distilled from a +48.5k-node PHP repro graph: + +* a **service class** (`ChargeCustomerService`) with one member method, wired + the way extraction really wires it: the class->method edge carries + ``context=None``, so a ``call`` context filter cannot leave the class node. +* **three known callers**, two through real ``call``-context edges and one + (`BalanceCustomerAccountService.settle()`) reachable only through a + ``references`` edge, because its receiver is a docblock-typed, + constructor-assigned property that PHP extraction does not resolve — an + extraction-side gap deliberately reproduced rather than papered over. +* a **camelCase verb-prefix decoy** (`callStoreWithAmount()`) whose normalized + label prefix-matches the query word "calls", sitting in a **busy test-method + neighborhood** — the junk that the per-term seed guarantee used to buy an + unconditional seat. Two of its neighbors carry the other relational words the + measured phrasings use ("callers", "uses"). +* a **generic-noun hub class** (`Customer`) with a wide member fan-out, one + reference hop away from the service — the traversal explosion the + covered-term skip targets. +* a **doc file** (`coder.md`) that prefix-matches the query word "code". + +The graph is undirected with per-edge ``_src``/``_tgt`` markers, exactly how +``graphify query`` loads a graph (cli.py: query keeps the graph undirected so +BFS reaches callers as well as callees, and preserves direction per edge for +rendering). Tests that drive ``_query_graph_text`` therefore see what the CLI +sees. +""" +from __future__ import annotations + +import ast + +import networkx as nx + +# --- node ids, exported so tests never hard-code string literals ------------- + +SERVICE = "app/Services/ChargeCustomerService.php::ChargeCustomerService" +SERVICE_METHOD = "app/Services/ChargeCustomerService.php::ChargeCustomerService.charge" +CALLERS = ( + "app/Http/Controllers/BillingController.php::BillingController.processPayment", + "app/Jobs/SubscriptionRenewalJob.php::SubscriptionRenewalJob.handle", + "app/Services/BalanceCustomerAccountService.php::BalanceCustomerAccountService.settle", +) +DECOY = "tests/Feature/StorePaymentTest.php::callStoreWithAmount" +CALLERS_DECOY = "tests/Feature/StorePaymentTest.php::StorePaymentTest.testCallersAreNotified" +USES_DECOY = "tests/Feature/StorePaymentTest.php::StorePaymentTest.testStoreUsesRetryPolicy" +HUB = "app/Models/Customer.php::Customer" +DOC = "docs/coder.md" +CALLS_SYMBOL = "app/Support/EventLog.php::EventLog.calls" + +# Labels are what the rendered output and the `Start:` header actually show, so +# assertions read these rather than node ids. +LABELS = { + SERVICE: "ChargeCustomerService", + SERVICE_METHOD: "ChargeCustomerService.charge()", + CALLERS[0]: "BillingController.processPayment()", + CALLERS[1]: "SubscriptionRenewalJob.handle()", + CALLERS[2]: "BalanceCustomerAccountService.settle()", + DECOY: "callStoreWithAmount()", + CALLERS_DECOY: "StorePaymentTest.testCallersAreNotified()", + USES_DECOY: "StorePaymentTest.testStoreUsesRetryPolicy()", + HUB: "Customer", + DOC: "coder.md", + CALLS_SYMBOL: "calls", +} + +_OTHER_TEST_METHODS = ( + "testRefundIsIssued", + "testTimeoutIsRetried", + "testAmountIsRounded", + "testIdempotencyKeyIsStable", + "testCurrencyIsValidated", + "testPartialFailureIsLogged", +) + +_HUB_MEMBERS = ( + "getName", "getEmail", "getPhone", "getAddress", "isActive", "markActive", + "subscriptions", "invoices", "notes", "toArray", "scopeActive", "fresh", +) + +_DOC_SECTIONS = ("Overview", "Setup", "Runbook") + + +def label_of(node_id: str) -> str: + """The rendered label for a fixture node id.""" + return LABELS[node_id] + + +def caller_labels() -> list[str]: + """Labels of the three ground-truth callers of the service.""" + return [LABELS[nid] for nid in CALLERS] + + +def _add(G: nx.Graph, nid: str, label: str, src: str, loc: str, community: int) -> None: + G.add_node(nid, label=label, source_file=src, source_location=loc, community=community) + + +def _link(G: nx.Graph, src: str, tgt: str, relation: str, context: str | None) -> None: + # `_src`/`_tgt` preserve the logical direction on an undirected graph, the + # same way `graphify query` loads one. + G.add_edge(src, tgt, relation=relation, context=context, + confidence="EXTRACTED", _src=src, _tgt=tgt) + + +def make_charge_fixture(*, calls_symbol: bool = False) -> nx.Graph: + """Build the shared seeding fixture. + + `calls_symbol` adds a corpus-legit production symbol literally labelled + `calls` (an `EventLog.calls` property). It is off by default so the + phrasing tests see the same corpus the measurements were taken on, and on + only for the test that pins "demotion is not stopwording": that symbol must + still win a seed on exact-match merit. + """ + G = nx.Graph() + + # --- the service and its three known callers ----------------------------- + _add(G, SERVICE, LABELS[SERVICE], "app/Services/ChargeCustomerService.php", "L12", 0) + _add(G, SERVICE_METHOD, LABELS[SERVICE_METHOD], + "app/Services/ChargeCustomerService.php", "L28", 0) + # Class -> member edges carry no context: this is why a heuristic `call` + # filter strands a class-node seed (the co-cause the starvation fallback + # addresses). + _link(G, SERVICE, SERVICE_METHOD, "method", None) + + _add(G, CALLERS[0], LABELS[CALLERS[0]], + "app/Http/Controllers/BillingController.php", "L40", 0) + _link(G, CALLERS[0], SERVICE_METHOD, "calls", "call") + + _add(G, CALLERS[1], LABELS[CALLERS[1]], + "app/Jobs/SubscriptionRenewalJob.php", "L22", 0) + _link(G, CALLERS[1], SERVICE_METHOD, "calls", "call") + + # Third caller: its receiver is a docblock-typed, constructor-assigned + # property, so extraction never emits the `calls` edge. It stays reachable + # through the import/reference edge it does get. + _add(G, CALLERS[2], LABELS[CALLERS[2]], + "app/Services/BalanceCustomerAccountService.php", "L31", 0) + _link(G, CALLERS[2], SERVICE, "references", "import") + + # --- the verb-prefix decoy and its busy test neighborhood ---------------- + test_file = "tests/Feature/StorePaymentTest.php" + _add(G, DECOY, LABELS[DECOY], test_file, "L44", 1) + for nid, loc in ( + (CALLERS_DECOY, "L58"), + (USES_DECOY, "L71"), + ): + _add(G, nid, LABELS[nid], test_file, loc, 1) + _link(G, nid, DECOY, "calls", "call") + for i, name in enumerate(_OTHER_TEST_METHODS): + nid = f"{test_file}::StorePaymentTest.{name}" + _add(G, nid, f"StorePaymentTest.{name}()", test_file, f"L{80 + i * 12}", 1) + _link(G, nid, DECOY, "calls", "call") + + # --- the generic-noun hub, one reference hop from the service ------------ + _add(G, HUB, LABELS[HUB], "app/Models/Customer.php", "L9", 0) + _link(G, SERVICE_METHOD, HUB, "references", "parameter_type") + for i, name in enumerate(_HUB_MEMBERS): + nid = f"app/Models/Customer.php::Customer.{name}" + _add(G, nid, f"Customer.{name}()", "app/Models/Customer.php", f"L{20 + i * 9}", 0) + _link(G, HUB, nid, "method", None) + + # --- the doc file that prefix-matches "code" ----------------------------- + _add(G, DOC, LABELS[DOC], "docs/coder.md", "L1", 2) + for i, section in enumerate(_DOC_SECTIONS): + nid = f"docs/coder.md#{section}" + _add(G, nid, f"coder.md#{section}", "docs/coder.md", f"L{10 + i * 20}", 2) + _link(G, DOC, nid, "contains", None) + + # --- optional: a production symbol literally named `calls` --------------- + if calls_symbol: + owner = "app/Support/EventLog.php::EventLog" + _add(G, owner, "EventLog", "app/Support/EventLog.php", "L7", 0) + _add(G, CALLS_SYMBOL, LABELS[CALLS_SYMBOL], "app/Support/EventLog.php", "L15", 0) + _link(G, owner, CALLS_SYMBOL, "field", "field") + + return G + + +def start_labels(text: str) -> list[str]: + """Seed labels parsed out of a `_query_graph_text` header's `Start: [...]`. + + Seed assertions read this rather than the whole body, so "is it seeded?" + can never be confused with "did it get traversed into?", and exact list + membership keeps `ChargeCustomerService` from matching + `ChargeCustomerService.charge()`. + """ + for part in text.split("\n", 1)[0].split(" | "): + if part.startswith("Start:"): + return ast.literal_eval(part[len("Start:"):].strip()) + raise AssertionError(f"no Start: segment in header: {text.splitlines()[:1]}") + + +def shown_nodes(text: str) -> list[str]: + """Labels of the NODE lines that actually survived the token budget.""" + labels = [] + for line in text.splitlines(): + if line.startswith("NODE "): + labels.append(line[len("NODE "):].split(" [", 1)[0]) + return labels diff --git a/tests/test_serve_seeding.py b/tests/test_serve_seeding.py new file mode 100644 index 000000000..d2171d5e9 --- /dev/null +++ b/tests/test_serve_seeding.py @@ -0,0 +1,498 @@ +"""Query seeding hygiene on a realistic corpus fixture (#2507). + +Three behaviours share one fixture (`tests/seeding_fixtures.py`), because they +are three halves of the same natural-language question — "who calls X?": + +1. **Relational-verb demotion** (landed in 0.9.35): a word naming the *relation* + being asked about no longer buys an unconditional seed through the per-term + guarantee (#1445). The tests in the first section are regression pins for + that landed behaviour, re-stated against extraction-shaped wiring. +2. **Covered-term guarantee skip** (0.9.36): a term an already-picked seed's + label plainly matches is not starved, so it claims no extra seed. +3. **Heuristic-filter starvation fallback** (0.9.36): an *inferred* context + filter that discovers nothing beyond the seeds is relaxed and said so in the + header; an *explicit* one is always honoured. + +Assertions read external behaviour only — the `Start:` header, the rendered +NODE lines, the header's node count and `Context:` segment — never the +relational vocabulary itself, so tuning that vocabulary cannot break a test. +The one exception is the bound comparison in +`test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded`, which reads the +vocabulary to *construct* its pre-fix baseline rather than to assert on it. +""" +import networkx as nx + +from graphify.serve import ( + _RELATIONAL_INTENT_TERMS, + _bfs, + _pick_seeds, + _query_graph_text, + _query_terms, + _score_query, +) +from tests.seeding_fixtures import ( + CALLERS, + CALLERS_DECOY, + CALLS_SYMBOL, + DECOY, + DOC, + HUB, + SERVICE, + SERVICE_METHOD, + USES_DECOY, + caller_labels, + label_of, + make_charge_fixture, + shown_nodes, + start_labels, +) + + +def _nodes_found(text: str) -> int: + """The `N nodes found` count from a `_query_graph_text` header.""" + for part in text.split("\n", 1)[0].split(" | "): + if part.endswith(" nodes found"): + return int(part.split(" ", 1)[0]) + raise AssertionError(f"no node count in header: {text.splitlines()[:1]}") + + +def _context_segment(text: str) -> str: + """The `Context: ...` segment of a `_query_graph_text` header, or "" if the + query ran unfiltered. Read as a whole so the source and any relaxation note + are asserted where the caller actually sees them.""" + for part in text.split("\n", 1)[0].split(" | "): + if part.startswith("Context:"): + return part + return "" + + +# --------------------------------------------------------------------------- # +# Relational-verb demotion — regression pins for the 0.9.35 behaviour # +# # +# `_make_callers_graph` in test_serve.py pins the same change on a graph whose # +# call edges hang off the class node. These re-state it on the wiring # +# extraction actually emits (calls land on the method; the class->method edge # +# carries `context=None`) and against a decoy that sits in a busy test # +# neighborhood, which is what made the seat expensive in the first place. # +# --------------------------------------------------------------------------- # + +def test_who_calls_phrasing_does_not_seed_verb_prefix_decoy(): + """"Who calls ChargeCustomerService?" must not seed `callStoreWithAmount()`. + + The decoy loses everywhere on merit (it scores ~9x below the gap-window + cutoff); it only ever entered the seed list because "calls" held an + unconditional per-term seat. Seed level only — what the inferred `call` + filter then does to the class seed is the starvation fallback's problem, + pinned further down. + """ + G = make_charge_fixture() + seeds = start_labels( + _query_graph_text(G, "Who calls ChargeCustomerService?", mode="bfs", depth=2) + ) + assert label_of(SERVICE) in seeds + assert label_of(DECOY) not in seeds, f"verb-prefix decoy still seeded: {seeds}" + + +def test_all_relational_query_keeps_its_per_term_guarantee(): + """A query made only of relational words keeps the guarantee (mirroring the + all-stopword fallback in `_query_terms`): demoting every term would leave + nothing to guarantee, so the winner map is kept unfiltered. + + "uses"' winner scores ~25x below the gap-window cutoff here, so it is seeded + only if the guarantee survives — which is what makes this test load-bearing + rather than decorative, and what makes it a sharper probe of the fallback + than a single-word query is. + """ + G = make_charge_fixture() + seeds = start_labels(_query_graph_text(G, "calls uses", mode="bfs", depth=2)) + assert label_of(DECOY) in seeds + assert label_of(USES_DECOY) in seeds, ( + f"all-relational query lost its per-term guarantee: {seeds}" + ) + + +def test_relational_word_with_exact_match_still_seeds_on_merit(): + """Demotion is not stopwording: a corpus symbol literally labelled `calls` + keeps its exact-match dominance and is seeded through the ordinary gap + window, while the unrelated verb-prefix decoy stays out.""" + G = make_charge_fixture(calls_symbol=True) + seeds = start_labels( + _query_graph_text(G, "Who calls ChargeCustomerService?", mode="bfs", depth=2) + ) + assert label_of(CALLS_SYMBOL) in seeds, ( + f"exact-match `calls` symbol lost its seed: {seeds}" + ) + assert label_of(SERVICE) in seeds + assert label_of(DECOY) not in seeds + + +def test_scorer_and_picker_are_unchanged_for_direct_callers(): + """Demotion is wired from the query pipeline only. Callers that drive the + scorer and the seed picker directly — `path`, `explain`, the + legacy-equality property tests, the benchmark's two arms — must still see + the relational term score and still receive its guaranteed seed.""" + G = make_charge_fixture() + terms = _query_terms("Who calls ChargeCustomerService?") + qs = _score_query(G, terms, collect_per_term_seeds=True) + + assert qs.best_seed_by_term.get("calls") == DECOY, ( + "scorer stopped recording the relational term's per-term winner" + ) + seeds = _pick_seeds(qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term) + assert DECOY in seeds, "demotion leaked into _pick_seeds' semantics" + + +# --------------------------------------------------------------------------- # +# Covered-term guarantee skip # +# # +# A query term that an already-picked seed's normalized label plainly matches # +# — the scorer's own weakest tier, so "this seed would have matched the term # +# in scoring" — is not *starved*, and starvation is the only thing the #1445 # +# guarantee exists to prevent. It therefore claims no additional seed. The # +# refined invariant: every term with any match is matched by at least one # +# seed (previously: every term's own singleton winner gets a slot). # +# --------------------------------------------------------------------------- # + +_GENERIC_NOUN_QUESTION = "what code uses ChargeCustomerService to charge a customer" +# The hub's 12 members sit one `references` hop past the service, so they enter a +# depth-2 traversal only when the hub itself is seeded. Depth 2 is also the depth +# the repro measurements were taken at. +_GENERIC_NOUN_DEPTH = 2 +# Terms that exercise both sides of the refined invariant on this fixture: +# "customer" is covered by the `ChargeCustomerService` seed's label, "code" is +# covered by no seed at all (its winner is the `coder.md` prefix decoy — the +# documented residual, and here the load-bearing proof that recovery survives). +_COVERED_AND_STARVED_QUESTION = "ChargeCustomerService customer code" + + +def test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded(): + """"what code uses X to charge a customer" must not seed the `Customer` hub. + + "customer" and "charge" are both substrings of the `ChargeCustomerService` + seed's label, so neither is starved and neither buys a seat. The bound is + asserted against the pre-fix seed list's own traversal rather than a + hard-coded number: the comparison mirrors 0.9.35's demotion so what it + measures is this change alone. This phrasing triggers no context filter, so + the comparison traversal is unfiltered like the pipeline's. + """ + G = make_charge_fixture() + text = _query_graph_text( + G, _GENERIC_NOUN_QUESTION, mode="bfs", depth=_GENERIC_NOUN_DEPTH + ) + seeds = start_labels(text) + shown = shown_nodes(text) + + assert label_of(SERVICE) in seeds + assert label_of(HUB) not in seeds, f"generic-noun hub still seeded: {seeds}" + for caller in caller_labels(): + assert caller in shown, f"caller {caller!r} missing from shown output:\n{text}" + + hub_fanout = [lbl for lbl in shown if lbl.startswith(label_of(HUB) + ".")] + assert not hub_fanout, f"hub member fan-out flooded the answer: {hub_fanout}" + + qs = _score_query(G, _query_terms(_GENERIC_NOUN_QUESTION), collect_per_term_seeds=True) + demoted = { + term: nid + for term, nid in qs.best_seed_by_term.items() + if term not in _RELATIONAL_INTENT_TERMS + } + pre_skip_seeds = _pick_seeds(qs.ranked, G=G, best_seed_by_term=demoted) + pre_skip_nodes, _edges = _bfs(G, pre_skip_seeds, _GENERIC_NOUN_DEPTH) + assert _nodes_found(text) < len(pre_skip_nodes), ( + f"traversal not bounded: {_nodes_found(text)} nodes from {seeds} is no smaller " + f"than the {len(pre_skip_nodes)} the pre-skip seed list {pre_skip_seeds} reached" + ) + + +def test_covered_term_skips_guarantee_while_starved_term_is_still_recovered(): + """The picker seam, where the starvation-recovery and dedup prior art sits. + + Both halves of the refined invariant in one test: the covered term loses its + guaranteed seat, and the genuinely starved term keeps its — the skip + provably cannot reintroduce starvation. + """ + G = make_charge_fixture() + qs = _score_query( + G, _query_terms(_COVERED_AND_STARVED_QUESTION), collect_per_term_seeds=True + ) + seeds = _pick_seeds( + qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term, skip_covered_terms=True + ) + + assert SERVICE in seeds + assert HUB not in seeds, ( + "'customer' is a substring of the top-ranked seed's label, so it is not starved" + ) + assert DOC in seeds, ( + "'code' is matched by no picked seed's label — the #1445 guarantee must still fire" + ) + + +def test_only_the_top_ranked_seed_can_declare_a_term_covered(): + """A substring collision inside a *lower-ranked* seed must not starve a term. + + Coverage means "the query's dominant match already answers this term", which + is a claim only the top-ranked seed is entitled to make. Let any picked seed + make it and an unrelated one absorbs a term by coincidence: `ReportService` + contains the letters of "port", so a corpus symbol literally named `port` + loses the guaranteed seat that is the only way it can enter this seed list — + the #1597 concern (a corpus may legitimately name a symbol after a common + word) reappearing one layer down. + + Ranks are supplied directly rather than scored, the same way + `test_coverage_is_judged_on_the_seed_label_never_its_node_id` does: the point + is a specific gap-window shape (a dominant match, an unrelated runner-up that + happens to contain the term, and the term's own winner below the cutoff), and + naming it beats coaxing a synthetic corpus into producing it. + """ + G = nx.Graph() + G.add_node("cfg", label="port", source_file="src/config/server.py") + G.add_node("report", label="ReportService", source_file="app/Reports/ReportService.php") + G.add_node("boot", label="ServerBootstrap", source_file="src/server_bootstrap.py") + + qs = _score_query(G, ["port"], collect_per_term_seeds=True) + # Premise: the term's winner is the exact-match node, by ~7300x — `port` is a + # real symbol here, not a coincidence, and `ReportService` is the coincidence. + assert qs.best_seed_by_term["port"] == "cfg" + + scored = [(1000.0, "boot"), (300.0, "report"), (1.0, "cfg")] + seeds = _pick_seeds( + scored, G=G, best_seed_by_term=qs.best_seed_by_term, skip_covered_terms=True + ) + + # Premise: both of the unrelated nodes clear the gap window, and the term's + # own winner does not — so the guarantee is its only way in. + assert seeds[0] == "boot", f"top-ranked seed is not the dominant match: {seeds}" + assert "report" in seeds + assert "cfg" in seeds, ( + "'port' was declared covered by `ReportService`, a lower-ranked seed that " + f"merely contains the letters — the term's real winner was starved: {seeds}" + ) + + +def test_covered_term_skip_is_off_unless_the_caller_opts_in(): + """Default-off: identical results for every caller that does not opt in. + + `path`, `explain`, the legacy-equality property tests and the benchmark's two + arms all reach `_pick_seeds` without the flag; passing it explicitly False + must be the same call. + """ + G = make_charge_fixture() + qs = _score_query( + G, _query_terms(_COVERED_AND_STARVED_QUESTION), collect_per_term_seeds=True + ) + legacy = _pick_seeds(qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term) + + assert legacy == _pick_seeds( + qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term, skip_covered_terms=False + ) + assert HUB in legacy, f"the covered-term skip leaked into the default picker: {legacy}" + assert DOC in legacy + + +def test_coverage_is_judged_on_the_seed_label_never_its_node_id(): + """A labelless seed must not declare a term covered through its node id. + + `norm_label == ""` ghost nodes are real (see `_fold_node_aliases` in + build.py: an alias-only node enters the graph with no label) and they can + still be seeds, because the source-file tier scores them. The dedup gate + falls back to the node id for such a node — it has to, since a labelless + node needs *something* unique to dedup on — but the coverage predicate must + not, or the ghost's path fragments silently cover unrelated terms and starve + their winners. `_score_nodes`' substring tier reads `norm_label` only, so a + seed with no label covers nothing. + + Still load-bearing under the top-ranked-seed-only rule: the ghost is forced + to be the *only* gap-window seed below, so it IS `seeds[0]` — the one seed + entitled to declare coverage. Narrowing which seed may cover does not narrow + what this test traps. + """ + ghost = "src/customer_utils.py::helper" + G = nx.Graph() + G.add_node(ghost, label="", source_file="src/customer_utils.py") + G.add_node("cm", label="CustomerModel", source_file="app/Models/CustomerModel.php") + + qs = _score_query(G, ["customer"], collect_per_term_seeds=True) + # Premise 1: the term's real winner is the labelled node. + assert qs.best_seed_by_term["customer"] == "cm" + # Premise 2: the labelless ghost still scores — via the source-file tier — + # so it is a legitimate seed candidate rather than a hypothetical. + assert dict((nid, s) for s, nid in qs.ranked)[ghost] > 0 + + # Force the ghost to be the only gap-window seed, the way an unrelated exact + # match does on a real corpus (the #1445 shape): `cm` can now enter only + # through the per-term guarantee. + seeds = _pick_seeds( + [(1000.0, ghost), (1.0, "cm")], + G=G, + best_seed_by_term=qs.best_seed_by_term, + skip_covered_terms=True, + ) + assert ghost in seeds + assert "cm" in seeds, ( + "'customer' was treated as covered by a seed with no label — coverage read " + f"the node id, so the term's winner was starved: {seeds}" + ) + + +# --------------------------------------------------------------------------- # +# Heuristic-context-filter starvation fallback # +# # +# A class node has no call-context edges of its own: calls attach to its # +# methods, and the class->member edge carries `context=None`. So the `call` # +# filter that "Who calls X?" infers strands a perfectly-seeded class seed at # +# exactly one node. When an *inferred* filter discovers nothing beyond the # +# seeds, the query retraverses unfiltered and says so in the header; an # +# *explicit* filter is always honored. # +# --------------------------------------------------------------------------- # + +def test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed(): + """"Who calls ChargeCustomerService?" end to end. + + The inferred `call` filter leaves the class seed with nowhere to go, so the + traversal relaxes and every known caller renders — including the one + reachable only through its `references` edge. The header keeps the failure + mode visible: the heuristic context is still reported, annotated as relaxed, + so a fallback answer never reads as a filtered one. + """ + G = make_charge_fixture() + text = _query_graph_text(G, "Who calls ChargeCustomerService?", mode="bfs", depth=2) + seeds = start_labels(text) + shown = shown_nodes(text) + context = _context_segment(text) + + assert label_of(SERVICE) in seeds + assert label_of(DECOY) not in seeds, f"verb-prefix decoy seeded: {seeds}" + for caller in caller_labels(): + assert caller in shown, f"caller {caller!r} missing from shown output:\n{text}" + assert label_of(DECOY) not in shown, f"decoy neighborhood leaked in:\n{text}" + assert "heuristic" in context, f"header lost the inferred context: {context!r}" + assert "relaxed" in context, f"header does not report the relaxation: {context!r}" + + +def test_callers_of_phrasing_renders_all_callers_and_drops_junk_seed(): + """The agent-noun phrasing, "callers of X". + + 0.9.35 added "caller"/"callers" to `_CONTEXT_HINTS`, so this phrasing now + infers the same `call` filter "Who calls X?" does — and therefore inherits + the same stranding on a class seed, where before the hint entry it + traversed unfiltered. Same repair, same annotated header; the seed + assertions additionally pin that the busy test method whose label carries + "callers" stays out of both the seed list and the shown output. + """ + G = make_charge_fixture() + text = _query_graph_text(G, "callers of ChargeCustomerService", mode="bfs", depth=2) + seeds = start_labels(text) + shown = shown_nodes(text) + + assert label_of(SERVICE) in seeds + assert label_of(CALLERS_DECOY) not in seeds, f"junk test-method seeded: {seeds}" + for caller in caller_labels(): + assert caller in shown, f"caller {caller!r} missing from shown output:\n{text}" + # The junk seed's whole neighborhood is what used to eat the budget. + assert label_of(DECOY) not in shown + assert label_of(CALLERS_DECOY) not in shown + assert "relaxed" in _context_segment(text), ( + f"header does not report the relaxation: {_context_segment(text)!r}" + ) + + +def test_expanding_heuristic_filter_is_left_in_force(): + """A heuristic filter that does reach past the seeds is not second-guessed. + + Seeded on a *method* node — `BillingController.processPayment()`, whose + identifier the question names directly — the inferred `call` filter walks + two real call edges, so no fallback fires. What proves the filter is still + doing its job rather than having been quietly dropped is what is *missing*: + the `references`-only caller and the service class itself, whose `method` + edge carries no context. Relaxing would pull both in. + + The question says "invoked" rather than "calls": same inferred `call` + filter, but no fixture label matches it, so the seed set is exactly the one + identifier and the assertions below read only the fallback's behavior. + """ + G = make_charge_fixture() + text = _query_graph_text(G, "Who invoked BillingController?", mode="bfs", depth=2) + seeds = start_labels(text) + shown = shown_nodes(text) + context = _context_segment(text) + + assert seeds == [label_of(CALLERS[0])], f"unexpected seed set: {seeds}" + assert "heuristic" in context, f"header lost the inferred context: {context!r}" + assert "relax" not in context, f"expanding filter was needlessly relaxed: {context!r}" + assert label_of(SERVICE_METHOD) in shown + assert label_of(CALLERS[1]) in shown + for filtered_out in (label_of(CALLERS[2]), label_of(SERVICE)): + assert filtered_out not in shown, ( + f"filter no longer in force — {filtered_out!r} is reachable only " + f"through a non-`call` edge:\n{text}" + ) + + +def test_explicit_context_filter_never_falls_back_even_when_stranded(): + """The identical stranding, with the filter passed explicitly: honored. + + An explicit instruction is never overridden, so the answer stays at the seed + alone and the header reports an unqualified explicit filter. + """ + G = make_charge_fixture() + text = _query_graph_text( + G, "Who calls ChargeCustomerService?", mode="bfs", depth=2, + context_filters=["call"], + ) + shown = shown_nodes(text) + context = _context_segment(text) + + assert shown == [label_of(SERVICE)], f"explicit filter was relaxed:\n{text}" + assert "explicit" in context, f"header lost the explicit source: {context!r}" + assert "relax" not in context, f"explicit filter was annotated as relaxed: {context!r}" + + +def test_starvation_fallback_is_identical_in_both_traversal_modes(): + """Mode choice must not change filter behavior: DFS relaxes exactly where + BFS does, and reaches the same nodes.""" + G = make_charge_fixture() + question = "Who calls ChargeCustomerService?" + bfs = _query_graph_text(G, question, mode="bfs", depth=2) + dfs = _query_graph_text(G, question, mode="dfs", depth=2) + + for mode, text in (("BFS", bfs), ("DFS", dfs)): + context = _context_segment(text) + assert "relaxed" in context, f"{mode} did not relax: {context!r}" + for caller in caller_labels(): + assert caller in shown_nodes(text), ( + f"{mode} missing caller {caller!r}:\n{text}" + ) + assert set(shown_nodes(bfs)) == set(shown_nodes(dfs)), ( + "traversal modes disagree under the fallback:\n" + f"BFS={shown_nodes(bfs)}\nDFS={shown_nodes(dfs)}" + ) + + +def test_single_node_expansion_is_not_starvation(): + """The threshold is *zero* expansion, not "few nodes" — one discovered node + is enough to leave the filter alone. + + Same single-seed scenario as `test_expanding_heuristic_filter_is_left_in_force`, + with one local tweak: the other caller's call edge into the service method is + dropped, so the heuristic `call` filter reaches exactly one node beyond the + seed instead of two. Pinning this boundary is what stops the threshold from + drifting into a tuning constant (`<= len(seeds) + 1` and friends): such an + implementation relaxes here — throwing away a filter that had in fact found + its match, and dragging in the class node and the hub — which this test + rejects. Asserted for both modes, since the threshold is shared. + """ + for mode in ("bfs", "dfs"): + G = make_charge_fixture() + G.remove_edge(CALLERS[1], SERVICE_METHOD) + text = _query_graph_text(G, "Who invoked BillingController?", mode=mode, depth=2) + seeds = start_labels(text) + shown = shown_nodes(text) + + assert set(shown) == set(seeds) | {label_of(SERVICE_METHOD)}, ( + f"{mode}: expected exactly one node beyond the seeds:\n{text}" + ) + assert "relax" not in _context_segment(text), ( + f"{mode}: a filter that discovered a node was relaxed anyway — the " + f"threshold is no longer zero expansion:\n{text}" + )