Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
90 changes: 84 additions & 6 deletions graphify/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 []
Expand All @@ -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()
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading