Skip to main content

rust_igraph/algorithms/community/
louvain.rs

1//! Louvain multilevel community detection (ALGO-CO-002).
2//!
3//! Counterpart of `igraph_community_multilevel()` from
4//! `references/igraph/src/community/louvain.c`.
5//!
6//! Blondel, Guillaume, Lambiotte, Lefebvre (2008):
7//! *Fast unfolding of communities in large networks.* JSTAT 10008(10):
8//! 6. <https://doi.org/10.1088/1742-5468/2008/10/P10008>
9//!
10//! Two-phase loop:
11//!
12//! - **Local moving**: in a deterministic-but-seeded shuffled order,
13//!   each vertex is greedily reassigned to the neighbour community
14//!   that maximises modularity gain `ΔQ`.
15//! - **Aggregation**: every community becomes a super-vertex; intra-
16//!   community edges turn into self-loops and parallel inter-community
17//!   edges are summed.
18//!
19//! Repeats until a pass produces no improvement (modularity does not
20//! strictly increase). Modularity is the Newman-Girvan formulation,
21//! optionally with a resolution parameter `γ` (default 1.0).
22//!
23//! Self-rolled, dependency-free. Determinism comes from an inline
24//! `SplitMix64` PRNG seeded by the caller; the default convenience
25//! wrappers use seed `0` so that repeated calls on the same graph
26//! produce identical partitions.
27
28// All `usize` -> `u32` casts in this module are bounded by `n`
29// (the graph vertex count), which originates from `Graph::vcount(): u32`
30// and so can never truncate. The single-char names (`u`, `v`, `w`, `c`)
31// are domain-standard for graph endpoints / weights / communities.
32#![allow(
33    clippy::cast_possible_truncation,
34    clippy::cast_sign_loss,
35    clippy::many_single_char_names
36)]
37
38use crate::core::graph::EdgeId;
39use crate::core::{Graph, IgraphError, IgraphResult};
40
41const MAX_LEVELS: usize = 64;
42const MAX_PASSES_PER_LEVEL: usize = 256;
43
44/// Result of a Louvain run.
45///
46/// `membership` is a length-`vcount` vector of compacted community
47/// labels (each in `0..k` where `k` is the number of communities at
48/// the final level). `modularity` is the final modularity score.
49/// `levels` and `modularities` record the per-level partitions and
50/// modularity values respectively (length = number of aggregation
51/// rounds + 1).
52#[derive(Debug, Clone)]
53pub struct LouvainResult {
54    pub membership: Vec<u32>,
55    pub modularity: f64,
56    pub levels: Vec<Vec<u32>>,
57    pub modularities: Vec<f64>,
58}
59
60/// Run Louvain with the default options (`γ = 1`, unweighted,
61/// deterministic seed `0`).
62///
63/// # Errors
64/// - [`IgraphError::Unsupported`] if `graph` is directed.
65///
66/// # Examples
67///
68/// ```
69/// use rust_igraph::{Graph, louvain};
70///
71/// // Two triangles joined by a bridge edge form two obvious communities.
72/// let mut g = Graph::with_vertices(6);
73/// for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
74///     g.add_edge(u, v).unwrap();
75/// }
76/// let result = louvain(&g).unwrap();
77/// assert_eq!(result.membership[0], result.membership[1]);
78/// assert_eq!(result.membership[3], result.membership[4]);
79/// assert_ne!(result.membership[0], result.membership[3]);
80/// assert!(result.modularity > 0.35);
81/// ```
82pub fn louvain(graph: &Graph) -> IgraphResult<LouvainResult> {
83    louvain_with_options(graph, None, 1.0, 0)
84}
85
86/// Run Louvain with per-edge weights (`γ = 1`, deterministic seed `0`).
87///
88/// # Errors
89/// - [`IgraphError::Unsupported`] if `graph` is directed.
90/// - [`IgraphError::InvalidArgument`] if `weights.len() != ecount` or
91///   any weight is negative or NaN.
92///
93/// # Examples
94///
95/// ```
96/// use rust_igraph::{Graph, louvain_weighted};
97///
98/// // Two K3s + bridge. Heavy intra-clique weights vs a thin bridge:
99/// // Louvain splits the two triangles.
100/// let mut g = Graph::with_vertices(6);
101/// for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
102///     g.add_edge(u, v).unwrap();
103/// }
104/// let weights = vec![10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 0.01];
105/// let r = louvain_weighted(&g, &weights).unwrap();
106/// assert_eq!(r.membership[0], r.membership[1]);
107/// assert_ne!(r.membership[0], r.membership[3]);
108/// ```
109pub fn louvain_weighted(graph: &Graph, weights: &[f64]) -> IgraphResult<LouvainResult> {
110    louvain_with_options(graph, Some(weights), 1.0, 0)
111}
112
113/// Run Louvain with the full set of options.
114///
115/// - `weights`: per-edge weights or `None` for unit weights. Must be
116///   non-negative and not NaN.
117/// - `resolution`: γ ≥ 0. Lower values produce fewer, larger communities;
118///   higher values produce more, smaller communities. `γ = 1` recovers
119///   the classical Newman-Girvan modularity.
120/// - `seed`: PRNG seed for the node-order shuffle. Same seed produces
121///   the same partition on the same graph.
122///
123/// # Errors
124/// - [`IgraphError::Unsupported`] if `graph` is directed.
125/// - [`IgraphError::InvalidArgument`] for malformed weights or
126///   `resolution < 0` / `!resolution.is_finite()`.
127///
128/// # Examples
129///
130/// ```
131/// use rust_igraph::{Graph, louvain_with_options};
132///
133/// let mut g = Graph::with_vertices(6);
134/// for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
135///     g.add_edge(u, v).unwrap();
136/// }
137/// // Determinism: same seed reproduces the same partition bit-for-bit.
138/// let a = louvain_with_options(&g, None, 1.0, 42).unwrap();
139/// let b = louvain_with_options(&g, None, 1.0, 42).unwrap();
140/// assert_eq!(a.membership, b.membership);
141/// ```
142pub fn louvain_with_options(
143    graph: &Graph,
144    weights: Option<&[f64]>,
145    resolution: f64,
146    seed: u64,
147) -> IgraphResult<LouvainResult> {
148    if graph.is_directed() {
149        return Err(IgraphError::Unsupported(
150            "louvain requires an undirected graph",
151        ));
152    }
153    if !resolution.is_finite() || resolution < 0.0 {
154        return Err(IgraphError::InvalidArgument(
155            "resolution parameter must be non-negative and finite".to_string(),
156        ));
157    }
158    validate_weights(graph, weights)?;
159
160    let vcount = graph.vcount() as usize;
161    if vcount == 0 {
162        return Ok(LouvainResult {
163            membership: Vec::new(),
164            modularity: 0.0,
165            levels: Vec::new(),
166            modularities: Vec::new(),
167        });
168    }
169
170    // Build the initial working representation from the input graph.
171    let mut state = build_initial_state(graph, weights)?;
172
173    // Track the cumulative mapping from original vertex IDs to the
174    // current super-vertex IDs. Starts as the identity permutation.
175    let mut original_to_current: Vec<u32> = (0..vcount).map(|i| i as u32).collect();
176
177    let mut levels: Vec<Vec<u32>> = Vec::new();
178    let mut modularities: Vec<f64> = Vec::new();
179    let mut rng = SplitMix64::new(seed);
180
181    for _level in 0..MAX_LEVELS {
182        let initial_n = state.n;
183        let prev_q = current_modularity(&state, resolution);
184
185        // Run one full local-moving pass loop until no further
186        // improvement happens within this level.
187        let (q, changed_any) = local_moving_loop(&mut state, resolution, &mut rng);
188
189        // Re-index the resulting membership to contiguous [0, k) labels.
190        let k = reindex_membership(&mut state.membership);
191
192        // Update the cumulative original→current mapping.
193        for slot in &mut original_to_current {
194            *slot = state.membership[*slot as usize];
195        }
196
197        // Record the level snapshot (membership on original vertices).
198        levels.push(original_to_current.clone());
199        modularities.push(q);
200
201        // Stop if no merge happened or modularity didn't improve.
202        if !changed_any || k as usize == initial_n || q <= prev_q + Q_EPS {
203            break;
204        }
205
206        // Aggregate into super-vertex graph for next level.
207        state = aggregate(&state, k as usize);
208    }
209
210    // The cumulative mapping IS the final membership.
211    let membership = original_to_current;
212    let modularity = modularities.last().copied().unwrap_or(0.0);
213
214    Ok(LouvainResult {
215        membership,
216        modularity,
217        levels,
218        modularities,
219    })
220}
221
222// ---------- modularity-gain epsilon ----------
223
224const Q_EPS: f64 = 1e-10;
225
226// ---------- internal state ----------
227
228/// Working graph + per-community state for one level. Non-self-loop
229/// edges contribute two adjacency entries (one from each endpoint);
230/// self-loops never appear in `adj` — they live in `loop_weight`.
231///
232/// Self-loops follow the upstream `IGRAPH_LOOPS_TWICE` convention: a
233/// raw self-loop of weight `w` contributes `2w` to both `loop_weight`
234/// and `vertex_weight` (`k_v`), matching `A_vv = 2w` in the symmetric
235/// adjacency matrix.
236struct WorkingState {
237    n: usize,
238    /// `adj[v]` = list of `(neighbour, weight)` for every non-loop
239    /// incident edge of `v`. Each non-loop edge appears twice (once
240    /// per endpoint).
241    adj: Vec<Vec<(u32, f64)>>,
242    /// `loop_weight[v]` = `2 ×` raw self-loop weight at vertex `v`.
243    loop_weight: Vec<f64>,
244    /// `vertex_weight[v]` = `k_v` = sum of `A_vu` for all `u`. For a
245    /// non-loop edge of weight `w`, contributes `w` to both endpoints;
246    /// for a self-loop, contributes `2w` to the single endpoint.
247    vertex_weight: Vec<f64>,
248    /// `membership[v]` = current community label.
249    membership: Vec<u32>,
250    /// Per-community total incident weight, summed with the same loop
251    /// convention as `vertex_weight`.
252    weight_all: Vec<f64>,
253    /// Per-community internal weight `Σ_{i,j ∈ c} A_ij` =
254    /// `2 ×` non-loop intra weight `+ 2 ×` raw loop weight.
255    weight_inside: Vec<f64>,
256    /// Per-community vertex count.
257    size: Vec<u32>,
258    /// `2 × Σ_e w_e` — Newman-Girvan normaliser used in the gain.
259    weight_sum: f64,
260}
261
262fn build_initial_state(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<WorkingState> {
263    let n = graph.vcount() as usize;
264    let m = graph.ecount();
265    let m_u32 = u32::try_from(m)
266        .map_err(|_| IgraphError::InvalidArgument("edge count exceeds u32::MAX".into()))?;
267    let mut edges: Vec<(u32, u32, f64)> = Vec::with_capacity(m);
268    for e in 0..m_u32 {
269        let (u, v) = graph.edge(e as EdgeId)?;
270        let w = weights.map_or(1.0, |ws| ws[e as usize]);
271        let (a, b) = if u <= v { (u, v) } else { (v, u) };
272        edges.push((a, b, w));
273    }
274    Ok(init_from_edges(n, &edges))
275}
276
277/// Shared initialisation: build a `WorkingState` from a list of
278/// `(u, v, w)` edges with `u <= v`. Self-loops follow the upstream
279/// `IGRAPH_LOOPS_TWICE` convention — a loop of weight `w` contributes
280/// `2w` to `weight_all` and `2w` to `weight_inside` (since `A_vv = 2w`
281/// in the symmetric adjacency).
282fn init_from_edges(n: usize, edges: &[(u32, u32, f64)]) -> WorkingState {
283    let mut adj: Vec<Vec<(u32, f64)>> = vec![Vec::new(); n];
284    let mut loop_weight = vec![0.0_f64; n];
285    let mut vertex_weight = vec![0.0_f64; n];
286    let mut total: f64 = 0.0;
287    for &(u, v, w) in edges {
288        total += w;
289        let uu = u as usize;
290        let vv = v as usize;
291        if uu == vv {
292            loop_weight[uu] += 2.0 * w;
293            vertex_weight[uu] += 2.0 * w;
294        } else {
295            adj[uu].push((v, w));
296            adj[vv].push((u, w));
297            vertex_weight[uu] += w;
298            vertex_weight[vv] += w;
299        }
300    }
301
302    let membership: Vec<u32> = (0..n as u32).collect();
303    let weight_all = vertex_weight.clone();
304    let weight_inside = loop_weight.clone();
305    let size = vec![1_u32; n];
306    let weight_sum = 2.0 * total;
307
308    WorkingState {
309        n,
310        adj,
311        loop_weight,
312        vertex_weight,
313        membership,
314        weight_all,
315        weight_inside,
316        size,
317        weight_sum,
318    }
319}
320
321/// Modularity of the current partition, computed directly from the
322/// per-community sums. Mirrors the upstream `i_multilevel_community_modularity`.
323fn current_modularity(state: &WorkingState, resolution: f64) -> f64 {
324    if state.weight_sum <= 0.0 {
325        return 0.0;
326    }
327    let m = state.weight_sum;
328    let mut q = 0.0_f64;
329    for c in 0..state.n {
330        if state.size[c] > 0 {
331            let kall = state.weight_all[c];
332            q += (state.weight_inside[c] - resolution * kall * kall / m) / m;
333        }
334    }
335    q
336}
337
338/// Repeatedly applies local-moving passes until a pass produces no
339/// improvement. Returns `(final_modularity, any_pass_changed_at_least_one)`.
340fn local_moving_loop(
341    state: &mut WorkingState,
342    resolution: f64,
343    rng: &mut SplitMix64,
344) -> (f64, bool) {
345    let mut any_changed = false;
346    let mut q = current_modularity(state, resolution);
347    let mut node_order: Vec<usize> = (0..state.n).collect();
348    if state.n > 1 {
349        shuffle_in_place(&mut node_order, rng);
350    }
351
352    for _ in 0..MAX_PASSES_PER_LEVEL {
353        let pass_q = q;
354        let mut changed = false;
355
356        // Upstream applies a single random inversion to node_order
357        // every pass (rather than a full reshuffle) — see issue #2650.
358        if state.n > 1 {
359            let i1 = rng.gen_index(state.n);
360            let i2 = rng.gen_index(state.n);
361            node_order.swap(i1, i2);
362        }
363
364        for &ni in &node_order {
365            local_move_vertex(state, ni, resolution, &mut changed);
366        }
367
368        q = current_modularity(state, resolution);
369        if changed && q > pass_q + Q_EPS {
370            any_changed = true;
371            continue;
372        }
373        break;
374    }
375
376    (q, any_changed)
377}
378
379/// Greedy move of one vertex to its best neighbour community.
380///
381/// Mirrors the body of `igraph_i_community_multilevel_step` lines
382/// 407-472.
383fn local_move_vertex(state: &mut WorkingState, ni: usize, resolution: f64, changed: &mut bool) {
384    let weight_all_v = state.vertex_weight[ni];
385    let weight_loop_v = state.loop_weight[ni];
386    let old_id = state.membership[ni] as usize;
387
388    // Build the neighbour-community → summed-weight table. We allocate
389    // per call because n changes between levels; the cost is amortised
390    // by `adj[ni].len()` being small for sparse graphs.
391    let mut neigh_communities: Vec<u32> = Vec::with_capacity(state.adj[ni].len());
392    let mut neigh_weights: Vec<f64> = Vec::with_capacity(state.adj[ni].len());
393    let mut weight_inside_old = 0.0_f64; // edge weight from ni back to its old community
394
395    for &(nei, w) in &state.adj[ni] {
396        let c = state.membership[nei as usize];
397        if c as usize == old_id {
398            weight_inside_old += w;
399        }
400        // Linear scan is fine: degrees are small for real graphs and
401        // the slot count is bounded by deg(ni).
402        if let Some(idx) = neigh_communities.iter().position(|&x| x == c) {
403            neigh_weights[idx] += w;
404        } else {
405            neigh_communities.push(c);
406            neigh_weights.push(w);
407        }
408    }
409
410    // Temporarily strip vertex from its old community.
411    state.size[old_id] -= 1;
412    state.weight_all[old_id] -= weight_all_v;
413    state.weight_inside[old_id] -= 2.0 * weight_inside_old + weight_loop_v;
414
415    // Hunt for the best move.
416    let mut best_id = old_id;
417    let mut best_gain = 0.0_f64;
418    let mut best_weight = weight_inside_old;
419    for (idx, &c) in neigh_communities.iter().enumerate() {
420        let w_to_c = neigh_weights[idx];
421        // Skip moves to the dissolved old slot when re-checking the
422        // identity move — the empty-community check below handles it.
423        let kall_c = state.weight_all[c as usize];
424        // gain = w_to_c - γ * kall_c * weight_all_v / weight_sum
425        let gain = w_to_c - resolution * kall_c * weight_all_v / state.weight_sum;
426        if gain > best_gain {
427            best_gain = gain;
428            best_id = c as usize;
429            best_weight = w_to_c;
430        }
431    }
432
433    // Apply the chosen move (which may be the old community).
434    state.membership[ni] = best_id as u32;
435    state.size[best_id] += 1;
436    state.weight_all[best_id] += weight_all_v;
437    state.weight_inside[best_id] += 2.0 * best_weight + weight_loop_v;
438
439    if best_id != old_id {
440        *changed = true;
441    }
442}
443
444/// Reindex `membership` so labels become a contiguous `0..k`
445/// permutation, then return `k`.
446fn reindex_membership(membership: &mut [u32]) -> u32 {
447    if membership.is_empty() {
448        return 0;
449    }
450    let max = *membership.iter().max().unwrap_or(&0);
451    let mut remap: Vec<i64> = vec![-1; max as usize + 1];
452    let mut next: u32 = 0;
453    for slot in membership.iter_mut() {
454        let old = *slot as usize;
455        if remap[old] < 0 {
456            remap[old] = i64::from(next);
457            next += 1;
458        }
459        *slot = remap[old] as u32;
460    }
461    next
462}
463
464/// Build the level-`l+1` super-graph: every community becomes a
465/// single super-vertex, parallel inter-community edges are summed,
466/// intra-community edges become self-loops. Goes through
467/// [`init_from_edges`] so the loop accounting stays consistent with
468/// the level-0 initialisation.
469fn aggregate(state: &WorkingState, k: usize) -> WorkingState {
470    // Per-super-vertex (raw) self-loop weight accumulator. Intra-edges
471    // and the previous level's loops both feed into here.
472    let mut loop_raw: Vec<f64> = vec![0.0_f64; k];
473    // For inter-super-vertex edges: keep a dedup adjacency restricted
474    // to `mu < mv` so each super-edge appears exactly once.
475    let mut inter: Vec<Vec<(u32, f64)>> = vec![Vec::new(); k];
476
477    // Pre-existing self-loops at the working level. `state.loop_weight`
478    // is stored doubled (IGRAPH_LOOPS_TWICE convention from init), so
479    // the raw weight is half.
480    for u in 0..state.n {
481        let raw = state.loop_weight[u] * 0.5;
482        if raw > 0.0 {
483            let mu = state.membership[u] as usize;
484            loop_raw[mu] += raw;
485        }
486    }
487
488    // Non-loop edges in the current `adj`. Each edge appears twice
489    // (once per endpoint); dedupe via `u < v`.
490    for u in 0..state.n {
491        for &(v, w) in &state.adj[u] {
492            let v_us = v as usize;
493            if u >= v_us {
494                continue;
495            }
496            let mu = state.membership[u] as usize;
497            let mv = state.membership[v_us] as usize;
498            if mu == mv {
499                loop_raw[mu] += w;
500            } else {
501                let (a, b) = if mu < mv { (mu, mv) } else { (mv, mu) };
502                push_or_merge(&mut inter[a], b as u32, w);
503            }
504        }
505    }
506
507    // Flatten into an edge list with `u <= v` and feed through the
508    // shared initialiser.
509    let mut edges: Vec<(u32, u32, f64)> = Vec::new();
510    for u in 0..k {
511        if loop_raw[u] > 0.0 {
512            edges.push((u as u32, u as u32, loop_raw[u]));
513        }
514        for &(v, w) in &inter[u] {
515            edges.push((u as u32, v, w));
516        }
517    }
518
519    init_from_edges(k, &edges)
520}
521
522fn push_or_merge(list: &mut Vec<(u32, f64)>, neighbour: u32, weight: f64) {
523    for slot in list.iter_mut() {
524        if slot.0 == neighbour {
525            slot.1 += weight;
526            return;
527        }
528    }
529    list.push((neighbour, weight));
530}
531
532// ---------- weight validation ----------
533
534fn validate_weights(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<()> {
535    let Some(w) = weights else {
536        return Ok(());
537    };
538    let m = graph.ecount();
539    if w.len() != m {
540        return Err(IgraphError::InvalidArgument(format!(
541            "weights vector size ({}) differs from edge count ({})",
542            w.len(),
543            m
544        )));
545    }
546    for (e, &v) in w.iter().enumerate() {
547        if v.is_nan() {
548            return Err(IgraphError::InvalidArgument(format!(
549                "weight at edge {e} is NaN"
550            )));
551        }
552        if v < 0.0 {
553            return Err(IgraphError::InvalidArgument(format!(
554                "weight at edge {e} is negative ({v}); louvain weights must be non-negative"
555            )));
556        }
557    }
558    Ok(())
559}
560
561// ---------- PRNG ----------
562
563struct SplitMix64(u64);
564
565impl SplitMix64 {
566    fn new(seed: u64) -> Self {
567        Self(seed)
568    }
569    fn next_u64(&mut self) -> u64 {
570        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
571        let mut z = self.0;
572        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
573        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
574        z ^ (z >> 31)
575    }
576    fn gen_index(&mut self, bound: usize) -> usize {
577        debug_assert!(bound > 0);
578        let r = self.next_u64() % (bound as u64);
579        usize::try_from(r).unwrap_or(0)
580    }
581}
582
583fn shuffle_in_place<T>(slice: &mut [T], rng: &mut SplitMix64) {
584    // Fisher-Yates.
585    let len = slice.len();
586    for i in (1..len).rev() {
587        let j = rng.gen_index(i + 1);
588        slice.swap(i, j);
589    }
590}
591
592#[cfg(test)]
593#[allow(clippy::float_cmp)]
594mod tests {
595    use super::*;
596
597    fn add_undirected_edges(g: &mut Graph, edges: &[(u32, u32)]) {
598        for &(u, v) in edges {
599            g.add_edge(u, v).unwrap();
600        }
601    }
602
603    #[test]
604    fn empty_graph_returns_empty_membership() {
605        let g = Graph::with_vertices(0);
606        let r = louvain(&g).unwrap();
607        assert_eq!(r.membership.len(), 0);
608        assert_eq!(r.modularity, 0.0);
609        assert!(r.levels.is_empty());
610    }
611
612    #[test]
613    fn isolated_vertices_each_their_own_community() {
614        let g = Graph::with_vertices(4);
615        let r = louvain(&g).unwrap();
616        // With no edges and weight_sum = 0, the local-moving pass should not
617        // produce any move; we return the trivial identity partition.
618        assert_eq!(r.membership.len(), 4);
619        // Ensure labels are within [0, 4).
620        for &c in &r.membership {
621            assert!(c < 4);
622        }
623    }
624
625    #[test]
626    fn single_edge_two_communities_or_merged() {
627        let mut g = Graph::with_vertices(2);
628        g.add_edge(0, 1).unwrap();
629        let r = louvain(&g).unwrap();
630        // Either both in same community (Q = 0 for a single edge,
631        // both singletons gives Q = -1/2 so merging is preferred).
632        assert_eq!(r.membership[0], r.membership[1]);
633    }
634
635    #[test]
636    fn two_triangles_bridge_finds_two_communities() {
637        let mut g = Graph::with_vertices(6);
638        add_undirected_edges(
639            &mut g,
640            &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
641        );
642        let r = louvain(&g).unwrap();
643        // {0,1,2} and {3,4,5} should be split. Robust to label
644        // permutation.
645        assert_eq!(r.membership[0], r.membership[1]);
646        assert_eq!(r.membership[1], r.membership[2]);
647        assert_eq!(r.membership[3], r.membership[4]);
648        assert_eq!(r.membership[4], r.membership[5]);
649        assert_ne!(r.membership[0], r.membership[3]);
650        assert!(r.modularity > 0.35);
651    }
652
653    #[test]
654    fn complete_k5_gives_one_or_two_communities() {
655        let mut g = Graph::with_vertices(5);
656        for u in 0..5u32 {
657            for v in (u + 1)..5 {
658                g.add_edge(u, v).unwrap();
659            }
660        }
661        let r = louvain(&g).unwrap();
662        // K_n's modularity is bounded by 0 — any partition gives Q ≤ 0
663        // with equality at the trivial single-community partition.
664        // Louvain should converge to the single-community case.
665        let k: std::collections::BTreeSet<_> = r.membership.iter().copied().collect();
666        assert!(k.len() <= 2, "K5 should yield ≤ 2 communities");
667        assert!(r.modularity >= -1e-9);
668    }
669
670    #[test]
671    fn disconnected_components_separate_communities() {
672        let mut g = Graph::with_vertices(8);
673        // Two K_4 components.
674        for u in 0..4u32 {
675            for v in (u + 1)..4 {
676                g.add_edge(u, v).unwrap();
677            }
678        }
679        for u in 4..8u32 {
680            for v in (u + 1)..8 {
681                g.add_edge(u, v).unwrap();
682            }
683        }
684        let r = louvain(&g).unwrap();
685        assert_eq!(r.membership[0], r.membership[1]);
686        assert_eq!(r.membership[4], r.membership[5]);
687        assert_ne!(r.membership[0], r.membership[4]);
688        assert!(r.modularity > 0.4);
689    }
690
691    #[test]
692    fn directed_graph_returns_unsupported() {
693        let mut g = Graph::new(3, true).unwrap();
694        g.add_edge(0, 1).unwrap();
695        assert!(louvain(&g).is_err());
696    }
697
698    #[test]
699    fn weights_length_mismatch_errors() {
700        let mut g = Graph::with_vertices(3);
701        g.add_edge(0, 1).unwrap();
702        g.add_edge(1, 2).unwrap();
703        let r = louvain_weighted(&g, &[1.0]);
704        assert!(r.is_err());
705    }
706
707    #[test]
708    fn weights_negative_errors() {
709        let mut g = Graph::with_vertices(3);
710        g.add_edge(0, 1).unwrap();
711        g.add_edge(1, 2).unwrap();
712        let r = louvain_weighted(&g, &[1.0, -0.5]);
713        assert!(r.is_err());
714    }
715
716    #[test]
717    fn weights_nan_errors() {
718        let mut g = Graph::with_vertices(3);
719        g.add_edge(0, 1).unwrap();
720        g.add_edge(1, 2).unwrap();
721        let r = louvain_weighted(&g, &[1.0, f64::NAN]);
722        assert!(r.is_err());
723    }
724
725    #[test]
726    fn negative_resolution_errors() {
727        let mut g = Graph::with_vertices(3);
728        g.add_edge(0, 1).unwrap();
729        assert!(louvain_with_options(&g, None, -0.1, 0).is_err());
730    }
731
732    #[test]
733    fn weighted_unit_matches_unweighted() {
734        let mut g = Graph::with_vertices(6);
735        add_undirected_edges(
736            &mut g,
737            &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
738        );
739        let ones = vec![1.0; g.ecount()];
740        let a = louvain(&g).unwrap();
741        let b = louvain_weighted(&g, &ones).unwrap();
742        // Modularity should match closely.
743        assert!((a.modularity - b.modularity).abs() < 1e-9);
744        // Membership equivalence (up to label permutation): two
745        // vertices are in the same community in one iff they're in
746        // the same in the other.
747        for i in 0..6 {
748            for j in 0..6 {
749                let ai = a.membership[i] == a.membership[j];
750                let bi = b.membership[i] == b.membership[j];
751                assert_eq!(ai, bi, "partition mismatch at ({i},{j})");
752            }
753        }
754    }
755
756    #[test]
757    fn modularity_matches_external_recompute() {
758        // Cross-check our reported modularity against the standalone
759        // `modularity()` function on the same partition.
760        let mut g = Graph::with_vertices(6);
761        add_undirected_edges(
762            &mut g,
763            &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
764        );
765        let r = louvain(&g).unwrap();
766        let q_recomputed =
767            crate::algorithms::community::modularity::modularity(&g, &r.membership, 1.0)
768                .unwrap()
769                .unwrap();
770        assert!(
771            (r.modularity - q_recomputed).abs() < 1e-9,
772            "louvain reports {} but modularity() reports {}",
773            r.modularity,
774            q_recomputed
775        );
776    }
777
778    #[test]
779    fn resolution_zero_gives_single_community() {
780        // γ = 0 → all the modularity penalty disappears, the
781        // optimiser is allowed to lump everything together.
782        let mut g = Graph::with_vertices(6);
783        add_undirected_edges(
784            &mut g,
785            &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
786        );
787        let r = louvain_with_options(&g, None, 0.0, 0).unwrap();
788        let k: std::collections::BTreeSet<_> = r.membership.iter().copied().collect();
789        assert_eq!(k.len(), 1);
790    }
791
792    #[test]
793    fn resolution_high_gives_many_communities() {
794        let mut g = Graph::with_vertices(6);
795        add_undirected_edges(
796            &mut g,
797            &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
798        );
799        let r = louvain_with_options(&g, None, 10.0, 0).unwrap();
800        let k: std::collections::BTreeSet<_> = r.membership.iter().copied().collect();
801        assert!(
802            k.len() >= 3,
803            "with γ=10 we expect more granular communities"
804        );
805    }
806
807    #[test]
808    fn deterministic_with_seed() {
809        let mut g = Graph::with_vertices(10);
810        for u in 0..10u32 {
811            for v in (u + 1)..10 {
812                if (u + v) % 3 != 0 {
813                    g.add_edge(u, v).unwrap();
814                }
815            }
816        }
817        let a = louvain_with_options(&g, None, 1.0, 42).unwrap();
818        let b = louvain_with_options(&g, None, 1.0, 42).unwrap();
819        assert_eq!(a.membership, b.membership);
820        assert!((a.modularity - b.modularity).abs() < 1e-12);
821    }
822
823    #[test]
824    fn levels_recorded() {
825        let mut g = Graph::with_vertices(6);
826        add_undirected_edges(
827            &mut g,
828            &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
829        );
830        let r = louvain(&g).unwrap();
831        assert!(!r.levels.is_empty());
832        assert_eq!(r.levels.len(), r.modularities.len());
833        // Last level's membership should match the final membership.
834        assert_eq!(*r.levels.last().unwrap(), r.membership);
835    }
836
837    #[test]
838    fn karate_club_finds_known_partition() {
839        // Karate club: well-studied 4-community partition with
840        // Q ≈ 0.42 reported in the original Blondel et al. paper.
841        // Using just edges in this test to avoid an external
842        // fixture dependency.
843        let edges: &[(u32, u32)] = &[
844            (0, 1),
845            (0, 2),
846            (0, 3),
847            (0, 4),
848            (0, 5),
849            (0, 6),
850            (0, 7),
851            (0, 8),
852            (0, 10),
853            (0, 11),
854            (0, 12),
855            (0, 13),
856            (0, 17),
857            (0, 19),
858            (0, 21),
859            (0, 31),
860            (1, 2),
861            (1, 3),
862            (1, 7),
863            (1, 13),
864            (1, 17),
865            (1, 19),
866            (1, 21),
867            (1, 30),
868            (2, 3),
869            (2, 7),
870            (2, 8),
871            (2, 9),
872            (2, 13),
873            (2, 27),
874            (2, 28),
875            (2, 32),
876            (3, 7),
877            (3, 12),
878            (3, 13),
879            (4, 6),
880            (4, 10),
881            (5, 6),
882            (5, 10),
883            (5, 16),
884            (6, 16),
885            (8, 30),
886            (8, 32),
887            (8, 33),
888            (9, 33),
889            (13, 33),
890            (14, 32),
891            (14, 33),
892            (15, 32),
893            (15, 33),
894            (18, 32),
895            (18, 33),
896            (19, 33),
897            (20, 32),
898            (20, 33),
899            (22, 32),
900            (22, 33),
901            (23, 25),
902            (23, 27),
903            (23, 29),
904            (23, 32),
905            (23, 33),
906            (24, 25),
907            (24, 27),
908            (24, 31),
909            (25, 31),
910            (26, 29),
911            (26, 33),
912            (27, 33),
913            (28, 31),
914            (28, 33),
915            (29, 32),
916            (29, 33),
917            (30, 32),
918            (30, 33),
919            (31, 32),
920            (31, 33),
921            (32, 33),
922        ];
923        let mut g = Graph::with_vertices(34);
924        add_undirected_edges(&mut g, edges);
925        let r = louvain(&g).unwrap();
926        assert!(
927            r.modularity > 0.39,
928            "karate club Louvain Q should exceed 0.39, got {}",
929            r.modularity
930        );
931        // Should split into at least 2 and at most 6 communities.
932        let k: std::collections::BTreeSet<_> = r.membership.iter().copied().collect();
933        assert!(k.len() >= 2 && k.len() <= 6);
934    }
935}