skip reclustering when topology is unchanged

Add a pre-cluster topology comparison fast path in update rebuilds so unchanged graphs short-circuit before clustering and report generation, preventing residual run-to-run community-count drift.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ahmad Fathallah
2026-05-12 03:36:26 +03:00
committed by FatahChan
co-authored by Cursor
parent ef0e6ee681
commit 28fc71eddc
2 changed files with 105 additions and 6 deletions
+82 -6
View File
@@ -37,11 +37,6 @@ def _rebuild_lock(out_dir: Path, *, blocking: bool = False):
except BlockingIOError:
yield False
return
try:
fh.write(str(os.getpid()))
fh.flush()
except OSError:
pass
yield True
finally:
try:
@@ -147,6 +142,66 @@ def _canonical_graph_for_compare(graph_data: dict) -> dict:
return canonical
def _canonical_topology_for_compare(graph_data: dict) -> dict:
canonical = dict(graph_data)
canonical.pop("built_at_commit", None)
nodes = canonical.get("nodes")
if isinstance(nodes, list):
norm_nodes = []
for node in nodes:
if not isinstance(node, dict):
continue
n = dict(node)
n.pop("community", None)
n.pop("norm_label", None)
norm_nodes.append(n)
canonical["nodes"] = sorted(
norm_nodes,
key=lambda item: json.dumps(item, sort_keys=True, ensure_ascii=False, default=str),
)
for key in ("links", "edges"):
items = canonical.get(key)
if not isinstance(items, list):
continue
norm_edges = []
for edge in items:
if not isinstance(edge, dict):
continue
e = dict(edge)
true_src = e.pop("_src", None)
true_tgt = e.pop("_tgt", None)
if true_src is not None and true_tgt is not None:
e["source"] = true_src
e["target"] = true_tgt
e.pop("confidence_score", None)
norm_edges.append(e)
canonical[key] = sorted(
norm_edges,
key=lambda item: json.dumps(item, sort_keys=True, ensure_ascii=False, default=str),
)
hyperedges = canonical.get("hyperedges")
if isinstance(hyperedges, list):
canonical["hyperedges"] = sorted(
hyperedges,
key=lambda item: json.dumps(item, sort_keys=True, ensure_ascii=False, default=str),
)
return canonical
def _topology_from_graph(G) -> dict:
from networkx.readwrite import json_graph
try:
data = json_graph.node_link_data(G, edges="links")
except TypeError:
data = json_graph.node_link_data(G)
data["hyperedges"] = getattr(G, "graph", {}).get("hyperedges", [])
return data
def _report_for_compare(report_text: str) -> str:
return re.sub(r"^- Built from commit: `[^`]+`\n?", "", report_text, flags=re.MULTILINE)
@@ -165,7 +220,7 @@ def _rebuild_code(
acquire_lock: bool = True,
block_on_lock: bool = False,
) -> bool:
"""Re-run AST extraction + build + optional cluster + report for code files.
"""Re-run AST extraction + build + optional cluster + report for code files. No LLM needed.
When ``force`` is True the node-count safety check in ``to_json`` is bypassed
so the rebuilt graph overwrites graph.json even if it has fewer nodes.
@@ -364,6 +419,27 @@ def _rebuild_code(
}
G = build_from_json(result)
candidate_topology = _topology_from_graph(G)
if existing_graph_data:
try:
same_topology = (
json.dumps(_canonical_topology_for_compare(existing_graph_data), sort_keys=True, ensure_ascii=False)
== json.dumps(_canonical_topology_for_compare(candidate_topology), sort_keys=True, ensure_ascii=False)
)
except Exception:
same_topology = False
if same_topology:
try:
from graphify.detect import save_manifest
save_manifest(detected["files"])
except Exception:
pass
flag = out / "needs_update"
if flag.exists():
flag.unlink()
print("[graphify watch] No code-graph topology changes detected; outputs left untouched.")
return True
communities = cluster(G)
previous_node_community = _node_community_map(existing_graph_data)
if previous_node_community:
+23
View File
@@ -127,3 +127,26 @@ def test_rebuild_code_is_idempotent_when_cluster_ids_flap(tmp_path, monkeypatch)
assert first_graph == second_graph
assert first_report == second_report
def test_rebuild_code_skips_cluster_when_topology_unchanged(tmp_path, monkeypatch):
from graphify import cluster as cluster_mod
from graphify.watch import _rebuild_code
src = tmp_path / "app.py"
src.write_text("def alpha():\n return 1\n\ndef beta():\n return alpha()\n", encoding="utf-8")
calls = {"n": 0}
def cluster_once(G):
calls["n"] += 1
if calls["n"] > 1:
raise AssertionError("cluster() should be skipped when topology is unchanged")
return {0: sorted(G.nodes())}
monkeypatch.setattr(cluster_mod, "cluster", cluster_once)
monkeypatch.setattr(cluster_mod, "score_all", lambda _G, comm: {cid: 1.0 for cid in comm})
assert _rebuild_code(tmp_path)
assert _rebuild_code(tmp_path)
assert calls["n"] == 1