From 28fc71eddc68daeed1870df145832a806a518895 Mon Sep 17 00:00:00 2001 From: Ahmad Fathallah Date: Tue, 12 May 2026 03:21:19 +0300 Subject: [PATCH] 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 --- graphify/watch.py | 88 +++++++++++++++++++++++++++++++++++++++++---- tests/test_watch.py | 23 ++++++++++++ 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/graphify/watch.py b/graphify/watch.py index 7c5dca63..5fe3f24e 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -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: diff --git a/tests/test_watch.py b/tests/test_watch.py index c5eff427..5cacb018 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -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