Skip to main content

rust_igraph/algorithms/community/
leiden.rs

1//! Leiden community detection (ALGO-CO-003).
2//!
3//! Counterpart of `igraph_community_leiden()` /
4//! `igraph_community_leiden_simple()` from
5//! `references/igraph/src/community/leiden.c`.
6//!
7//! Traag, Waltman, van Eck (2019): *From Louvain to Leiden: guaranteeing
8//! well-connected communities*. Scientific Reports 9(1) 5233.
9//! <https://doi.org/10.1038/s41598-019-41695-z>
10//!
11//! Three-phase per-iteration loop, repeated until the partition is
12//! stable (or for a fixed `n_iterations`):
13//!
14//! - **Local moving (fast)**: a queue of unstable vertices is processed
15//!   in seeded random order; each vertex is greedily moved to the
16//!   neighbour cluster that strictly improves the quality. Whenever a
17//!   vertex moves, its stable neighbours not in the new cluster are
18//!   pushed back onto the queue.
19//! - **Refinement**: each cluster is split into well-connected
20//!   subclusters by re-running a local-moving pass starting from a
21//!   singleton partition within the cluster. Candidate merges are
22//!   sampled with probability `∝ exp(diff/β)` over the *non-negative*
23//!   diffs — this is the key fix vs Louvain that guarantees connected,
24//!   well-separated communities.
25//! - **Aggregation**: the super-graph's vertices are the **refined**
26//!   subclusters (not the original clusters); the initial partition of
27//!   the super-graph maps each refined subcluster back to its parent
28//!   cluster. This is what lets later iterations recover the
29//!   refinement-introduced splits.
30//!
31//! The quality function is the generic Reichardt-Bornholdt form
32//!
33//! ```text
34//!   Q = (1 / 2m) Σ_c (e_c − γ · N_c²)        (undirected)
35//! ```
36//!
37//! parameterised by per-vertex weights `n_i` (so `N_c = Σ_{v∈c} n_v`)
38//! and the resolution `γ`. Setting `n_i = k_i` (degree) and dividing
39//! `γ` by `2m` recovers Newman-Girvan modularity; setting `n_i = 1`
40//! recovers the Constant Potts Model; setting `n_i = 1` and scaling
41//! `γ` by the weighted density recovers the Erdős-Rényi objective.
42//! These three objectives are exposed via [`LeidenObjective`].
43//!
44//! Self-rolled, dependency-free. Determinism comes from an inline
45//! `SplitMix64` PRNG seeded by the caller. The convenience entrypoint
46//! [`leiden`] pins `seed = 0` so repeated calls on the same graph
47//! produce identical partitions.
48
49// All `usize` -> `u32` casts in this module are bounded by `n`
50// (the graph vertex count or its aggregated super-graph), which
51// originates from `Graph::vcount(): u32` and so can never truncate.
52// The single-char names (`u`, `v`, `w`, `c`, `m`) are domain-standard
53// for graph endpoints / weights / communities / edge count.
54//
55// `needless_range_loop` is allowed because we frequently iterate by
56// index across several parallel arrays (membership / vertex_weight /
57// loop_weight); zipping them all up loses both clarity and (in some
58// hot loops) speed. `cast_precision_loss` is allowed because the
59// graph quantities exceed `2^53` only on inputs vastly larger than
60// the algorithm can handle anyway. `unnecessary_wraps` /
61// `ptr_arg` / `too_many_lines` are allowed for kernel functions that
62// directly mirror the structure of the upstream C source.
63#![allow(
64    clippy::cast_possible_truncation,
65    clippy::cast_precision_loss,
66    clippy::cast_sign_loss,
67    clippy::many_single_char_names,
68    clippy::needless_range_loop,
69    clippy::ptr_arg,
70    clippy::too_many_arguments,
71    clippy::too_many_lines,
72    clippy::unnecessary_wraps
73)]
74
75use crate::core::graph::EdgeId;
76use crate::core::{Graph, IgraphError, IgraphResult};
77
78/// Hard cap on aggregation rounds inside a single Leiden iteration.
79/// Real-world graphs converge in handfuls of levels; the cap is a
80/// pathological-input guard.
81const MAX_LEVELS_PER_ITERATION: usize = 64;
82
83/// Default randomness used in the refinement step — same default as
84/// upstream `igraph_community_leiden_simple` (β = 0.01).
85pub const LEIDEN_DEFAULT_BETA: f64 = 0.01;
86
87/// Default number of outer iterations. The reference paper and
88/// upstream both note that two iterations are usually enough.
89pub const LEIDEN_DEFAULT_ITERATIONS: i32 = 2;
90
91/// Quality function (objective) that Leiden optimises. Mirrors
92/// `igraph_leiden_objective_t`.
93#[derive(Debug, Copy, Clone, Eq, PartialEq)]
94pub enum LeidenObjective {
95    /// Generalised modularity
96    /// `Q = 1/(2m) Σ_ij (A_ij − γ k_i k_j / (2m)) δ(c_i,c_j)`.
97    /// Edge weights must not be negative.
98    Modularity,
99    /// Constant Potts Model
100    /// `Q = 1/(2m) Σ_ij (A_ij − γ) δ(c_i,c_j)`.
101    /// Edge weights are allowed to be negative.
102    Cpm,
103    /// Erdős-Rényi `Q = 1/(2m) Σ_ij (A_ij − γ·p) δ(c_i,c_j)`, with
104    /// `p` the weighted density. Edge weights must not be negative.
105    Er,
106}
107
108/// Full set of options for [`leiden_with_options`]. Construct via
109/// `LeidenOptions::default()` and then mutate the fields you care
110/// about, mirroring the way upstream C exposes per-parameter defaults.
111#[derive(Debug, Clone)]
112pub struct LeidenOptions {
113    /// Quality function. Default [`LeidenObjective::Modularity`].
114    pub objective: LeidenObjective,
115    /// Resolution parameter γ. Default `1.0`. For CPM, sensible values
116    /// are very small (e.g. `0.05`); for modularity, `1.0` is the
117    /// classical choice.
118    pub resolution: f64,
119    /// Refinement randomness. Default [`LEIDEN_DEFAULT_BETA`].
120    pub beta: f64,
121    /// Number of outer iterations to run. Negative ⇒ iterate until a
122    /// pass produces no change. Default [`LEIDEN_DEFAULT_ITERATIONS`].
123    pub n_iterations: i32,
124    /// PRNG seed (drives both the local-moving shuffle and the
125    /// refinement sampling). Default `0`.
126    pub seed: u64,
127    /// If `Some`, start from this membership vector instead of the
128    /// singleton partition. Length must equal `vcount`.
129    pub start: Option<Vec<u32>>,
130}
131
132impl Default for LeidenOptions {
133    fn default() -> Self {
134        Self {
135            objective: LeidenObjective::Modularity,
136            resolution: 1.0,
137            beta: LEIDEN_DEFAULT_BETA,
138            n_iterations: LEIDEN_DEFAULT_ITERATIONS,
139            seed: 0,
140            start: None,
141        }
142    }
143}
144
145/// Result of a Leiden run.
146#[derive(Debug, Clone)]
147pub struct LeidenResult {
148    /// Length-`vcount` vector of compacted community labels in `0..k`.
149    pub membership: Vec<u32>,
150    /// Final value of the chosen objective function on `membership`.
151    pub quality: f64,
152    /// Number of distinct communities (`k`).
153    pub nb_clusters: u32,
154    /// Number of outer iterations actually executed.
155    pub n_iterations_run: u32,
156    /// Quality value at the end of each outer iteration.
157    pub qualities: Vec<f64>,
158}
159
160// ============================================================================
161//                              Public API
162// ============================================================================
163
164/// Run Leiden with the default modularity objective, `γ = 1`,
165/// `β = 0.01`, two iterations, seed `0`.
166///
167/// # Errors
168/// - [`IgraphError::Unsupported`] if `graph` is directed.
169///
170/// # Examples
171///
172/// ```
173/// use rust_igraph::{Graph, leiden};
174///
175/// // Two triangles joined by a single bridge edge.
176/// let mut g = Graph::with_vertices(6);
177/// for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
178///     g.add_edge(u, v).unwrap();
179/// }
180/// let r = leiden(&g).unwrap();
181/// assert_eq!(r.membership[0], r.membership[1]);
182/// assert_eq!(r.membership[3], r.membership[4]);
183/// assert_ne!(r.membership[0], r.membership[3]);
184/// assert!(r.quality > 0.30);
185/// ```
186pub fn leiden(graph: &Graph) -> IgraphResult<LeidenResult> {
187    leiden_with_options(graph, None, &LeidenOptions::default())
188}
189
190/// Run Leiden with per-edge weights (modularity objective, `γ = 1`,
191/// `β = 0.01`, two iterations, seed `0`).
192///
193/// # Errors
194/// - [`IgraphError::Unsupported`] if `graph` is directed.
195/// - [`IgraphError::InvalidArgument`] if `weights.len() != ecount`,
196///   any weight is non-finite, or any weight is negative.
197///
198/// # Examples
199///
200/// ```
201/// use rust_igraph::{Graph, leiden_weighted};
202///
203/// // Two K3s + bridge with heavy intra-clique and very thin bridge:
204/// // Leiden splits the two triangles.
205/// let mut g = Graph::with_vertices(6);
206/// for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
207///     g.add_edge(u, v).unwrap();
208/// }
209/// let weights = vec![10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 0.01];
210/// let r = leiden_weighted(&g, &weights).unwrap();
211/// assert_eq!(r.membership[0], r.membership[1]);
212/// assert_ne!(r.membership[0], r.membership[3]);
213/// ```
214pub fn leiden_weighted(graph: &Graph, weights: &[f64]) -> IgraphResult<LeidenResult> {
215    leiden_with_options(graph, Some(weights), &LeidenOptions::default())
216}
217
218/// Run Leiden with the full set of options.
219///
220/// # Errors
221/// - [`IgraphError::Unsupported`] if `graph` is directed.
222/// - [`IgraphError::InvalidArgument`] for malformed weights,
223///   `opts.resolution < 0`, `!opts.beta.is_finite()`, `opts.beta < 0`,
224///   `!opts.resolution.is_finite()`, negative weights with
225///   [`LeidenObjective::Modularity`] or [`LeidenObjective::Er`], or a
226///   non-`None` `opts.start` whose length differs from `vcount`.
227///
228/// # Examples
229///
230/// ```
231/// use rust_igraph::{Graph, LeidenObjective, LeidenOptions, leiden_with_options};
232///
233/// // Same partition every time for a fixed seed.
234/// let mut g = Graph::with_vertices(6);
235/// for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
236///     g.add_edge(u, v).unwrap();
237/// }
238/// let opts = LeidenOptions {
239///     objective: LeidenObjective::Cpm,
240///     resolution: 0.1,
241///     seed: 42,
242///     ..LeidenOptions::default()
243/// };
244/// let a = leiden_with_options(&g, None, &opts).unwrap();
245/// let b = leiden_with_options(&g, None, &opts).unwrap();
246/// assert_eq!(a.membership, b.membership);
247/// ```
248pub fn leiden_with_options(
249    graph: &Graph,
250    weights: Option<&[f64]>,
251    opts: &LeidenOptions,
252) -> IgraphResult<LeidenResult> {
253    if graph.is_directed() {
254        return Err(IgraphError::Unsupported(
255            "leiden currently supports undirected graphs only",
256        ));
257    }
258    if !opts.resolution.is_finite() || opts.resolution < 0.0 {
259        return Err(IgraphError::InvalidArgument(
260            "resolution parameter must be non-negative and finite".to_string(),
261        ));
262    }
263    if !opts.beta.is_finite() || opts.beta < 0.0 {
264        return Err(IgraphError::InvalidArgument(
265            "beta parameter must be non-negative and finite".to_string(),
266        ));
267    }
268    validate_weights(graph, weights, opts.objective)?;
269
270    let n = graph.vcount() as usize;
271
272    if n == 0 {
273        return Ok(LeidenResult {
274            membership: Vec::new(),
275            quality: 0.0,
276            nb_clusters: 0,
277            n_iterations_run: 0,
278            qualities: Vec::new(),
279        });
280    }
281
282    // Initial membership: start vector or singleton partition.
283    let mut membership: Vec<u32> = if let Some(start) = opts.start.as_deref() {
284        if start.len() != n {
285            return Err(IgraphError::InvalidArgument(format!(
286                "start membership length ({}) differs from vcount ({n})",
287                start.len()
288            )));
289        }
290        let mut m = start.to_vec();
291        // Reindex to guarantee dense [0, k) labels.
292        reindex_membership(&mut m);
293        m
294    } else {
295        (0..n as u32).collect()
296    };
297
298    // Compute (effective_resolution, vertex_weights) from the chosen
299    // objective. These are passed to the kernel verbatim.
300    let edge_weights: Vec<f64> = match weights {
301        Some(w) => w.to_vec(),
302        None => vec![1.0; graph.ecount()],
303    };
304    let (eff_resolution, vertex_weights) =
305        derive_objective_params(graph, &edge_weights, opts.objective, opts.resolution)?;
306
307    let mut rng = SplitMix64::new(opts.seed);
308
309    let mut qualities: Vec<f64> = Vec::new();
310    let mut n_iterations_run: u32 = 0;
311    let mut changed = true;
312
313    let max_iter = if opts.n_iterations < 0 {
314        u32::MAX
315    } else {
316        u32::try_from(opts.n_iterations).unwrap_or(u32::MAX)
317    };
318
319    for _itr in 0..max_iter {
320        if opts.n_iterations < 0 && !changed {
321            break;
322        }
323        changed = false;
324        community_leiden_pass(
325            graph,
326            &edge_weights,
327            &vertex_weights,
328            eff_resolution,
329            opts.beta,
330            &mut membership,
331            &mut rng,
332            &mut changed,
333        )?;
334        n_iterations_run += 1;
335        let q = compute_quality(
336            graph,
337            &edge_weights,
338            &vertex_weights,
339            &membership,
340            eff_resolution,
341        );
342        qualities.push(q);
343    }
344
345    let nb_clusters = reindex_membership(&mut membership);
346    let quality = compute_quality(
347        graph,
348        &edge_weights,
349        &vertex_weights,
350        &membership,
351        eff_resolution,
352    );
353
354    Ok(LeidenResult {
355        membership,
356        quality,
357        nb_clusters,
358        n_iterations_run,
359        qualities,
360    })
361}
362
363// ============================================================================
364//                       Objective parameter derivation
365// ============================================================================
366
367/// Derive `(effective_resolution, vertex_weights)` from the user-facing
368/// objective. Mirrors the dispatch table in `igraph_community_leiden_simple`.
369fn derive_objective_params(
370    graph: &Graph,
371    edge_weights: &[f64],
372    objective: LeidenObjective,
373    user_resolution: f64,
374) -> IgraphResult<(f64, Vec<f64>)> {
375    let n = graph.vcount() as usize;
376    let m = graph.ecount();
377
378    match objective {
379        LeidenObjective::Modularity => {
380            // Vertex weights = weighted degree (strength); IGRAPH_LOOPS so
381            // self-loops count once each — matches igraph_strength().
382            let mut strength = vec![0.0_f64; n];
383            for e in 0..m {
384                let e_id = u32::try_from(e).map_err(|_| {
385                    IgraphError::InvalidArgument("edge count exceeds u32::MAX".into())
386                })?;
387                let (u, v) = graph.edge(e_id as EdgeId)?;
388                let w = edge_weights[e];
389                strength[u as usize] += w;
390                if u != v {
391                    strength[v as usize] += w;
392                }
393            }
394            let total: f64 = strength.iter().sum();
395            if total <= 0.0 {
396                // No edges, or all-zero weights ⇒ no rescaling to do
397                // and γ has no effect on any non-trivial cluster.
398                return Ok((user_resolution, strength));
399            }
400            // Undirected: total = 2m. Rescale γ by 1/(2m).
401            Ok((user_resolution / total, strength))
402        }
403        LeidenObjective::Cpm => Ok((user_resolution, vec![1.0; n])),
404        LeidenObjective::Er => {
405            // Density with loops allowed: p = total_weight / (n*(n+1)/2).
406            let total: f64 = edge_weights.iter().sum();
407            if n == 0 {
408                return Ok((user_resolution, Vec::new()));
409            }
410            let denom = (n as f64) * (n as f64 + 1.0) * 0.5;
411            let p = if denom > 0.0 { total / denom } else { 0.0 };
412            Ok((user_resolution * p, vec![1.0; n]))
413        }
414    }
415}
416
417// ============================================================================
418//                            Working state (per level)
419// ============================================================================
420
421/// Working graph + per-cluster bookkeeping for one aggregation level.
422///
423/// We carry around the explicit edge weights vector parallel to the
424/// adjacency list because the per-vertex weight `n_v` lives separately
425/// (it is *not* the degree in the generic quality formula — it is the
426/// caller-chosen objective weight).
427///
428/// Non-loop edges appear twice in `adj` (once per endpoint). Self-loops
429/// live in `loop_weight` (raw weight, not doubled).
430struct LeidenLevel {
431    n: usize,
432    /// `adj[v]` = `(neighbour, weight)` for every non-loop incident edge.
433    adj: Vec<Vec<(u32, f64)>>,
434    /// Raw self-loop weight at `v` (not doubled).
435    loop_weight: Vec<f64>,
436    /// Caller-chosen per-vertex weight `n_v` (degree for modularity,
437    /// `1.0` for CPM/ER, etc.). Distinct from the actual graph degree.
438    vertex_weight: Vec<f64>,
439}
440
441fn build_level_from_graph(
442    graph: &Graph,
443    edge_weights: &[f64],
444    vertex_weights: &[f64],
445) -> LeidenLevel {
446    let n = graph.vcount() as usize;
447    let mut adj: Vec<Vec<(u32, f64)>> = vec![Vec::new(); n];
448    let mut loop_weight = vec![0.0_f64; n];
449    let m = graph.ecount();
450    for e in 0..m {
451        // We trust the graph's edge() to succeed because we already
452        // validated weights.len() == ecount upstream.
453        let Ok((u, v)) = graph.edge(e as EdgeId) else {
454            continue;
455        };
456        let w = edge_weights[e];
457        if u == v {
458            loop_weight[u as usize] += w;
459        } else {
460            adj[u as usize].push((v, w));
461            adj[v as usize].push((u, w));
462        }
463    }
464    LeidenLevel {
465        n,
466        adj,
467        loop_weight,
468        vertex_weight: vertex_weights.to_vec(),
469    }
470}
471
472fn build_level_from_edges(
473    n: usize,
474    edges: &[(u32, u32, f64)],
475    vertex_weights: Vec<f64>,
476) -> LeidenLevel {
477    let mut adj: Vec<Vec<(u32, f64)>> = vec![Vec::new(); n];
478    let mut loop_weight = vec![0.0_f64; n];
479    for &(u, v, w) in edges {
480        if u == v {
481            loop_weight[u as usize] += w;
482        } else {
483            adj[u as usize].push((v, w));
484            adj[v as usize].push((u, w));
485        }
486    }
487    LeidenLevel {
488        n,
489        adj,
490        loop_weight,
491        vertex_weight: vertex_weights,
492    }
493}
494
495// ============================================================================
496//                            One full pass (= one C "community_leiden")
497// ============================================================================
498
499fn community_leiden_pass(
500    graph: &Graph,
501    edge_weights: &[f64],
502    vertex_weights: &[f64],
503    resolution: f64,
504    beta: f64,
505    membership: &mut Vec<u32>,
506    rng: &mut SplitMix64,
507    changed: &mut bool,
508) -> IgraphResult<()> {
509    let n = graph.vcount() as usize;
510    if n == 0 {
511        return Ok(());
512    }
513
514    // Build the level-0 working state from the input graph.
515    let mut level = build_level_from_graph(graph, edge_weights, vertex_weights);
516
517    // The "aggregate vertex" map records, for each original vertex,
518    // its current super-vertex id. Starts as identity.
519    let mut aggregate_vertex: Vec<u32> = (0..n as u32).collect();
520
521    // Working membership on the current level. Starts as the caller's
522    // initial membership.
523    let mut work_membership: Vec<u32> = membership.clone();
524
525    // Reindex once up-front; subsequent values are produced by
526    // leiden_fastmove_vertices/reindex calls inside the loop.
527    reindex_membership(&mut work_membership);
528
529    for level_idx in 0..MAX_LEVELS_PER_ITERATION {
530        // ---- Phase 1: fast local moves --------------------------------
531        let (nb_clusters, level_changed) =
532            leiden_fastmove_vertices(&level, resolution, &mut work_membership, rng);
533        if level_changed {
534            *changed = true;
535        }
536
537        // Continue only if there is room to coarsen further.
538        let continue_clustering = (nb_clusters as usize) < level.n;
539        if !continue_clustering {
540            break;
541        }
542
543        // On every non-first level, propagate the current super-vertex
544        // membership down to the original vertices (parallels the
545        // `if (level > 0) ... membership[i] = i_membership[aggregate_vertex[i]]`
546        // block in the C source).
547        if level_idx > 0 {
548            for i in 0..n {
549                let v_agg = aggregate_vertex[i] as usize;
550                membership[i] = work_membership[v_agg];
551            }
552        } else {
553            // Level 0: the membership is the work_membership itself.
554            membership.copy_from_slice(&work_membership);
555        }
556
557        // ---- Phase 2: refinement --------------------------------------
558        let mut refined_membership: Vec<u32> = vec![0; level.n];
559        let mut nb_refined_clusters: usize = 0;
560
561        // Bucket vertices by their (current) cluster.
562        let mut clusters: Vec<Vec<u32>> = vec![Vec::new(); nb_clusters as usize];
563        for v in 0..level.n {
564            let c = work_membership[v] as usize;
565            clusters[c].push(v as u32);
566        }
567
568        for (c, vertex_subset) in clusters.iter().enumerate() {
569            leiden_merge_vertices(
570                &level,
571                vertex_subset,
572                &work_membership,
573                c as u32,
574                resolution,
575                beta,
576                &mut nb_refined_clusters,
577                &mut refined_membership,
578                rng,
579            );
580        }
581
582        // If refinement didn't actually split anything (every refined
583        // cluster is a singleton), fall back to aggregating on the
584        // current clustering instead.
585        if nb_refined_clusters >= level.n {
586            refined_membership.copy_from_slice(&work_membership);
587            nb_refined_clusters = nb_clusters as usize;
588        }
589
590        // ---- Update the original→super map for the next level --------
591        for i in 0..n {
592            let v_agg = aggregate_vertex[i] as usize;
593            aggregate_vertex[i] = refined_membership[v_agg];
594        }
595
596        // ---- Phase 3: aggregate --------------------------------------
597        let (next_level, next_membership) = leiden_aggregate(
598            &level,
599            &work_membership,
600            &refined_membership,
601            nb_refined_clusters,
602        );
603
604        level = next_level;
605        work_membership = next_membership;
606        reindex_membership(&mut work_membership);
607    }
608
609    // Re-derive the final original-vertex membership from the most
610    // recent work_membership through the aggregate_vertex map. If we
611    // only ran one level (no aggregation), work_membership *is* the
612    // answer for the original vertices.
613    if level.n == n {
614        membership.copy_from_slice(&work_membership);
615    } else {
616        for i in 0..n {
617            let v_agg = aggregate_vertex[i] as usize;
618            membership[i] = work_membership[v_agg];
619        }
620    }
621
622    // Compact labels to [0, k).
623    reindex_membership(membership);
624    Ok(())
625}
626
627// ============================================================================
628//                          Phase 1: fastmove vertices
629// ============================================================================
630
631fn leiden_fastmove_vertices(
632    level: &LeidenLevel,
633    resolution: f64,
634    membership: &mut [u32],
635    rng: &mut SplitMix64,
636) -> (u32, bool) {
637    let n = level.n;
638    if n == 0 {
639        return (0, false);
640    }
641
642    // Per-cluster weight + count administration.
643    let mut cluster_weight = vec![0.0_f64; n];
644    let mut cluster_size: Vec<u32> = vec![0; n];
645    for v in 0..n {
646        let c = membership[v] as usize;
647        cluster_weight[c] += level.vertex_weight[v];
648        cluster_size[c] += 1;
649    }
650
651    // Stack of empty cluster ids — picking the top gives an unused
652    // slot we can recycle when a vertex prefers to be a singleton.
653    let mut empty_clusters: Vec<u32> = Vec::new();
654    for c in 0..n {
655        if cluster_size[c] == 0 {
656            empty_clusters.push(c as u32);
657        }
658    }
659
660    // Initial queue: every vertex in a shuffled order.
661    let mut order: Vec<u32> = (0..n as u32).collect();
662    if n > 1 {
663        shuffle_in_place(&mut order, rng);
664    }
665    let mut queue: std::collections::VecDeque<u32> = order.into_iter().collect();
666    let mut stable: Vec<bool> = vec![false; n];
667
668    // Scratch: per-cluster summed edge weight from the popped vertex,
669    // plus the list of touched neighbour-clusters so we can reset them
670    // in O(deg) instead of O(n).
671    let mut edge_weight_per_cluster = vec![0.0_f64; n];
672    let mut neighbor_clusters: Vec<u32> = Vec::new();
673    let mut cluster_touched: Vec<bool> = vec![false; n];
674
675    let mut changed = false;
676
677    while let Some(v) = queue.pop_front() {
678        let v_idx = v as usize;
679        let weight_v = level.vertex_weight[v_idx];
680        let current_cluster = membership[v_idx] as usize;
681
682        // Strip v from its cluster.
683        cluster_weight[current_cluster] -= weight_v;
684        cluster_size[current_cluster] -= 1;
685        if cluster_size[current_cluster] == 0 {
686            empty_clusters.push(current_cluster as u32);
687        }
688
689        // Default neighbour set: include the top of the empty stack so
690        // that "become a new singleton" is always an option.
691        neighbor_clusters.clear();
692        let empty_top = empty_clusters.last().copied();
693        if let Some(top) = empty_top {
694            let top_us = top as usize;
695            if !cluster_touched[top_us] {
696                cluster_touched[top_us] = true;
697                neighbor_clusters.push(top);
698            }
699        }
700
701        // Sum edge weights into each neighbour cluster.
702        for &(u, w) in &level.adj[v_idx] {
703            let u_us = u as usize;
704            if u_us == v_idx {
705                continue;
706            }
707            let c = membership[u_us];
708            let c_us = c as usize;
709            if !cluster_touched[c_us] {
710                cluster_touched[c_us] = true;
711                neighbor_clusters.push(c);
712            }
713            edge_weight_per_cluster[c_us] += w;
714        }
715
716        // Score the identity move (back into current_cluster).
717        let mut best_cluster = current_cluster as u32;
718        let mut max_diff = edge_weight_per_cluster[current_cluster]
719            - weight_v * cluster_weight[current_cluster] * resolution;
720
721        for &c in &neighbor_clusters {
722            let c_us = c as usize;
723            let diff = edge_weight_per_cluster[c_us] - weight_v * cluster_weight[c_us] * resolution;
724            if diff > max_diff {
725                best_cluster = c;
726                max_diff = diff;
727            }
728        }
729
730        // Reset scratch slots.
731        for &c in &neighbor_clusters {
732            let c_us = c as usize;
733            edge_weight_per_cluster[c_us] = 0.0;
734            cluster_touched[c_us] = false;
735        }
736
737        let best_us = best_cluster as usize;
738
739        // Re-attach v to best_cluster.
740        cluster_weight[best_us] += weight_v;
741        cluster_size[best_us] += 1;
742        if empty_top == Some(best_cluster) && cluster_size[best_us] == 1 {
743            empty_clusters.pop();
744        }
745
746        // Mark v stable (regardless of whether it actually moved).
747        stable[v_idx] = true;
748
749        if best_us != current_cluster {
750            changed = true;
751            membership[v_idx] = best_cluster;
752            // Push stable neighbours that aren't in the new cluster back
753            // onto the queue — they may want to follow v.
754            for &(u, _) in &level.adj[v_idx] {
755                let u_us = u as usize;
756                if stable[u_us] && membership[u_us] as usize != best_us {
757                    queue.push_back(u);
758                    stable[u_us] = false;
759                }
760            }
761        }
762    }
763
764    let nb_clusters = reindex_membership(membership);
765    (nb_clusters, changed)
766}
767
768// ============================================================================
769//                              Phase 2: refinement
770// ============================================================================
771
772fn leiden_merge_vertices(
773    level: &LeidenLevel,
774    vertex_subset: &[u32],
775    parent_membership: &[u32],
776    parent_cluster: u32,
777    resolution: f64,
778    beta: f64,
779    nb_refined_clusters: &mut usize,
780    refined_membership: &mut [u32],
781    rng: &mut SplitMix64,
782) {
783    let n_sub = vertex_subset.len();
784    if n_sub == 0 {
785        return;
786    }
787    if n_sub == 1 {
788        // Singleton-cluster shortcut: a single vertex is always its own
789        // refined cluster. We still need to assign it a refined id.
790        refined_membership[vertex_subset[0] as usize] = (*nb_refined_clusters) as u32;
791        *nb_refined_clusters += 1;
792        return;
793    }
794
795    // Local cluster bookkeeping over the n_sub-vertex sub-problem.
796    let mut sub_cluster_weight = vec![0.0_f64; n_sub];
797    let mut sub_nb_vertices: Vec<u32> = vec![0; n_sub];
798    let mut external_edge_weight = vec![0.0_f64; n_sub];
799    let mut total_subset_weight = 0.0_f64;
800
801    // Initial singleton partition inside the subset: each vertex maps
802    // to a unique local cluster index 0..n_sub. We reuse the global
803    // `refined_membership` slot for storage but treat it as
804    // local-index-space inside this function.
805    for (i, &v) in vertex_subset.iter().enumerate() {
806        let v_us = v as usize;
807        refined_membership[v_us] = i as u32;
808        sub_cluster_weight[i] += level.vertex_weight[v_us];
809        total_subset_weight += level.vertex_weight[v_us];
810        sub_nb_vertices[i] += 1;
811
812        for &(u, w) in &level.adj[v_us] {
813            let u_us = u as usize;
814            if u_us != v_us && parent_membership[u_us] == parent_cluster {
815                external_edge_weight[i] += w;
816            }
817        }
818    }
819
820    // Walk the subset in shuffled order.
821    let mut order: Vec<u32> = vertex_subset.to_vec();
822    if n_sub > 1 {
823        shuffle_in_place(&mut order, rng);
824    }
825
826    let mut non_singleton: Vec<bool> = vec![false; n_sub];
827    let mut edge_weight_per_local = vec![0.0_f64; n_sub];
828    let mut neighbor_locals: Vec<u32> = Vec::new();
829    let mut local_touched: Vec<bool> = vec![false; n_sub];
830    let mut cum_trans_diff = vec![0.0_f64; n_sub];
831
832    for &v in &order {
833        let v_us = v as usize;
834        let current = refined_membership[v_us] as usize;
835
836        // Skip non-singletons — refinement only ever moves out of a
837        // singleton (per the Leiden paper / C reference).
838        if non_singleton[current] {
839            continue;
840        }
841
842        // Skip vertices whose singleton is not well-connected to the
843        // rest of the parent cluster.
844        let vw_prod =
845            sub_cluster_weight[current] * (total_subset_weight - sub_cluster_weight[current]);
846        if external_edge_weight[current] < vw_prod * resolution {
847            continue;
848        }
849
850        // Detach v from its singleton.
851        sub_cluster_weight[current] = 0.0;
852        sub_nb_vertices[current] = 0;
853
854        // Build the neighbour-cluster list, restricted to neighbours
855        // that share the same parent_cluster.
856        neighbor_locals.clear();
857        if !local_touched[current] {
858            local_touched[current] = true;
859            neighbor_locals.push(current as u32);
860        }
861        for &(u, w) in &level.adj[v_us] {
862            let u_us = u as usize;
863            if u_us != v_us && parent_membership[u_us] == parent_cluster {
864                let c = refined_membership[u_us];
865                let c_us = c as usize;
866                if !local_touched[c_us] {
867                    local_touched[c_us] = true;
868                    neighbor_locals.push(c);
869                }
870                edge_weight_per_local[c_us] += w;
871            }
872        }
873
874        // Score moves.
875        let weight_v = level.vertex_weight[v_us];
876        let mut best_cluster = current as u32;
877        let mut max_diff = 0.0_f64;
878        let mut total_cum = 0.0_f64;
879
880        for (j, &c) in neighbor_locals.iter().enumerate() {
881            let c_us = c as usize;
882            let vw_prod_c =
883                sub_cluster_weight[c_us] * (total_subset_weight - sub_cluster_weight[c_us]);
884            if external_edge_weight[c_us] >= vw_prod_c * resolution {
885                let diff =
886                    edge_weight_per_local[c_us] - weight_v * sub_cluster_weight[c_us] * resolution;
887                if diff > max_diff {
888                    best_cluster = c;
889                    max_diff = diff;
890                }
891                if diff >= 0.0 {
892                    if beta > 0.0 {
893                        total_cum += (diff / beta).exp();
894                    } else if diff > 0.0 {
895                        // β → 0 limit collapses sampling to argmax;
896                        // a non-zero positive diff dominates.
897                        total_cum = f64::INFINITY;
898                    }
899                }
900            }
901            cum_trans_diff[j] = total_cum;
902        }
903
904        // Pick a target cluster.
905        let chosen_cluster = if total_cum.is_finite() && total_cum > 0.0 {
906            let r = rng.next_f64() * total_cum;
907            // Find first j such that cum_trans_diff[j] >= r.
908            let mut idx = neighbor_locals.len() - 1;
909            for (j, &cum) in cum_trans_diff
910                .iter()
911                .enumerate()
912                .take(neighbor_locals.len())
913            {
914                if cum >= r {
915                    idx = j;
916                    break;
917                }
918            }
919            neighbor_locals[idx]
920        } else if total_cum == 0.0 {
921            // No eligible (>0) cluster — leave in current.
922            current as u32
923        } else {
924            best_cluster
925        };
926
927        let chosen_us = chosen_cluster as usize;
928
929        // Re-attach v to chosen cluster.
930        sub_cluster_weight[chosen_us] += weight_v;
931        sub_nb_vertices[chosen_us] += 1;
932
933        // Update external_edge_weight[chosen]: edges from v to
934        // vertices already in chosen go internal; edges from v to
935        // vertices in other (parent-internal) clusters go external.
936        for &(u, w) in &level.adj[v_us] {
937            let u_us = u as usize;
938            if parent_membership[u_us] == parent_cluster {
939                if refined_membership[u_us] as usize == chosen_us {
940                    external_edge_weight[chosen_us] -= w;
941                } else {
942                    external_edge_weight[chosen_us] += w;
943                }
944            }
945        }
946
947        if chosen_us != current {
948            refined_membership[v_us] = chosen_cluster;
949            non_singleton[chosen_us] = true;
950        }
951
952        // Reset scratch slots for the next iteration.
953        for &c in &neighbor_locals {
954            let c_us = c as usize;
955            edge_weight_per_local[c_us] = 0.0;
956            local_touched[c_us] = false;
957        }
958    }
959
960    // Translate local cluster ids back into the global numbering, with
961    // dense contiguous labels starting at `*nb_refined_clusters`.
962    let base = *nb_refined_clusters;
963    // local_to_global[i] = u32::MAX when unassigned, else the global id.
964    let mut local_to_global: Vec<u32> = vec![u32::MAX; n_sub];
965    let mut next_global = base;
966    for &v in vertex_subset {
967        let v_us = v as usize;
968        let local = refined_membership[v_us] as usize;
969        if local_to_global[local] == u32::MAX {
970            local_to_global[local] = next_global as u32;
971            next_global += 1;
972        }
973        refined_membership[v_us] = local_to_global[local];
974    }
975    *nb_refined_clusters = next_global;
976}
977
978// ============================================================================
979//                              Phase 3: aggregate
980// ============================================================================
981
982/// Aggregate the current level on the basis of `refined_membership`.
983/// Returns the new level and the new working membership (mapping each
984/// new super-vertex back to its parent cluster).
985fn leiden_aggregate(
986    level: &LeidenLevel,
987    parent_membership: &[u32],
988    refined_membership: &[u32],
989    nb_refined: usize,
990) -> (LeidenLevel, Vec<u32>) {
991    let k = nb_refined;
992
993    // Build new edge list. We aggregate inter-cluster edges via a
994    // dedup-by-linear-scan adjacency map (one Vec<(u,w)> per cluster,
995    // entries kept with `to > from` so each super-edge appears once),
996    // and self-loops via a per-cluster accumulator.
997    let mut inter: Vec<Vec<(u32, f64)>> = vec![Vec::new(); k];
998    let mut loops: Vec<f64> = vec![0.0_f64; k];
999    let mut agg_vertex_weight: Vec<f64> = vec![0.0_f64; k];
1000    // Membership of each refined cluster = its parent cluster id (taken
1001    // from any of its vertices — they all share the same parent).
1002    let mut new_membership: Vec<u32> = vec![0; k];
1003
1004    for v in 0..level.n {
1005        let c = refined_membership[v] as usize;
1006        agg_vertex_weight[c] += level.vertex_weight[v];
1007        new_membership[c] = parent_membership[v];
1008
1009        // Carry self-loops over.
1010        let lw = level.loop_weight[v];
1011        if lw > 0.0 {
1012            loops[c] += lw;
1013        }
1014
1015        for &(u, w) in &level.adj[v] {
1016            let u_us = u as usize;
1017            // Each non-loop edge appears twice in `adj`; only process
1018            // it from the lower endpoint to avoid double-counting.
1019            if u_us <= v {
1020                continue;
1021            }
1022            let c2 = refined_membership[u_us] as usize;
1023            if c == c2 {
1024                loops[c] += w;
1025            } else {
1026                let (a, b) = if c < c2 { (c, c2) } else { (c2, c) };
1027                push_or_merge(&mut inter[a], b as u32, w);
1028            }
1029        }
1030    }
1031
1032    // Flatten into an edge list with `from <= to`.
1033    let mut edges: Vec<(u32, u32, f64)> = Vec::new();
1034    for c in 0..k {
1035        if loops[c] > 0.0 {
1036            edges.push((c as u32, c as u32, loops[c]));
1037        }
1038        for &(c2, w) in &inter[c] {
1039            edges.push((c as u32, c2, w));
1040        }
1041    }
1042
1043    let next_level = build_level_from_edges(k, &edges, agg_vertex_weight);
1044    (next_level, new_membership)
1045}
1046
1047fn push_or_merge(list: &mut Vec<(u32, f64)>, neighbour: u32, weight: f64) {
1048    for slot in list.iter_mut() {
1049        if slot.0 == neighbour {
1050            slot.1 += weight;
1051            return;
1052        }
1053    }
1054    list.push((neighbour, weight));
1055}
1056
1057// ============================================================================
1058//                              Quality computation
1059// ============================================================================
1060
1061/// Quality of a membership on the original graph, matching upstream
1062/// `leiden_quality()`. Undirected only.
1063fn compute_quality(
1064    graph: &Graph,
1065    edge_weights: &[f64],
1066    vertex_weights: &[f64],
1067    membership: &[u32],
1068    resolution: f64,
1069) -> f64 {
1070    let n = graph.vcount() as usize;
1071    let m = graph.ecount();
1072    if n == 0 {
1073        return 0.0;
1074    }
1075
1076    let mut q = 0.0_f64;
1077    let mut total_edge_weight = 0.0_f64;
1078    for e in 0..m {
1079        let Ok((u, v)) = graph.edge(e as EdgeId) else {
1080            continue;
1081        };
1082        let w = edge_weights[e];
1083        total_edge_weight += w;
1084        if membership[u as usize] == membership[v as usize] {
1085            // Undirected: directed_multiplier = 2.0
1086            q += 2.0 * w;
1087        }
1088    }
1089
1090    // Cluster sums of vertex weights.
1091    let k = membership.iter().copied().max().map_or(0, |m| m + 1) as usize;
1092    let mut cluster_weight = vec![0.0_f64; k];
1093    for v in 0..n {
1094        cluster_weight[membership[v] as usize] += vertex_weights[v];
1095    }
1096    for c in 0..k {
1097        q -= resolution * cluster_weight[c] * cluster_weight[c];
1098    }
1099
1100    if total_edge_weight <= 0.0 {
1101        return 0.0;
1102    }
1103    q / (2.0 * total_edge_weight)
1104}
1105
1106// ============================================================================
1107//                            Membership reindexing
1108// ============================================================================
1109
1110/// Reindex `membership` so labels become a contiguous `0..k`
1111/// permutation, then return `k`.
1112fn reindex_membership(membership: &mut [u32]) -> u32 {
1113    if membership.is_empty() {
1114        return 0;
1115    }
1116    let max = *membership.iter().max().unwrap_or(&0);
1117    let mut remap: Vec<i64> = vec![-1; max as usize + 1];
1118    let mut next: u32 = 0;
1119    for slot in membership.iter_mut() {
1120        let old = *slot as usize;
1121        if remap[old] < 0 {
1122            remap[old] = i64::from(next);
1123            next += 1;
1124        }
1125        *slot = remap[old] as u32;
1126    }
1127    next
1128}
1129
1130// ============================================================================
1131//                              Weight validation
1132// ============================================================================
1133
1134fn validate_weights(
1135    graph: &Graph,
1136    weights: Option<&[f64]>,
1137    objective: LeidenObjective,
1138) -> IgraphResult<()> {
1139    let Some(w) = weights else {
1140        return Ok(());
1141    };
1142    let m = graph.ecount();
1143    if w.len() != m {
1144        return Err(IgraphError::InvalidArgument(format!(
1145            "weights vector size ({}) differs from edge count ({m})",
1146            w.len(),
1147        )));
1148    }
1149    let allow_negative = matches!(objective, LeidenObjective::Cpm);
1150    for (e, &v) in w.iter().enumerate() {
1151        if !v.is_finite() {
1152            return Err(IgraphError::InvalidArgument(format!(
1153                "weight at edge {e} is not finite ({v})"
1154            )));
1155        }
1156        if !allow_negative && v < 0.0 {
1157            return Err(IgraphError::InvalidArgument(format!(
1158                "weight at edge {e} is negative ({v}); leiden with this objective requires non-negative weights"
1159            )));
1160        }
1161    }
1162    Ok(())
1163}
1164
1165// ============================================================================
1166//                                  PRNG
1167// ============================================================================
1168
1169struct SplitMix64(u64);
1170
1171impl SplitMix64 {
1172    fn new(seed: u64) -> Self {
1173        // Avoid the all-zero pathological state.
1174        Self(seed.wrapping_add(0x9E37_79B9_7F4A_7C15))
1175    }
1176    fn next_u64(&mut self) -> u64 {
1177        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
1178        let mut z = self.0;
1179        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1180        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1181        z ^ (z >> 31)
1182    }
1183    fn gen_index(&mut self, bound: usize) -> usize {
1184        debug_assert!(bound > 0);
1185        let r = self.next_u64() % (bound as u64);
1186        usize::try_from(r).unwrap_or(0)
1187    }
1188    /// Uniform `[0, 1)` via the top 53 bits — standard practice.
1189    fn next_f64(&mut self) -> f64 {
1190        ((self.next_u64() >> 11) as f64) * (1.0 / ((1u64 << 53) as f64))
1191    }
1192}
1193
1194fn shuffle_in_place<T>(slice: &mut [T], rng: &mut SplitMix64) {
1195    let len = slice.len();
1196    for i in (1..len).rev() {
1197        let j = rng.gen_index(i + 1);
1198        slice.swap(i, j);
1199    }
1200}
1201
1202// ============================================================================
1203//                                  Tests
1204// ============================================================================
1205
1206#[cfg(test)]
1207#[allow(clippy::float_cmp)]
1208mod tests {
1209    use super::*;
1210
1211    fn add_edges(g: &mut Graph, edges: &[(u32, u32)]) {
1212        for &(u, v) in edges {
1213            g.add_edge(u, v).unwrap();
1214        }
1215    }
1216
1217    #[test]
1218    fn empty_graph_returns_empty_membership() {
1219        let g = Graph::with_vertices(0);
1220        let r = leiden(&g).unwrap();
1221        assert_eq!(r.membership.len(), 0);
1222        assert_eq!(r.quality, 0.0);
1223        assert_eq!(r.nb_clusters, 0);
1224    }
1225
1226    #[test]
1227    fn isolated_vertices_keep_singletons_after_modularity_reduction() {
1228        // With modularity objective and no edges, vertex strengths are
1229        // all 0 → effective_resolution stays at the user value but γ
1230        // multiplies a 0 cluster_weight, so any partition has the same
1231        // quality. We accept whatever the algorithm produces but
1232        // require well-formed output.
1233        let g = Graph::with_vertices(4);
1234        let r = leiden(&g).unwrap();
1235        assert_eq!(r.membership.len(), 4);
1236        let k = r.nb_clusters;
1237        assert!((1..=4).contains(&k));
1238    }
1239
1240    #[test]
1241    fn two_triangles_bridge_split() {
1242        let mut g = Graph::with_vertices(6);
1243        add_edges(
1244            &mut g,
1245            &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
1246        );
1247        let r = leiden(&g).unwrap();
1248        assert_eq!(r.membership[0], r.membership[1]);
1249        assert_eq!(r.membership[0], r.membership[2]);
1250        assert_eq!(r.membership[3], r.membership[4]);
1251        assert_eq!(r.membership[3], r.membership[5]);
1252        assert_ne!(r.membership[0], r.membership[3]);
1253    }
1254
1255    #[test]
1256    fn directed_input_rejected() {
1257        let mut g = Graph::new(3, true).unwrap();
1258        g.add_edge(0, 1).unwrap();
1259        assert!(leiden(&g).is_err());
1260        assert!(leiden_weighted(&g, &[1.0]).is_err());
1261        assert!(leiden_with_options(&g, None, &LeidenOptions::default()).is_err());
1262    }
1263
1264    #[test]
1265    fn weight_validation() {
1266        let mut g = Graph::with_vertices(3);
1267        g.add_edge(0, 1).unwrap();
1268        g.add_edge(1, 2).unwrap();
1269        assert!(leiden_weighted(&g, &[1.0]).is_err());
1270        assert!(leiden_weighted(&g, &[1.0, -0.1]).is_err());
1271        assert!(leiden_weighted(&g, &[1.0, f64::NAN]).is_err());
1272        assert!(leiden_weighted(&g, &[1.0, f64::INFINITY]).is_err());
1273        // CPM allows negative weights.
1274        let opts = LeidenOptions {
1275            objective: LeidenObjective::Cpm,
1276            ..LeidenOptions::default()
1277        };
1278        assert!(leiden_with_options(&g, Some(&[1.0, -0.1]), &opts).is_ok());
1279    }
1280
1281    #[test]
1282    fn deterministic_under_seed() {
1283        let mut g = Graph::with_vertices(6);
1284        add_edges(
1285            &mut g,
1286            &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
1287        );
1288        let opts = LeidenOptions {
1289            seed: 12345,
1290            ..LeidenOptions::default()
1291        };
1292        let a = leiden_with_options(&g, None, &opts).unwrap();
1293        let b = leiden_with_options(&g, None, &opts).unwrap();
1294        assert_eq!(a.membership, b.membership);
1295        assert!((a.quality - b.quality).abs() < 1e-12);
1296    }
1297
1298    #[test]
1299    fn cpm_objective_works() {
1300        let mut g = Graph::with_vertices(6);
1301        add_edges(
1302            &mut g,
1303            &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
1304        );
1305        // For CPM on the two-triangles-plus-bridge graph (N=6, m=7):
1306        //   q_union = 14 − γ·36     (one cluster of 6)
1307        //   q_split = 12 − γ·18     (two cliques of 3)
1308        // Split is optimal iff γ > (14 − 12) / (36 − 18) = 1/9 ≈ 0.111.
1309        // Pick γ = 0.5 to leave headroom for the local-move heuristic.
1310        let opts = LeidenOptions {
1311            objective: LeidenObjective::Cpm,
1312            resolution: 0.5,
1313            ..LeidenOptions::default()
1314        };
1315        let r = leiden_with_options(&g, None, &opts).unwrap();
1316        assert_eq!(r.membership[0], r.membership[1]);
1317        assert_eq!(r.membership[0], r.membership[2]);
1318        assert_eq!(r.membership[3], r.membership[4]);
1319        assert_eq!(r.membership[3], r.membership[5]);
1320        assert_ne!(r.membership[0], r.membership[3]);
1321    }
1322
1323    #[test]
1324    fn negative_resolution_rejected() {
1325        let g = Graph::with_vertices(3);
1326        let opts = LeidenOptions {
1327            resolution: -0.1,
1328            ..LeidenOptions::default()
1329        };
1330        assert!(leiden_with_options(&g, None, &opts).is_err());
1331    }
1332
1333    #[test]
1334    fn negative_beta_rejected() {
1335        let g = Graph::with_vertices(3);
1336        let opts = LeidenOptions {
1337            beta: -0.1,
1338            ..LeidenOptions::default()
1339        };
1340        assert!(leiden_with_options(&g, None, &opts).is_err());
1341    }
1342}