mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-26 01:01:38 +00:00
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:
committed by
FatahChan
co-authored by
Cursor
parent
ef0e6ee681
commit
28fc71eddc
+82
-6
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user