diff --git a/graphify/build.py b/graphify/build.py index f721382b..d9b5e768 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -1980,19 +1980,31 @@ def build_merge( return G -def prefix_graph_for_global(G: nx.Graph, repo_tag: str) -> nx.Graph: +def prefix_graph_for_global( + G: nx.Graph, repo_tag: str, community_offset: int = 0 +) -> nx.Graph: """Return a copy of G with all node IDs prefixed with repo_tag::. Labels are preserved unchanged (for display). A 'local_id' attribute is added to each node so the original ID can be recovered. Edges and their directional attributes (_src/_tgt) are rewritten to match the new prefixed IDs. The 'repo' attribute is set on every node. + + community_offset shifts each node's integer 'community' id into a shared + id space and records the original in 'local_community': every input graph + numbers its communities from 0, so ids carried across a merge unchanged + collide and the aggregated community view fuses unrelated communities + into one meta-node (#3014). 0 (the default) leaves communities untouched. """ relabel = {n: f"{repo_tag}::{n}" for n in G.nodes} H = nx.relabel_nodes(G, relabel, copy=True) for node, data in H.nodes(data=True): data["repo"] = repo_tag data.setdefault("local_id", node.split("::", 1)[1]) + cid = data.get("community") + if community_offset and isinstance(cid, int): + data["local_community"] = cid + data["community"] = cid + community_offset for u, v, data in H.edges(data=True): if "_src" in data and data["_src"] in relabel: data["_src"] = relabel[data["_src"]] diff --git a/graphify/cli.py b/graphify/cli.py index 454c900d..bd295681 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -2541,11 +2541,25 @@ def dispatch_command(cmd: str) -> None: # diagnosis in PR #1691). Collect every input's prefixed hyperedges # and re-attach the union after composing. collected_hyperedges: list = [] + # Offset each input's community ids into a shared id space as it is + # prefixed: every input numbers its communities from 0, so ids carried + # across unchanged collide in the merged graph and the aggregated + # community view fuses unrelated communities into one meta-node (#3014). + # The first input keeps its original ids (offset 0); local_community on + # the prefixed nodes preserves each repo's own partition. + community_offset = 0 for G, repo_tag in zip(graphs, repo_tags): - prefixed = _to_simple(_prefix(G, repo_tag)) + prefixed = _to_simple(_prefix(G, repo_tag, community_offset=community_offset)) hes = prefixed.graph.get("hyperedges") if isinstance(hes, list): collected_hyperedges.extend(h for h in hes if isinstance(h, dict)) + cids = [ + d["community"] + for _, d in prefixed.nodes(data=True) + if isinstance(d.get("community"), int) + ] + if cids: + community_offset = max(community_offset, max(cids) + 1) merged = _nx.compose(merged, prefixed) # A contract type both repos declare arrives as two unconnected nodes, # since every id is repo-prefixed. Link them so a traversal can cross diff --git a/tests/test_global_graph.py b/tests/test_global_graph.py index 389c88d2..ad5da774 100644 --- a/tests/test_global_graph.py +++ b/tests/test_global_graph.py @@ -81,6 +81,38 @@ def test_prefix_graph_rewrites_edge_directional_attributes(): assert data["_tgt"] == "repoA::collections" +def test_prefix_graph_offsets_community_ids(): + """#3014: every input graph numbers its communities from 0, so a merge + carrying ids across unchanged fuses community 0 of one repo with community + 0 of another into a single meta-node in the aggregated view. An offset must + shift integer ids into a shared id space and keep the per-repo id in + local_community.""" + from graphify.build import prefix_graph_for_global + G = _make_graph( + [{"id": "a", "community": 0}, {"id": "b", "community": 1}], [], + ) + H = prefix_graph_for_global(G, "repoA", community_offset=5) + assert H.nodes["repoA::a"]["community"] == 5 + assert H.nodes["repoA::a"]["local_community"] == 0 + assert H.nodes["repoA::b"]["community"] == 6 + assert H.nodes["repoA::b"]["local_community"] == 1 + + +def test_prefix_graph_zero_offset_leaves_communities_untouched(): + """The default offset must be a no-op — no community rewrite, no + local_community noise — so single-repo callers (global store, tests) + keep their ids exactly as stored.""" + from graphify.build import prefix_graph_for_global + G = _make_graph( + [{"id": "a", "community": 0}, {"id": "b", "community": 1}], [], + ) + H = prefix_graph_for_global(G, "repoA") + assert H.nodes["repoA::a"]["community"] == 0 + assert H.nodes["repoA::b"]["community"] == 1 + assert "local_community" not in H.nodes["repoA::a"] + assert "local_community" not in H.nodes["repoA::b"] + + def test_prune_repo_removes_correct_nodes(): from graphify.build import prune_repo_from_graph diff --git a/tests/test_merge_graphs_cli.py b/tests/test_merge_graphs_cli.py index e0203a5a..22948df8 100644 --- a/tests/test_merge_graphs_cli.py +++ b/tests/test_merge_graphs_cli.py @@ -247,3 +247,43 @@ def test_merge_graphs_reads_top_level_only_hyperedges(tmp_path): assert [h["id"] for h in data["hyperedges"]] == ["alpha::h_top"] assert data["hyperedges"][0]["nodes"] == ["alpha::x"] + + +def _write_with_communities(p: Path, nodes): + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps({ + "directed": False, "multigraph": False, "graph": {}, + "nodes": [{"id": nid, "community": cid} for nid, cid in nodes], + "links": [], + })) + + +def test_merge_graphs_offsets_communities_so_repos_do_not_fuse(tmp_path): + # #3014: every input numbers its communities from 0, so merge-graphs + # carrying ids across unchanged made community 0 of repo alpha and + # community 0 of repo beta the SAME community — the aggregated community + # view then fused unrelated communities into one meta-node. Each input's + # ids must be offset into a shared id space, with the per-repo partition + # kept in local_community. + a = tmp_path / "alpha" / "graphify-out" / "graph.json" + b = tmp_path / "beta" / "graphify-out" / "graph.json" + _write_with_communities(a, [("a0", 0), ("a1", 0), ("a2", 1)]) + _write_with_communities(b, [("b0", 0), ("b1", 1), ("b2", 1), ("b3", 2)]) + out = tmp_path / "merged.json" + + r = _run(["merge-graphs", str(a), str(b), "--out", str(out)], tmp_path) + assert r.returncode == 0, r.stderr + data = json.loads(out.read_text()) + a_nodes = [n for n in data["nodes"] if n["id"].startswith("alpha::")] + b_nodes = [n for n in data["nodes"] if n["id"].startswith("beta::")] + # the first input keeps its original ids (offset 0) + assert {n["community"] for n in a_nodes} == {0, 1} + # the second input is shifted past the first: {2, 3, 4}, disjoint + assert {n["community"] for n in b_nodes} == {2, 3, 4} + assert not ( + {n["community"] for n in a_nodes} & {n["community"] for n in b_nodes} + ), "community ids still collide across repos (#3014)" + # all five distinct communities survive the merge + assert len({n["community"] for n in data["nodes"]}) == 5 + # the per-repo partition is preserved + assert {n["local_community"] for n in b_nodes} == {0, 1, 2}