Repository navigation
merge-graphs does not namespace the community node attribute, so communities collide across repos and the aggregated HTML view fuses unrelated ones into a single meta-node #3014
Description
merge-graphs prefixes node ids with the repo tag, rewrites edge _src/_tgt, prefixes hyperedge ids and remaps their members, and stamps repo on every node — but the per-node community attribute is carried across unchanged. Each input graph numbers its communities from 0, so the merged graph inherits N overlapping id spaces.
That is invisible in graph.json itself, but the aggregated community view (taken for any merged graph above the 5000-node viz limit) groups by the bare community id, so communities from different repos that happen to share an integer are drawn as one meta-node.
Reproduction
Merging three unrelated repos (11707 + 2057 + 1892 nodes; 754 + 203 + 172 communities):
$ graphify merge-graphs A/graphify-out/graph.json B/graphify-out/graph.json C/graphify-out/graph.json --out merged/graphify-out/graph.json
Merged 3 graphs -> 15656 nodes, 25936 edges
distinct community ids: 754 # expected 1129 = 754 + 203 + 172
community ids shared by >1 repo: 203
754 is exactly the largest input's community count, which is what makes it read as plausible rather than wrong.
Community 0 in the merged graph holds 227 nodes drawn from all three repos:
community 0 -> A: 114 nodes "Preflight Check Suite"
B: 49 nodes "Canary Disk Budget"
C: 64 nodes "Heading Declaration Gate Tests"
Three unrelated communities, one meta-node:
$ graphify export html --graph merged/graphify-out/graph.json
Graph has 15656 nodes (above 5000 limit). Building aggregated community view...
graph.html written (aggregated: 754 community nodes, 1577 cross-community edges)
After namespacing (repo, community) locally, the same graph exports 1129 community nodes.
Mechanism
build.py::prefix_graph_for_global disambiguates everything else per repo but never touches community. Its own comment credits PR #1691's diagnosis for the hyperedge half:
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])
# ... edges (_src/_tgt), hyperedge ids and members all remapped ...
# 'community' is not remappedThe consumer side then groups on the bare id — cli.py, the export html path that reconstructs communities from node attributes:
reconstructed: dict[int, list[str]] = {}
for node_id, data in G.nodes(data=True):
cid_raw = data.get("community")
...
reconstructed.setdefault(cid, []).append(str(node_id))and exporters/html.py emits one meta-node per key:
node_to_community = {nid: cid for cid, members in communities.items() for nid in members}
for cid, members in communities.items():
meta.add_node(str(cid), label=(community_labels or {}).get(cid, f"Community {cid}"))merge-graphs writes no .graphify_analysis.json, so a merged graph always takes that reconstruction path rather than a sidecar.
Prior art
PR #1691 ("fix(merge): preserve repository-local identities") proposed keeping "existing community partitions distinct for each repository" and noted "Community IDs overlap". It was closed unmerged in favour of the fix shipped for #2484, which covers the hyperedge half of the same PR; the community half does not appear in the shipped function. #2134 separately describes merge-graphs and the global graph as unioning "repositories side by side", which matches what I see.
Workaround, and why I still think this is worth fixing
graphify cluster-only <merged-dir> --graph <merged graph.json> --no-label re-partitions the merged graph and yields unique ids (1044 communities here, none spanning a repo). It discards the curated per-repo community names, so those have to be regenerated.
So there is a one-command fix, and you could reasonably say the raw merge is simply un-analysed and re-clustering was the missing step. Two things make that a harder sell from the user side:
extracttells you to do it andmerge-graphsdoes not.extractprints a "next: rungraphify cluster-only ..." line;merge-graphsprints only the node/edge counts and the output path, andreferences/github-and-merge.mdgoes straight frommerge-graphsto querying the merged graph with no re-cluster step.- Nothing detects the bad state.
export htmlsilently reconstructs communities from the node attribute and renders the fused view;export wikidoes have a guard for untrustworthy community data, but it only fires whencommunitiesis empty, and here it is populated and wrong. So the failure is silent in every path.
Even just prefixing the ids at merge time, or printing the same "run cluster-only next" hint that extract prints, would close it.
Suggested fix
Namespace community in prefix_graph_for_global alongside the hyperedge ids that are already prefixed there — either f"{repo_tag}::{cid}" or an offset per input — and key any combined community_labels the same way. Keeping the pre-merge value in something like local_community would preserve the per-repo partition for callers that want it.
Environment
graphify 0.9.49 (pipx), Python 3.13, Debian 13. Also reproduced on 0.9.48.
Activity
temsecanvil commentedon Aug 24, 2026
Independent reproduction on a second machine, with different numbers, which I think is worth adding because the failure mode is a plausible-looking number rather than an obvious break.
A colleague ran the same three repos through the same pipeline on Windows, with different chunk boundaries and a separately-built extraction (so the per-repo graphs are not copies of mine):
The point of including both: in each case the merged graph inherits exactly the largest input's community count — 754 and 856 are each that run's own
tempered-ansiblecommunity count. So the symptom is not a crash or an obviously wrong number, it is a number that looks entirely reasonable for a merged graph and silently is not. Confirmed end to end on the second run too: the aggregated HTML draws 1,281 community nodes after namespacing(repo, community), and 856 unfixed.The second reader also independently traced it to the same place —
prefix_graph_for_globalrewrites node ids,repo,local_id, edge_src/_tgtand, since #2484, hyperedge ids and member lists, and never touchescommunity.Worth noting the precedent that sits inside that same function: this exact collision class was already hit for hyperedges and fixed by namespacing them (#2484, following #1691 "preserve repository-local identities").
communitylooks like the one attribute the same reasoning was never extended to, which is also why the fix is likely a couple of lines in a place that already does this for its neighbours.One small addition to the workaround note above:
to_html(...)raisesValueError: too large for HTML vizwhennode_limitis leftNone, sonode_limit=5000is what actually selects the aggregated community view — relevant to anyone trying to reproduce the fused render directly rather than through the CLI.fix(build): offset community ids per input in merge-graphs (#3014)
test(merge): pin same-order community-offset byte-reproducibility (#3014
chore: bump to 0.9.50
safishamsi commentedon Aug 25, 2026
Fixed in v0.9.50: https://github.com/Graphify-Labs/graphify/releases/tag/v0.9.50. Closing as resolved.
Merge pull request #26 from TelB-io/fix/global-add-community-offset-s…