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 <noreply@anthropic.com>
(cherry picked from commit 39a24a0626)
This commit is contained in:
neo1777 authored and safishamsi committed 2026-09-30 20:41:10 +01:00
1 parent f9a6893844
commit 480115033d
2 files changed
+105 -2

No files matched your search

+30 -2
View File
@@ -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))
+75
View File
@@ -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()