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