From 480115033da4148d6bedae4bed72ec7e8a34f8be Mon Sep 17 00:00:00 2001 From: neo1777 <38768605+neo1777@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:23:17 +0200 Subject: [PATCH] fix(cluster): keep hub-only neighbours with their hub under --exclude-hubs With exclude_hubs_percentile set, excluded hubs are removed from the graph that gets partitioned. A node whose only neighbours are hubs (e.g. a local symbol of a hub file, or a dependency entry of a manifest) is then isolated in that subgraph and came back as a singleton community, while the hub itself was reattached by majority vote. Every such node ended up cut off from the only node it connects to, and the community count inflated with singletons. Such nodes are now held out of the partition and placed after the hubs are reattached, with the same majority vote the hubs use; since all of their neighbours are hubs and every hub is placed by then, the vote always has a winner. A self-loop does not count as a neighbour. True isolates, and nodes whose only edge is a self-loop, are unchanged. Follow-up to #919, where --exclude-hubs was introduced. The cluster() docstring now says so. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 39a24a06260c2fc4f41e0f8a311510b9f3dde810) --- graphify/cluster.py | 32 ++++++++++++- tests/test_cluster_exclude_hubs.py | 75 ++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 tests/test_cluster_exclude_hubs.py diff --git a/graphify/cluster.py b/graphify/cluster.py index 6af9e1a6a..db78d55a2 100644 --- a/graphify/cluster.py +++ b/graphify/cluster.py @@ -247,8 +247,9 @@ def cluster( <1.0 = fewer larger communities. Default 1.0. exclude_hubs_percentile: if set (0-100), nodes whose degree exceeds this percentile are excluded from partitioning and reattached to their - majority-vote neighbour community afterwards. Useful for staging/utility - super-hubs that inflate god-node rankings (#919). + majority-vote neighbour community afterwards. Nodes whose only + neighbours are excluded hubs follow them, by the same vote. Useful for + staging/utility super-hubs that inflate god-node rankings (#919). """ if G.number_of_nodes() == 0: return {} @@ -272,6 +273,21 @@ def cluster( excluded = hub_nodes isolates = [n for n in G.nodes() if G.degree(n) == 0 and n not in excluded] connected_nodes = [n for n in G.nodes() if G.degree(n) > 0 and n not in excluded] + # A node whose only neighbours are excluded hubs is isolated in the + # partitioned subgraph purely because of the exclusion. Hold it out of the + # partition and place it with its hub(s) below, instead of letting it come + # back as a singleton cut off from the only node it connects to. + stranded: list[str] = [] + if hub_nodes: + # A self-loop is not a neighbour: judge a node by its other neighbours. + stranded = [ + n for n in connected_nodes + if (nbs := [nb for nb in G.neighbors(n) if nb != n]) + and all(nb in hub_nodes for nb in nbs) + ] + if stranded: + _stranded = set(stranded) + connected_nodes = [n for n in connected_nodes if n not in _stranded] connected = G.subgraph(connected_nodes) raw: dict[int, list[str]] = {} @@ -303,6 +319,18 @@ def cluster( raw[next_cid] = [hub] node_community[hub] = next_cid next_cid += 1 + # Every neighbour of a stranded node is a hub, and every hub is placed + # by now, so the same majority vote always has a winner. + for node in sorted(stranded, key=str): + votes = {} + for nb in G.neighbors(node): + if nb == node: + continue + cid = node_community[nb] + votes[cid] = votes.get(cid, 0) + 1 + best = min(votes, key=lambda c: (-votes[c], c)) + raw[best].append(node) + node_community[node] = best # Split oversized communities max_size = max(_MIN_SPLIT_SIZE, int(G.number_of_nodes() * _MAX_COMMUNITY_FRACTION)) diff --git a/tests/test_cluster_exclude_hubs.py b/tests/test_cluster_exclude_hubs.py new file mode 100644 index 000000000..918e62d40 --- /dev/null +++ b/tests/test_cluster_exclude_hubs.py @@ -0,0 +1,75 @@ +"""cluster() under exclude_hubs_percentile keeps hub-only neighbours with their hub. + +Excluded hubs are removed from the subgraph that gets partitioned, so a node +whose only neighbours are hubs is isolated there. It used to come back as a +singleton community while the hub itself was reattached by majority vote. +""" +from __future__ import annotations + +import networkx as nx + +from graphify.cluster import cluster + + +def _hub_with_private_leaves(): + """Two 4-cliques, one hub H joined to all of A and to b1, and three + leaves that are connected to H only. Degrees: H=8, A=4, b1=4, B=3, + leaves=1, so exclude_hubs_percentile=90 excludes exactly H.""" + G = nx.Graph() + A = ["a1", "a2", "a3", "a4"] + B = ["b1", "b2", "b3", "b4"] + for group in (A, B): + for i, u in enumerate(group): + for v in group[i + 1:]: + G.add_edge(u, v) + for a in A: + G.add_edge("H", a) + G.add_edge("H", "b1") + for leaf in ("l1", "l2", "l3"): + G.add_edge("H", leaf) + return G + + +def test_exclude_hubs_keeps_hub_only_neighbours_with_their_hub(): + """A node whose only neighbours are excluded hubs has degree 0 in the + partitioned subgraph. It used to come back as a singleton community while + the hub itself was reattached elsewhere, so every such node was cut off + from the only node it connects to.""" + G = _hub_with_private_leaves() + communities = cluster(G, exclude_hubs_percentile=90) + node_to_cid = {n: cid for cid, nodes in communities.items() for n in nodes} + assert set(node_to_cid) == set(G.nodes()) + for leaf in ("l1", "l2", "l3"): + assert node_to_cid[leaf] == node_to_cid["H"] + assert not [nodes for nodes in communities.values() if len(nodes) == 1] + + +def test_exclude_hubs_leaves_true_isolates_alone(): + G = _hub_with_private_leaves() + G.add_node("lonely") + communities = cluster(G, exclude_hubs_percentile=90) + assert ["lonely"] in communities.values() + + +def test_exclude_hubs_without_hubs_is_unchanged(): + G = _hub_with_private_leaves() + assert cluster(G, exclude_hubs_percentile=100) == cluster(G) + + +def test_exclude_hubs_hub_only_neighbour_with_self_loop_joins_its_hub(): + """A self-loop is not a real neighbour: a node whose other neighbours are + all hubs is stranded just the same.""" + G = _hub_with_private_leaves() + G.add_edge("l1", "l1") + communities = cluster(G, exclude_hubs_percentile=90) + node_to_cid = {n: cid for cid, nodes in communities.items() for n in nodes} + assert node_to_cid["l1"] == node_to_cid["H"] + + +def test_exclude_hubs_self_loop_only_node_is_left_alone(): + """A node whose only edge is a self-loop has no hub to join; it keeps + the behaviour it has without the exclusion (its own community).""" + G = _hub_with_private_leaves() + G.add_edge("solo", "solo") + communities = cluster(G, exclude_hubs_percentile=90) + assert ["solo"] in communities.values()