diff --git a/graphify/cluster.py b/graphify/cluster.py index 682210700..0b91cc06f 100644 --- a/graphify/cluster.py +++ b/graphify/cluster.py @@ -211,7 +211,7 @@ def cluster( final_communities: list[list[str]] = [] for nodes in raw.values(): if len(nodes) > max_size: - final_communities.extend(_split_community(G, nodes)) + final_communities.extend(_split_community(G, nodes, resolution)) else: final_communities.append(nodes) @@ -220,7 +220,7 @@ def cluster( second_pass: list[list[str]] = [] for nodes in final_communities: if len(nodes) >= _COHESION_SPLIT_MIN_SIZE and cohesion_score(G, nodes) < _COHESION_SPLIT_THRESHOLD: - splits = _split_community(G, nodes) + splits = _split_community(G, nodes, resolution) second_pass.extend(splits if len(splits) > 1 else [nodes]) else: second_pass.append(nodes) @@ -236,14 +236,19 @@ def cluster( return {i: sorted(nodes) for i, nodes in enumerate(final_communities)} -def _split_community(G: nx.Graph, nodes: list[str]) -> list[list[str]]: - """Run a second Leiden pass on a community subgraph to split it further.""" +def _split_community(G: nx.Graph, nodes: list[str], resolution: float = 1.0) -> list[list[str]]: + """Run a second Leiden pass on a community subgraph to split it further. + + ``resolution`` mirrors the value cluster() partitioned the whole graph with, + so --resolution keeps its meaning on the split passes instead of silently + reverting to 1.0 here. + """ subgraph = G.subgraph(nodes) if subgraph.number_of_edges() == 0: # No edges - split into individual nodes return [[n] for n in sorted(nodes)] try: - sub_partition = _partition(subgraph) + sub_partition = _partition(subgraph, resolution=resolution) sub_communities: dict[int, list[str]] = {} for node, cid in sub_partition.items(): sub_communities.setdefault(cid, []).append(node) diff --git a/tests/test_cluster.py b/tests/test_cluster.py index 21fd2ca3a..7df301e4a 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -98,3 +98,43 @@ def test_remap_communities_to_previous_assigns_deterministic_new_ids(): assert list(remapped.keys()) == [0, 1] assert remapped[0] == ["x", "y", "z"] assert remapped[1] == ["m"] + + +def test_split_community_forwards_resolution_to_partition(monkeypatch): + from graphify import cluster as cluster_mod + + seen: list[float] = [] + + def fake_partition(G, resolution=1.0): + seen.append(resolution) + return {n: 0 for n in G.nodes} + + monkeypatch.setattr(cluster_mod, "_partition", fake_partition) + G = nx.complete_graph(6) + G = nx.relabel_nodes(G, {i: str(i) for i in G.nodes}) + cluster_mod._split_community(G, list(G.nodes), resolution=3.0) + assert seen == [3.0] + + +def test_cluster_forwards_resolution_to_split_passes(monkeypatch): + from graphify import cluster as cluster_mod + + seen: list[float] = [] + + def fake_split(G, nodes, resolution=1.0): + seen.append(resolution) + return [sorted(nodes)] + + monkeypatch.setattr(cluster_mod, "_split_community", fake_split) + + # Two 20-node cliques joined by a single edge: 40 nodes total, so max_size is + # max(10, 40 * 0.25) = 10 and each clique trips the oversized-split path. + G = nx.Graph() + for offset in (0, 20): + clique = [f"n{offset + i}" for i in range(20)] + G.add_edges_from((a, b) for i, a in enumerate(clique) for b in clique[i + 1:]) + G.add_edge("n0", "n20") + + cluster_mod.cluster(G, resolution=0.5) + assert seen, "expected the oversized-community split path to run" + assert set(seen) == {0.5}