#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::many_single_char_names
)]
use crate::core::graph::EdgeId;
use crate::core::{Graph, IgraphError, IgraphResult};
const MAX_LEVELS: usize = 64;
const MAX_PASSES_PER_LEVEL: usize = 256;
#[derive(Debug, Clone)]
pub struct LouvainResult {
pub membership: Vec<u32>,
pub modularity: f64,
pub levels: Vec<Vec<u32>>,
pub modularities: Vec<f64>,
}
pub fn louvain(graph: &Graph) -> IgraphResult<LouvainResult> {
louvain_with_options(graph, None, 1.0, 0)
}
pub fn louvain_weighted(graph: &Graph, weights: &[f64]) -> IgraphResult<LouvainResult> {
louvain_with_options(graph, Some(weights), 1.0, 0)
}
pub fn louvain_with_options(
graph: &Graph,
weights: Option<&[f64]>,
resolution: f64,
seed: u64,
) -> IgraphResult<LouvainResult> {
if graph.is_directed() {
return Err(IgraphError::Unsupported(
"louvain requires an undirected graph",
));
}
if !resolution.is_finite() || resolution < 0.0 {
return Err(IgraphError::InvalidArgument(
"resolution parameter must be non-negative and finite".to_string(),
));
}
validate_weights(graph, weights)?;
let vcount = graph.vcount() as usize;
if vcount == 0 {
return Ok(LouvainResult {
membership: Vec::new(),
modularity: 0.0,
levels: Vec::new(),
modularities: Vec::new(),
});
}
let mut state = build_initial_state(graph, weights)?;
let mut original_to_current: Vec<u32> = (0..vcount).map(|i| i as u32).collect();
let mut levels: Vec<Vec<u32>> = Vec::new();
let mut modularities: Vec<f64> = Vec::new();
let mut rng = SplitMix64::new(seed);
for _level in 0..MAX_LEVELS {
let initial_n = state.n;
let prev_q = current_modularity(&state, resolution);
let (q, changed_any) = local_moving_loop(&mut state, resolution, &mut rng);
let k = reindex_membership(&mut state.membership);
for slot in &mut original_to_current {
*slot = state.membership[*slot as usize];
}
levels.push(original_to_current.clone());
modularities.push(q);
if !changed_any || k as usize == initial_n || q <= prev_q + Q_EPS {
break;
}
state = aggregate(&state, k as usize);
}
let membership = original_to_current;
let modularity = modularities.last().copied().unwrap_or(0.0);
Ok(LouvainResult {
membership,
modularity,
levels,
modularities,
})
}
const Q_EPS: f64 = 1e-10;
struct WorkingState {
n: usize,
adj: Vec<Vec<(u32, f64)>>,
loop_weight: Vec<f64>,
vertex_weight: Vec<f64>,
membership: Vec<u32>,
weight_all: Vec<f64>,
weight_inside: Vec<f64>,
size: Vec<u32>,
weight_sum: f64,
}
fn build_initial_state(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<WorkingState> {
let n = graph.vcount() as usize;
let m = graph.ecount();
let m_u32 = u32::try_from(m)
.map_err(|_| IgraphError::InvalidArgument("edge count exceeds u32::MAX".into()))?;
let mut edges: Vec<(u32, u32, f64)> = Vec::with_capacity(m);
for e in 0..m_u32 {
let (u, v) = graph.edge(e as EdgeId)?;
let w = weights.map_or(1.0, |ws| ws[e as usize]);
let (a, b) = if u <= v { (u, v) } else { (v, u) };
edges.push((a, b, w));
}
Ok(init_from_edges(n, &edges))
}
fn init_from_edges(n: usize, edges: &[(u32, u32, f64)]) -> WorkingState {
let mut adj: Vec<Vec<(u32, f64)>> = vec![Vec::new(); n];
let mut loop_weight = vec![0.0_f64; n];
let mut vertex_weight = vec![0.0_f64; n];
let mut total: f64 = 0.0;
for &(u, v, w) in edges {
total += w;
let uu = u as usize;
let vv = v as usize;
if uu == vv {
loop_weight[uu] += 2.0 * w;
vertex_weight[uu] += 2.0 * w;
} else {
adj[uu].push((v, w));
adj[vv].push((u, w));
vertex_weight[uu] += w;
vertex_weight[vv] += w;
}
}
let membership: Vec<u32> = (0..n as u32).collect();
let weight_all = vertex_weight.clone();
let weight_inside = loop_weight.clone();
let size = vec![1_u32; n];
let weight_sum = 2.0 * total;
WorkingState {
n,
adj,
loop_weight,
vertex_weight,
membership,
weight_all,
weight_inside,
size,
weight_sum,
}
}
fn current_modularity(state: &WorkingState, resolution: f64) -> f64 {
if state.weight_sum <= 0.0 {
return 0.0;
}
let m = state.weight_sum;
let mut q = 0.0_f64;
for c in 0..state.n {
if state.size[c] > 0 {
let kall = state.weight_all[c];
q += (state.weight_inside[c] - resolution * kall * kall / m) / m;
}
}
q
}
fn local_moving_loop(
state: &mut WorkingState,
resolution: f64,
rng: &mut SplitMix64,
) -> (f64, bool) {
let mut any_changed = false;
let mut q = current_modularity(state, resolution);
let mut node_order: Vec<usize> = (0..state.n).collect();
if state.n > 1 {
shuffle_in_place(&mut node_order, rng);
}
for _ in 0..MAX_PASSES_PER_LEVEL {
let pass_q = q;
let mut changed = false;
if state.n > 1 {
let i1 = rng.gen_index(state.n);
let i2 = rng.gen_index(state.n);
node_order.swap(i1, i2);
}
for &ni in &node_order {
local_move_vertex(state, ni, resolution, &mut changed);
}
q = current_modularity(state, resolution);
if changed && q > pass_q + Q_EPS {
any_changed = true;
continue;
}
break;
}
(q, any_changed)
}
fn local_move_vertex(state: &mut WorkingState, ni: usize, resolution: f64, changed: &mut bool) {
let weight_all_v = state.vertex_weight[ni];
let weight_loop_v = state.loop_weight[ni];
let old_id = state.membership[ni] as usize;
let mut neigh_communities: Vec<u32> = Vec::with_capacity(state.adj[ni].len());
let mut neigh_weights: Vec<f64> = Vec::with_capacity(state.adj[ni].len());
let mut weight_inside_old = 0.0_f64;
for &(nei, w) in &state.adj[ni] {
let c = state.membership[nei as usize];
if c as usize == old_id {
weight_inside_old += w;
}
if let Some(idx) = neigh_communities.iter().position(|&x| x == c) {
neigh_weights[idx] += w;
} else {
neigh_communities.push(c);
neigh_weights.push(w);
}
}
state.size[old_id] -= 1;
state.weight_all[old_id] -= weight_all_v;
state.weight_inside[old_id] -= 2.0 * weight_inside_old + weight_loop_v;
let mut best_id = old_id;
let mut best_gain = 0.0_f64;
let mut best_weight = weight_inside_old;
for (idx, &c) in neigh_communities.iter().enumerate() {
let w_to_c = neigh_weights[idx];
let kall_c = state.weight_all[c as usize];
let gain = w_to_c - resolution * kall_c * weight_all_v / state.weight_sum;
if gain > best_gain {
best_gain = gain;
best_id = c as usize;
best_weight = w_to_c;
}
}
state.membership[ni] = best_id as u32;
state.size[best_id] += 1;
state.weight_all[best_id] += weight_all_v;
state.weight_inside[best_id] += 2.0 * best_weight + weight_loop_v;
if best_id != old_id {
*changed = true;
}
}
fn reindex_membership(membership: &mut [u32]) -> u32 {
if membership.is_empty() {
return 0;
}
let max = *membership.iter().max().unwrap_or(&0);
let mut remap: Vec<i64> = vec![-1; max as usize + 1];
let mut next: u32 = 0;
for slot in membership.iter_mut() {
let old = *slot as usize;
if remap[old] < 0 {
remap[old] = i64::from(next);
next += 1;
}
*slot = remap[old] as u32;
}
next
}
fn aggregate(state: &WorkingState, k: usize) -> WorkingState {
let mut loop_raw: Vec<f64> = vec![0.0_f64; k];
let mut inter: Vec<Vec<(u32, f64)>> = vec![Vec::new(); k];
for u in 0..state.n {
let raw = state.loop_weight[u] * 0.5;
if raw > 0.0 {
let mu = state.membership[u] as usize;
loop_raw[mu] += raw;
}
}
for u in 0..state.n {
for &(v, w) in &state.adj[u] {
let v_us = v as usize;
if u >= v_us {
continue;
}
let mu = state.membership[u] as usize;
let mv = state.membership[v_us] as usize;
if mu == mv {
loop_raw[mu] += w;
} else {
let (a, b) = if mu < mv { (mu, mv) } else { (mv, mu) };
push_or_merge(&mut inter[a], b as u32, w);
}
}
}
let mut edges: Vec<(u32, u32, f64)> = Vec::new();
for u in 0..k {
if loop_raw[u] > 0.0 {
edges.push((u as u32, u as u32, loop_raw[u]));
}
for &(v, w) in &inter[u] {
edges.push((u as u32, v, w));
}
}
init_from_edges(k, &edges)
}
fn push_or_merge(list: &mut Vec<(u32, f64)>, neighbour: u32, weight: f64) {
for slot in list.iter_mut() {
if slot.0 == neighbour {
slot.1 += weight;
return;
}
}
list.push((neighbour, weight));
}
fn validate_weights(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<()> {
let Some(w) = weights else {
return Ok(());
};
let m = graph.ecount();
if w.len() != m {
return Err(IgraphError::InvalidArgument(format!(
"weights vector size ({}) differs from edge count ({})",
w.len(),
m
)));
}
for (e, &v) in w.iter().enumerate() {
if v.is_nan() {
return Err(IgraphError::InvalidArgument(format!(
"weight at edge {e} is NaN"
)));
}
if v < 0.0 {
return Err(IgraphError::InvalidArgument(format!(
"weight at edge {e} is negative ({v}); louvain weights must be non-negative"
)));
}
}
Ok(())
}
struct SplitMix64(u64);
impl SplitMix64 {
fn new(seed: u64) -> Self {
Self(seed)
}
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn gen_index(&mut self, bound: usize) -> usize {
debug_assert!(bound > 0);
let r = self.next_u64() % (bound as u64);
usize::try_from(r).expect("bound fits in usize by construction")
}
}
fn shuffle_in_place<T>(slice: &mut [T], rng: &mut SplitMix64) {
let len = slice.len();
for i in (1..len).rev() {
let j = rng.gen_index(i + 1);
slice.swap(i, j);
}
}
#[cfg(test)]
#[allow(clippy::float_cmp)]
mod tests {
use super::*;
fn add_undirected_edges(g: &mut Graph, edges: &[(u32, u32)]) {
for &(u, v) in edges {
g.add_edge(u, v).unwrap();
}
}
#[test]
fn empty_graph_returns_empty_membership() {
let g = Graph::with_vertices(0);
let r = louvain(&g).unwrap();
assert_eq!(r.membership.len(), 0);
assert_eq!(r.modularity, 0.0);
assert!(r.levels.is_empty());
}
#[test]
fn isolated_vertices_each_their_own_community() {
let g = Graph::with_vertices(4);
let r = louvain(&g).unwrap();
assert_eq!(r.membership.len(), 4);
for &c in &r.membership {
assert!(c < 4);
}
}
#[test]
fn single_edge_two_communities_or_merged() {
let mut g = Graph::with_vertices(2);
g.add_edge(0, 1).unwrap();
let r = louvain(&g).unwrap();
assert_eq!(r.membership[0], r.membership[1]);
}
#[test]
fn two_triangles_bridge_finds_two_communities() {
let mut g = Graph::with_vertices(6);
add_undirected_edges(
&mut g,
&[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
);
let r = louvain(&g).unwrap();
assert_eq!(r.membership[0], r.membership[1]);
assert_eq!(r.membership[1], r.membership[2]);
assert_eq!(r.membership[3], r.membership[4]);
assert_eq!(r.membership[4], r.membership[5]);
assert_ne!(r.membership[0], r.membership[3]);
assert!(r.modularity > 0.35);
}
#[test]
fn complete_k5_gives_one_or_two_communities() {
let mut g = Graph::with_vertices(5);
for u in 0..5u32 {
for v in (u + 1)..5 {
g.add_edge(u, v).unwrap();
}
}
let r = louvain(&g).unwrap();
let k: std::collections::BTreeSet<_> = r.membership.iter().copied().collect();
assert!(k.len() <= 2, "K5 should yield ≤ 2 communities");
assert!(r.modularity >= -1e-9);
}
#[test]
fn disconnected_components_separate_communities() {
let mut g = Graph::with_vertices(8);
for u in 0..4u32 {
for v in (u + 1)..4 {
g.add_edge(u, v).unwrap();
}
}
for u in 4..8u32 {
for v in (u + 1)..8 {
g.add_edge(u, v).unwrap();
}
}
let r = louvain(&g).unwrap();
assert_eq!(r.membership[0], r.membership[1]);
assert_eq!(r.membership[4], r.membership[5]);
assert_ne!(r.membership[0], r.membership[4]);
assert!(r.modularity > 0.4);
}
#[test]
fn directed_graph_returns_unsupported() {
let mut g = Graph::new(3, true).unwrap();
g.add_edge(0, 1).unwrap();
assert!(louvain(&g).is_err());
}
#[test]
fn weights_length_mismatch_errors() {
let mut g = Graph::with_vertices(3);
g.add_edge(0, 1).unwrap();
g.add_edge(1, 2).unwrap();
let r = louvain_weighted(&g, &[1.0]);
assert!(r.is_err());
}
#[test]
fn weights_negative_errors() {
let mut g = Graph::with_vertices(3);
g.add_edge(0, 1).unwrap();
g.add_edge(1, 2).unwrap();
let r = louvain_weighted(&g, &[1.0, -0.5]);
assert!(r.is_err());
}
#[test]
fn weights_nan_errors() {
let mut g = Graph::with_vertices(3);
g.add_edge(0, 1).unwrap();
g.add_edge(1, 2).unwrap();
let r = louvain_weighted(&g, &[1.0, f64::NAN]);
assert!(r.is_err());
}
#[test]
fn negative_resolution_errors() {
let mut g = Graph::with_vertices(3);
g.add_edge(0, 1).unwrap();
assert!(louvain_with_options(&g, None, -0.1, 0).is_err());
}
#[test]
fn weighted_unit_matches_unweighted() {
let mut g = Graph::with_vertices(6);
add_undirected_edges(
&mut g,
&[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
);
let ones = vec![1.0; g.ecount()];
let a = louvain(&g).unwrap();
let b = louvain_weighted(&g, &ones).unwrap();
assert!((a.modularity - b.modularity).abs() < 1e-9);
for i in 0..6 {
for j in 0..6 {
let ai = a.membership[i] == a.membership[j];
let bi = b.membership[i] == b.membership[j];
assert_eq!(ai, bi, "partition mismatch at ({i},{j})");
}
}
}
#[test]
fn modularity_matches_external_recompute() {
let mut g = Graph::with_vertices(6);
add_undirected_edges(
&mut g,
&[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
);
let r = louvain(&g).unwrap();
let q_recomputed =
crate::algorithms::community::modularity::modularity(&g, &r.membership, 1.0)
.unwrap()
.unwrap();
assert!(
(r.modularity - q_recomputed).abs() < 1e-9,
"louvain reports {} but modularity() reports {}",
r.modularity,
q_recomputed
);
}
#[test]
fn resolution_zero_gives_single_community() {
let mut g = Graph::with_vertices(6);
add_undirected_edges(
&mut g,
&[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
);
let r = louvain_with_options(&g, None, 0.0, 0).unwrap();
let k: std::collections::BTreeSet<_> = r.membership.iter().copied().collect();
assert_eq!(k.len(), 1);
}
#[test]
fn resolution_high_gives_many_communities() {
let mut g = Graph::with_vertices(6);
add_undirected_edges(
&mut g,
&[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
);
let r = louvain_with_options(&g, None, 10.0, 0).unwrap();
let k: std::collections::BTreeSet<_> = r.membership.iter().copied().collect();
assert!(
k.len() >= 3,
"with γ=10 we expect more granular communities"
);
}
#[test]
fn deterministic_with_seed() {
let mut g = Graph::with_vertices(10);
for u in 0..10u32 {
for v in (u + 1)..10 {
if (u + v) % 3 != 0 {
g.add_edge(u, v).unwrap();
}
}
}
let a = louvain_with_options(&g, None, 1.0, 42).unwrap();
let b = louvain_with_options(&g, None, 1.0, 42).unwrap();
assert_eq!(a.membership, b.membership);
assert!((a.modularity - b.modularity).abs() < 1e-12);
}
#[test]
fn levels_recorded() {
let mut g = Graph::with_vertices(6);
add_undirected_edges(
&mut g,
&[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
);
let r = louvain(&g).unwrap();
assert!(!r.levels.is_empty());
assert_eq!(r.levels.len(), r.modularities.len());
assert_eq!(*r.levels.last().unwrap(), r.membership);
}
#[test]
fn karate_club_finds_known_partition() {
let edges: &[(u32, u32)] = &[
(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),
];
let mut g = Graph::with_vertices(34);
add_undirected_edges(&mut g, edges);
let r = louvain(&g).unwrap();
assert!(
r.modularity > 0.39,
"karate club Louvain Q should exceed 0.39, got {}",
r.modularity
);
let k: std::collections::BTreeSet<_> = r.membership.iter().copied().collect();
assert!(k.len() >= 2 && k.len() <= 6);
}
}