Skip to content

Commit 04f33ae

Browse files
authored
fix(kg): rebuild the knowledge graph when the builder changes, not just the graph (#1592)
* fix(kg): rebuild the knowledge graph when the builder changes, not just the graph `compute_kg_fingerprint` measures the input graph — node count, edge count, community count, first 100 community keys — and `should_skip_kg_rebuild` keeps the stored artifact whenever those four hold still. They hold still when the *builder* changes, and this release changed it twice. `_EDGE_TYPE_MAP` gained six types (framework, the three dynamic_* kinds, reads, method_implements) and the export drops every unmapped type outright, so the knowledge graph now carries edge classes it used to discard. `_curate_entry_points` also re-ranks `project.entry_points`, which reaches get_overview, the C4 architecture view and CLAUDE.md through `entry_points_json`. Neither moves a node or an edge count, so an existing store would serve the old artifact until some unrelated commit happened to shift one. KG_BUILDER_VERSION is folded into the fingerprint: one bump is one rebuild per store, on the next ordinary `repowise update`. That rebuild is the deterministic skeleton and curation passes over a graph the run already holds — no model, no re-index, nothing for the user to do. Not a loop either: the new value is stored as the artifact's fingerprint, so the following run matches and skips. * fix(kg): say what the rebuild actually costs, and pin the version as a literal Adversarial review of the previous commit found three claims in it that the code does not support. "On the next ordinary `repowise update`" is not true for a repo with no new commits: that run returns NOOP (command.py:908) before the refresh is reached, and the renderer-staleness gate beside it fingerprints only the file-page and spotlight templates, so a builder bump does not retire the shortcut. A quiet repo stays on its old artifact until something changes. That is the right trade — the alternative is every idle repo in the world re-curating on a version bump — but the docstring stated it as a guarantee. "No model" is only true off the docs path. A non-None refresh result feeds enrich_knowledge_graph (command.py:1770), one LLM call per five layers, and the comment above that call still claimed an unchanged KG never pays it. Both now say what happens and why it is worth paying: the layers moved, so the names the carry-forward restored were written against the old ones. The stability test asserted only that the constant is a non-empty str, which a value derived from the module's own bytes — the exact failure its docstring named — would have passed. Replaced with the literal, so bumping it is a line somebody edits and a reviewer sees. Not applied from the review: stamping the fingerprint into state when the disk export fails. should_skip_kg_rebuild also requires kg_path.exists(), so a failed export rebuilds on the next run regardless of the stored value; stamping it would instead assert the artifact matches the current builder when the write that would have made that true is the one that failed. Verified end to end on a fresh microdot index: a comment-only commit at the same version does not rebuild (fingerprint held at 2cf47159), the same commit with the version bumped does (0630f7fd), and the commit after that does not.
1 parent a17099c commit 04f33ae

3 files changed

Lines changed: 80 additions & 4 deletions

File tree

packages/cli/src/repowise/cli/commands/update_cmd/command.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1764,9 +1764,13 @@ async def _generate_with_checkpoint() -> list:
17641764
flush_cost_tracker(cost_tracker)
17651765

17661766
# LLM re-enrichment of the refreshed KG (layer naming + summary backfill
1767-
# from this run's regenerated pages), mirroring the init pipeline. Only
1768-
# runs when the graph shape changed — carry-forward already preserved the
1769-
# prior names, so an unchanged KG never pays an enrichment call.
1767+
# from this run's regenerated pages), mirroring the init pipeline. Runs
1768+
# whenever the refresh returned a result, which is the graph shape moving
1769+
# *or* KG_BUILDER_VERSION being bumped by a release — the second is a graph
1770+
# that did not change, and it still pays here. That is deliberate and it is
1771+
# the one cost of the version bump: a wider edge map or a new entry-point
1772+
# ranking changes the layers, and layer names the carry-forward restored
1773+
# were written against the old ones.
17701774
if knowledge_graph_result is not None:
17711775
try:
17721776
from repowise.core.generation.knowledge_graph import enrich_knowledge_graph

packages/core/src/repowise/core/analysis/knowledge_graph.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,16 @@ def _slugify(text: str) -> str:
205205
# Edge type mapping
206206
# ---------------------------------------------------------------------------
207207

208+
# Bump when the builder or the curation pass changes what it emits from an
209+
# unchanged graph — a wider `_EDGE_TYPE_MAP`, a different entry-point ranking,
210+
# a new node field. Folded into `compute_kg_fingerprint`, which otherwise only
211+
# measures the input graph and would let an existing store serve the old
212+
# artifact indefinitely. See that function for why one bump is one rebuild.
213+
#
214+
# "2": `_EDGE_TYPE_MAP` gained six types and `_curate_entry_points` changed its
215+
# ranking, neither of which moves a node or edge count.
216+
KG_BUILDER_VERSION = "2"
217+
208218
# An unmapped type is dropped from the export entirely (see the
209219
# `if not kg_type: continue` below), which is silent. Six real types used to be
210220
# missing — framework, the three dynamic_* kinds, reads and method_implements —
@@ -410,10 +420,41 @@ def build_knowledge_graph_skeleton(
410420

411421

412422
def compute_kg_fingerprint(graph_builder: Any) -> str:
413-
"""Compute a fingerprint from the graph state for incremental skip logic."""
423+
"""Compute a fingerprint from the graph state for incremental skip logic.
424+
425+
The four graph measures describe the *input* graph. They cannot see a
426+
change in how this module turns that graph into an export, and the export
427+
is not total: :data:`_EDGE_TYPE_MAP` drops every unmapped edge type. So
428+
widening that map — six types in 0.43.0 — changes what the knowledge graph
429+
contains while all four measures hold still, and the stored artifact would
430+
keep the narrower edge set until some unrelated commit happened to move a
431+
node or edge count. Same for the ordering the curation pass applies to
432+
``project.entry_points``, which every orientation surface reads.
433+
434+
:data:`KG_BUILDER_VERSION` closes that: bump it in the same commit as a
435+
change to what the builder or the curation pass emits, and every existing
436+
store rebuilds its knowledge graph once, on its next ``repowise update``
437+
that has work to do. Two limits on that sentence, both deliberate:
438+
439+
* An update with nothing to do returns ``NOOP`` before the refresh is
440+
reached, so a repo with no new commits stays on its old artifact until
441+
something changes. That is the right trade — the alternative is making
442+
every quiet repo in the world re-curate on a version bump — but it does
443+
mean this is not a hard guarantee of "everyone, once".
444+
* The rebuild itself is deterministic and model-free (skeleton plus
445+
curation over a graph the run already holds, betweenness served from its
446+
disk cache). On a **docs** update the non-``None`` result then also
447+
triggers one KG layer-enrichment LLM call per five layers, which is real
448+
spend the caller pays for a graph whose shape did not change. Index-only
449+
and keyless runs never reach it.
450+
451+
One rebuild and not a loop: the new value is stored as the artifact's
452+
fingerprint, so the next run matches and skips.
453+
"""
414454
g = graph_builder.graph()
415455
cd = graph_builder.community_detection()
416456
parts = [
457+
KG_BUILDER_VERSION,
417458
str(g.number_of_nodes()),
418459
str(g.number_of_edges()),
419460
str(len(set(cd.values()))),

tests/unit/analysis/test_kg_skip_logic.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import pytest
99

10+
from repowise.core.analysis import knowledge_graph
1011
from repowise.core.analysis.knowledge_graph import (
1112
_KG_SCHEMA_VERSION,
1213
KnowledgeGraphResult,
@@ -181,6 +182,36 @@ def test_different_communities_different_fingerprint(self):
181182
)
182183
assert compute_kg_fingerprint(gb1) != compute_kg_fingerprint(gb2)
183184

185+
def test_builder_version_changes_the_fingerprint(self, monkeypatch):
186+
"""The one input that is not a measurement of the graph.
187+
188+
Every other test here varies the graph and expects the fingerprint to
189+
follow. This is the opposite case, and the one the skip logic could not
190+
express before: the graph is identical and the *builder* changed, which
191+
is what a release does when it widens ``_EDGE_TYPE_MAP`` or re-ranks
192+
entry points. Without this fold an existing store keeps the artifact a
193+
narrower builder wrote, for as long as its node and edge counts happen
194+
to hold still.
195+
"""
196+
gb = self._make_graph_builder(["a.py", "b.py"], [("a.py", "b.py")], {"a.py": 0, "b.py": 0})
197+
before = compute_kg_fingerprint(gb)
198+
199+
monkeypatch.setattr(knowledge_graph, "KG_BUILDER_VERSION", "test-next")
200+
assert compute_kg_fingerprint(gb) != before
201+
202+
def test_builder_version_is_a_hand_edited_literal(self):
203+
"""The bump is a decision, so it has to be visible in a diff.
204+
205+
Deliberately asserts the literal. The first cut of this test asserted
206+
only ``isinstance(..., str)`` and non-emptiness, which a value derived
207+
from the module's own bytes — the exact failure it claimed to guard —
208+
would have passed unchanged. There is no way to check "somebody chose
209+
this" other than to pin what they chose, and the cost is the honest
210+
one: whoever bumps the constant updates this line and sees, in the
211+
diff, that they are asking every existing store to re-curate.
212+
"""
213+
assert knowledge_graph.KG_BUILDER_VERSION == "2"
214+
184215

185216
# ---------------------------------------------------------------------------
186217
# Skip logic integration (unit-level)

0 commit comments

Comments
 (0)