Skip to main content

rust_igraph/algorithms/community/
label_propagation.rs

1//! Label propagation community detection (ALGO-CO-004).
2//!
3//! Counterpart of `igraph_community_label_propagation()` from
4//! `references/igraph/src/community/label_propagation.c`.
5//!
6//! Raghavan, Albert, Kumara (2007): *Near linear time algorithm to detect
7//! community structures in large-scale networks.* Phys Rev E **76**, 036106.
8//! <https://doi.org/10.1103/PhysRevE.76.036106>
9//!
10//! Traag, Šubelj (2023): *Large network community detection by fast label
11//! propagation.* Scientific Reports **13**:1. The queue-based "fast"
12//! variant. <https://doi.org/10.1038/s41598-023-29610-z>
13//!
14//! Each vertex's label is updated to a label that is *dominant* among its
15//! neighbours (i.e. has the largest summed weight among neighbour labels).
16//! Three variants are implemented, mirroring `igraph_lpa_variant_t`:
17//!
18//! - [`LpaVariant::Fast`] — queue-based (Traag-Šubelj). Vertices to be
19//!   re-considered live in a FIFO queue; whenever a vertex changes label,
20//!   its neighbours not in the new community and not already queued are
21//!   re-enqueued. Terminates when the queue is empty.
22//! - [`LpaVariant::Dominance`] — alternating update + control iterations
23//!   over the full vertex set. Control iterations re-check that every
24//!   vertex still carries a dominant label.
25//! - [`LpaVariant::Retention`] — only relabel a vertex when its current
26//!   label is not dominant; stop when a full sweep produces no change.
27//!
28//! Weights, optional initial labels, and an optional `fixed` mask
29//! (vertices whose label may not change) are supported. Unlabelled
30//! vertices (those carrying `-1` in `initial`) are filled in by a
31//! post-pass BFS that gives each remaining unlabelled component its own
32//! fresh label.
33//!
34//! Self-rolled, dependency-free. Determinism comes from an inline
35//! `SplitMix64` PRNG seeded by the caller; the convenience entrypoint
36//! [`label_propagation`] pins `seed = 0` so repeated calls on the same
37//! graph produce identical partitions.
38
39// All `usize` -> `u32` casts in this module are bounded by `n`
40// (graph vertex count) which originates from `Graph::vcount(): u32`
41// and so can never truncate. The single-char names (`u`, `v`, `w`, `e`,
42// `k`) are domain-standard for graph endpoints / weights / edges /
43// labels. `needless_range_loop`, `ptr_arg`, `too_many_arguments`,
44// `too_many_lines`, `unnecessary_wraps` are allowed for kernel
45// functions that mirror the upstream C source.
46#![allow(
47    clippy::cast_possible_truncation,
48    clippy::cast_possible_wrap,
49    clippy::cast_precision_loss,
50    clippy::cast_sign_loss,
51    clippy::float_cmp,
52    clippy::many_single_char_names,
53    clippy::needless_range_loop,
54    clippy::ptr_arg,
55    clippy::too_many_arguments,
56    clippy::too_many_lines,
57    clippy::unnecessary_wraps
58)]
59
60use std::collections::VecDeque;
61
62use crate::core::graph::EdgeId;
63use crate::core::{Graph, IgraphError, IgraphResult};
64
65/// Which variant of label propagation to run. Mirrors
66/// `igraph_lpa_variant_t`.
67#[derive(Debug, Copy, Clone, Eq, PartialEq)]
68pub enum LpaVariant {
69    /// Queue-based fast variant (Traag-Šubelj 2023). Default — matches
70    /// the python-igraph default.
71    Fast,
72    /// Alternating update / control iterations over the full vertex set.
73    Dominance,
74    /// Sweep all vertices; only relabel when the current label is not
75    /// dominant.
76    Retention,
77}
78
79/// Full option bag for [`label_propagation_with_options`].
80#[derive(Debug, Clone)]
81pub struct LpaOptions {
82    /// Algorithm variant. Default [`LpaVariant::Fast`].
83    pub variant: LpaVariant,
84    /// Optional initial labels. `None` ⇒ every vertex starts as its own
85    /// singleton. When `Some`, length must equal `vcount`; negative
86    /// entries mean "unlabelled" (filled in by the final BFS step);
87    /// non-negative entries must satisfy `label ≤ vcount - 1`.
88    pub initial: Option<Vec<i32>>,
89    /// Optional per-vertex fixed mask. `true` means the vertex's label
90    /// is frozen for the duration of the algorithm. Only meaningful
91    /// together with `initial`; unlabelled fixed vertices are silently
92    /// un-fixed (matches the upstream warning behaviour). Length must
93    /// equal `vcount`.
94    pub fixed: Option<Vec<bool>>,
95    /// PRNG seed driving the node-order shuffle and the
96    /// uniform-random tiebreak among dominant labels. Default `0`.
97    pub seed: u64,
98}
99
100impl Default for LpaOptions {
101    fn default() -> Self {
102        Self {
103            variant: LpaVariant::Fast,
104            initial: None,
105            fixed: None,
106            seed: 0,
107        }
108    }
109}
110
111/// Result of a label-propagation run.
112#[derive(Debug, Clone)]
113pub struct LpaResult {
114    /// Length-`vcount` vector of compacted community labels in `0..k`.
115    pub membership: Vec<u32>,
116    /// Number of distinct communities (`k`).
117    pub nb_clusters: u32,
118}
119
120// ============================================================================
121//                              Public API
122// ============================================================================
123
124/// Run label propagation with the default options
125/// ([`LpaVariant::Fast`], no initial labels, no fixed vertices, seed `0`).
126///
127/// # Errors
128/// - [`IgraphError::Unsupported`] if `graph` is directed (use
129///   [`label_propagation_with_options`] once mode-aware overloads land).
130///
131/// # Examples
132///
133/// ```
134/// use rust_igraph::{Graph, label_propagation};
135///
136/// // Two K4 cliques joined by a single bridge edge — each vertex on the
137/// // bridge endpoints has 3 in-cluster neighbours vs 1 cross-cluster, so
138/// // LPA reliably keeps the bridge cut.
139/// let mut g = Graph::with_vertices(8);
140/// for u in 0..4 {
141///     for v in (u + 1)..4 {
142///         g.add_edge(u, v).unwrap();
143///     }
144/// }
145/// for u in 4..8 {
146///     for v in (u + 1)..8 {
147///         g.add_edge(u, v).unwrap();
148///     }
149/// }
150/// g.add_edge(3, 4).unwrap();
151/// let r = label_propagation(&g).unwrap();
152/// assert_eq!(r.membership[0], r.membership[1]);
153/// assert_eq!(r.membership[4], r.membership[5]);
154/// assert_ne!(r.membership[0], r.membership[4]);
155/// ```
156pub fn label_propagation(graph: &Graph) -> IgraphResult<LpaResult> {
157    label_propagation_with_options(graph, None, &LpaOptions::default())
158}
159
160/// Run label propagation with per-edge weights (default variant, no
161/// initial labels, no fixed vertices, seed `0`).
162///
163/// # Errors
164/// - [`IgraphError::Unsupported`] if `graph` is directed.
165/// - [`IgraphError::InvalidArgument`] if `weights.len() != ecount`, any
166///   weight is non-finite, or any weight is negative.
167///
168/// # Examples
169///
170/// ```
171/// use rust_igraph::{Graph, label_propagation_weighted};
172///
173/// let mut g = Graph::with_vertices(6);
174/// for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
175///     g.add_edge(u, v).unwrap();
176/// }
177/// let weights = vec![10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 0.01];
178/// let r = label_propagation_weighted(&g, &weights).unwrap();
179/// assert_eq!(r.membership[0], r.membership[1]);
180/// assert_ne!(r.membership[0], r.membership[3]);
181/// ```
182pub fn label_propagation_weighted(graph: &Graph, weights: &[f64]) -> IgraphResult<LpaResult> {
183    label_propagation_with_options(graph, Some(weights), &LpaOptions::default())
184}
185
186/// Run label propagation with the full option bag.
187///
188/// # Errors
189/// - [`IgraphError::Unsupported`] if `graph` is directed.
190/// - [`IgraphError::InvalidArgument`] for malformed weights/initial/fixed
191///   inputs as documented on [`LpaOptions`].
192///
193/// # Examples
194///
195/// ```
196/// use rust_igraph::{Graph, LpaOptions, LpaVariant, label_propagation_with_options};
197///
198/// // Same partition every time for a fixed seed.
199/// let mut g = Graph::with_vertices(6);
200/// for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
201///     g.add_edge(u, v).unwrap();
202/// }
203/// let opts = LpaOptions {
204///     variant: LpaVariant::Dominance,
205///     seed: 42,
206///     ..LpaOptions::default()
207/// };
208/// let a = label_propagation_with_options(&g, None, &opts).unwrap();
209/// let b = label_propagation_with_options(&g, None, &opts).unwrap();
210/// assert_eq!(a.membership, b.membership);
211/// ```
212pub fn label_propagation_with_options(
213    graph: &Graph,
214    weights: Option<&[f64]>,
215    opts: &LpaOptions,
216) -> IgraphResult<LpaResult> {
217    if graph.is_directed() {
218        return Err(IgraphError::Unsupported(
219            "label_propagation currently supports undirected graphs only",
220        ));
221    }
222    validate_weights(graph, weights)?;
223
224    let n = graph.vcount() as usize;
225    if n == 0 {
226        return Ok(LpaResult {
227            membership: Vec::new(),
228            nb_clusters: 0,
229        });
230    }
231
232    // Validate + materialise initial labels into a working membership
233    // vector (negative entries kept as i64::MIN sentinel → translated
234    // back to `-1` semantics through `is_labelled`).
235    let mut membership: Vec<i32> = if let Some(init) = opts.initial.as_deref() {
236        if init.len() != n {
237            return Err(IgraphError::InvalidArgument(format!(
238                "initial labels length ({}) differs from vcount ({n})",
239                init.len()
240            )));
241        }
242        let mut max_label: i32 = -1;
243        for &k in init {
244            if k > max_label {
245                max_label = k;
246            }
247        }
248        if max_label >= 0 && i64::from(max_label) > (n as i64 - 1) {
249            return Err(IgraphError::InvalidArgument(format!(
250                "initial labels must be in [0, vcount - 1] = [0, {}]; saw {max_label}",
251                n - 1
252            )));
253        }
254        init.iter().map(|&k| if k < 0 { -1 } else { k }).collect()
255    } else {
256        (0..n).map(|i| i as i32).collect()
257    };
258
259    // Materialise the fixed mask. Unlabelled-and-fixed vertices are
260    // silently un-fixed (mirrors upstream's behaviour of emitting a
261    // warning and clearing the bit in the working copy).
262    let fixed: Option<Vec<bool>> = match opts.fixed.as_deref() {
263        Some(f) => {
264            if f.len() != n {
265                return Err(IgraphError::InvalidArgument(format!(
266                    "fixed mask length ({}) differs from vcount ({n})",
267                    f.len()
268                )));
269            }
270            if opts.initial.is_none() {
271                // Upstream emits a warning and ignores the mask
272                // entirely. We do the same.
273                None
274            } else {
275                let mut out = f.to_vec();
276                for v in 0..n {
277                    if out[v] && membership[v] < 0 {
278                        out[v] = false;
279                    }
280                }
281                Some(out)
282            }
283        }
284        None => None,
285    };
286
287    // Build a per-vertex adjacency list once, paired with each edge's
288    // weight. IGRAPH_LOOPS_ONCE convention: self-loops appear once.
289    let edge_weights: Vec<f64> = match weights {
290        Some(w) => w.to_vec(),
291        None => vec![1.0; graph.ecount()],
292    };
293    let adj = build_adjacency(graph, &edge_weights)?;
294
295    let mut rng = SplitMix64::new(opts.seed);
296
297    match opts.variant {
298        LpaVariant::Fast => {
299            lpa_fast(&adj, &mut membership, fixed.as_deref(), &mut rng);
300        }
301        LpaVariant::Dominance => {
302            lpa_dominance_or_retention(
303                &adj,
304                &mut membership,
305                fixed.as_deref(),
306                /* retention */ false,
307                &mut rng,
308            );
309        }
310        LpaVariant::Retention => {
311            lpa_dominance_or_retention(
312                &adj,
313                &mut membership,
314                fixed.as_deref(),
315                /* retention */ true,
316                &mut rng,
317            );
318        }
319    }
320
321    // Compact labels into 0..k and BFS-fill any remaining unlabelled
322    // components with fresh labels.
323    let nb_clusters = finalize_labels(graph, &mut membership, fixed.as_deref(), &mut rng)?;
324
325    // i32 -> u32 once we know everything is in [0, k).
326    let membership: Vec<u32> = membership.iter().map(|&k| k as u32).collect();
327    Ok(LpaResult {
328        membership,
329        nb_clusters,
330    })
331}
332
333// ============================================================================
334//                           Adjacency construction
335// ============================================================================
336
337/// Build a `(neighbour, weight)` adjacency list with the upstream
338/// `IGRAPH_LOOPS_ONCE` convention — a self-loop contributes a single
339/// `(v, w)` entry to `adj[v]`, not two.
340fn build_adjacency(graph: &Graph, edge_weights: &[f64]) -> IgraphResult<Vec<Vec<(u32, f64)>>> {
341    let n = graph.vcount() as usize;
342    let m = graph.ecount();
343    let mut adj: Vec<Vec<(u32, f64)>> = vec![Vec::new(); n];
344    for e in 0..m {
345        let e_id = u32::try_from(e)
346            .map_err(|_| IgraphError::InvalidArgument("edge count exceeds u32::MAX".into()))?;
347        let (u, v) = graph.edge(e_id as EdgeId)?;
348        let w = edge_weights[e];
349        adj[u as usize].push((v, w));
350        if u != v {
351            adj[v as usize].push((u, w));
352        }
353    }
354    Ok(adj)
355}
356
357// ============================================================================
358//                          Fast variant (Traag-Šubelj 2023)
359// ============================================================================
360
361fn lpa_fast(
362    adj: &[Vec<(u32, f64)>],
363    membership: &mut [i32],
364    fixed: Option<&[bool]>,
365    rng: &mut SplitMix64,
366) {
367    let n = adj.len();
368    if n == 0 {
369        return;
370    }
371
372    // Per-label accumulator + book-keeping for clearing only touched
373    // labels (the same trick the C source uses: `nonzero_labels`).
374    let mut label_weight = vec![0.0_f64; n];
375    let mut touched: Vec<i32> = Vec::with_capacity(8);
376    let mut dominant: Vec<i32> = Vec::with_capacity(2);
377
378    // Initial queue: every not-fixed vertex in a shuffled order.
379    let mut order: Vec<u32> = match fixed {
380        Some(mask) => (0..n as u32).filter(|&v| !mask[v as usize]).collect(),
381        None => (0..n as u32).collect(),
382    };
383    shuffle_in_place(&mut order, rng);
384
385    let mut queue: VecDeque<u32> = order.iter().copied().collect();
386    let mut in_queue = vec![false; n];
387    for &v in &order {
388        in_queue[v as usize] = true;
389    }
390
391    while let Some(v1) = queue.pop_front() {
392        in_queue[v1 as usize] = false;
393
394        // Accumulate per-label weight across v1's neighbours.
395        let neighbours = &adj[v1 as usize];
396        let mut max_count: f64 = 0.0;
397        dominant.clear();
398        touched.clear();
399        for &(v2, w) in neighbours {
400            let k = membership[v2 as usize];
401            if k < 0 {
402                continue;
403            }
404            let was_zero = label_weight[k as usize] == 0.0;
405            label_weight[k as usize] += w;
406            if was_zero {
407                touched.push(k);
408            }
409            let lw = label_weight[k as usize];
410            if max_count < lw {
411                max_count = lw;
412                dominant.clear();
413                dominant.push(k);
414            } else if (max_count - lw).abs() < f64::EPSILON || max_count == lw {
415                dominant.push(k);
416            }
417        }
418
419        if !dominant.is_empty() {
420            let current = membership[v1 as usize];
421            let pick = rng.gen_index(dominant.len());
422            let new_label = dominant[pick];
423
424            if new_label != current {
425                // Re-enqueue any neighbour that is not in the new
426                // community and not already in the queue (and not
427                // fixed).
428                for &(v2, _) in neighbours {
429                    let v2u = v2 as usize;
430                    if in_queue[v2u] {
431                        continue;
432                    }
433                    let neigh_label = membership[v2u];
434                    let is_fixed = fixed.is_some_and(|m| m[v2u]);
435                    if neigh_label != new_label && !is_fixed {
436                        queue.push_back(v2);
437                        in_queue[v2u] = true;
438                    }
439                }
440            }
441            membership[v1 as usize] = new_label;
442        }
443
444        // Reset only the labels we actually touched.
445        for &k in &touched {
446            label_weight[k as usize] = 0.0;
447        }
448    }
449}
450
451// ============================================================================
452//                       Dominance + Retention variants
453// ============================================================================
454
455fn lpa_dominance_or_retention(
456    adj: &[Vec<(u32, f64)>],
457    membership: &mut [i32],
458    fixed: Option<&[bool]>,
459    retention: bool,
460    rng: &mut SplitMix64,
461) {
462    let n = adj.len();
463    if n == 0 {
464        return;
465    }
466
467    let mut label_weight = vec![0.0_f64; n];
468    let mut touched: Vec<i32> = Vec::with_capacity(8);
469    let mut dominant: Vec<i32> = Vec::with_capacity(2);
470
471    let mut order: Vec<u32> = match fixed {
472        Some(mask) => (0..n as u32).filter(|&v| !mask[v as usize]).collect(),
473        None => (0..n as u32).collect(),
474    };
475
476    let mut running = true;
477    let mut control_iteration = true;
478    while running {
479        if retention {
480            running = false;
481            shuffle_in_place(&mut order, rng);
482        } else if control_iteration {
483            running = false;
484        } else {
485            shuffle_in_place(&mut order, rng);
486        }
487
488        for &v1 in &order {
489            // Accumulate per-label weight.
490            let neighbours = &adj[v1 as usize];
491            let mut max_count: f64 = 0.0;
492            dominant.clear();
493            touched.clear();
494            for &(v2, w) in neighbours {
495                let k = membership[v2 as usize];
496                if k < 0 {
497                    continue;
498                }
499                let was_zero = label_weight[k as usize] == 0.0;
500                label_weight[k as usize] += w;
501                if was_zero {
502                    touched.push(k);
503                }
504                let lw = label_weight[k as usize];
505                if max_count < lw {
506                    max_count = lw;
507                    dominant.clear();
508                    dominant.push(k);
509                } else if max_count == lw {
510                    dominant.push(k);
511                }
512            }
513
514            if !dominant.is_empty() {
515                if retention {
516                    let current = membership[v1 as usize];
517                    let keep_current = current >= 0
518                        && (current as usize) < n
519                        && label_weight[current as usize] >= max_count;
520                    if !keep_current {
521                        let pick = rng.gen_index(dominant.len());
522                        let new_label = dominant[pick];
523                        if new_label != current {
524                            running = true;
525                        }
526                        membership[v1 as usize] = new_label;
527                    }
528                } else if control_iteration {
529                    let current = membership[v1 as usize];
530                    let still_dominant = current >= 0
531                        && (current as usize) < n
532                        && label_weight[current as usize] >= max_count;
533                    if !still_dominant {
534                        running = true;
535                    }
536                } else {
537                    let pick = rng.gen_index(dominant.len());
538                    membership[v1 as usize] = dominant[pick];
539                }
540            }
541
542            for &k in &touched {
543                label_weight[k as usize] = 0.0;
544            }
545        }
546
547        if !retention {
548            control_iteration = !control_iteration;
549        }
550    }
551}
552
553// ============================================================================
554//                                Finalize
555// ============================================================================
556
557/// Compact labels to `[0, k)`, then BFS-fill any remaining unlabelled
558/// vertices (each unlabelled connected component receives its own fresh
559/// label). Returns `k`.
560fn finalize_labels(
561    graph: &Graph,
562    membership: &mut [i32],
563    fixed: Option<&[bool]>,
564    rng: &mut SplitMix64,
565) -> IgraphResult<u32> {
566    let n = membership.len();
567
568    // First pass: dense relabel via a sparse map from "original label"
569    // to "compacted label". `relabel[k] == -1` marks "not seen yet".
570    let mut max_seen: i32 = -1;
571    for &k in membership.iter() {
572        if k > max_seen {
573            max_seen = k;
574        }
575    }
576    let span = max_seen.max(-1) + 1;
577    let mut relabel: Vec<i32> = vec![-1; span.max(0) as usize];
578
579    let mut next_label: i32 = 0;
580    let mut unlabelled_left = false;
581    for v in 0..n {
582        let k = membership[v];
583        if k >= 0 {
584            let slot = k as usize;
585            if relabel[slot] == -1 {
586                relabel[slot] = next_label;
587                membership[v] = next_label;
588                next_label += 1;
589            } else {
590                membership[v] = relabel[slot];
591            }
592        } else {
593            unlabelled_left = true;
594        }
595    }
596
597    if unlabelled_left {
598        // BFS-fill: every unlabelled connected component gets its own
599        // new label. Skip fixed vertices in the seed-iteration order
600        // (mirrors upstream).
601        let mut order: Vec<u32> = match fixed {
602            Some(mask) => (0..n as u32).filter(|&v| !mask[v as usize]).collect(),
603            None => (0..n as u32).collect(),
604        };
605        shuffle_in_place(&mut order, rng);
606
607        let mut bfs_queue: VecDeque<u32> = VecDeque::new();
608        for &seed in &order {
609            if membership[seed as usize] >= 0 {
610                continue;
611            }
612            let label = next_label;
613            next_label += 1;
614            membership[seed as usize] = label;
615            bfs_queue.push_back(seed);
616            while let Some(v) = bfs_queue.pop_front() {
617                let neighbours = graph.neighbors(v)?;
618                for n_v in neighbours {
619                    if membership[n_v as usize] < 0 {
620                        membership[n_v as usize] = label;
621                        bfs_queue.push_back(n_v);
622                    }
623                }
624            }
625        }
626    }
627
628    Ok(next_label as u32)
629}
630
631// ============================================================================
632//                              Weights validation
633// ============================================================================
634
635fn validate_weights(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<()> {
636    let Some(w) = weights else {
637        return Ok(());
638    };
639    let m = graph.ecount();
640    if w.len() != m {
641        return Err(IgraphError::InvalidArgument(format!(
642            "weights length ({}) differs from edge count ({m})",
643            w.len()
644        )));
645    }
646    for (e, &v) in w.iter().enumerate() {
647        if !v.is_finite() {
648            return Err(IgraphError::InvalidArgument(format!(
649                "weight at edge {e} is not finite ({v})"
650            )));
651        }
652        if v < 0.0 {
653            return Err(IgraphError::InvalidArgument(format!(
654                "weight at edge {e} is negative ({v}); label_propagation requires non-negative weights"
655            )));
656        }
657    }
658    Ok(())
659}
660
661// ============================================================================
662//                                  PRNG
663// ============================================================================
664
665struct SplitMix64(u64);
666
667impl SplitMix64 {
668    fn new(seed: u64) -> Self {
669        Self(seed.wrapping_add(0x9E37_79B9_7F4A_7C15))
670    }
671    fn next_u64(&mut self) -> u64 {
672        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
673        let mut z = self.0;
674        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
675        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
676        z ^ (z >> 31)
677    }
678    fn gen_index(&mut self, bound: usize) -> usize {
679        debug_assert!(bound > 0);
680        let r = self.next_u64() % (bound as u64);
681        usize::try_from(r).unwrap_or(0)
682    }
683}
684
685fn shuffle_in_place<T>(slice: &mut [T], rng: &mut SplitMix64) {
686    let len = slice.len();
687    for i in (1..len).rev() {
688        let j = rng.gen_index(i + 1);
689        slice.swap(i, j);
690    }
691}
692
693// ============================================================================
694//                                  Tests
695// ============================================================================
696
697#[cfg(test)]
698#[allow(clippy::float_cmp)]
699mod tests {
700    use super::*;
701
702    fn add_edges(g: &mut Graph, edges: &[(u32, u32)]) {
703        for &(u, v) in edges {
704            g.add_edge(u, v).unwrap();
705        }
706    }
707
708    fn assert_dense_labels(r: &LpaResult, n: usize) {
709        assert_eq!(r.membership.len(), n);
710        if n == 0 {
711            assert_eq!(r.nb_clusters, 0);
712            return;
713        }
714        let max = *r.membership.iter().max().unwrap_or(&0);
715        assert!((max as usize) < n);
716        assert_eq!(max + 1, r.nb_clusters);
717        let mut seen = vec![false; r.nb_clusters as usize];
718        for &m in &r.membership {
719            seen[m as usize] = true;
720        }
721        assert!(seen.into_iter().all(|b| b));
722    }
723
724    #[test]
725    fn empty_graph_returns_empty_membership() {
726        let g = Graph::with_vertices(0);
727        let r = label_propagation(&g).unwrap();
728        assert_eq!(r.membership.len(), 0);
729        assert_eq!(r.nb_clusters, 0);
730    }
731
732    #[test]
733    fn isolated_vertices_each_get_own_label() {
734        let g = Graph::with_vertices(4);
735        let r = label_propagation(&g).unwrap();
736        assert_dense_labels(&r, 4);
737        assert_eq!(r.nb_clusters, 4);
738    }
739
740    #[test]
741    fn two_triangles_bridge_split() {
742        let mut g = Graph::with_vertices(6);
743        add_edges(
744            &mut g,
745            &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
746        );
747        let r = label_propagation(&g).unwrap();
748        // Triangles should be intact; the partition itself can vary by
749        // seed but each clique must stay together.
750        assert_eq!(r.membership[0], r.membership[1]);
751        assert_eq!(r.membership[0], r.membership[2]);
752        assert_eq!(r.membership[3], r.membership[4]);
753        assert_eq!(r.membership[3], r.membership[5]);
754        assert_dense_labels(&r, 6);
755    }
756
757    #[test]
758    fn directed_input_rejected() {
759        let mut g = Graph::new(3, true).unwrap();
760        g.add_edge(0, 1).unwrap();
761        assert!(label_propagation(&g).is_err());
762        assert!(label_propagation_weighted(&g, &[1.0]).is_err());
763        assert!(label_propagation_with_options(&g, None, &LpaOptions::default()).is_err());
764    }
765
766    #[test]
767    fn weight_validation() {
768        let mut g = Graph::with_vertices(3);
769        g.add_edge(0, 1).unwrap();
770        g.add_edge(1, 2).unwrap();
771        // wrong length
772        assert!(label_propagation_weighted(&g, &[1.0]).is_err());
773        // NaN
774        assert!(label_propagation_weighted(&g, &[1.0, f64::NAN]).is_err());
775        // negative
776        assert!(label_propagation_weighted(&g, &[1.0, -0.1]).is_err());
777    }
778
779    #[test]
780    fn determinism_under_seed_fast() {
781        let mut g = Graph::with_vertices(8);
782        for &(u, v) in &[
783            (0, 1),
784            (0, 2),
785            (1, 2),
786            (2, 3),
787            (3, 4),
788            (4, 5),
789            (4, 6),
790            (5, 6),
791            (5, 7),
792        ] {
793            g.add_edge(u, v).unwrap();
794        }
795        let opts = LpaOptions {
796            seed: 12345,
797            ..LpaOptions::default()
798        };
799        let a = label_propagation_with_options(&g, None, &opts).unwrap();
800        let b = label_propagation_with_options(&g, None, &opts).unwrap();
801        assert_eq!(a.membership, b.membership);
802    }
803
804    #[test]
805    fn determinism_under_seed_dominance() {
806        let mut g = Graph::with_vertices(6);
807        add_edges(
808            &mut g,
809            &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
810        );
811        let opts = LpaOptions {
812            variant: LpaVariant::Dominance,
813            seed: 99,
814            ..LpaOptions::default()
815        };
816        let a = label_propagation_with_options(&g, None, &opts).unwrap();
817        let b = label_propagation_with_options(&g, None, &opts).unwrap();
818        assert_eq!(a.membership, b.membership);
819    }
820
821    #[test]
822    fn determinism_under_seed_retention() {
823        let mut g = Graph::with_vertices(6);
824        add_edges(
825            &mut g,
826            &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
827        );
828        let opts = LpaOptions {
829            variant: LpaVariant::Retention,
830            seed: 7,
831            ..LpaOptions::default()
832        };
833        let a = label_propagation_with_options(&g, None, &opts).unwrap();
834        let b = label_propagation_with_options(&g, None, &opts).unwrap();
835        assert_eq!(a.membership, b.membership);
836    }
837
838    #[test]
839    fn unit_weights_match_unweighted() {
840        let mut g = Graph::with_vertices(6);
841        add_edges(
842            &mut g,
843            &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
844        );
845        let opts = LpaOptions {
846            seed: 42,
847            ..LpaOptions::default()
848        };
849        let a = label_propagation_with_options(&g, None, &opts).unwrap();
850        let ones = vec![1.0; g.ecount()];
851        let b = label_propagation_with_options(&g, Some(&ones), &opts).unwrap();
852        assert_eq!(a.membership, b.membership);
853    }
854
855    #[test]
856    fn initial_validation() {
857        let mut g = Graph::with_vertices(3);
858        g.add_edge(0, 1).unwrap();
859        // wrong length
860        let opts = LpaOptions {
861            initial: Some(vec![0, 1]),
862            ..LpaOptions::default()
863        };
864        assert!(label_propagation_with_options(&g, None, &opts).is_err());
865        // label out of range
866        let opts = LpaOptions {
867            initial: Some(vec![0, 1, 99]),
868            ..LpaOptions::default()
869        };
870        assert!(label_propagation_with_options(&g, None, &opts).is_err());
871    }
872
873    #[test]
874    fn fixed_validation() {
875        let mut g = Graph::with_vertices(3);
876        g.add_edge(0, 1).unwrap();
877        // wrong length
878        let opts = LpaOptions {
879            initial: Some(vec![0, 1, 2]),
880            fixed: Some(vec![false, false]),
881            ..LpaOptions::default()
882        };
883        assert!(label_propagation_with_options(&g, None, &opts).is_err());
884    }
885
886    #[test]
887    fn fixed_vertices_keep_their_labels() {
888        // K3 with labels [7, 7, 9]; fix all three.
889        let mut g = Graph::with_vertices(3);
890        add_edges(&mut g, &[(0, 1), (0, 2), (1, 2)]);
891        let opts = LpaOptions {
892            initial: Some(vec![0, 0, 1]),
893            fixed: Some(vec![true, true, true]),
894            ..LpaOptions::default()
895        };
896        let r = label_propagation_with_options(&g, None, &opts).unwrap();
897        assert_eq!(r.membership.len(), 3);
898        // Labels are compacted, but the co-membership relation must
899        // be preserved: vertex 0 == 1, vertex 2 ≠ them.
900        assert_eq!(r.membership[0], r.membership[1]);
901        assert_ne!(r.membership[0], r.membership[2]);
902    }
903
904    #[test]
905    fn unlabelled_component_gets_fresh_label() {
906        // Three disconnected K2s. Start with one labelled, two not.
907        let mut g = Graph::with_vertices(6);
908        add_edges(&mut g, &[(0, 1), (2, 3), (4, 5)]);
909        let opts = LpaOptions {
910            initial: Some(vec![0, 0, -1, -1, -1, -1]),
911            ..LpaOptions::default()
912        };
913        let r = label_propagation_with_options(&g, None, &opts).unwrap();
914        // K2 0-1 keeps the original label; the other two components
915        // get fresh labels each. Total 3 communities.
916        assert_eq!(r.nb_clusters, 3);
917        assert_eq!(r.membership[0], r.membership[1]);
918        assert_eq!(r.membership[2], r.membership[3]);
919        assert_eq!(r.membership[4], r.membership[5]);
920    }
921
922    #[test]
923    fn ignore_fixed_when_no_initial() {
924        // fixed is silently ignored if initial is None — matches
925        // upstream warning behaviour.
926        let mut g = Graph::with_vertices(3);
927        add_edges(&mut g, &[(0, 1), (1, 2)]);
928        let opts = LpaOptions {
929            fixed: Some(vec![true, true, true]),
930            ..LpaOptions::default()
931        };
932        // Should run successfully and treat all vertices as updatable.
933        let r = label_propagation_with_options(&g, None, &opts).unwrap();
934        assert_eq!(r.membership.len(), 3);
935    }
936
937    #[test]
938    fn self_loops_handled() {
939        // Self-loops count once toward the vertex's own label (matches
940        // IGRAPH_LOOPS_ONCE). The result must still be a well-formed
941        // partition.
942        let mut g = Graph::with_vertices(3);
943        add_edges(&mut g, &[(0, 0), (0, 1), (1, 2)]);
944        let r = label_propagation(&g).unwrap();
945        assert_dense_labels(&r, 3);
946    }
947
948    #[test]
949    fn three_variants_all_run_on_karate_size_input() {
950        // A bigger graph: 10 vertices in two K5s + bridge.
951        let mut g = Graph::with_vertices(10);
952        for c in 0..2 {
953            let base = c * 5;
954            for u in 0..5 {
955                for v in (u + 1)..5 {
956                    g.add_edge(base + u, base + v).unwrap();
957                }
958            }
959        }
960        g.add_edge(4, 5).unwrap();
961
962        for variant in [
963            LpaVariant::Fast,
964            LpaVariant::Dominance,
965            LpaVariant::Retention,
966        ] {
967            let opts = LpaOptions {
968                variant,
969                seed: 0,
970                ..LpaOptions::default()
971            };
972            let r = label_propagation_with_options(&g, None, &opts).unwrap();
973            assert_dense_labels(&r, 10);
974            // Each K5 internally must stay together.
975            for u in 0..5 {
976                assert_eq!(r.membership[u], r.membership[0]);
977                assert_eq!(r.membership[u + 5], r.membership[5]);
978            }
979        }
980    }
981}