use std::collections::{BTreeMap, VecDeque};
pub(super) const DEFAULT_GAMMA: f64 = 1.0;
const EPS: f64 = 1e-12;
const MAX_LEVELS: usize = 64;
#[derive(Clone)]
pub(super) struct LeidenGraph {
n: usize,
adjacency: Vec<Vec<(usize, f64)>>,
strength: Vec<f64>,
total_weight: f64,
}
impl LeidenGraph {
pub(super) fn new(n: usize, edges: &[(usize, usize, f64)]) -> Self {
let mut acc: Vec<BTreeMap<usize, f64>> = vec![BTreeMap::new(); n];
let mut strength = vec![0.0_f64; n];
let mut total_weight = 0.0_f64;
for &(u, v, w) in edges {
if u == v {
*acc[u].entry(u).or_insert(0.0) += w;
strength[u] += 2.0 * w;
total_weight += w;
} else {
*acc[u].entry(v).or_insert(0.0) += w;
*acc[v].entry(u).or_insert(0.0) += w;
strength[u] += w;
strength[v] += w;
total_weight += w;
}
}
let adjacency = acc
.into_iter()
.map(|m| m.into_iter().collect::<Vec<_>>())
.collect();
Self {
n,
adjacency,
strength,
total_weight,
}
}
}
struct Partition {
community_of: Vec<usize>,
sigma_tot: Vec<f64>,
}
impl Partition {
fn singletons(g: &LeidenGraph) -> Self {
Self {
community_of: (0..g.n).collect(),
sigma_tot: g.strength.clone(),
}
}
}
fn local_moving(g: &LeidenGraph, partition: &mut Partition, gamma: f64) -> bool {
let two_m = 2.0 * g.total_weight;
if two_m <= 0.0 {
return false;
}
partition.sigma_tot.resize(g.n, 0.0);
let mut comm_size = vec![0usize; g.n];
for &c in &partition.community_of {
comm_size[c] += 1;
}
let mut free_ids: Vec<usize> = (0..g.n).filter(|&c| comm_size[c] == 0).collect();
let mut queue: VecDeque<usize> = (0..g.n).collect();
let mut in_queue = vec![true; g.n];
let mut moved_any = false;
while let Some(u) = queue.pop_front() {
in_queue[u] = false;
let k_u = g.strength[u];
let current = partition.community_of[u];
let mut to_comm: BTreeMap<usize, f64> = BTreeMap::new();
for &(v, w) in &g.adjacency[u] {
if v == u {
continue;
}
*to_comm.entry(partition.community_of[v]).or_insert(0.0) += w;
}
partition.sigma_tot[current] -= k_u;
comm_size[current] -= 1;
let k_u_current = to_comm.get(¤t).copied().unwrap_or(0.0);
let mut best_comm = current;
let mut best_gain = k_u_current - gamma * k_u * partition.sigma_tot[current] / two_m;
for (&comm, &k_u_comm) in &to_comm {
if comm == current {
continue;
}
let gain = k_u_comm - gamma * k_u * partition.sigma_tot[comm] / two_m;
if gain > best_gain + EPS {
best_gain = gain;
best_comm = comm;
}
}
let target = if comm_size[current] > 0 && 0.0 > best_gain + EPS {
free_ids
.pop()
.expect("a size->=2 community leaves a free id")
} else {
best_comm
};
partition.sigma_tot[target] += k_u;
comm_size[target] += 1;
partition.community_of[u] = target;
if target != current {
if comm_size[current] == 0 {
free_ids.push(current);
}
moved_any = true;
for &(v, _) in &g.adjacency[u] {
if v != u && !in_queue[v] {
in_queue[v] = true;
queue.push_back(v);
}
}
}
}
moved_any
}
fn refine_partition(g: &LeidenGraph, partition: &Partition, gamma: f64) -> Partition {
let n = g.n;
let two_m = 2.0 * g.total_weight;
let mut within_parent = vec![0.0_f64; n];
for (u, slot) in within_parent.iter_mut().enumerate() {
let pu = partition.community_of[u];
let mut sum = 0.0;
for &(v, w) in &g.adjacency[u] {
if v != u && partition.community_of[v] == pu {
sum += w;
}
}
*slot = sum;
}
let mut refined_of: Vec<usize> = (0..n).collect();
let mut k_refined = g.strength.clone(); let mut conn = within_parent.clone(); let mut size = vec![1usize; n];
if two_m <= 0.0 {
return Partition {
community_of: refined_of,
sigma_tot: k_refined,
};
}
for u in 0..n {
if refined_of[u] != u || size[u] != 1 {
continue;
}
let pu = partition.community_of[u];
let k_c = partition.sigma_tot[pu]; let k_u = g.strength[u];
let mut to_ref: BTreeMap<usize, f64> = BTreeMap::new();
for &(v, w) in &g.adjacency[u] {
if v == u || partition.community_of[v] != pu {
continue;
}
*to_ref.entry(refined_of[v]).or_insert(0.0) += w;
}
let mut best_comm = u; let mut best_gain = 0.0_f64;
for (&r, &k_u_r) in &to_ref {
if r == u {
continue;
}
let threshold = gamma * k_refined[r] * (k_c - k_refined[r]) / two_m;
if conn[r] + EPS < threshold {
continue;
}
let gain = k_u_r - gamma * k_u * k_refined[r] / two_m;
if gain > best_gain + EPS {
best_gain = gain;
best_comm = r;
}
}
if best_comm != u {
let k_u_r = to_ref.get(&best_comm).copied().unwrap_or(0.0);
conn[best_comm] += within_parent[u] - 2.0 * k_u_r;
k_refined[best_comm] += k_u;
size[best_comm] += 1;
refined_of[u] = best_comm;
}
}
Partition {
community_of: refined_of,
sigma_tot: k_refined,
}
}
fn aggregate_graph(
g: &LeidenGraph,
refined: &Partition,
partition: &Partition,
) -> (LeidenGraph, Partition, Vec<usize>) {
let n = g.n;
let mut dense: Vec<Option<usize>> = vec![None; n];
let mut reps: Vec<usize> = Vec::new(); let mut super_of = vec![0usize; n];
for (u, slot) in super_of.iter_mut().enumerate() {
let r = refined.community_of[u];
let sid = match dense[r] {
Some(s) => s,
None => {
let s = reps.len();
dense[r] = Some(s);
reps.push(u);
s
}
};
*slot = sid;
}
let super_n = reps.len();
let mut acc: BTreeMap<(usize, usize), f64> = BTreeMap::new();
for u in 0..n {
let su = super_of[u];
for &(v, w) in &g.adjacency[u] {
if v < u {
continue;
}
let sv = super_of[v];
let key = if su <= sv { (su, sv) } else { (sv, su) };
*acc.entry(key).or_insert(0.0) += w;
}
}
let edges: Vec<(usize, usize, f64)> = acc.into_iter().map(|((a, b), w)| (a, b, w)).collect();
let super_graph = LeidenGraph::new(super_n, &edges);
let community_of: Vec<usize> = reps
.iter()
.map(|&rep| partition.community_of[rep])
.collect();
let mut super_partition = Partition {
community_of,
sigma_tot: Vec::new(),
};
renumber_dense(&mut super_partition, &super_graph);
(super_graph, super_partition, super_of)
}
fn renumber_dense(partition: &mut Partition, g: &LeidenGraph) {
let mut remap: BTreeMap<usize, usize> = BTreeMap::new();
let mut next = 0usize;
for c in partition.community_of.iter_mut() {
let new = match remap.get(c) {
Some(&x) => x,
None => {
let x = next;
remap.insert(*c, x);
next += 1;
x
}
};
*c = new;
}
let mut sigma = vec![0.0_f64; next];
for u in 0..g.n {
sigma[partition.community_of[u]] += g.strength[u];
}
partition.sigma_tot = sigma;
}
pub(super) fn detect_communities(g: &LeidenGraph, gamma: f64) -> Vec<usize> {
if g.n == 0 {
return Vec::new();
}
if 2.0 * g.total_weight <= 0.0 {
return (0..g.n).collect();
}
let mut current = g.clone();
let mut partition = Partition::singletons(¤t);
let mut level_maps: Vec<Vec<usize>> = Vec::new();
for _ in 0..MAX_LEVELS {
local_moving(¤t, &mut partition, gamma);
let refined = refine_partition(¤t, &partition, gamma);
let (super_graph, super_partition, super_of) =
aggregate_graph(¤t, &refined, &partition);
if super_graph.n == current.n {
break;
}
level_maps.push(super_of);
current = super_graph;
partition = super_partition;
}
let mut membership = partition.community_of.clone();
for map in level_maps.iter().rev() {
let mut lower = vec![0usize; map.len()];
for (low, &high) in map.iter().enumerate() {
lower[low] = membership[high];
}
membership = lower;
}
dense_relabel(&mut membership);
membership
}
fn dense_relabel(membership: &mut [usize]) {
let mut remap: BTreeMap<usize, usize> = BTreeMap::new();
let mut next = 0usize;
for c in membership.iter_mut() {
let new = match remap.get(c) {
Some(&x) => x,
None => {
let x = next;
remap.insert(*c, x);
next += 1;
x
}
};
*c = new;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_strength_invariant(g: &LeidenGraph) {
let sum: f64 = g.strength.iter().sum();
assert!(
(sum - 2.0 * g.total_weight).abs() < 1e-9,
"Σstrength={sum} != 2m={}",
2.0 * g.total_weight
);
}
fn assert_communities_connected(n: usize, edges: &[(usize, usize, f64)], membership: &[usize]) {
let mut adj = vec![Vec::new(); n];
for &(u, v, _) in edges {
if u != v && membership[u] == membership[v] {
adj[u].push(v);
adj[v].push(u);
}
}
let count = membership.iter().copied().max().map_or(0, |m| m + 1);
for comm in 0..count {
let members: Vec<usize> = (0..n).filter(|&u| membership[u] == comm).collect();
if members.len() <= 1 {
continue;
}
let start = members[0];
let mut seen = vec![false; n];
let mut stack = vec![start];
seen[start] = true;
let mut reached = 0;
while let Some(node) = stack.pop() {
reached += 1;
for &next in &adj[node] {
if !seen[next] {
seen[next] = true;
stack.push(next);
}
}
}
assert_eq!(
reached,
members.len(),
"community {comm} is not internally connected"
);
}
}
fn detect(n: usize, edges: &[(usize, usize, f64)]) -> Vec<usize> {
let g = LeidenGraph::new(n, edges);
detect_communities(&g, DEFAULT_GAMMA)
}
fn community_count(membership: &[usize]) -> usize {
membership.iter().copied().max().map_or(0, |m| m + 1)
}
fn triangle(base: usize) -> Vec<(usize, usize, f64)> {
vec![
(base, base + 1, 1.0),
(base + 1, base + 2, 1.0),
(base + 2, base, 1.0),
]
}
fn clique(base: usize, size: usize, weight: f64) -> Vec<(usize, usize, f64)> {
let mut edges = Vec::new();
for i in 0..size {
for j in (i + 1)..size {
edges.push((base + i, base + j, weight));
}
}
edges
}
fn modularity(g: &LeidenGraph, membership: &[usize], gamma: f64) -> f64 {
let two_m = 2.0 * g.total_weight;
if two_m <= 0.0 {
return 0.0;
}
let mut internal_double = 0.0;
for u in 0..g.n {
for &(v, w) in &g.adjacency[u] {
if v != u && membership[v] == membership[u] {
internal_double += w;
}
}
}
let k = membership.iter().copied().max().map_or(0, |m| m + 1);
let mut degree = vec![0.0_f64; k];
for u in 0..g.n {
degree[membership[u]] += g.strength[u];
}
let null: f64 = degree.iter().map(|d| (d / two_m) * (d / two_m)).sum();
internal_double / two_m - gamma * null
}
fn karate_club() -> Vec<(usize, usize, f64)> {
#[rustfmt::skip]
let pairs: &[(usize, usize)] = &[
(0, 1), (0, 2), (0, 3), (0, 4), (0, 5), (0, 6), (0, 7), (0, 8),
(0, 10), (0, 11), (0, 12), (0, 13), (0, 17), (0, 19), (0, 21), (0, 31),
(1, 2), (1, 3), (1, 7), (1, 13), (1, 17), (1, 19), (1, 21), (1, 30),
(2, 3), (2, 7), (2, 8), (2, 9), (2, 13), (2, 27), (2, 28), (2, 32),
(3, 7), (3, 12), (3, 13),
(4, 6), (4, 10),
(5, 6), (5, 10), (5, 16),
(6, 16),
(8, 30), (8, 32), (8, 33),
(9, 33),
(13, 33),
(14, 32), (14, 33),
(15, 32), (15, 33),
(18, 32), (18, 33),
(19, 33),
(20, 32), (20, 33),
(22, 32), (22, 33),
(23, 25), (23, 27), (23, 29), (23, 32), (23, 33),
(24, 25), (24, 27), (24, 31),
(25, 31),
(26, 29), (26, 33),
(27, 33),
(28, 31), (28, 33),
(29, 32), (29, 33),
(30, 32), (30, 33),
(31, 32), (31, 33),
(32, 33),
];
pairs.iter().map(|&(u, v)| (u, v, 1.0)).collect()
}
fn assert_no_improving_single_move(g: &LeidenGraph, membership: &[usize], gamma: f64) {
let base = modularity(g, membership, gamma);
let fresh = membership.iter().copied().max().map_or(0, |m| m + 1);
for u in 0..g.n {
let original = membership[u];
for target in 0..=fresh {
if target == original {
continue;
}
let mut trial = membership.to_vec();
trial[u] = target;
let q = modularity(g, &trial, gamma);
assert!(
q <= base + 1e-9,
"node {u}: moving to community {target} raises Q from {base} to {q}"
);
}
}
}
#[test]
fn two_triangles_with_bridge_split() {
let mut edges = triangle(0);
edges.extend(triangle(3));
edges.push((2, 3, 1.0)); let membership = detect(6, &edges);
assert_eq!(community_count(&membership), 2);
assert_eq!(membership[0], membership[1]);
assert_eq!(membership[1], membership[2]);
assert_eq!(membership[3], membership[4]);
assert_eq!(membership[4], membership[5]);
assert_ne!(membership[0], membership[3]);
assert_communities_connected(6, &edges, &membership);
}
#[test]
fn two_cliques_recover_planted_partition() {
let mut edges = clique(0, 5, 1.0);
edges.extend(clique(5, 5, 1.0));
edges.push((0, 5, 1.0));
edges.push((4, 9, 1.0));
let membership = detect(10, &edges);
assert_eq!(community_count(&membership), 2);
for node in 1..5 {
assert_eq!(membership[0], membership[node]);
}
for node in 6..10 {
assert_eq!(membership[5], membership[node]);
}
assert_ne!(membership[0], membership[5]);
assert_communities_connected(10, &edges, &membership);
}
#[test]
fn no_edge_multi_node_graph_is_all_singletons() {
let membership = detect(3, &[]);
assert_eq!(membership, vec![0, 1, 2]);
}
#[test]
fn empty_graph_returns_empty() {
assert_eq!(detect(0, &[]), Vec::<usize>::new());
}
#[test]
fn single_node_returns_one_community() {
assert_eq!(detect(1, &[]), vec![0]);
}
#[test]
fn triangle_with_isolated_node_yields_two_communities() {
let edges = triangle(0);
let membership = detect(4, &edges);
assert_eq!(community_count(&membership), 2);
assert_eq!(membership[0], membership[1]);
assert_eq!(membership[1], membership[2]);
assert_ne!(membership[0], membership[3]);
}
#[test]
fn four_node_path_is_not_all_singletons() {
let edges = [(0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0)];
let membership = detect(4, &edges);
assert!(
community_count(&membership) < 4,
"path collapsed to {} singletons",
community_count(&membership)
);
assert_communities_connected(4, &edges, &membership);
}
#[test]
fn duplicate_and_reciprocal_edges_fold_by_summation() {
let g = LeidenGraph::new(2, &[(0, 1, 1.0), (1, 0, 2.0)]);
assert_eq!(g.adjacency[0], vec![(1, 3.0)]);
assert_eq!(g.adjacency[1], vec![(0, 3.0)]);
assert_eq!(g.strength, vec![3.0, 3.0]);
assert_eq!(g.total_weight, 3.0);
assert_strength_invariant(&g);
}
#[test]
fn self_loop_contributes_double_strength_single_weight() {
let g = LeidenGraph::new(2, &[(0, 0, 2.0), (0, 1, 1.0)]);
assert_eq!(g.adjacency[0], vec![(0, 2.0), (1, 1.0)]);
assert_eq!(g.strength[0], 5.0); assert_eq!(g.strength[1], 1.0);
assert_eq!(g.total_weight, 3.0); assert_strength_invariant(&g);
}
#[test]
fn aggregation_preserves_strength_invariant_and_total_weight() {
let mut edges = triangle(0);
edges.extend(triangle(3));
edges.push((2, 3, 1.0));
let g = LeidenGraph::new(6, &edges);
let original_total = g.total_weight;
let mut partition = Partition::singletons(&g);
local_moving(&g, &mut partition, DEFAULT_GAMMA);
let refined = refine_partition(&g, &partition, DEFAULT_GAMMA);
let (super_graph, _, _) = aggregate_graph(&g, &refined, &partition);
assert_strength_invariant(&super_graph);
assert!((super_graph.total_weight - original_total).abs() < 1e-9);
}
#[test]
fn multi_level_back_projection_maps_original_nodes() {
let mut edges = Vec::new();
for cluster in 0..4 {
edges.extend(triangle(cluster * 3));
}
edges.push((2, 3, 0.1));
edges.push((5, 6, 0.1));
edges.push((8, 9, 0.1));
let membership = detect(12, &edges);
for cluster in 0..4 {
let base = cluster * 3;
assert_eq!(membership[base], membership[base + 1]);
assert_eq!(membership[base + 1], membership[base + 2]);
}
assert_eq!(community_count(&membership), 4);
assert_communities_connected(12, &edges, &membership);
}
#[test]
fn output_is_deterministic_across_repeats() {
let mut edges = clique(0, 5, 1.0);
edges.extend(clique(5, 5, 1.0));
edges.push((0, 5, 1.0));
let first = detect(10, &edges);
for _ in 0..10 {
assert_eq!(detect(10, &edges), first);
}
}
#[test]
fn output_is_invariant_to_edge_list_order() {
let mut edges = clique(0, 5, 1.0);
edges.extend(clique(5, 5, 1.0));
edges.push((0, 5, 1.0));
edges.push((4, 9, 1.0));
let baseline = detect(10, &edges);
let mut reversed = edges.clone();
reversed.reverse();
assert_eq!(detect(10, &reversed), baseline);
}
#[test]
fn weighting_pulls_a_node_toward_the_heavier_community() {
let mut edges = clique(1, 3, 1.0);
edges.extend(clique(4, 3, 1.0));
edges.push((0, 1, 0.5)); edges.push((0, 4, 5.0)); let membership = detect(7, &edges);
assert_eq!(membership[0], membership[4]);
assert_communities_connected(7, &edges, &membership);
}
#[test]
fn local_moving_isolates_over_merged_self_loop_cluster() {
let g = LeidenGraph::new(2, &[(0, 0, 10.0), (1, 1, 10.0), (0, 1, 0.1)]);
let mut partition = Partition {
community_of: vec![0, 0],
sigma_tot: vec![40.2, 0.0],
};
local_moving(&g, &mut partition, DEFAULT_GAMMA);
assert_ne!(
partition.community_of[0], partition.community_of[1],
"an isolation move must split the over-merged self-loop cluster"
);
}
#[test]
fn zachary_karate_club_finds_modular_structure() {
let edges = karate_club();
let g = LeidenGraph::new(34, &edges);
let membership = detect_communities(&g, DEFAULT_GAMMA);
let q = modularity(&g, &membership, DEFAULT_GAMMA);
let count = community_count(&membership);
assert!(
(2..=5).contains(&count),
"unexpected community count {count}"
);
assert!(q >= 0.40, "modularity Q={q} far below the ~0.42 optimum");
assert!(
q <= 0.42 + 1e-6,
"modularity Q={q} exceeds theoretical optimum"
);
assert_communities_connected(34, &edges, &membership);
assert_eq!(detect_communities(&g, DEFAULT_GAMMA), membership); }
#[test]
fn local_moving_partition_has_no_improving_single_node_move() {
let two_cliques = {
let mut e = clique(0, 5, 1.0);
e.extend(clique(5, 5, 1.0));
e.push((0, 5, 1.0));
e.push((4, 9, 1.0));
(10usize, e)
};
for (n, edges) in [two_cliques, (34usize, karate_club())] {
let g = LeidenGraph::new(n, &edges);
let mut partition = Partition::singletons(&g);
local_moving(&g, &mut partition, DEFAULT_GAMMA);
let distinct = partition
.community_of
.iter()
.copied()
.collect::<std::collections::BTreeSet<usize>>()
.len();
assert!(
1 < distinct && distinct < n,
"local moving produced a degenerate partition: {distinct} communities over {n} nodes"
);
assert_no_improving_single_move(&g, &partition.community_of, DEFAULT_GAMMA);
}
}
}