Skip to main content

bixverse_methods/graph/
leiden.rs

1//! Leiden and Louvain community detection on modularity.
2//!
3//! One engine for both: Louvain is Leiden with the refinement phase switched
4//! off. Phase semantics follow igraph's `igraph_community_leiden` (fast local
5//! moving with a queue of unstable nodes, randomised constrained merging in the
6//! refinement, aggregation on the refined partition seeded with the unrefined
7//! one). Weights are read in `T`, but every node, cluster and edge total is
8//! held in `f64`, since the cluster totals are updated millions of times.
9//!
10//! Departure from igraph: the graph is relabelled into BFS order first, and
11//! Leiden's local moving visits nodes in that order from a seeded random
12//! start rather than in a full shuffle. On kNN graphs of unordered cells this
13//! keeps the neighbour scans cache-local and needs about half the node
14//! evaluations, at the same modularity. Louvain keeps the full shuffle, as the
15//! ordered sweep costs it modularity without a refinement to repair it.
16//!
17//! Local moving defaults to the sequential queue. [`LocalMoving::Coloured`]
18//! parallelises it on large levels, see its docs; it stays independent of the
19//! thread count.
20//!
21//! Determinism: the queue is sequential and seeded. Refinement
22//! runs in parallel over clusters, each cluster with its own RNG derived from
23//! `(seed, iteration, level, cluster)`, and aggregation is parallel over
24//! refined clusters with a fixed reduction order. The partition therefore
25//! depends on the seed only, not on the thread count.
26
27use num_traits::{ToPrimitive, Zero};
28use rand::prelude::*;
29use rayon::prelude::*;
30use std::cell::RefCell;
31use std::collections::VecDeque;
32use std::sync::atomic::{AtomicBool, Ordering::Relaxed};
33
34use crate::graph::community_detections::remap_communities_by_size;
35use crate::prelude::*;
36
37////////////
38// Consts //
39////////////
40
41/// Nodes in a level below which refinement and aggregation run sequentially.
42/// Louvain is also called from inside parallel loops over seeds and
43/// resolutions (fast_clusters), where small levels are not worth a fork.
44const LEIDEN_PAR_MIN_NODES: usize = 4096;
45
46/// Safety cap on rounds of the parallel local-moving modes. Each round only
47/// revisits nodes whose neighbourhood changed, so real graphs stop far below
48/// it.
49const PAR_MOVE_MAX_ROUNDS: usize = 1000;
50
51/// Nodes per rayon task when deciding moves in parallel. Per-node work follows
52/// the degree, so small tasks balance better.
53const PAR_MOVE_MIN_LEN: usize = 64;
54
55/// Target id of a move into an empty cluster, resolved when it is applied.
56const NEW_CLUSTER: u32 = u32::MAX;
57
58/// Safety cap on full iterations when iterating until stable
59/// (`n_iterations: None`), against a partition that oscillates.
60const MAX_ITERATIONS: usize = 1000;
61
62/// Nodes per partial sum of the level-0 modularity. Fixed, so the stopping
63/// decision of `min_gain` does not depend on the thread count.
64const QUALITY_CHUNK: usize = 4096;
65
66////////////
67// Params //
68////////////
69
70/// How the local-moving phase visits the nodes.
71#[derive(Clone, Copy, Debug, Default, PartialEq)]
72pub enum LocalMoving {
73    /// Traag's fast local moving: a sequential queue of unstable nodes
74    #[default]
75    Queue,
76    /// Greedy colour classes of a seeded node order. Each class decides its
77    /// moves in parallel against the state frozen at its start (no two nodes
78    /// of a class are neighbours, so their edge weights to clusters are
79    /// exact), then applies them in node order, re-checking each gain against
80    /// the live cluster totals. The scheme is the one in `clust-libreville-rs`
81    /// and `leiden-rs`.
82    Coloured,
83}
84
85/// Parse a [`LocalMoving`] name.
86///
87/// ### Params
88///
89/// * `s` - `"queue"` or `"coloured"`, case-insensitive
90///
91/// ### Returns
92///
93/// The [`LocalMoving`], or `None` for an unknown name
94pub fn parse_local_moving(s: &str) -> Option<LocalMoving> {
95    match s.to_lowercase().as_str() {
96        "queue" => Some(LocalMoving::Queue),
97        "coloured" | "colored" => Some(LocalMoving::Coloured),
98        _ => None,
99    }
100}
101
102/// Parameters for [`leiden_sparse_graph`].
103///
104/// ### Fields
105///
106/// * `resolution` - Modularity resolution parameter (gamma)
107/// * `beta` - Randomness of the refinement merges; the target is drawn with
108///   probability proportional to `exp(gain / beta)`. igraph default 0.01.
109/// * `n_iterations` - Number of full Leiden iterations, each starting from
110///   the previous partition. `None` iterates until an iteration changes
111///   nothing (capped at 1000). Default 5; igraph's R default is 2.
112/// * `min_gain` - With `n_iterations: None`, also stop once an iteration
113///   raises the modularity by no more than this. 0 waits for a fixed point.
114/// * `refine` - Run the Leiden refinement phase. `false` gives Louvain.
115/// * `seed` - Seed for the node orders and refinement draws
116/// * `local_moving` - How local moving visits the nodes; the parallel modes
117///   only run on levels of at least `LEIDEN_PAR_MIN_NODES` (4096) nodes,
118///   smaller ones use the queue
119#[derive(Clone, Copy, Debug)]
120pub struct LeidenParams {
121    /// Modularity resolution parameter (gamma)
122    pub resolution: f64,
123    /// Randomness of the refinement merges
124    pub beta: f64,
125    /// Number of full iterations; `None` until stable
126    pub n_iterations: Option<usize>,
127    /// Modularity gain at or below which `n_iterations: None` stops
128    pub min_gain: f64,
129    /// Run the refinement phase (Leiden) or not (Louvain)
130    pub refine: bool,
131    /// Random seed
132    pub seed: u64,
133    /// Local-moving strategy
134    pub local_moving: LocalMoving,
135}
136
137impl LeidenParams {
138    /// Generate new Leiden parameters.
139    ///
140    /// ### Params
141    ///
142    /// * `resolution` - Modularity resolution parameter (gamma)
143    /// * `beta` - Randomness of the refinement merges
144    /// * `n_iterations` - Number of full iterations; `None` until stable
145    /// * `refine` - Run the refinement phase (Leiden) or not (Louvain)
146    /// * `seed` - Random seed
147    ///
148    /// ### Returns
149    ///
150    /// Initialised self, with `min_gain` 0
151    pub fn new(
152        resolution: f64,
153        beta: f64,
154        n_iterations: Option<usize>,
155        refine: bool,
156        seed: u64,
157    ) -> Self {
158        Self {
159            resolution,
160            beta,
161            n_iterations,
162            min_gain: 0.0,
163            refine,
164            seed,
165            local_moving: LocalMoving::Queue,
166        }
167    }
168}
169
170/// Defaults match `igraph::cluster_leiden(objective_function = "modularity")`,
171/// except 5 iterations rather than 2, as two leave Leiden short of converged
172/// on large graphs.
173impl Default for LeidenParams {
174    fn default() -> Self {
175        Self {
176            resolution: 1.0,
177            beta: 0.01,
178            n_iterations: Some(5),
179            min_gain: 0.0,
180            refine: true,
181            seed: 42,
182            local_moving: LocalMoving::Queue,
183        }
184    }
185}
186
187////////////////
188// LevelGraph //
189////////////////
190
191/// Read access to the adjacency of one level of the hierarchy.
192///
193/// Level 0 is the caller's graph relabelled into BFS order, every later level
194/// an aggregate; both are an [`AggGraph`]. [`SparseGraph`] implements it too,
195/// for the BFS and the relabelling themselves.
196trait LevelGraph: Sync {
197    /// Edge weight type
198    type W: Copy + Zero + Send + Sync + ToPrimitive;
199
200    /// Number of nodes in this level
201    fn n(&self) -> usize;
202
203    /// Neighbours and weights of node `i`
204    fn row(&self, i: usize) -> (&[u32], &[Self::W]);
205}
206
207impl<T> LevelGraph for SparseGraph<T>
208where
209    T: BixverseFloat + Send + Sync + std::iter::Sum,
210{
211    type W = T;
212
213    #[inline]
214    fn n(&self) -> usize {
215        self.get_node_number()
216    }
217
218    #[inline]
219    fn row(&self, i: usize) -> (&[u32], &[T]) {
220        self.get_neighbours_raw(i)
221    }
222}
223
224//////////////
225// AggGraph //
226//////////////
227
228/// Symmetric CSR for one level of the hierarchy.
229///
230/// Level 0 is the caller's graph relabelled into BFS order, weights in the
231/// caller's type. Aggregated levels hold f64 weights and no self-loops: local
232/// moving and refinement ignore them, and the node weights carry the full
233/// degree separately.
234struct AggGraph<W> {
235    /// Row offsets, length n + 1
236    indptr: Vec<usize>,
237    /// Column indices
238    indices: Vec<u32>,
239    /// Edge weights
240    weights: Vec<W>,
241}
242
243impl<W: Copy + Zero + Send + Sync + ToPrimitive> LevelGraph for AggGraph<W> {
244    type W = W;
245
246    #[inline]
247    fn n(&self) -> usize {
248        self.indptr.len() - 1
249    }
250
251    #[inline]
252    fn row(&self, i: usize) -> (&[u32], &[W]) {
253        let (s, e) = (self.indptr[i], self.indptr[i + 1]);
254        (&self.indices[s..e], &self.weights[s..e])
255    }
256}
257
258/////////////
259// Helpers //
260/////////////
261
262/// Weight to f64. `ToPrimitive` on f32/f64 cannot fail.
263///
264/// ### Params
265///
266/// * `w` - Edge weight
267///
268/// ### Returns
269///
270/// The weight as f64
271#[inline(always)]
272fn wf<W: ToPrimitive>(w: W) -> f64 {
273    w.to_f64().expect("float to f64 cannot fail")
274}
275
276/// SplitMix64 finaliser, used to derive independent per-cluster seeds.
277///
278/// ### Params
279///
280/// * `x` - Value to mix
281///
282/// ### Returns
283///
284/// Mixed 64-bit value
285#[inline]
286fn splitmix64(mut x: u64) -> u64 {
287    x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
288    x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
289    x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
290    x ^ (x >> 31)
291}
292
293/// Derive a seed from a base seed and a list of indices.
294///
295/// ### Params
296///
297/// * `seed` - Base seed
298/// * `parts` - Indices identifying the stream (iteration, level, cluster)
299///
300/// ### Returns
301///
302/// Derived seed
303fn derive_seed(seed: u64, parts: &[u64]) -> u64 {
304    parts
305        .iter()
306        .fold(splitmix64(seed), |acc, &p| splitmix64(acc ^ p))
307}
308
309/// Renumber labels to `0..k` in order of first appearance.
310///
311/// ### Params
312///
313/// * `labels` - Labels, each `< labels.len()`; renumbered in place
314///
315/// ### Returns
316///
317/// Number of distinct labels
318fn reindex(labels: &mut [u32]) -> usize {
319    let mut map = vec![u32::MAX; labels.len()];
320    let mut next = 0u32;
321    for l in labels.iter_mut() {
322        let m = &mut map[*l as usize];
323        if *m == u32::MAX {
324            *m = next;
325            next += 1;
326        }
327        *l = *m;
328    }
329    next as usize
330}
331
332/// Group nodes by label (counting sort), members in increasing node order.
333///
334/// ### Params
335///
336/// * `labels` - Label per node, contiguous in `0..k`
337/// * `k` - Number of labels
338///
339/// ### Returns
340///
341/// `(offsets, members)`: members of label `c` are
342/// `members[offsets[c]..offsets[c + 1]]`
343fn group_by_label(labels: &[u32], k: usize) -> (Vec<usize>, Vec<u32>) {
344    let mut offsets = vec![0usize; k + 1];
345    for &l in labels {
346        offsets[l as usize + 1] += 1;
347    }
348    for c in 0..k {
349        offsets[c + 1] += offsets[c];
350    }
351    let mut cursor = offsets.clone();
352    let mut members = vec![0u32; labels.len()];
353    for (v, &l) in labels.iter().enumerate() {
354        members[cursor[l as usize]] = v as u32;
355        cursor[l as usize] += 1;
356    }
357    (offsets, members)
358}
359
360/// Modularity of a partition of one level, as the stopping rule of
361/// `min_gain` sees it.
362///
363/// The internal weight is summed in chunks of [`QUALITY_CHUNK`] nodes, combined
364/// in order, so the value (and the decision to stop) does not depend on the
365/// thread count.
366///
367/// ### Params
368///
369/// * `g` - Graph of this level, self-loops included
370/// * `node_w` - Node weights
371/// * `membership` - Cluster per node, labels `< n`
372/// * `res` - Resolution divided by `2m`
373/// * `two_m` - Total node weight
374///
375/// ### Returns
376///
377/// The modularity
378fn level_quality<G: LevelGraph>(
379    g: &G,
380    node_w: &[f64],
381    membership: &[u32],
382    res: f64,
383    two_m: f64,
384) -> f64 {
385    let n = g.n();
386    let partials: Vec<f64> = (0..n.div_ceil(QUALITY_CHUNK))
387        .into_par_iter()
388        .map(|chunk| {
389            let mut s = 0.0;
390            for v in chunk * QUALITY_CHUNK..((chunk + 1) * QUALITY_CHUNK).min(n) {
391                let c = membership[v];
392                let (nbrs, ws) = g.row(v);
393                for (&u, &w) in nbrs.iter().zip(ws) {
394                    if membership[u as usize] == c {
395                        s += wf(w);
396                    }
397                }
398            }
399            s
400        })
401        .collect();
402    let internal: f64 = partials.iter().sum();
403    let mut tot = vec![0.0f64; n];
404    for (&c, &k) in membership.iter().zip(node_w) {
405        tot[c as usize] += k;
406    }
407    let squares: f64 = tot.iter().map(|t| t * t).sum();
408    (internal - res * squares) / two_m
409}
410
411/// Modularity of a partition.
412///
413/// `Q = 1/2m * sum_c (e_c - gamma * K_c^2 / 2m)`, with `e_c` twice the internal
414/// edge weight and `K_c` the summed degree of cluster `c`. Self-loops count
415/// once per CSR entry, as in igraph.
416///
417/// ### Params
418///
419/// * `graph` - Undirected [`SparseGraph`]
420/// * `labels` - Community per node
421/// * `resolution` - Resolution parameter (gamma)
422///
423/// ### Returns
424///
425/// The modularity
426pub fn modularity<T>(graph: &SparseGraph<T>, labels: &[usize], resolution: f64) -> f64
427where
428    T: BixverseFloat + Send + Sync + std::iter::Sum,
429{
430    let n = graph.get_node_number();
431    let k = labels.iter().copied().max().map_or(0, |m| m + 1);
432    let mut internal = 0.0f64;
433    let mut two_m = 0.0f64;
434    let mut cluster_w = vec![0.0f64; k];
435    for v in 0..n {
436        let (nbrs, ws) = graph.get_neighbours_raw(v);
437        let mut deg = 0.0;
438        for (&u, &w) in nbrs.iter().zip(ws) {
439            let w = wf(w);
440            deg += w;
441            if labels[u as usize] == labels[v] {
442                internal += w;
443            }
444        }
445        two_m += deg;
446        cluster_w[labels[v]] += deg;
447    }
448    if two_m == 0.0 {
449        return 0.0;
450    }
451    let penalty: f64 = cluster_w.iter().map(|&c| c * c).sum::<f64>() * resolution / two_m;
452    (internal - penalty) / two_m
453}
454
455//////////////////
456// Local moving //
457//////////////////
458
459/// Fast local moving (Traag et al. 2019, Alg. A.2 `MoveNodesFast`).
460///
461/// Nodes sit in a queue, initially in shuffled or rotated order. A node takes
462/// the neighbouring cluster (or an empty one) with the strictly best gain over
463/// staying; when it moves, its stable neighbours outside the new cluster are
464/// re-queued. Self-loops are skipped in the scan.
465///
466/// ### Params
467///
468/// * `g` - Graph of this level
469/// * `node_w` - Node weights (degrees at level 0, summed degrees after)
470/// * `membership` - Initial partition, labels `< n`; updated and reindexed
471/// * `res` - Resolution divided by `2m` of the level-0 graph
472/// * `max_evals` - Cap on node evaluations (Louvain safeguard)
473/// * `shuffle` - Visit nodes in random order instead of rotated node order
474/// * `rng` - Random number generator for the initial order
475///
476/// ### Returns
477///
478/// `(number of clusters, whether any node moved)`
479fn move_nodes<G: LevelGraph>(
480    g: &G,
481    node_w: &[f64],
482    membership: &mut [u32],
483    res: f64,
484    max_evals: usize,
485    shuffle: bool,
486    rng: &mut StdRng,
487) -> (usize, bool) {
488    let n = g.n();
489
490    // Without shuffling, nodes are visited in node order from a random start.
491    // Node order is BFS order at level 0 and follows it at coarser levels, so
492    // the scans stay cache-local and need about half the evaluations. Leiden
493    // only: without refinement, the ordered sweep snowballs clusters and costs
494    // Louvain modularity.
495    let queue_order: Vec<u32> = if shuffle {
496        let mut order: Vec<u32> = (0..n as u32).collect();
497        order.shuffle(rng);
498        order
499    } else {
500        let start = if n > 0 { rng.random_range(0..n) } else { 0 };
501        (start..n).chain(0..start).map(|v| v as u32).collect()
502    };
503    let mut changed = false;
504    let mut queue: VecDeque<u32> = queue_order.into();
505    let mut stable = vec![false; n];
506
507    let mut cluster_w = vec![0.0f64; n];
508    let mut cluster_n = vec![0u32; n];
509    for v in 0..n {
510        let c = membership[v] as usize;
511        cluster_w[c] += node_w[v];
512        cluster_n[c] += 1;
513    }
514    let mut empty: Vec<u32> = (0..n as u32)
515        .filter(|&c| cluster_n[c as usize] == 0)
516        .collect();
517
518    let mut w_to = vec![0.0f64; n];
519    let mut added = vec![false; n];
520    let mut nbr_clusters: Vec<u32> = Vec::with_capacity(64);
521
522    let mut evals = 0usize;
523    while let Some(v) = queue.pop_front() {
524        if evals >= max_evals {
525            break;
526        }
527        evals += 1;
528
529        let v = v as usize;
530        let cur = membership[v];
531        let kv = node_w[v];
532
533        cluster_w[cur as usize] -= kv;
534        cluster_n[cur as usize] -= 1;
535        if cluster_n[cur as usize] == 0 {
536            empty.push(cur);
537        }
538
539        // an empty cluster is always a candidate (the node on its own)
540        let e = *empty
541            .last()
542            .expect("node just left a cluster, so one is free or v is alone");
543        nbr_clusters.push(e);
544        added[e as usize] = true;
545
546        let (nbrs, ws) = g.row(v);
547        for (&u, &w) in nbrs.iter().zip(ws) {
548            if u as usize == v {
549                continue;
550            }
551            let c = membership[u as usize];
552            if !added[c as usize] {
553                added[c as usize] = true;
554                nbr_clusters.push(c);
555            }
556            w_to[c as usize] += wf(w);
557        }
558
559        let mut best = cur;
560        let mut best_gain = w_to[cur as usize] - kv * cluster_w[cur as usize] * res;
561        for &c in &nbr_clusters {
562            let gain = w_to[c as usize] - kv * cluster_w[c as usize] * res;
563            // strictly improving only, needed for convergence
564            if gain > best_gain {
565                best = c;
566                best_gain = gain;
567            }
568            w_to[c as usize] = 0.0;
569            added[c as usize] = false;
570        }
571        nbr_clusters.clear();
572
573        cluster_w[best as usize] += kv;
574        cluster_n[best as usize] += 1;
575        if empty.last() == Some(&best) {
576            empty.pop();
577        }
578
579        stable[v] = true;
580        if best != cur {
581            changed = true;
582            membership[v] = best;
583            for &u in nbrs {
584                let u = u as usize;
585                if stable[u] && membership[u] != best {
586                    stable[u] = false;
587                    queue.push_back(u as u32);
588                }
589            }
590        }
591    }
592
593    (reindex(membership), changed)
594}
595
596///////////////////////////
597// Parallel local moving //
598///////////////////////////
599
600/// Open-addressing map from cluster id to edge weight, for one row.
601///
602/// Sized to the row rather than to the cluster count, so the per-thread
603/// scratch does not grow with n, as an n-long array per thread would.
604#[derive(Default)]
605struct ClusterTable {
606    /// Cluster id per slot, `u32::MAX` when free
607    keys: Vec<u32>,
608    /// Summed edge weight per slot
609    vals: Vec<f64>,
610    /// Occupied slots in insertion order
611    used: Vec<u32>,
612}
613
614impl ClusterTable {
615    /// Make room for a row of `deg` entries; the table must be empty.
616    ///
617    /// ### Params
618    ///
619    /// * `deg` - Entries in the row
620    #[inline]
621    fn reserve(&mut self, deg: usize) {
622        let cap = (2 * deg).next_power_of_two().max(16);
623        if self.keys.len() < cap {
624            self.keys = vec![u32::MAX; cap];
625            self.vals = vec![0.0; cap];
626        }
627    }
628
629    /// Slot of `c`, or the free slot where it goes.
630    ///
631    /// ### Params
632    ///
633    /// * `c` - Cluster id
634    ///
635    /// ### Returns
636    ///
637    /// The slot index
638    #[inline]
639    fn slot(&self, c: u32) -> usize {
640        let mask = self.keys.len() - 1;
641        let mut i = (c.wrapping_mul(0x9E37_79B1) as usize) & mask;
642        while self.keys[i] != c && self.keys[i] != u32::MAX {
643            i = (i + 1) & mask;
644        }
645        i
646    }
647
648    /// Add `w` to cluster `c`.
649    ///
650    /// ### Params
651    ///
652    /// * `c` - Cluster id
653    /// * `w` - Edge weight
654    #[inline]
655    fn add(&mut self, c: u32, w: f64) {
656        let i = self.slot(c);
657        if self.keys[i] == u32::MAX {
658            self.keys[i] = c;
659            self.vals[i] = w;
660            self.used.push(i as u32);
661        } else {
662            self.vals[i] += w;
663        }
664    }
665
666    /// Summed weight to cluster `c`, 0 if absent.
667    ///
668    /// ### Params
669    ///
670    /// * `c` - Cluster id
671    ///
672    /// ### Returns
673    ///
674    /// The weight
675    #[inline]
676    fn get(&self, c: u32) -> f64 {
677        let i = self.slot(c);
678        if self.keys[i] == c { self.vals[i] } else { 0.0 }
679    }
680
681    /// Empty the table.
682    #[inline]
683    fn clear(&mut self) {
684        for &i in &self.used {
685            self.keys[i as usize] = u32::MAX;
686        }
687        self.used.clear();
688    }
689}
690
691thread_local! {
692    /// Per-thread [`ClusterTable`] for [`best_move`]. Empty between uses.
693    static MOVE_SCRATCH: RefCell<ClusterTable> = RefCell::new(ClusterTable::default());
694}
695
696/// A move decided by [`best_move`].
697#[derive(Clone, Copy, Debug)]
698struct Move {
699    /// Node
700    v: u32,
701    /// Target cluster, or [`NEW_CLUSTER`]
702    to: u32,
703    /// Edge weight from `v` to the target (0 for a new cluster)
704    w_to: f64,
705    /// Edge weight from `v` to its own cluster, `v` excluded
706    w_from: f64,
707}
708
709/// Local moving with the strategy in `mode`.
710///
711/// Levels below [`LEIDEN_PAR_MIN_NODES`] nodes always take the queue: they
712/// are cheap and gain nothing from a fork.
713///
714/// ### Params
715///
716/// * `g` - Graph of this level
717/// * `node_w` - Node weights
718/// * `membership` - Initial partition, labels `< n`; updated and reindexed
719/// * `res` - Resolution divided by `2m`
720/// * `max_evals` - Cap on node evaluations
721/// * `shuffle` - Queue only: random instead of rotated node order
722/// * `mode` - The [`LocalMoving`] strategy
723/// * `rng` - Random number generator for the node orders
724///
725/// ### Returns
726///
727/// `(number of clusters, whether any node moved)`
728#[allow(clippy::too_many_arguments)]
729fn local_move<G: LevelGraph>(
730    g: &G,
731    node_w: &[f64],
732    membership: &mut [u32],
733    res: f64,
734    max_evals: usize,
735    shuffle: bool,
736    mode: LocalMoving,
737    rng: &mut StdRng,
738) -> (usize, bool) {
739    if g.n() < LEIDEN_PAR_MIN_NODES {
740        return move_nodes(g, node_w, membership, res, max_evals, shuffle, rng);
741    }
742    match mode {
743        LocalMoving::Queue => move_nodes(g, node_w, membership, res, max_evals, shuffle, rng),
744        LocalMoving::Coloured => move_nodes_coloured(g, node_w, membership, res, max_evals, rng),
745    }
746}
747
748/// Best move of `v` against fixed cluster totals.
749///
750/// Candidates are the clusters of `v`'s neighbours and, if `v` is not alone,
751/// an empty cluster at gain 0. Only a strict improvement over staying counts;
752/// ties between candidates go to the lower cluster id, and an empty cluster
753/// is only taken when no neighbouring cluster beats staying.
754///
755/// ### Params
756///
757/// * `g` - Graph of this level
758/// * `v` - The node
759/// * `membership` - Cluster per node
760/// * `cluster_w` - Summed node weight per cluster, `v` included
761/// * `cluster_n` - Nodes per cluster
762/// * `kv` - Node weight of `v`
763/// * `res` - Resolution divided by `2m`
764/// * `table` - Scratch, empty
765///
766/// ### Returns
767///
768/// The move, or `None` to stay
769#[allow(clippy::too_many_arguments)]
770#[inline]
771fn best_move<G: LevelGraph>(
772    g: &G,
773    v: u32,
774    membership: &[u32],
775    cluster_w: &[f64],
776    cluster_n: &[u32],
777    kv: f64,
778    res: f64,
779    table: &mut ClusterTable,
780) -> Option<Move> {
781    let (nbrs, ws) = g.row(v as usize);
782    table.reserve(nbrs.len());
783    for (&u, &w) in nbrs.iter().zip(ws) {
784        if u != v {
785            table.add(membership[u as usize], wf(w));
786        }
787    }
788    let own = membership[v as usize];
789    let w_from = table.get(own);
790    let stay = w_from - kv * (cluster_w[own as usize] - kv) * res;
791
792    let mut best = (own, stay);
793    let mut w_best = 0.0;
794    for &i in &table.used {
795        let c = table.keys[i as usize];
796        if c == own {
797            continue;
798        }
799        let gain = table.vals[i as usize] - kv * cluster_w[c as usize] * res;
800        if gain > best.1 || (gain == best.1 && best.0 != own && c < best.0) {
801            best = (c, gain);
802            w_best = table.vals[i as usize];
803        }
804    }
805    if best.0 == own && cluster_n[own as usize] > 1 && 0.0 > stay {
806        best = (NEW_CLUSTER, 0.0);
807        w_best = 0.0;
808    }
809    table.clear();
810    (best.0 != own).then_some(Move {
811        v,
812        to: best.0,
813        w_to: w_best,
814        w_from,
815    })
816}
817
818/// Decide the moves of `nodes` in parallel against fixed cluster totals.
819///
820/// ### Params
821///
822/// * `g` - Graph of this level
823/// * `nodes` - Nodes to evaluate, sorted
824/// * `membership` - Cluster per node
825/// * `cluster_w` - Summed node weight per cluster
826/// * `cluster_n` - Nodes per cluster
827/// * `node_w` - Node weights
828/// * `res` - Resolution divided by `2m`
829///
830/// ### Returns
831///
832/// The moves, in the order of `nodes`
833fn decide_moves<G: LevelGraph>(
834    g: &G,
835    nodes: &[u32],
836    membership: &[u32],
837    cluster_w: &[f64],
838    cluster_n: &[u32],
839    node_w: &[f64],
840    res: f64,
841) -> Vec<Move> {
842    let decide = |&v: &u32| {
843        MOVE_SCRATCH.with(|s| {
844            best_move(
845                g,
846                v,
847                membership,
848                cluster_w,
849                cluster_n,
850                node_w[v as usize],
851                res,
852                &mut s.borrow_mut(),
853            )
854        })
855    };
856    nodes
857        .par_iter()
858        .with_min_len(PAR_MOVE_MIN_LEN)
859        .filter_map(decide)
860        .collect()
861}
862
863/// Flag the neighbours of the moved nodes that now sit in another cluster.
864///
865/// ### Params
866///
867/// * `g` - Graph of this level
868/// * `moved` - Nodes that moved
869/// * `membership` - Cluster per node, after the moves
870/// * `pending` - Pending flag per node
871///
872/// ### Returns
873///
874/// The nodes whose flag this call set, in no particular order
875fn flag_neighbours<G: LevelGraph>(
876    g: &G,
877    moved: &[u32],
878    membership: &[u32],
879    pending: &[AtomicBool],
880) -> Vec<u32> {
881    // load first: most neighbours are already pending, and a swap would
882    // write their cache line anyway
883    let flag = |&v: &u32| {
884        let c = membership[v as usize];
885        g.row(v as usize).0.iter().copied().filter(move |&u| {
886            let p = &pending[u as usize];
887            membership[u as usize] != c && !p.load(Relaxed) && !p.swap(true, Relaxed)
888        })
889    };
890    moved
891        .par_iter()
892        .with_min_len(PAR_MOVE_MIN_LEN)
893        .flat_map_iter(flag)
894        .collect()
895}
896
897/// Take a pending list: sort it (so the result does not depend on the order
898/// in which the flags were set) and clear the flags.
899///
900/// ### Params
901///
902/// * `list` - Pending nodes; emptied
903/// * `pending` - Pending flag per node
904///
905/// ### Returns
906///
907/// The nodes, sorted
908fn take_pending(list: &mut Vec<u32>, pending: &[AtomicBool]) -> Vec<u32> {
909    let mut nodes = std::mem::take(list);
910    nodes.par_sort_unstable();
911    nodes
912        .par_iter()
913        .with_min_len(PAR_MOVE_MIN_LEN)
914        .for_each(|&v| pending[v as usize].store(false, Relaxed));
915    nodes
916}
917
918/// Summed node weight and node count per cluster.
919///
920/// ### Params
921///
922/// * `membership` - Cluster per node, labels `< n`
923/// * `node_w` - Node weights
924///
925/// ### Returns
926///
927/// `(cluster weights, cluster sizes, empty clusters in descending id order)`
928fn cluster_totals(membership: &[u32], node_w: &[f64]) -> (Vec<f64>, Vec<u32>, Vec<u32>) {
929    let n = membership.len();
930    let mut cluster_w = vec![0.0f64; n];
931    let mut cluster_n = vec![0u32; n];
932    for (v, &c) in membership.iter().enumerate() {
933        cluster_w[c as usize] += node_w[v];
934        cluster_n[c as usize] += 1;
935    }
936    let empty = (0..n as u32)
937        .rev()
938        .filter(|&c| cluster_n[c as usize] == 0)
939        .collect();
940    (cluster_w, cluster_n, empty)
941}
942
943/// Greedy first-fit colouring in the given order.
944///
945/// The colours of a node's neighbours below 64 go into one `u64` mask, so the
946/// first free colour is a `trailing_ones`; only a node whose neighbours hold
947/// all of 0..64 rescans with a full flag array. kNN graphs need far fewer
948/// colours than that.
949///
950/// ### Params
951///
952/// * `g` - Graph of this level
953/// * `order` - Visiting order
954///
955/// ### Returns
956///
957/// `(colour classes, each sorted by node id; colour per node)`
958fn greedy_colouring<G: LevelGraph>(g: &G, order: &[u32]) -> (Vec<Vec<u32>>, Vec<u32>) {
959    let n = g.n();
960    let mut colour = vec![u32::MAX; n];
961    let mut used: Vec<bool> = Vec::new();
962    let mut classes: Vec<Vec<u32>> = Vec::new();
963    for &v in order {
964        let nbrs = g.row(v as usize).0;
965        let mut mask = 0u64;
966        for &u in nbrs {
967            let c = colour[u as usize];
968            if c < 64 {
969                mask |= 1 << c;
970            }
971        }
972        let c = if mask != u64::MAX {
973            mask.trailing_ones() as usize
974        } else {
975            used.resize(classes.len(), false);
976            for &u in nbrs {
977                let cu = colour[u as usize];
978                if cu != u32::MAX {
979                    used[cu as usize] = true;
980                }
981            }
982            let c = used.iter().position(|&x| !x).unwrap_or(used.len());
983            used.iter_mut().for_each(|x| *x = false);
984            c
985        };
986        if c == classes.len() {
987            classes.push(Vec::new());
988        }
989        colour[v as usize] = c as u32;
990        classes[c].push(v);
991    }
992    classes.par_iter_mut().for_each(|c| c.sort_unstable());
993    (classes, colour)
994}
995
996/// Greedy colouring in node order from a seeded start.
997///
998/// At level 0 node order is BFS order, which keeps the scan cache-local.
999///
1000/// ### Params
1001///
1002/// * `g` - Graph of this level
1003/// * `rng` - Random number generator for the start
1004///
1005/// ### Returns
1006///
1007/// `(colour classes, each sorted by node id; colour per node)`
1008fn seeded_colouring<G: LevelGraph>(g: &G, rng: &mut StdRng) -> (Vec<Vec<u32>>, Vec<u32>) {
1009    let n = g.n();
1010    let start = rng.random_range(0..n) as u32;
1011    let order: Vec<u32> = (start..n as u32).chain(0..start).collect();
1012    greedy_colouring(g, &order)
1013}
1014
1015/// Local moving over greedy colour classes, see [`LocalMoving::Coloured`].
1016///
1017/// Colours come from [`seeded_colouring`]. Each class keeps a list of its
1018/// pending nodes; the classes are swept in turn until none is pending, so a
1019/// sweep costs what its pending nodes cost.
1020///
1021/// ### Params
1022///
1023/// * `g` - Graph of this level
1024/// * `node_w` - Node weights
1025/// * `membership` - Initial partition, labels `< n`; updated and reindexed
1026/// * `res` - Resolution divided by `2m`
1027/// * `max_evals` - Cap on node evaluations
1028/// * `rng` - Random number generator for the colouring start
1029///
1030/// ### Returns
1031///
1032/// `(number of clusters, whether any node moved)`
1033///
1034/// ### References
1035///
1036/// Lu, Halappanavar & Kalyanaraman, Parallel Computing, 2015 (colouring);
1037/// Sahu, arXiv:2312.13936, 2023 (GVE-Leiden, pruning)
1038fn move_nodes_coloured<G: LevelGraph>(
1039    g: &G,
1040    node_w: &[f64],
1041    membership: &mut [u32],
1042    res: f64,
1043    max_evals: usize,
1044    rng: &mut StdRng,
1045) -> (usize, bool) {
1046    let n = g.n();
1047    let (classes, colour) = seeded_colouring(g, rng);
1048
1049    let (mut cluster_w, mut cluster_n, mut empty) = cluster_totals(membership, node_w);
1050    let pending: Vec<AtomicBool> = (0..n).map(|_| AtomicBool::new(true)).collect();
1051    let mut lists = classes;
1052    let mut moved: Vec<u32> = Vec::new();
1053    let mut changed = false;
1054    let mut evals = 0usize;
1055
1056    'sweeps: for _ in 0..PAR_MOVE_MAX_ROUNDS {
1057        if lists.iter().all(|l| l.is_empty()) {
1058            break;
1059        }
1060        for c in 0..lists.len() {
1061            if lists[c].is_empty() {
1062                continue;
1063            }
1064            let nodes = take_pending(&mut lists[c], &pending);
1065            evals += nodes.len();
1066            let moves = decide_moves(g, &nodes, membership, &cluster_w, &cluster_n, node_w, res);
1067            moved.clear();
1068            for m in moves {
1069                let (v, kv) = (m.v as usize, node_w[m.v as usize]);
1070                let from = membership[v] as usize;
1071                let stay = m.w_from - kv * (cluster_w[from] - kv) * res;
1072                let go = if m.to == NEW_CLUSTER {
1073                    0.0
1074                } else {
1075                    m.w_to - kv * cluster_w[m.to as usize] * res
1076                };
1077                // no longer an improvement, or already alone
1078                if go <= stay || (m.to == NEW_CLUSTER && cluster_n[from] == 1) {
1079                    continue;
1080                }
1081                let to = if m.to == NEW_CLUSTER {
1082                    empty
1083                        .pop()
1084                        .expect("a cluster with two or more nodes leaves one id free")
1085                } else {
1086                    m.to
1087                } as usize;
1088                cluster_w[from] -= kv;
1089                cluster_n[from] -= 1;
1090                if cluster_n[from] == 0 {
1091                    empty.push(from as u32);
1092                }
1093                cluster_w[to] += kv;
1094                cluster_n[to] += 1;
1095                membership[v] = to as u32;
1096                moved.push(v as u32);
1097            }
1098            changed |= !moved.is_empty();
1099            for u in flag_neighbours(g, &moved, membership, &pending) {
1100                lists[colour[u as usize] as usize].push(u);
1101            }
1102            if evals >= max_evals {
1103                break 'sweeps;
1104            }
1105        }
1106    }
1107
1108    (reindex(membership), changed)
1109}
1110
1111////////////////
1112// Refinement //
1113////////////////
1114
1115/// Per-thread scratch for refining one cluster, indexed by local position.
1116///
1117/// ### Fields
1118///
1119/// * `cluster_w` - Summed node weight per refined sub-cluster
1120/// * `ext` - Edge weight from a sub-cluster to the rest of its cluster
1121/// * `w_to` - Edge weight from the current node to each sub-cluster
1122/// * `added` - Membership flags for `cands`
1123/// * `non_singleton` - Sub-cluster has already absorbed a node
1124/// * `cands` - Candidate sub-clusters for the current node
1125/// * `cum` - Cumulative sampling weights aligned with `cands`
1126/// * `gains` - Gains aligned with `cands` (`NAN` when not eligible)
1127#[derive(Default)]
1128struct RefineScratch {
1129    cluster_w: Vec<f64>,
1130    ext: Vec<f64>,
1131    w_to: Vec<f64>,
1132    added: Vec<bool>,
1133    non_singleton: Vec<bool>,
1134    cands: Vec<u32>,
1135    cum: Vec<f64>,
1136    gains: Vec<f64>,
1137}
1138
1139impl RefineScratch {
1140    /// Reset the scratch for a cluster of `s` nodes.
1141    ///
1142    /// ### Params
1143    ///
1144    /// * `s` - Number of nodes in the cluster
1145    fn reset(&mut self, s: usize) {
1146        for v in [&mut self.cluster_w, &mut self.ext, &mut self.w_to] {
1147            v.clear();
1148            v.resize(s, 0.0);
1149        }
1150        for v in [&mut self.added, &mut self.non_singleton] {
1151            v.clear();
1152            v.resize(s, false);
1153        }
1154    }
1155}
1156
1157/// Refine one cluster by randomised constrained merging (Traag et al. 2019,
1158/// Alg. A.2 `MergeNodesSubset`).
1159///
1160/// Every node starts as its own sub-cluster. Each node is visited once in a
1161/// random order; if it is still a singleton and well connected to the rest of
1162/// the cluster, it joins a well-connected sub-cluster with non-negative gain,
1163/// drawn with probability proportional to `exp(gain / beta)`. Staying put has
1164/// gain 0 and is always a candidate.
1165///
1166/// ### Params
1167///
1168/// * `g` - Graph of this level
1169/// * `node_w` - Node weights
1170/// * `membership` - Unrefined partition of this level
1171/// * `local_idx` - Position of each node within its cluster's member list
1172/// * `members` - Nodes of this cluster, in increasing order
1173/// * `cluster` - Label of this cluster in `membership`
1174/// * `res` - Resolution divided by `2m`
1175/// * `beta` - Randomness of the merges
1176/// * `seed` - Seed for this cluster
1177/// * `sc` - Scratch buffers
1178///
1179/// ### Returns
1180///
1181/// `(local sub-cluster per member, number of sub-clusters)`, labels
1182/// contiguous in order of first appearance
1183#[allow(clippy::too_many_arguments)]
1184fn refine_cluster<G: LevelGraph>(
1185    g: &G,
1186    node_w: &[f64],
1187    membership: &[u32],
1188    local_idx: &[u32],
1189    members: &[u32],
1190    cluster: u32,
1191    res: f64,
1192    beta: f64,
1193    seed: u64,
1194    sc: &mut RefineScratch,
1195) -> (Vec<u32>, usize) {
1196    let s = members.len();
1197    let mut label: Vec<u32> = (0..s as u32).collect();
1198    if s == 1 {
1199        return (label, 1);
1200    }
1201    sc.reset(s);
1202
1203    let mut total_w = 0.0f64;
1204    for (i, &v) in members.iter().enumerate() {
1205        let v = v as usize;
1206        sc.cluster_w[i] = node_w[v];
1207        total_w += node_w[v];
1208        let (nbrs, ws) = g.row(v);
1209        for (&u, &w) in nbrs.iter().zip(ws) {
1210            if u as usize != v && membership[u as usize] == cluster {
1211                sc.ext[i] += wf(w);
1212            }
1213        }
1214    }
1215
1216    let mut rng = StdRng::seed_from_u64(seed);
1217    let mut order: Vec<u32> = (0..s as u32).collect();
1218    order.shuffle(&mut rng);
1219
1220    for &i in &order {
1221        let i = i as usize;
1222        let cur = label[i] as usize;
1223        let kv = node_w[members[i] as usize];
1224        let well_connected = |sc: &RefineScratch, c: usize| {
1225            sc.ext[c] >= sc.cluster_w[c] * (total_w - sc.cluster_w[c]) * res
1226        };
1227
1228        if sc.non_singleton[cur] || !well_connected(sc, cur) {
1229            continue;
1230        }
1231
1232        let ext_v = sc.ext[cur];
1233        sc.cluster_w[cur] = 0.0;
1234
1235        sc.cands.push(cur as u32);
1236        sc.added[cur] = true;
1237        let (nbrs, ws) = g.row(members[i] as usize);
1238        for (&u, &w) in nbrs.iter().zip(ws) {
1239            let u = u as usize;
1240            if u == members[i] as usize || membership[u] != cluster {
1241                continue;
1242            }
1243            let c = label[local_idx[u] as usize] as usize;
1244            if !sc.added[c] {
1245                sc.added[c] = true;
1246                sc.cands.push(c as u32);
1247            }
1248            sc.w_to[c] += wf(w);
1249        }
1250
1251        let mut best = cur;
1252        let mut max_gain = 0.0f64;
1253        for &c in &sc.cands {
1254            let c = c as usize;
1255            let gain = if well_connected(sc, c) {
1256                sc.w_to[c] - kv * sc.cluster_w[c] * res
1257            } else {
1258                f64::NAN
1259            };
1260            if gain > max_gain {
1261                max_gain = gain;
1262                best = c;
1263            }
1264            sc.gains.push(gain);
1265        }
1266
1267        // shift by the max gain so exp() cannot overflow; same distribution
1268        let mut total = 0.0f64;
1269        for &gain in &sc.gains {
1270            if gain >= 0.0 {
1271                total += ((gain - max_gain) / beta).exp();
1272            }
1273            sc.cum.push(total);
1274        }
1275        let chosen = if total > 0.0 && total.is_finite() {
1276            let r = rng.random::<f64>() * total;
1277            let idx = sc.cum.partition_point(|&x| x <= r).min(sc.cands.len() - 1);
1278            sc.cands[idx] as usize
1279        } else {
1280            best
1281        };
1282
1283        let w_chosen = sc.w_to[chosen];
1284        for &c in &sc.cands {
1285            sc.w_to[c as usize] = 0.0;
1286            sc.added[c as usize] = false;
1287        }
1288        sc.cands.clear();
1289        sc.gains.clear();
1290        sc.cum.clear();
1291
1292        sc.cluster_w[chosen] += kv;
1293        if chosen != cur {
1294            // E(T + v, C - T - v) = E(T, C - T) + E(v, C - v) - 2 E(v, T)
1295            sc.ext[chosen] += ext_v - 2.0 * w_chosen;
1296            label[i] = chosen as u32;
1297            sc.non_singleton[chosen] = true;
1298        }
1299    }
1300
1301    let k = reindex(&mut label);
1302    (label, k)
1303}
1304
1305/// Refine every cluster of the unrefined partition.
1306///
1307/// Parallel over clusters when the level is large enough. Each cluster draws
1308/// from its own seeded RNG, and sub-cluster ids are laid out in cluster order,
1309/// so the result does not depend on the thread count.
1310///
1311/// ### Params
1312///
1313/// * `g` - Graph of this level
1314/// * `node_w` - Node weights
1315/// * `membership` - Unrefined partition, contiguous labels
1316/// * `n_clusters` - Number of clusters in `membership`
1317/// * `res` - Resolution divided by `2m`
1318/// * `beta` - Randomness of the merges
1319/// * `seed` - Seed for this iteration and level
1320///
1321/// ### Returns
1322///
1323/// `(refined partition, number of refined clusters)`
1324fn refine_partition<G: LevelGraph>(
1325    g: &G,
1326    node_w: &[f64],
1327    membership: &[u32],
1328    n_clusters: usize,
1329    res: f64,
1330    beta: f64,
1331    seed: u64,
1332) -> (Vec<u32>, usize) {
1333    let n = g.n();
1334    let (offsets, members) = group_by_label(membership, n_clusters);
1335    let mut local_idx = vec![0u32; n];
1336    for c in 0..n_clusters {
1337        for (i, &v) in members[offsets[c]..offsets[c + 1]].iter().enumerate() {
1338            local_idx[v as usize] = i as u32;
1339        }
1340    }
1341
1342    let run = |sc: &mut RefineScratch, c: usize| {
1343        refine_cluster(
1344            g,
1345            node_w,
1346            membership,
1347            &local_idx,
1348            &members[offsets[c]..offsets[c + 1]],
1349            c as u32,
1350            res,
1351            beta,
1352            derive_seed(seed, &[c as u64]),
1353            sc,
1354        )
1355    };
1356    let per_cluster: Vec<(Vec<u32>, usize)> = if n >= LEIDEN_PAR_MIN_NODES {
1357        (0..n_clusters)
1358            .into_par_iter()
1359            .map_init(RefineScratch::default, run)
1360            .collect()
1361    } else {
1362        let mut sc = RefineScratch::default();
1363        (0..n_clusters).map(|c| run(&mut sc, c)).collect()
1364    };
1365
1366    let mut refined = vec![0u32; n];
1367    let mut base = 0u32;
1368    for (c, (labels, k)) in per_cluster.into_iter().enumerate() {
1369        for (&v, &l) in members[offsets[c]..offsets[c + 1]].iter().zip(&labels) {
1370            refined[v as usize] = base + l;
1371        }
1372        base += k as u32;
1373    }
1374    (refined, base as usize)
1375}
1376
1377/////////////////
1378// Aggregation //
1379/////////////////
1380
1381/// Collapse each refined cluster into one node.
1382///
1383/// Parallel over refined clusters when the level is large enough, with a
1384/// dense per-thread accumulator cleared through its touched list. Rows come
1385/// out in member order, so the sums are deterministic.
1386///
1387/// ### Params
1388///
1389/// * `g` - Graph of this level
1390/// * `node_w` - Node weights
1391/// * `refined` - Refined partition, contiguous labels
1392/// * `n_refined` - Number of refined clusters
1393/// * `membership` - Unrefined partition; refines `refined`
1394///
1395/// ### Returns
1396///
1397/// `(aggregated graph, aggregated node weights, initial aggregated partition)`
1398fn aggregate<G: LevelGraph>(
1399    g: &G,
1400    node_w: &[f64],
1401    refined: &[u32],
1402    n_refined: usize,
1403    membership: &[u32],
1404) -> (AggGraph<f64>, Vec<f64>, Vec<u32>) {
1405    let (offsets, members) = group_by_label(refined, n_refined);
1406
1407    let row = |(acc, seen, touched): &mut (Vec<f64>, Vec<bool>, Vec<u32>), c: usize| {
1408        if acc.len() < n_refined {
1409            acc.resize(n_refined, 0.0);
1410            seen.resize(n_refined, false);
1411        }
1412        let mut w_sum = 0.0;
1413        for &v in &members[offsets[c]..offsets[c + 1]] {
1414            w_sum += node_w[v as usize];
1415            let (nbrs, ws) = g.row(v as usize);
1416            for (&u, &w) in nbrs.iter().zip(ws) {
1417                let c2 = refined[u as usize];
1418                if c2 as usize == c {
1419                    continue;
1420                }
1421                if !seen[c2 as usize] {
1422                    seen[c2 as usize] = true;
1423                    touched.push(c2);
1424                }
1425                acc[c2 as usize] += wf(w);
1426            }
1427        }
1428        let mut idx = Vec::with_capacity(touched.len());
1429        let mut wts = Vec::with_capacity(touched.len());
1430        for &c2 in touched.iter() {
1431            idx.push(c2);
1432            wts.push(acc[c2 as usize]);
1433            acc[c2 as usize] = 0.0;
1434            seen[c2 as usize] = false;
1435        }
1436        touched.clear();
1437        (idx, wts, w_sum)
1438    };
1439
1440    let rows: Vec<(Vec<u32>, Vec<f64>, f64)> = if g.n() >= LEIDEN_PAR_MIN_NODES {
1441        (0..n_refined)
1442            .into_par_iter()
1443            .map_init(|| (Vec::new(), Vec::new(), Vec::new()), row)
1444            .collect()
1445    } else {
1446        let mut scratch = (Vec::new(), Vec::new(), Vec::new());
1447        (0..n_refined).map(|c| row(&mut scratch, c)).collect()
1448    };
1449
1450    let mut indptr = Vec::with_capacity(n_refined + 1);
1451    indptr.push(0usize);
1452    for r in &rows {
1453        indptr.push(indptr.last().unwrap() + r.0.len());
1454    }
1455    let nnz = *indptr.last().unwrap();
1456    let mut indices = Vec::with_capacity(nnz);
1457    let mut weights = Vec::with_capacity(nnz);
1458    let mut agg_w = Vec::with_capacity(n_refined);
1459    for (idx, wts, w_sum) in rows {
1460        indices.extend_from_slice(&idx);
1461        weights.extend_from_slice(&wts);
1462        agg_w.push(w_sum);
1463    }
1464
1465    let agg_membership = (0..n_refined)
1466        .map(|c| membership[members[offsets[c]] as usize])
1467        .collect();
1468
1469    (
1470        AggGraph {
1471            indptr,
1472            indices,
1473            weights,
1474        },
1475        agg_w,
1476        agg_membership,
1477    )
1478}
1479
1480////////////////
1481// Reordering //
1482////////////////
1483
1484/// Breadth-first node order, restarting at the lowest unvisited node for
1485/// each further component.
1486///
1487/// ### Params
1488///
1489/// * `g` - Graph to order
1490///
1491/// ### Returns
1492///
1493/// `order`, with `order[new] = old`
1494fn bfs_order<G: LevelGraph>(g: &G) -> Vec<u32> {
1495    let n = g.n();
1496    let mut seen = vec![false; n];
1497    let mut order = Vec::with_capacity(n);
1498    for s in 0..n {
1499        if seen[s] {
1500            continue;
1501        }
1502        seen[s] = true;
1503        let mut head = order.len();
1504        order.push(s as u32);
1505        while head < order.len() {
1506            let v = order[head] as usize;
1507            head += 1;
1508            for &u in g.row(v).0 {
1509                if !seen[u as usize] {
1510                    seen[u as usize] = true;
1511                    order.push(u);
1512                }
1513            }
1514        }
1515    }
1516    order
1517}
1518
1519/// Relabel a graph into a new node order.
1520///
1521/// ### Params
1522///
1523/// * `g` - Graph to relabel
1524/// * `order` - `order[new] = old`
1525/// * `pos` - Inverse of `order`, `pos[old] = new`
1526///
1527/// ### Returns
1528///
1529/// The relabelled graph, weights in the original type
1530fn permute_graph<G: LevelGraph>(g: &G, order: &[u32], pos: &[u32]) -> AggGraph<G::W> {
1531    let mut indptr = Vec::with_capacity(order.len() + 1);
1532    indptr.push(0usize);
1533    for &old in order {
1534        indptr.push(indptr.last().unwrap() + g.row(old as usize).0.len());
1535    }
1536    let nnz = *indptr.last().unwrap();
1537    let mut indices = vec![0u32; nnz];
1538    let mut weights = vec![G::W::zero(); nnz];
1539
1540    // one disjoint (indices, weights) slice per new row, filled in parallel
1541    let mut rows = Vec::with_capacity(order.len());
1542    let (mut idx_rest, mut w_rest) = (indices.as_mut_slice(), weights.as_mut_slice());
1543    for r in 0..order.len() {
1544        let len = indptr[r + 1] - indptr[r];
1545        let (idx_row, idx_tail) = idx_rest.split_at_mut(len);
1546        let (w_row, w_tail) = w_rest.split_at_mut(len);
1547        rows.push((idx_row, w_row));
1548        idx_rest = idx_tail;
1549        w_rest = w_tail;
1550    }
1551    rows.par_iter_mut()
1552        .zip(order.par_iter())
1553        .for_each(|((idx_row, w_row), &old)| {
1554            let (nbrs, ws) = g.row(old as usize);
1555            for (d, &u) in idx_row.iter_mut().zip(nbrs) {
1556                *d = pos[u as usize];
1557            }
1558            w_row.copy_from_slice(ws);
1559        });
1560
1561    AggGraph {
1562        indptr,
1563        indices,
1564        weights,
1565    }
1566}
1567
1568//////////
1569// Core //
1570//////////
1571
1572/// One full Leiden (or Louvain) iteration, starting from `membership`.
1573///
1574/// Local moving, refinement and aggregation per level, until local moving
1575/// leaves every node of a level in its own cluster.
1576///
1577/// ### Params
1578///
1579/// * `graph` - Level-0 graph
1580/// * `node_w` - Level-0 node weights (degrees)
1581/// * `membership` - Partition of the level-0 nodes; updated
1582/// * `res` - Resolution divided by `2m`
1583/// * `params` - The [`LeidenParams`]
1584/// * `iteration` - Index of this iteration, for seeding
1585/// * `max_evals` - Node-evaluation cap per local-moving phase, in units of n
1586/// * `multi_level` - `false` stops after the first local-moving phase
1587/// * `level0` - Local moving of level 0: `(graph, node weights, membership,
1588///   res, rng) -> (number of clusters, whether any node moved)`, membership
1589///   reindexed
1590///
1591/// ### Returns
1592///
1593/// Whether any node moved
1594#[allow(clippy::too_many_arguments)]
1595fn leiden_iteration<G, E, F>(
1596    graph: &G,
1597    node_w: &[f64],
1598    membership: &mut [u32],
1599    res: f64,
1600    params: &LeidenParams,
1601    iteration: u64,
1602    max_evals: usize,
1603    multi_level: bool,
1604    level0: &mut F,
1605) -> Result<bool, E>
1606where
1607    G: LevelGraph,
1608    F: FnMut(&G, &[f64], &mut [u32], f64, &mut StdRng) -> Result<(usize, bool), E>,
1609{
1610    let n = graph.n();
1611    let mut rng = StdRng::seed_from_u64(derive_seed(params.seed, &[iteration]));
1612    reindex(membership);
1613
1614    let (n_clusters, mut changed) = level0(graph, node_w, membership, res, &mut rng)?;
1615    if !multi_level || n_clusters == n {
1616        return Ok(changed);
1617    }
1618
1619    let (refined, n_refined) = refined_or_unrefined(
1620        graph, node_w, membership, n_clusters, res, params, iteration, 0,
1621    );
1622    let mut aggregate_node = refined.clone();
1623    let (mut g, mut w, mut agg_memb) = aggregate(graph, node_w, &refined, n_refined, membership);
1624
1625    let mut level = 1u64;
1626    loop {
1627        let gn = g.n();
1628        let (nc, ch) = local_move(
1629            &g,
1630            &w,
1631            &mut agg_memb,
1632            res,
1633            max_evals.saturating_mul(gn),
1634            !params.refine,
1635            params.local_moving,
1636            &mut rng,
1637        );
1638        changed |= ch;
1639        for (m, &a) in membership.iter_mut().zip(&aggregate_node) {
1640            *m = agg_memb[a as usize];
1641        }
1642        if nc == gn {
1643            break;
1644        }
1645
1646        let (refined, n_refined) =
1647            refined_or_unrefined(&g, &w, &agg_memb, nc, res, params, iteration, level);
1648        for a in aggregate_node.iter_mut() {
1649            *a = refined[*a as usize];
1650        }
1651        (g, w, agg_memb) = aggregate(&g, &w, &refined, n_refined, &agg_memb);
1652        level += 1;
1653    }
1654
1655    Ok(changed)
1656}
1657
1658/// Refined partition for Leiden, the unrefined one for Louvain.
1659///
1660/// As in igraph, a refinement that merged nothing falls back to the unrefined
1661/// partition, or the hierarchy would not shrink.
1662///
1663/// ### Params
1664///
1665/// * `g` - Graph of this level
1666/// * `node_w` - Node weights
1667/// * `membership` - Unrefined partition, contiguous labels
1668/// * `n_clusters` - Number of clusters in `membership`
1669/// * `res` - Resolution divided by `2m`
1670/// * `params` - The [`LeidenParams`]
1671/// * `iteration` - Iteration index, for seeding
1672/// * `level` - Level index, for seeding
1673///
1674/// ### Returns
1675///
1676/// `(partition to aggregate on, number of clusters in it)`
1677#[allow(clippy::too_many_arguments)]
1678fn refined_or_unrefined<G: LevelGraph>(
1679    g: &G,
1680    node_w: &[f64],
1681    membership: &[u32],
1682    n_clusters: usize,
1683    res: f64,
1684    params: &LeidenParams,
1685    iteration: u64,
1686    level: u64,
1687) -> (Vec<u32>, usize) {
1688    if params.refine {
1689        let seed = derive_seed(params.seed, &[iteration, level]);
1690        let (refined, k) =
1691            refine_partition(g, node_w, membership, n_clusters, res, params.beta, seed);
1692        if k < g.n() {
1693            return (refined, k);
1694        }
1695    }
1696    (membership.to_vec(), n_clusters)
1697}
1698
1699/// Level-0 local moving on another backend (a GPU), for
1700/// [`community_detection_with`].
1701///
1702/// The engine hands over the BFS-relabelled level-0 graph once, then one
1703/// colouring per iteration. Coarser levels, refinement and aggregation stay on
1704/// the CPU.
1705pub trait LocalMover {
1706    /// Backend error
1707    type Error: From<MethodsErrors>;
1708
1709    /// Take the level-0 graph. Called once, before the first
1710    /// [`LocalMover::move_nodes`].
1711    ///
1712    /// ### Params
1713    ///
1714    /// * `indptr` - Row offsets, length n + 1
1715    /// * `indices` - Column indices, symmetric, self-loops possible
1716    /// * `weights` - Edge weights
1717    /// * `node_w` - Node weights (weighted degree, self-loops included)
1718    ///
1719    /// ### Returns
1720    ///
1721    /// `Ok(())`, or the backend's error
1722    fn prepare<W: ToPrimitive + Sync>(
1723        &mut self,
1724        indptr: &[usize],
1725        indices: &[u32],
1726        weights: &[W],
1727        node_w: &[f64],
1728    ) -> Result<(), Self::Error>;
1729
1730    /// Coloured local moving on the prepared graph, see
1731    /// [`LocalMoving::Coloured`]. The apply rule may differ from the CPU's.
1732    ///
1733    /// ### Params
1734    ///
1735    /// * `membership` - Initial partition, labels `< n`; updated, labels
1736    ///   `< n` but not necessarily contiguous
1737    /// * `classes` - Colour classes, each sorted by node id
1738    /// * `colour` - Colour per node
1739    /// * `res` - Resolution divided by `2m`
1740    ///
1741    /// ### Returns
1742    ///
1743    /// Whether any node moved
1744    fn move_nodes(
1745        &mut self,
1746        membership: &mut [u32],
1747        classes: &[Vec<u32>],
1748        colour: &[u32],
1749        res: f64,
1750    ) -> Result<bool, Self::Error>;
1751}
1752
1753/// Shared body of [`community_detection`] and [`community_detection_with`].
1754///
1755/// ### Params
1756///
1757/// * `graph` - Undirected [`SparseGraph`]
1758/// * `params` - The [`LeidenParams`]
1759/// * `max_evals` - Node-evaluation cap per local-moving phase, in units of n
1760/// * `multi_level` - `false` stops after the first local-moving phase
1761/// * `level0` - Level-0 local moving, see [`leiden_iteration`]
1762///
1763/// ### Returns
1764///
1765/// Community per node, `0` the largest
1766fn detect<T, E, F>(
1767    graph: &SparseGraph<T>,
1768    params: &LeidenParams,
1769    max_evals: usize,
1770    multi_level: bool,
1771    mut level0: F,
1772) -> Result<Vec<usize>, E>
1773where
1774    T: BixverseFloat + Send + Sync + std::iter::Sum,
1775    E: From<MethodsErrors> + Send,
1776    F: FnMut(&AggGraph<T>, &[f64], &mut [u32], f64, &mut StdRng) -> Result<(usize, bool), E> + Send,
1777{
1778    if graph.is_directed() {
1779        return Err(MethodsErrors::GraphDirectedError.into());
1780    }
1781    let n = graph.get_node_number();
1782    if n == 0 {
1783        return Ok(Vec::new());
1784    }
1785    assert!(n < u32::MAX as usize, "more than u32::MAX nodes");
1786
1787    let order = bfs_order(graph);
1788    let mut pos = vec![0u32; n];
1789    for (new, &old) in order.iter().enumerate() {
1790        pos[old as usize] = new as u32;
1791    }
1792    let g = permute_graph(graph, &order, &pos);
1793
1794    let node_w: Vec<f64> = (0..n)
1795        .into_par_iter()
1796        .map(|v| g.row(v).1.iter().map(|&w| wf(w)).sum())
1797        .collect();
1798    let two_m: f64 = node_w.iter().sum();
1799
1800    let mut membership: Vec<u32> = (0..n as u32).collect();
1801    if two_m > 0.0 {
1802        let res = params.resolution / two_m;
1803        // Run the loop on a pool thread: the parallel local-moving modes issue
1804        // thousands of short parallel calls, and each one from outside the pool
1805        // is an injection plus a blocking wake-up.
1806        rayon::scope(|_| -> Result<(), E> {
1807            let until_stable = params.n_iterations.is_none();
1808            let cap = params.n_iterations.map_or(MAX_ITERATIONS, |k| k.max(1));
1809            let use_gain = until_stable && params.min_gain > 0.0;
1810            let mut quality = if use_gain {
1811                level_quality(&g, &node_w, &membership, res, two_m)
1812            } else {
1813                0.0
1814            };
1815            for it in 0..cap {
1816                let before = until_stable.then(|| membership.clone());
1817                leiden_iteration(
1818                    &g,
1819                    &node_w,
1820                    &mut membership,
1821                    res,
1822                    params,
1823                    it as u64,
1824                    max_evals,
1825                    multi_level,
1826                    &mut level0,
1827                )?;
1828                if let Some(mut before) = before {
1829                    // compare partitions, not labels; `before` is already
1830                    // reindexed unless this is the first iteration
1831                    reindex(&mut before);
1832                    let mut after = membership.clone();
1833                    reindex(&mut after);
1834                    if before == after {
1835                        break;
1836                    }
1837                }
1838                if use_gain {
1839                    let q = level_quality(&g, &node_w, &membership, res, two_m);
1840                    if q - quality <= params.min_gain {
1841                        break;
1842                    }
1843                    quality = q;
1844                }
1845            }
1846            Ok(())
1847        })?;
1848    }
1849
1850    // back from BFS labels to the caller's node order
1851    let labels: Vec<usize> = pos
1852        .iter()
1853        .map(|&new| membership[new as usize] as usize)
1854        .collect();
1855    Ok(remap_communities_by_size(&labels))
1856}
1857
1858/// Shared entry for Leiden and Louvain.
1859///
1860/// [`leiden_sparse_graph`] and [`louvain_sparse_graph`] are thin wrappers;
1861/// call this directly to cap the node evaluations or stop after one level.
1862///
1863/// ### Params
1864///
1865/// * `graph` - Undirected [`SparseGraph`]
1866/// * `params` - The [`LeidenParams`]
1867/// * `max_evals` - Node-evaluation cap per local-moving phase, in units of n
1868/// * `multi_level` - `false` stops after the first local-moving phase
1869///
1870/// ### Returns
1871///
1872/// Community per node, `0` the largest
1873pub fn community_detection<T>(
1874    graph: &SparseGraph<T>,
1875    params: &LeidenParams,
1876    max_evals: usize,
1877    multi_level: bool,
1878) -> Result<Vec<usize>, MethodsErrors>
1879where
1880    T: BixverseFloat + Send + Sync + std::iter::Sum,
1881{
1882    let (shuffle, mode) = (!params.refine, params.local_moving);
1883    detect(
1884        graph,
1885        params,
1886        max_evals,
1887        multi_level,
1888        |g, node_w, membership, res, rng| {
1889            let evals = max_evals.saturating_mul(g.n());
1890            Ok(local_move(
1891                g, node_w, membership, res, evals, shuffle, mode, rng,
1892            ))
1893        },
1894    )
1895}
1896
1897/// [`community_detection`] with level-0 local moving on a [`LocalMover`].
1898///
1899/// Every iteration colours level 0 as [`LocalMoving::Coloured`] does and
1900/// hands the classes to `mover`; coarser levels use `params.local_moving` on
1901/// the CPU. `max_evals` caps the CPU levels only.
1902///
1903/// ### Params
1904///
1905/// * `graph` - Undirected [`SparseGraph`]
1906/// * `params` - The [`LeidenParams`]
1907/// * `max_evals` - Node-evaluation cap per CPU local-moving phase, in units
1908///   of n
1909/// * `multi_level` - `false` stops after the first local-moving phase
1910/// * `mover` - The level-0 [`LocalMover`]
1911///
1912/// ### Returns
1913///
1914/// Community per node, `0` the largest
1915pub fn community_detection_with<T, M>(
1916    graph: &SparseGraph<T>,
1917    params: &LeidenParams,
1918    max_evals: usize,
1919    multi_level: bool,
1920    mover: &mut M,
1921) -> Result<Vec<usize>, M::Error>
1922where
1923    T: BixverseFloat + Send + Sync + std::iter::Sum,
1924    M: LocalMover + Send,
1925    M::Error: Send,
1926{
1927    let mut prepared = false;
1928    detect(
1929        graph,
1930        params,
1931        max_evals,
1932        multi_level,
1933        |g, node_w, membership, res, rng| {
1934            if !prepared {
1935                mover.prepare(&g.indptr, &g.indices, &g.weights, node_w)?;
1936                prepared = true;
1937            }
1938            let (classes, colour) = seeded_colouring(g, rng);
1939            let changed = mover.move_nodes(membership, &classes, &colour, res)?;
1940            Ok((reindex(membership), changed))
1941        },
1942    )
1943}
1944
1945//////////
1946// Main //
1947//////////
1948
1949/// Leiden community detection on modularity.
1950///
1951/// Mirrors `igraph::cluster_leiden(objective_function = "modularity")`.
1952/// Communities are guaranteed to be connected.
1953///
1954/// ### Params
1955///
1956/// * `graph` - Undirected [`SparseGraph`]
1957/// * `params` - Optional [`LeidenParams`]; defaults match igraph
1958///
1959/// ### Returns
1960///
1961/// Community per node, relabelled by size (`0` the largest)
1962///
1963/// ### References
1964///
1965/// Traag, Waltman & van Eck, Scientific Reports, 2019
1966pub fn leiden_sparse_graph<T>(
1967    graph: &SparseGraph<T>,
1968    params: Option<LeidenParams>,
1969) -> Result<Vec<usize>, MethodsErrors>
1970where
1971    T: BixverseFloat + Send + Sync + std::iter::Sum,
1972{
1973    let params = params.unwrap_or_default();
1974    community_detection(graph, &params, usize::MAX, true)
1975}
1976
1977/// Louvain community detection on modularity.
1978///
1979/// The Leiden engine with the refinement switched off. Multi-level runs two
1980/// iterations, since the second still improves the partition and later ones
1981/// rarely change it. Single-level runs one, the single local-moving phase of
1982/// Phenograph and doubletdetection.
1983///
1984/// ### Params
1985///
1986/// * `graph` - Undirected [`SparseGraph`]
1987/// * `resolution` - Resolution parameter
1988/// * `max_iter` - Cap on node evaluations per local-moving phase, in units of
1989///   the level's node count. A safeguard for degenerate graphs; the queue
1990///   terminates on its own otherwise.
1991/// * `multi_level` - If `true`, run the full multi-level Louvain. If `false`,
1992///   run only the first local-moving phase (Phenograph / doubletdetection
1993///   behaviour).
1994/// * `seed` - Seed for reproducibility
1995///
1996/// ### Returns
1997///
1998/// Community per node, relabelled by size (`0` the largest)
1999///
2000/// ### References
2001///
2002/// Blondel et al., J. Stat. Mech., 2008
2003pub fn louvain_sparse_graph<T>(
2004    graph: &SparseGraph<T>,
2005    resolution: T,
2006    max_iter: usize,
2007    multi_level: bool,
2008    seed: usize,
2009) -> Result<Vec<usize>, MethodsErrors>
2010where
2011    T: BixverseFloat + Send + Sync + std::iter::Sum,
2012{
2013    let iterations = if multi_level { 2 } else { 1 };
2014    let params = LeidenParams::new(wf(resolution), 0.01, Some(iterations), false, seed as u64);
2015    community_detection(graph, &params, max_iter, multi_level)
2016}
2017
2018///////////
2019// Tests //
2020///////////
2021
2022#[cfg(test)]
2023mod tests {
2024    use super::*;
2025    use bixverse_core::core::math::sparse::coo_to_csr;
2026
2027    /// Zachary karate club, 0-based, from `igraph::make_graph("Zachary")`.
2028    const KARATE: [usize; 156] = [
2029        0, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0, 7, 0, 8, 0, 10, 0, 11, 0, 12, 0, 13, 0, 17, 0, 19,
2030        0, 21, 0, 31, 1, 2, 1, 3, 1, 7, 1, 13, 1, 17, 1, 19, 1, 21, 1, 30, 2, 3, 2, 7, 2, 27, 2,
2031        28, 2, 32, 2, 9, 2, 8, 2, 13, 3, 7, 3, 12, 3, 13, 4, 6, 4, 10, 5, 6, 5, 10, 5, 16, 6, 16,
2032        8, 30, 8, 32, 8, 33, 9, 33, 13, 33, 14, 32, 14, 33, 15, 32, 15, 33, 18, 32, 18, 33, 19, 33,
2033        20, 32, 20, 33, 22, 32, 22, 33, 23, 25, 23, 27, 23, 32, 23, 33, 23, 29, 24, 25, 24, 27, 24,
2034        31, 25, 31, 26, 29, 26, 33, 27, 33, 28, 31, 28, 33, 29, 32, 29, 33, 30, 32, 30, 33, 31, 32,
2035        31, 33, 32, 33,
2036    ];
2037
2038    fn build_undirected<T: BixverseFloat + BixverseNumeric + std::iter::Sum>(
2039        n: usize,
2040        edges: &[(usize, usize, T)],
2041        directed: bool,
2042    ) -> SparseGraph<T> {
2043        let mut rows = Vec::with_capacity(edges.len() * 2);
2044        let mut cols = Vec::with_capacity(edges.len() * 2);
2045        let mut vals = Vec::with_capacity(edges.len() * 2);
2046        for &(u, v, w) in edges {
2047            rows.extend([u, v]);
2048            cols.extend([v, u]);
2049            vals.extend([w, w]);
2050        }
2051        let csr = coo_to_csr(&rows.index_cast(), &cols.index_cast(), &vals, (n, n));
2052        SparseGraph::new(n, csr, directed)
2053    }
2054
2055    fn karate() -> SparseGraph<f64> {
2056        let edges: Vec<(usize, usize, f64)> =
2057            KARATE.chunks_exact(2).map(|e| (e[0], e[1], 1.0)).collect();
2058        build_undirected(34, &edges, false)
2059    }
2060
2061    /// Planted partition: `k` blocks of `size` nodes, `d_in` random edges per
2062    /// node inside its block and `d_out` outside, integer weights 1..=3.
2063    fn planted(
2064        k: usize,
2065        size: usize,
2066        d_in: usize,
2067        d_out: usize,
2068        seed: u64,
2069    ) -> Vec<(usize, usize, f64)> {
2070        let n = k * size;
2071        let mut rng = StdRng::seed_from_u64(seed);
2072        let mut edges = Vec::new();
2073        for v in 0..n {
2074            let b = v / size;
2075            for _ in 0..d_in {
2076                let u = b * size + rng.random_range(0..size);
2077                if u != v {
2078                    edges.push((v, u, rng.random_range(1..=3) as f64));
2079                }
2080            }
2081            for _ in 0..d_out {
2082                let u = rng.random_range(0..n);
2083                if u / size != b {
2084                    edges.push((v, u, 1.0));
2085                }
2086            }
2087        }
2088        edges
2089    }
2090
2091    fn n_communities(labels: &[usize]) -> usize {
2092        labels.iter().max().map_or(0, |m| m + 1)
2093    }
2094
2095    /// Every community induces a connected subgraph.
2096    fn all_connected(graph: &SparseGraph<f64>, labels: &[usize]) -> bool {
2097        let n = graph.get_node_number();
2098        let mut seen = vec![false; n];
2099        let mut roots = vec![false; n_communities(labels)];
2100        for s in 0..n {
2101            if seen[s] {
2102                continue;
2103            }
2104            if roots[labels[s]] {
2105                return false;
2106            }
2107            roots[labels[s]] = true;
2108            let mut stack = vec![s];
2109            seen[s] = true;
2110            while let Some(v) = stack.pop() {
2111                for &u in graph.get_neighbours_raw(v).0 {
2112                    let u = u as usize;
2113                    if !seen[u] && labels[u] == labels[v] {
2114                        seen[u] = true;
2115                        stack.push(u);
2116                    }
2117                }
2118            }
2119        }
2120        true
2121    }
2122
2123    /// Modularity matches `igraph::modularity` (values from Rscript).
2124    #[test]
2125    fn test_modularity_matches_igraph() {
2126        let g = karate();
2127        let labels: Vec<usize> = (0..34).map(|i| i % 2).collect();
2128        approx::assert_relative_eq!(
2129            modularity(&g, &labels, 1.0),
2130            -0.000082182774,
2131            epsilon = 1e-10
2132        );
2133        approx::assert_relative_eq!(
2134            modularity(&g, &labels, 0.5),
2135            0.249958908613,
2136            epsilon = 1e-10
2137        );
2138    }
2139
2140    /// Leiden on karate reaches igraph's seed range (Rscript, 50 seeds:
2141    /// min 0.418803, median and max 0.419790).
2142    #[test]
2143    fn test_leiden_karate_modularity() {
2144        let g = karate();
2145        for seed in 0..10 {
2146            let p = LeidenParams {
2147                seed,
2148                ..Default::default()
2149            };
2150            let labels = leiden_sparse_graph(&g, Some(p)).unwrap();
2151            let q = modularity(&g, &labels, 1.0);
2152            assert!(q >= 0.418803 - 1e-6, "seed {seed}: modularity {q}");
2153            assert!(all_connected(&g, &labels));
2154        }
2155    }
2156
2157    /// Louvain on karate stays within igraph's Louvain range (min 0.388560).
2158    #[test]
2159    fn test_louvain_karate_modularity() {
2160        let g = karate();
2161        for seed in 0..10 {
2162            let labels = louvain_sparse_graph(&g, 1.0, 50, true, seed).unwrap();
2163            let q = modularity(&g, &labels, 1.0);
2164            assert!(q >= 0.388560 - 1e-6, "seed {seed}: modularity {q}");
2165        }
2166    }
2167
2168    /// Planted blocks are recovered exactly.
2169    #[test]
2170    fn test_leiden_recovers_planted_blocks() {
2171        let edges = planted(8, 200, 10, 1, 7);
2172        let g = build_undirected(1600, &edges, false);
2173        let labels = leiden_sparse_graph(&g, None).unwrap();
2174        assert_eq!(n_communities(&labels), 8);
2175        for v in 0..1600 {
2176            assert_eq!(labels[v], labels[(v / 200) * 200]);
2177        }
2178    }
2179
2180    /// Same seed gives the same partition at 1 and 4 threads, on a graph large
2181    /// enough to take the parallel refinement and aggregation paths.
2182    #[test]
2183    fn test_leiden_thread_count_invariant() {
2184        let edges = planted(20, 400, 8, 3, 11);
2185        let g = build_undirected(8000, &edges, false);
2186        assert!(g.get_node_number() >= LEIDEN_PAR_MIN_NODES);
2187        let run = |threads: usize| {
2188            rayon::ThreadPoolBuilder::new()
2189                .num_threads(threads)
2190                .build()
2191                .unwrap()
2192                .install(|| leiden_sparse_graph(&g, None).unwrap())
2193        };
2194        let one = run(1);
2195        assert_eq!(one, run(4));
2196        assert!(all_connected(&g, &one));
2197    }
2198
2199    /// Iterating until stable terminates, gives the same partition at 1 and 4
2200    /// threads (with and without `min_gain`, whose modularity sums are fixed
2201    /// order), keeps Leiden's communities connected, and lands at least as
2202    /// high as two iterations on a noisy planted partition.
2203    #[test]
2204    fn test_leiden_until_stable() {
2205        let edges = planted(20, 400, 8, 3, 11);
2206        let g = build_undirected(8000, &edges, false);
2207        let q_two = modularity(&g, &leiden_sparse_graph(&g, None).unwrap(), 1.0);
2208        for min_gain in [0.0, 1e-4] {
2209            let p = LeidenParams {
2210                n_iterations: None,
2211                min_gain,
2212                ..Default::default()
2213            };
2214            let run = |threads: usize| {
2215                rayon::ThreadPoolBuilder::new()
2216                    .num_threads(threads)
2217                    .build()
2218                    .unwrap()
2219                    .install(|| leiden_sparse_graph(&g, Some(p)).unwrap())
2220            };
2221            let one = run(1);
2222            assert_eq!(one, run(4), "min_gain {min_gain}");
2223            assert!(all_connected(&g, &one));
2224            let q = modularity(&g, &one, 1.0);
2225            assert!(q >= q_two - 1e-3, "min_gain {min_gain}: {q} vs {q_two}");
2226        }
2227    }
2228
2229    /// Leiden or Louvain with the given local-moving mode.
2230    fn run_mode(g: &SparseGraph<f64>, mode: LocalMoving, refine: bool, seed: u64) -> Vec<usize> {
2231        let p = LeidenParams {
2232            refine,
2233            n_iterations: Some(if refine { 2 } else { 1 }),
2234            seed,
2235            local_moving: mode,
2236            ..Default::default()
2237        };
2238        community_detection(g, &p, usize::MAX, true).unwrap()
2239    }
2240
2241    /// Coloured gives the same partition at 1 and 4 threads, for Leiden and
2242    /// Louvain, and Leiden's communities stay connected.
2243    #[test]
2244    fn test_parallel_local_moving_thread_count_invariant() {
2245        let edges = planted(20, 400, 8, 3, 11);
2246        let g = build_undirected(8000, &edges, false);
2247        for refine in [true, false] {
2248            let run = |threads: usize| {
2249                rayon::ThreadPoolBuilder::new()
2250                    .num_threads(threads)
2251                    .build()
2252                    .unwrap()
2253                    .install(|| run_mode(&g, LocalMoving::Coloured, refine, 3))
2254            };
2255            let one = run(1);
2256            assert_eq!(one, run(4), "refine {refine}");
2257            if refine {
2258                assert!(all_connected(&g, &one));
2259            }
2260        }
2261    }
2262
2263    /// Coloured recovers planted blocks exactly, on a graph large enough that
2264    /// it runs instead of the queue.
2265    #[test]
2266    fn test_parallel_local_moving_recovers_planted_blocks() {
2267        let edges = planted(8, 600, 10, 1, 7);
2268        let g = build_undirected(4800, &edges, false);
2269        assert!(g.get_node_number() >= LEIDEN_PAR_MIN_NODES);
2270        for refine in [true, false] {
2271            let labels = run_mode(&g, LocalMoving::Coloured, refine, 1);
2272            assert_eq!(n_communities(&labels), 8, "refine {refine}");
2273            for v in 0..4800 {
2274                assert_eq!(labels[v], labels[(v / 600) * 600], "refine {refine}");
2275            }
2276        }
2277    }
2278
2279    /// The mask colouring equals a plain first-fit pass, including past 64
2280    /// colours (an 80-node clique on top of a planted graph).
2281    #[test]
2282    fn test_greedy_colouring_matches_first_fit() {
2283        let mut edges = planted(4, 100, 12, 2, 5);
2284        for u in 0..80 {
2285            for v in u + 1..80 {
2286                edges.push((u, v, 1.0));
2287            }
2288        }
2289        let g = build_undirected(400, &edges, false);
2290        let order: Vec<u32> = (150..400).chain(0..150).collect();
2291        let (classes, colour) = greedy_colouring(&g, &order);
2292
2293        let mut want = vec![u32::MAX; 400];
2294        for &v in &order {
2295            let taken: Vec<u32> = g
2296                .row(v as usize)
2297                .0
2298                .iter()
2299                .map(|&u| want[u as usize])
2300                .collect();
2301            want[v as usize] = (0..).find(|c| !taken.contains(c)).unwrap();
2302        }
2303        assert_eq!(colour, want);
2304        assert!(classes.len() >= 80);
2305        for (c, members) in classes.iter().enumerate() {
2306            assert!(members.is_sorted());
2307            assert!(members.iter().all(|&v| colour[v as usize] == c as u32));
2308        }
2309    }
2310
2311    /// Below `LEIDEN_PAR_MIN_NODES` Coloured takes the queue, so karate gives
2312    /// the queue's partition.
2313    #[test]
2314    fn test_parallel_local_moving_small_graph_uses_queue() {
2315        let g = karate();
2316        assert_eq!(
2317            run_mode(&g, LocalMoving::Coloured, true, 5),
2318            run_mode(&g, LocalMoving::Queue, true, 5)
2319        );
2320    }
2321
2322    /// On a noisy planted partition Coloured lands within 1% of the queue's
2323    /// modularity, for Leiden and Louvain.
2324    #[test]
2325    fn test_parallel_local_moving_modularity_close_to_queue() {
2326        let edges = planted(20, 400, 8, 3, 11);
2327        let g = build_undirected(8000, &edges, false);
2328        for refine in [true, false] {
2329            let q_queue = modularity(&g, &run_mode(&g, LocalMoving::Queue, refine, 2), 1.0);
2330            let q = modularity(&g, &run_mode(&g, LocalMoving::Coloured, refine, 2), 1.0);
2331            assert!(
2332                q >= 0.99 * q_queue,
2333                "refine {refine}: {q} vs queue {q_queue}"
2334            );
2335        }
2336    }
2337
2338    /// The same seed never gives a different partition.
2339    #[test]
2340    fn test_leiden_same_seed_same_labels() {
2341        let edges = planted(10, 100, 6, 3, 3);
2342        let g = build_undirected(1000, &edges, false);
2343        let p = LeidenParams {
2344            seed: 5,
2345            ..Default::default()
2346        };
2347        assert_eq!(
2348            leiden_sparse_graph(&g, Some(p)).unwrap(),
2349            leiden_sparse_graph(&g, Some(p)).unwrap()
2350        );
2351    }
2352
2353    /// f32 and f64 weights give the same partition when the weights are exact
2354    /// in both, since all totals are held in f64.
2355    #[test]
2356    fn test_leiden_f32_matches_f64() {
2357        let edges = planted(6, 150, 6, 3, 9);
2358        let edges32: Vec<(usize, usize, f32)> =
2359            edges.iter().map(|&(u, v, w)| (u, v, w as f32)).collect();
2360        let g64 = build_undirected(900, &edges, false);
2361        let g32 = build_undirected(900, &edges32, false);
2362        assert_eq!(
2363            leiden_sparse_graph(&g64, None).unwrap(),
2364            leiden_sparse_graph(&g32, None).unwrap()
2365        );
2366    }
2367
2368    /// Leiden communities are connected on a sparse noisy graph.
2369    #[test]
2370    fn test_leiden_communities_connected() {
2371        let edges = planted(30, 60, 3, 2, 21);
2372        let g = build_undirected(1800, &edges, false);
2373        for seed in 0..5 {
2374            let p = LeidenParams {
2375                seed,
2376                ..Default::default()
2377            };
2378            let labels = leiden_sparse_graph(&g, Some(p)).unwrap();
2379            assert!(all_connected(&g, &labels), "seed {seed}");
2380        }
2381    }
2382
2383    #[test]
2384    fn test_leiden_directed_errors() {
2385        let edges: Vec<(usize, usize, f64)> =
2386            KARATE.chunks_exact(2).map(|e| (e[0], e[1], 1.0)).collect();
2387        let g = build_undirected(34, &edges, true);
2388        assert!(leiden_sparse_graph(&g, None).is_err());
2389    }
2390
2391    #[test]
2392    fn test_leiden_empty_and_edgeless() {
2393        let g = build_undirected::<f64>(0, &[], false);
2394        assert!(leiden_sparse_graph(&g, None).unwrap().is_empty());
2395        let g = build_undirected::<f64>(3, &[], false);
2396        assert_eq!(leiden_sparse_graph(&g, None).unwrap().len(), 3);
2397    }
2398}