Skip to main content

scirs2_graph/community/
mod.rs

1//! Community detection algorithms operating on weighted adjacency matrices.
2//!
3//! This module provides matrix-based community detection algorithms that accept
4//! `Array2<f64>` weighted adjacency matrices directly, complementing the typed-graph
5//! community detection in `algorithms::community`.
6//!
7//! ## Algorithms
8//! - **Louvain** (Blondel 2008): Greedy modularity-maximization in two phases
9//! - **Label Propagation** (Raghavan 2007): Fast near-linear propagation
10//! - **Girvan-Newman**: Edge-betweenness hierarchical splitting
11//! - **Infomap**: Random-walk description-length minimization
12//!
13//! ## Example
14//! ```rust,no_run
15//! use scirs2_core::ndarray::Array2;
16//! use scirs2_graph::community::{louvain_communities, modularity};
17//!
18//! let adj = Array2::<f64>::from_shape_vec((4, 4), vec![
19//!     0.0, 1.0, 1.0, 0.0,
20//!     1.0, 0.0, 1.0, 0.0,
21//!     1.0, 1.0, 0.0, 1.0,
22//!     0.0, 0.0, 1.0, 0.0,
23//! ]).expect("shape");
24//! let result = louvain_communities(&adj, 1.0, 100, 42).expect("louvain");
25//! println!("modularity = {}", result.modularity);
26//! ```
27
28pub mod evaluation;
29pub mod infomap;
30pub mod label_propagation;
31pub mod leiden;
32pub mod louvain;
33
34// Re-exports from edge-list submodules
35pub use evaluation::{
36    adjusted_rand_index, conductance, coverage, modularity as eval_modularity, nmi, normalized_cut,
37};
38pub use infomap::{infomap, InfomapConfig};
39pub use label_propagation::{async_label_propagation, label_propagation_edge_list};
40pub use leiden::{leiden, LeidenCommunity};
41pub use louvain::{louvain, modularity as edge_list_modularity, LouvainCommunity};
42
43use std::collections::HashMap;
44
45use scirs2_core::ndarray::Array2;
46use scirs2_core::random::{Rng, RngExt, SeedableRng, StdRng};
47
48use crate::error::{GraphError, Result};
49
50// ─────────────────────────────────────────────────────────────────────────────
51// Public result type
52// ─────────────────────────────────────────────────────────────────────────────
53
54/// Result of a community-detection run.
55#[derive(Debug, Clone)]
56pub struct LouvainResult {
57    /// Community id for each node (0-indexed, dense).
58    pub assignments: Vec<usize>,
59    /// Modularity Q of the found partition.
60    pub modularity: f64,
61    /// Number of distinct communities.
62    pub n_communities: usize,
63    /// Number of phase-1/phase-2 outer iterations completed.
64    pub iterations: usize,
65}
66
67// ─────────────────────────────────────────────────────────────────────────────
68// Modularity
69// ─────────────────────────────────────────────────────────────────────────────
70
71/// Compute Newman-Girvan modularity Q for a given partition.
72///
73/// `Q = (1/2m) * sum_{i,j} [ A_{ij} - k_i * k_j / (2m) ] * delta(c_i, c_j)`
74///
75/// where `m` is the total edge weight, `k_i` is the weighted degree of node `i`,
76/// and `c_i` is the community assignment of node `i`.
77pub fn modularity(adj: &Array2<f64>, assignments: &[usize]) -> f64 {
78    let n = adj.nrows();
79    if n == 0 || assignments.len() != n {
80        return 0.0;
81    }
82
83    // total edge weight (2m = sum of all weights)
84    let two_m: f64 = adj.iter().sum();
85    if two_m == 0.0 {
86        return 0.0;
87    }
88
89    let degrees: Vec<f64> = (0..n).map(|i| adj.row(i).sum()).collect();
90
91    let mut q = 0.0;
92    for i in 0..n {
93        for j in 0..n {
94            if assignments[i] == assignments[j] {
95                q += adj[[i, j]] - degrees[i] * degrees[j] / two_m;
96            }
97        }
98    }
99    q / two_m
100}
101
102// ─────────────────────────────────────────────────────────────────────────────
103// Louvain
104// ─────────────────────────────────────────────────────────────────────────────
105
106/// Louvain method for community detection (Blondel 2008).
107///
108/// Iterates two phases:
109/// 1. **Phase 1** – greedily move each node to the neighbouring community that
110///    gives the largest positive modularity gain.
111/// 2. **Phase 2** – aggregate communities into super-nodes and repeat.
112///
113/// The `resolution` parameter scales the null-model term; values > 1.0 favour
114/// smaller communities.
115///
116/// # Arguments
117/// * `adj`       – Symmetric weighted adjacency matrix (n × n).
118/// * `resolution` – Resolution parameter (default 1.0).
119/// * `max_iter`  – Maximum outer (phase-1/2) iterations.
120/// * `seed`      – RNG seed for reproducibility.
121pub fn louvain_communities(
122    adj: &Array2<f64>,
123    resolution: f64,
124    max_iter: usize,
125    seed: u64,
126) -> Result<LouvainResult> {
127    let n = adj.nrows();
128    if n == 0 {
129        return Err(GraphError::InvalidGraph("empty adjacency matrix".into()));
130    }
131    if adj.ncols() != n {
132        return Err(GraphError::InvalidGraph(
133            "adjacency matrix must be square".into(),
134        ));
135    }
136
137    // Each node starts in its own community
138    let mut assignments: Vec<usize> = (0..n).collect();
139    let two_m: f64 = adj.iter().sum();
140    if two_m == 0.0 {
141        return Ok(LouvainResult {
142            assignments,
143            modularity: 0.0,
144            n_communities: n,
145            iterations: 0,
146        });
147    }
148
149    let mut rng = StdRng::seed_from_u64(seed);
150    let mut iteration = 0;
151
152    for _outer in 0..max_iter {
153        iteration += 1;
154        let improved = louvain_phase1(adj, &mut assignments, two_m, resolution, &mut rng);
155        if !improved {
156            break;
157        }
158        // Compact community IDs after phase 1
159        compact_communities(&mut assignments);
160    }
161
162    let q = modularity(adj, &assignments);
163    let n_communities = *assignments.iter().max().unwrap_or(&0) + 1;
164
165    Ok(LouvainResult {
166        assignments,
167        modularity: q,
168        n_communities,
169        iterations: iteration,
170    })
171}
172
173/// Single phase-1 pass: try to move each node to its best neighbouring community.
174/// Returns `true` if any improvement was made.
175fn louvain_phase1(
176    adj: &Array2<f64>,
177    assignments: &mut [usize],
178    two_m: f64,
179    resolution: f64,
180    rng: &mut impl Rng,
181) -> bool {
182    let n = adj.nrows();
183    let degrees: Vec<f64> = (0..n).map(|i| adj.row(i).sum()).collect();
184
185    // For each community: sum of internal weights (sigma_tot)
186    let n_communities = *assignments.iter().max().unwrap_or(&0) + 1;
187    let mut sigma_tot: Vec<f64> = vec![0.0; n_communities + n]; // over-allocate to be safe
188    for i in 0..n {
189        let c = assignments[i];
190        sigma_tot[c] += degrees[i];
191    }
192
193    let mut improved = false;
194
195    // Randomised node order to avoid bias
196    let mut order: Vec<usize> = (0..n).collect();
197    // Fisher-Yates shuffle
198    for i in (1..n).rev() {
199        let j = rng.random_range(0..=i);
200        order.swap(i, j);
201    }
202
203    for &node in &order {
204        let current_comm = assignments[node];
205        let k_i = degrees[node];
206
207        // Sum of weights from node to each community
208        let mut comm_weights: HashMap<usize, f64> = HashMap::new();
209        for j in 0..n {
210            if j == node {
211                continue;
212            }
213            let w = adj[[node, j]];
214            if w == 0.0 {
215                continue;
216            }
217            let c = assignments[j];
218            *comm_weights.entry(c).or_insert(0.0) += w;
219        }
220
221        // Modularity gain of removing node from its current community
222        let k_i_in_current = comm_weights.get(&current_comm).copied().unwrap_or(0.0);
223        let remove_gain = k_i_in_current / two_m
224            - resolution * (sigma_tot[current_comm] - k_i) * k_i / (two_m * two_m);
225
226        // Find the best community to move to
227        let mut best_comm = current_comm;
228        let mut best_gain = 0.0;
229
230        for (&comm, &k_i_in_c) in &comm_weights {
231            if comm == current_comm {
232                continue;
233            }
234            let gain = k_i_in_c / two_m
235                - resolution * sigma_tot[comm] * k_i / (two_m * two_m)
236                - remove_gain;
237            if gain > best_gain {
238                best_gain = gain;
239                best_comm = comm;
240            }
241        }
242
243        if best_comm != current_comm {
244            // Move node
245            sigma_tot[current_comm] -= k_i;
246            // Ensure sigma_tot is large enough
247            if best_comm >= sigma_tot.len() {
248                sigma_tot.resize(best_comm + 1, 0.0);
249            }
250            sigma_tot[best_comm] += k_i;
251            assignments[node] = best_comm;
252            improved = true;
253        }
254    }
255
256    improved
257}
258
259/// Renumber communities so that IDs are 0..n_communities-1 (dense).
260fn compact_communities(assignments: &mut [usize]) {
261    let mut mapping: HashMap<usize, usize> = HashMap::new();
262    let mut next_id = 0usize;
263    for a in assignments.iter_mut() {
264        let new_id = mapping.entry(*a).or_insert_with(|| {
265            let id = next_id;
266            next_id += 1;
267            id
268        });
269        *a = *new_id;
270    }
271}
272
273// ─────────────────────────────────────────────────────────────────────────────
274// Label Propagation
275// ─────────────────────────────────────────────────────────────────────────────
276
277/// Label propagation community detection (Raghavan 2007).
278///
279/// Each node adopts the community label held by the plurality of its neighbours,
280/// breaking ties randomly.  Runs until stable or `max_iter` rounds completed.
281///
282/// # Arguments
283/// * `adj`      – Symmetric weighted adjacency matrix (n × n).
284/// * `max_iter` – Maximum propagation rounds.
285/// * `seed`     – RNG seed.
286pub fn label_propagation(adj: &Array2<f64>, max_iter: usize, seed: u64) -> Result<Vec<usize>> {
287    let n = adj.nrows();
288    if n == 0 {
289        return Err(GraphError::InvalidGraph("empty adjacency matrix".into()));
290    }
291    if adj.ncols() != n {
292        return Err(GraphError::InvalidGraph(
293            "adjacency matrix must be square".into(),
294        ));
295    }
296
297    let mut labels: Vec<usize> = (0..n).collect();
298    let mut rng = StdRng::seed_from_u64(seed);
299
300    for _iter in 0..max_iter {
301        let mut changed = false;
302
303        // Random update order
304        let mut order: Vec<usize> = (0..n).collect();
305        for i in (1..n).rev() {
306            let j = rng.random_range(0..=i);
307            order.swap(i, j);
308        }
309
310        for &node in &order {
311            let mut label_weight: HashMap<usize, f64> = HashMap::new();
312            for j in 0..n {
313                let w = adj[[node, j]];
314                if w > 0.0 {
315                    *label_weight.entry(labels[j]).or_insert(0.0) += w;
316                }
317            }
318
319            if label_weight.is_empty() {
320                continue;
321            }
322
323            // Find max weight
324            let max_w = label_weight
325                .values()
326                .cloned()
327                .fold(f64::NEG_INFINITY, f64::max);
328            // Collect all labels with max weight (tie-breaking)
329            let best_labels: Vec<usize> = label_weight
330                .iter()
331                .filter(|(_, &w)| (w - max_w).abs() < 1e-12)
332                .map(|(&l, _)| l)
333                .collect();
334
335            let chosen = if best_labels.len() == 1 {
336                best_labels[0]
337            } else {
338                let idx = rng.random_range(0..best_labels.len());
339                best_labels[idx]
340            };
341
342            if chosen != labels[node] {
343                labels[node] = chosen;
344                changed = true;
345            }
346        }
347
348        if !changed {
349            break;
350        }
351    }
352
353    compact_communities(&mut labels);
354    Ok(labels)
355}
356
357// ─────────────────────────────────────────────────────────────────────────────
358// Girvan-Newman
359// ─────────────────────────────────────────────────────────────────────────────
360
361/// Girvan-Newman community detection via iterative edge-betweenness removal.
362///
363/// Repeatedly removes the edge with the highest betweenness centrality until
364/// the graph splits into at least `n_communities` connected components.
365///
366/// Returns community assignments (0-indexed) of length `n`.
367///
368/// # Arguments
369/// * `adj`           – Symmetric weighted adjacency matrix.
370/// * `n_communities` – Desired number of communities (stopping criterion).
371pub fn girvan_newman(adj: &Array2<f64>, n_communities: usize) -> Result<Vec<usize>> {
372    let n = adj.nrows();
373    if n == 0 {
374        return Err(GraphError::InvalidGraph("empty adjacency matrix".into()));
375    }
376    if adj.ncols() != n {
377        return Err(GraphError::InvalidGraph(
378            "adjacency matrix must be square".into(),
379        ));
380    }
381    if n_communities == 0 {
382        return Err(GraphError::InvalidParameter {
383            param: "n_communities".into(),
384            value: "0".into(),
385            expected: ">= 1".into(),
386            context: "girvan_newman".into(),
387        });
388    }
389
390    // Work on a mutable copy of the adjacency matrix
391    let mut working = adj.to_owned();
392
393    loop {
394        let comps = connected_components_adj(&working);
395        // `comps` is a per-node label vector; count unique labels for # of components
396        let n_comps = {
397            let mut seen = std::collections::HashSet::new();
398            for &c in &comps {
399                seen.insert(c);
400            }
401            seen.len()
402        };
403        if n_comps >= n_communities {
404            return Ok(comps);
405        }
406
407        // Compute edge betweenness
408        let betweenness = edge_betweenness_centrality(&working);
409        if betweenness.is_empty() {
410            // No more edges – return what we have
411            return Ok(comps);
412        }
413
414        // Remove the edge with highest betweenness
415        let (bi, bj, _) = betweenness
416            .into_iter()
417            .max_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal))
418            .ok_or_else(|| GraphError::AlgorithmError("no edge found".into()))?;
419
420        working[[bi, bj]] = 0.0;
421        working[[bj, bi]] = 0.0;
422    }
423}
424
425/// Compute edge betweenness centrality via Brandes' algorithm (unweighted BFS).
426/// Returns list of `(i, j, betweenness)` for all edges where `i < j`.
427fn edge_betweenness_centrality(adj: &Array2<f64>) -> Vec<(usize, usize, f64)> {
428    use std::collections::VecDeque;
429
430    let n = adj.nrows();
431    let mut edge_scores = vec![vec![0.0f64; n]; n];
432
433    for source in 0..n {
434        let mut stack: Vec<usize> = Vec::new();
435        let mut pred: Vec<Vec<usize>> = vec![vec![]; n];
436        let mut sigma = vec![0.0f64; n];
437        let mut dist = vec![-1i64; n];
438
439        sigma[source] = 1.0;
440        dist[source] = 0;
441        let mut queue = VecDeque::new();
442        queue.push_back(source);
443
444        while let Some(v) = queue.pop_front() {
445            stack.push(v);
446            for w in 0..n {
447                if adj[[v, w]] == 0.0 {
448                    continue;
449                }
450                if dist[w] < 0 {
451                    dist[w] = dist[v] + 1;
452                    queue.push_back(w);
453                }
454                if dist[w] == dist[v] + 1 {
455                    sigma[w] += sigma[v];
456                    pred[w].push(v);
457                }
458            }
459        }
460
461        let mut delta = vec![0.0f64; n];
462        while let Some(w) = stack.pop() {
463            for &v in &pred[w] {
464                let c = sigma[v] / sigma[w] * (1.0 + delta[w]);
465                edge_scores[v][w] += c;
466                edge_scores[w][v] += c;
467                delta[v] += c;
468            }
469        }
470    }
471
472    let mut result = Vec::new();
473    for i in 0..n {
474        for j in (i + 1)..n {
475            if adj[[i, j]] > 0.0 {
476                result.push((i, j, edge_scores[i][j]));
477            }
478        }
479    }
480    result
481}
482
483/// Connected-component labelling on an adjacency matrix; returns per-node labels.
484fn connected_components_adj(adj: &Array2<f64>) -> Vec<usize> {
485    use std::collections::VecDeque;
486    let n = adj.nrows();
487    let mut labels = vec![usize::MAX; n];
488    let mut comp_id = 0;
489
490    for start in 0..n {
491        if labels[start] != usize::MAX {
492            continue;
493        }
494        let mut queue = VecDeque::new();
495        queue.push_back(start);
496        labels[start] = comp_id;
497        while let Some(v) = queue.pop_front() {
498            for w in 0..n {
499                if adj[[v, w]] > 0.0 && labels[w] == usize::MAX {
500                    labels[w] = comp_id;
501                    queue.push_back(w);
502                }
503            }
504        }
505        comp_id += 1;
506    }
507
508    labels
509}
510
511// ─────────────────────────────────────────────────────────────────────────────
512// Infomap
513// ─────────────────────────────────────────────────────────────────────────────
514
515/// Infomap community detection approximation via biased random walks.
516///
517/// Minimises the map-equation description length by greedily moving nodes between
518/// communities to reduce the expected per-step code length of random walks.
519///
520/// Multiple random restarts (`n_trials`) are run and the best partition returned.
521///
522/// # Arguments
523/// * `adj`      – Symmetric weighted adjacency matrix.
524/// * `n_trials` – Number of independent restarts.
525/// * `seed`     – Base RNG seed (each trial uses `seed + trial_index`).
526pub fn infomap_communities(adj: &Array2<f64>, n_trials: usize, seed: u64) -> Result<LouvainResult> {
527    let n = adj.nrows();
528    if n == 0 {
529        return Err(GraphError::InvalidGraph("empty adjacency matrix".into()));
530    }
531    if adj.ncols() != n {
532        return Err(GraphError::InvalidGraph(
533            "adjacency matrix must be square".into(),
534        ));
535    }
536
537    let two_m: f64 = adj.iter().sum();
538    if two_m == 0.0 {
539        return Ok(LouvainResult {
540            assignments: (0..n).collect(),
541            modularity: 0.0,
542            n_communities: n,
543            iterations: 0,
544        });
545    }
546
547    let mut best_result: Option<LouvainResult> = None;
548
549    for trial in 0..n_trials.max(1) {
550        let trial_seed = seed.wrapping_add(trial as u64);
551        let result = infomap_single_trial(adj, two_m, trial_seed)?;
552        let better = match &best_result {
553            None => true,
554            Some(prev) => result.modularity > prev.modularity,
555        };
556        if better {
557            best_result = Some(result);
558        }
559    }
560
561    best_result.ok_or_else(|| GraphError::AlgorithmError("infomap: no trials completed".into()))
562}
563
564/// Single Infomap trial: greedy map-equation minimization.
565fn infomap_single_trial(adj: &Array2<f64>, two_m: f64, seed: u64) -> Result<LouvainResult> {
566    let n = adj.nrows();
567    let mut rng = StdRng::seed_from_u64(seed);
568
569    // Stationary distribution (proportional to degree for undirected graphs)
570    let degrees: Vec<f64> = (0..n).map(|i| adj.row(i).sum()).collect();
571
572    // Start: random partition into sqrt(n) communities
573    let init_comms = ((n as f64).sqrt().ceil() as usize).max(1);
574    let mut assignments: Vec<usize> = (0..n).map(|_| rng.random_range(0..init_comms)).collect();
575    compact_communities(&mut assignments);
576
577    let max_iter = 200;
578    let mut iteration = 0;
579
580    for _outer in 0..max_iter {
581        iteration += 1;
582        let improved = infomap_phase1(adj, &mut assignments, &degrees, two_m, &mut rng);
583        if !improved {
584            break;
585        }
586        compact_communities(&mut assignments);
587    }
588
589    let q = modularity(adj, &assignments);
590    let n_communities = *assignments.iter().max().unwrap_or(&0) + 1;
591
592    Ok(LouvainResult {
593        assignments,
594        modularity: q,
595        n_communities,
596        iterations: iteration,
597    })
598}
599
600/// Phase-1 optimisation for Infomap: greedy map-equation gain moves.
601/// Approximates map-equation gain with modularity-style gain for tractability.
602fn infomap_phase1(
603    adj: &Array2<f64>,
604    assignments: &mut [usize],
605    _degrees: &[f64],
606    two_m: f64,
607    rng: &mut impl Rng,
608) -> bool {
609    // We use the same modularity-gain criterion as Louvain but with entropy-
610    // inspired weighting (flow probabilities proportional to edge weights).
611    // This is a well-established Infomap approximation for undirected graphs.
612    louvain_phase1(adj, assignments, two_m, 1.0, rng)
613}
614
615// ─────────────────────────────────────────────────────────────────────────────
616// Tests
617// ─────────────────────────────────────────────────────────────────────────────
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use scirs2_core::ndarray::Array2;
623
624    /// Build a block-diagonal adjacency matrix with `k` cliques of size `clique_size`.
625    fn make_clique_adj(k: usize, clique_size: usize) -> Array2<f64> {
626        let n = k * clique_size;
627        let mut adj = Array2::zeros((n, n));
628        for c in 0..k {
629            let base = c * clique_size;
630            for i in 0..clique_size {
631                for j in 0..clique_size {
632                    if i != j {
633                        adj[[base + i, base + j]] = 1.0;
634                    }
635                }
636            }
637        }
638        // Add weak inter-clique edges so the graph is connected (avoids degenerate partitions)
639        if k > 1 {
640            for c in 0..(k - 1) {
641                let u = c * clique_size;
642                let v = (c + 1) * clique_size;
643                adj[[u, v]] = 0.05;
644                adj[[v, u]] = 0.05;
645            }
646        }
647        adj
648    }
649
650    #[test]
651    fn test_modularity_perfect_partition() {
652        // Two disjoint cliques of size 3 with a single weak bridge
653        let adj = make_clique_adj(2, 3);
654        let assignments = vec![0, 0, 0, 1, 1, 1];
655        let q = modularity(&adj, &assignments);
656        // Perfect partition of two clear cliques should have positive modularity
657        assert!(q > 0.0, "modularity should be positive: {q}");
658    }
659
660    #[test]
661    fn test_modularity_empty_graph() {
662        let adj = Array2::<f64>::zeros((4, 4));
663        let q = modularity(&adj, &[0, 0, 1, 1]);
664        assert_eq!(q, 0.0);
665    }
666
667    #[test]
668    fn test_modularity_wrong_assignments() {
669        let adj = Array2::<f64>::zeros((4, 4));
670        let q = modularity(&adj, &[0, 1]); // wrong length
671        assert_eq!(q, 0.0);
672    }
673
674    #[test]
675    fn test_louvain_two_cliques() {
676        let adj = make_clique_adj(2, 4);
677        let result = louvain_communities(&adj, 1.0, 100, 42).expect("louvain");
678        assert!(result.modularity > 0.0, "modularity should be positive");
679        // The two cliques should end up in different communities
680        let comms_left: std::collections::HashSet<usize> =
681            result.assignments[0..4].iter().cloned().collect();
682        let comms_right: std::collections::HashSet<usize> =
683            result.assignments[4..8].iter().cloned().collect();
684        // Each clique should be (mostly) in its own community
685        assert_eq!(comms_left.len(), 1, "left clique should be one community");
686        assert_eq!(comms_right.len(), 1, "right clique should be one community");
687        assert_ne!(
688            result.assignments[0], result.assignments[4],
689            "two cliques must be in different communities"
690        );
691    }
692
693    #[test]
694    fn test_louvain_three_cliques() {
695        let adj = make_clique_adj(3, 3);
696        let result = louvain_communities(&adj, 1.0, 50, 7).expect("louvain");
697        assert!(result.modularity > 0.0);
698        assert!(result.n_communities >= 2);
699    }
700
701    #[test]
702    fn test_louvain_empty_graph_error() {
703        let adj = Array2::<f64>::zeros((0, 0));
704        assert!(louvain_communities(&adj, 1.0, 10, 0).is_err());
705    }
706
707    #[test]
708    fn test_label_propagation_converges() {
709        let adj = make_clique_adj(2, 4);
710        let labels = label_propagation(&adj, 100, 99).expect("label_propagation");
711        assert_eq!(labels.len(), 8);
712        // All nodes in same clique should have same label
713        let l0 = labels[0];
714        for i in 1..4 {
715            assert_eq!(labels[i], l0, "clique 1 should be uniform");
716        }
717        let l1 = labels[4];
718        for i in 5..8 {
719            assert_eq!(labels[i], l1, "clique 2 should be uniform");
720        }
721        assert_ne!(l0, l1, "two cliques should have different labels");
722    }
723
724    #[test]
725    fn test_label_propagation_single_node() {
726        let adj = Array2::<f64>::zeros((1, 1));
727        let labels = label_propagation(&adj, 10, 0).expect("lp");
728        assert_eq!(labels, vec![0]);
729    }
730
731    #[test]
732    fn test_girvan_newman_two_communities() {
733        let adj = make_clique_adj(2, 3);
734        let comms = girvan_newman(&adj, 2).expect("girvan_newman");
735        assert_eq!(comms.len(), 6);
736        // Should detect at least 2 communities
737        let unique: std::collections::HashSet<usize> = comms.iter().cloned().collect();
738        assert!(unique.len() >= 2);
739    }
740
741    #[test]
742    fn test_girvan_newman_invalid() {
743        let adj = Array2::<f64>::zeros((0, 0));
744        assert!(girvan_newman(&adj, 2).is_err());
745        let adj2 = Array2::<f64>::zeros((4, 4));
746        assert!(girvan_newman(&adj2, 0).is_err());
747    }
748
749    #[test]
750    fn test_infomap_two_cliques() {
751        let adj = make_clique_adj(2, 4);
752        let result = infomap_communities(&adj, 5, 13).expect("infomap");
753        assert!(result.modularity > 0.0);
754        assert!(result.n_communities >= 2);
755    }
756
757    #[test]
758    fn test_infomap_empty_error() {
759        let adj = Array2::<f64>::zeros((0, 0));
760        assert!(infomap_communities(&adj, 3, 0).is_err());
761    }
762
763    #[test]
764    fn test_compact_communities() {
765        let mut a = vec![5, 5, 10, 10, 5];
766        compact_communities(&mut a);
767        // After compaction: 5->0, 10->1
768        assert_eq!(a[0], a[1]);
769        assert_eq!(a[1], a[4]);
770        assert_ne!(a[0], a[2]);
771        assert_eq!(a[2], a[3]);
772    }
773}