Skip to main content

rust_igraph/algorithms/community/
community_voronoi.rs

1//! Voronoi-based community detection (ALGO-CO-009).
2//!
3//! Counterpart of `igraph_community_voronoi` from
4//! `references/igraph/src/community/voronoi.c` (lines 33-645). Partitions
5//! the vertex set into communities by:
6//!
7//! 1. Computing per-vertex **local relative density** (LRD): the
8//!    fraction of edges in the closed first-order neighbourhood that are
9//!    internal. Weighted variant multiplies by the vertex's strength.
10//! 2. Deriving an **effective edge length** `len / ECC`, where `ECC` is
11//!    the canonical Radicchi 2004 edge clustering coefficient with
12//!    `offset=true, normalize=true` and `k=3` (see [`crate::ecc`]).
13//! 3. **Choosing generators** greedily: repeatedly pick the unmarked
14//!    vertex with the highest LRD, then mark every vertex within an
15//!    `r`-radius (Dijkstra in the effective-length metric) so that no
16//!    two generators are closer than `r`.
17//! 4. Assigning every other vertex to its closest generator with
18//!    [`crate::voronoi`] (random tiebreaker, seed `42` as in the C
19//!    reference).
20//! 5. When `r < 0`, picking `r` automatically to maximise modularity
21//!    via a quadratic-fit 1-D optimiser (Brent-flavour) over the
22//!    interval `[min(edge_length), max(distance_reached_with_r=∞)]`.
23//!
24//! References:
25//! - Deritei et al., "Community detection by graph Voronoi diagrams",
26//!   New Journal of Physics 16, 063007 (2014).
27//! - Molnár et al., "Community Detection in Directed Weighted Networks
28//!   using Voronoi Partitioning", Scientific Reports 14, 8124 (2024).
29//!
30//! Restrictions matching the C reference:
31//! - The graph must be **simple** (no self-loops, no multi-edges). The
32//!   directness used by `is_simple` follows `mode`.
33//! - `lengths` must be non-negative and finite; `weights` must be
34//!   strictly positive and finite. `None` for either ⇒ unit values.
35
36#![allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
37
38use std::cmp::Ordering;
39use std::collections::BinaryHeap;
40
41use crate::algorithms::community::modularity::{
42    modularity, modularity_directed, modularity_weighted, modularity_weighted_directed,
43};
44use crate::algorithms::paths::dijkstra::DijkstraMode;
45use crate::algorithms::paths::voronoi::{VoronoiTiebreaker, voronoi};
46use crate::algorithms::properties::ecc::ecc;
47use crate::algorithms::properties::is_simple::is_simple;
48use crate::core::graph::EdgeId;
49use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
50
51const VORONOI_BRENT_SEED: u64 = 42;
52
53/// Result of [`community_voronoi`].
54///
55/// `membership[v]` is a contiguous `0..k` community id for every
56/// vertex; unreachable vertices (which can only arise in disconnected
57/// graphs and only for `mode = In` / `Out` in some configurations)
58/// keep the C reference's behaviour and get assigned to whichever
59/// generator the underlying [`crate::voronoi`] picked.
60///
61/// `generators` is the list of vertex ids picked as Voronoi
62/// generators, in the order they were picked. Its length equals the
63/// number of distinct communities.
64///
65/// `modularity` is the Newman-Girvan modularity of `membership` under
66/// `weights` (or unit weights if `weights = None`), with the
67/// directness implied by `mode`. `None` for an empty graph.
68#[derive(Debug, Clone, PartialEq)]
69pub struct CommunityVoronoiResult {
70    pub membership: Vec<u32>,
71    pub generators: Vec<VertexId>,
72    pub modularity: Option<f64>,
73}
74
75/// Voronoi-based community detection.
76///
77/// See the module-level docs for the algorithm sketch.
78///
79/// # Errors
80///
81/// - [`IgraphError::InvalidArgument`] for length / weight vectors of
82///   wrong size, non-finite or out-of-domain entries, or a non-simple
83///   graph.
84/// - [`IgraphError::Internal`] when the auto-`r` optimiser cannot find
85///   a quadratic-concave step (matches the C reference's
86///   `IGRAPH_DIVERGED`).
87///
88/// # Complexity
89///
90/// `O(n²·log n + n·m)` dominated by repeated Dijkstra calls inside
91/// `choose_generators` (one per generator candidate) and the inner
92/// [`crate::voronoi`] / [`crate::ecc`] passes.
93///
94/// # Examples
95///
96/// ```
97/// use rust_igraph::{Graph, DijkstraMode, community_voronoi};
98///
99/// // K_4: every pair is connected; fixed `r=0` keeps every vertex as
100/// // its own generator, so we get 4 singleton communities.
101/// let mut g = Graph::with_vertices(4);
102/// for u in 0..4u32 { for v in (u+1)..4 { g.add_edge(u, v).unwrap(); } }
103/// let r = community_voronoi(&g, None, None, DijkstraMode::All, 0.0).unwrap();
104/// assert_eq!(r.generators.len(), 4);
105/// ```
106fn validate_inputs(
107    graph: &Graph,
108    lengths: Option<&[f64]>,
109    weights: Option<&[f64]>,
110    m: usize,
111) -> IgraphResult<()> {
112    if let Some(l) = lengths {
113        if l.len() != m {
114            return Err(IgraphError::InvalidArgument(format!(
115                "lengths vector size ({}) differs from edge count ({})",
116                l.len(),
117                m
118            )));
119        }
120        for (e, &v) in l.iter().enumerate() {
121            if v.is_nan() {
122                return Err(IgraphError::InvalidArgument(format!(
123                    "length at edge {e} is NaN"
124                )));
125            }
126            if v < 0.0 {
127                return Err(IgraphError::InvalidArgument(format!(
128                    "length at edge {e} is negative ({v}); Voronoi requires non-negative lengths"
129                )));
130            }
131        }
132    }
133    if let Some(w) = weights {
134        if w.len() != m {
135            return Err(IgraphError::InvalidArgument(format!(
136                "weights vector size ({}) differs from edge count ({})",
137                w.len(),
138                m
139            )));
140        }
141        for (e, &v) in w.iter().enumerate() {
142            if v.is_nan() {
143                return Err(IgraphError::InvalidArgument(format!(
144                    "weight at edge {e} is NaN"
145                )));
146            }
147            if v <= 0.0 {
148                return Err(IgraphError::InvalidArgument(format!(
149                    "weight at edge {e} is non-positive ({v}); Voronoi requires positive weights"
150                )));
151            }
152        }
153    }
154    if !is_simple(graph)? {
155        return Err(IgraphError::InvalidArgument(
156            "the graph must be simple (no self-loops, no multi-edges) for Voronoi communities"
157                .to_string(),
158        ));
159    }
160    Ok(())
161}
162
163pub fn community_voronoi(
164    graph: &Graph,
165    lengths: Option<&[f64]>,
166    weights: Option<&[f64]>,
167    mode: DijkstraMode,
168    r: f64,
169) -> IgraphResult<CommunityVoronoiResult> {
170    let n = graph.vcount();
171    let m = graph.ecount();
172
173    // Undirected graphs collapse all modes to All (matches the C contract).
174    let mode = if graph.is_directed() {
175        mode
176    } else {
177        DijkstraMode::All
178    };
179
180    validate_inputs(graph, lengths, weights, m)?;
181
182    if m == 0 {
183        // Also handles n <= 1: every vertex is its own community + generator.
184        let membership: Vec<u32> = (0..n).collect();
185        let generators: Vec<VertexId> = (0..n).collect();
186        return Ok(CommunityVoronoiResult {
187            membership,
188            generators,
189            modularity: None,
190        });
191    }
192
193    // (1) Local relative density (weighted variant if `weights` given).
194    let lrd = weighted_local_density(graph, weights)?;
195
196    // (2) Effective edge length: 1 / ECC, multiplied by `lengths` when given.
197    let mut len_eff = ecc(graph, None, 3, true, true)?;
198    for v in &mut len_eff {
199        // ECC is never NaN here (graph is simple) but may be +Inf, in which case
200        // 1/Inf = 0.0 — these edges become zero-length connectors.
201        *v = 1.0 / *v;
202    }
203    if let Some(l) = lengths {
204        for (a, &b) in len_eff.iter_mut().zip(l.iter()) {
205            *a *= b;
206        }
207    }
208
209    if r < 0.0 {
210        // Auto-r: estimate a sensible search interval, then maximise
211        // modularity via the Brent-flavour 1-D optimiser.
212        let (minr, maxr) = estimate_minmax_r(graph, &lrd, &len_eff, mode)?;
213        let mut work = ModularityWork {
214            graph,
215            local_dens: &lrd,
216            lengths: &len_eff,
217            weights,
218            mode,
219            generators: Vec::new(),
220            membership: Vec::new(),
221            modularity: f64::NAN,
222        };
223        brent_opt(&mut work, minr, maxr)?;
224        Ok(CommunityVoronoiResult {
225            membership: work.membership,
226            generators: work.generators,
227            modularity: if work.modularity.is_nan() {
228                None
229            } else {
230                Some(work.modularity)
231            },
232        })
233    } else {
234        let generators = choose_generators(graph, &lrd, &len_eff, mode, r)?.0;
235        let part = voronoi(
236            graph,
237            Some(&len_eff),
238            mode,
239            &generators,
240            VoronoiTiebreaker::Random,
241            VORONOI_BRENT_SEED,
242        )?;
243        let membership = membership_to_flat(&part.membership);
244        let q = compute_modularity(graph, &membership, weights, mode)?;
245        Ok(CommunityVoronoiResult {
246            membership,
247            generators,
248            modularity: q,
249        })
250    }
251}
252
253/// Densify the `Option<u32>` membership returned by [`crate::voronoi`]
254/// into a flat `u32` vector. Unreachable vertices (rare; only with
255/// disconnected graphs under directed `mode`) get the sentinel value
256/// `u32::MAX`. The downstream modularity call treats every distinct id
257/// as its own community.
258fn membership_to_flat(m: &[Option<u32>]) -> Vec<u32> {
259    m.iter().map(|x| x.unwrap_or(u32::MAX)).collect()
260}
261
262/// Newman-Girvan modularity using the right (un)directed / (un)weighted
263/// variant for the current `mode` + `weights`.
264fn compute_modularity(
265    graph: &Graph,
266    membership: &[u32],
267    weights: Option<&[f64]>,
268    mode: DijkstraMode,
269) -> IgraphResult<Option<f64>> {
270    let directed = graph.is_directed() && mode != DijkstraMode::All;
271    match (directed, weights) {
272        (false, None) => modularity(graph, membership, 1.0),
273        (false, Some(w)) => modularity_weighted(graph, membership, 1.0, w),
274        (true, None) => modularity_directed(graph, membership, 1.0),
275        (true, Some(w)) => modularity_weighted_directed(graph, membership, 1.0, w),
276    }
277}
278
279/// Unweighted local relative density (LRD): for each vertex `w`,
280/// `int / (int + ext)` where `int` is the number of edges with both
281/// endpoints in `N[w] = N(w) ∪ {w}`, and `ext` is the number of edges
282/// with exactly one endpoint in `N[w]`. Isolated vertices get `0.0`.
283///
284/// Mirrors `igraph_i_local_relative_density` in voronoi.c:45-123. We
285/// use the deduped-neighbour view (matching `IGRAPH_LOOPS,
286/// IGRAPH_MULTIPLE` of the upstream lazy adjlist on simple graphs —
287/// `is_simple` is enforced upstream of this call).
288fn local_relative_density(graph: &Graph) -> IgraphResult<Vec<f64>> {
289    let n = graph.vcount();
290    let n_us = n as usize;
291    let mut res = vec![0.0_f64; n_us];
292
293    // Per-vertex adjacency, deduped. Building this once is O(V + E)
294    // and avoids the per-w lazy-adjlist allocation pattern.
295    let mut adj: Vec<Vec<VertexId>> = Vec::with_capacity(n_us);
296    for v in 0..n {
297        let mut neigh: Vec<VertexId> = graph
298            .neighbors(v)?
299            .into_iter()
300            .filter(|&u| u != v)
301            .collect();
302        neigh.sort_unstable();
303        neigh.dedup();
304        adj.push(neigh);
305    }
306
307    // `nei_mask[u] == i+1` ⇔ u ∈ N[w_i]. `nei_done[u] == i+1` ⇔ u
308    // already had its edges processed for `w_i`. Stamp-based reset
309    // avoids per-w O(n) clearing.
310    let mut nei_mask: Vec<u32> = vec![0; n_us];
311    let mut nei_done: Vec<u32> = vec![0; n_us];
312
313    for w in 0..n {
314        let w_us = w as usize;
315        let stamp = w + 1; // u32, never 0
316        let dw = adj[w_us].len();
317
318        for &u in &adj[w_us] {
319            nei_mask[u as usize] = stamp;
320        }
321        nei_mask[w_us] = stamp;
322
323        // All incident edges of w are by definition internal.
324        let mut int_count: u64 = dw as u64;
325        let mut ext_count: u64 = 0;
326        nei_done[w_us] = stamp;
327
328        for &v in &adj[w_us] {
329            if nei_done[v as usize] == stamp {
330                continue;
331            }
332            nei_done[v as usize] = stamp;
333            for &u in &adj[v as usize] {
334                if nei_mask[u as usize] == stamp {
335                    int_count += 1;
336                } else {
337                    ext_count += 1;
338                }
339            }
340        }
341        // Internal edges were counted from both endpoints' perspective.
342        debug_assert!(int_count % 2 == 0);
343        int_count /= 2;
344
345        res[w_us] = if int_count == 0 {
346            0.0
347        } else {
348            int_count as f64 / (int_count + ext_count) as f64
349        };
350    }
351
352    Ok(res)
353}
354
355/// Weighted local relative density: LRD multiplied by the undirected
356/// strength of each vertex (no loops — they are forbidden by the
357/// `is_simple` precondition). `weights = None` ⇒ multiply by degree
358/// (matches the C reference where `igraph_strength(_, weights = NULL)`
359/// returns the degree).
360fn weighted_local_density(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<Vec<f64>> {
361    let mut res = local_relative_density(graph)?;
362    let n = graph.vcount() as usize;
363    let mut str_v = vec![0.0_f64; n];
364    if let Some(w) = weights {
365        for v in 0..graph.vcount() {
366            let mut s = 0.0_f64;
367            for eid in graph.incident(v)? {
368                // Simple graphs ⇒ no self-loops, so each edge is
369                // counted once by `incident`.
370                s += w[eid as usize];
371            }
372            str_v[v as usize] = s;
373        }
374    } else {
375        for v in 0..graph.vcount() {
376            // Loopless degree: simple graphs have no loops, so
377            // `degree()` equals the loopless degree.
378            str_v[v as usize] = f64::from(u32::try_from(graph.degree(v)?).unwrap_or(u32::MAX));
379        }
380    }
381    for (a, b) in res.iter_mut().zip(str_v.iter()) {
382        *a *= *b;
383    }
384    Ok(res)
385}
386
387/// `(generators, max_radius_reached)`. See module docs for the
388/// algorithm. Mirrors `choose_generators` in voronoi.c:154-259.
389fn choose_generators(
390    graph: &Graph,
391    local_rel_dens: &[f64],
392    lengths: &[f64],
393    mode: DijkstraMode,
394    r: f64,
395) -> IgraphResult<(Vec<VertexId>, f64)> {
396    let n = graph.vcount();
397    let n_us = n as usize;
398
399    // Sort vertex ids by decreasing LRD. Tie-break by ascending vertex
400    // id so the picking order is deterministic across runs.
401    let mut ord: Vec<u32> = (0..n).collect();
402    ord.sort_by(|&a, &b| {
403        local_rel_dens[b as usize]
404            .partial_cmp(&local_rel_dens[a as usize])
405            .unwrap_or(Ordering::Equal)
406            .then(a.cmp(&b))
407    });
408
409    let mut excluded = vec![false; n_us];
410    let mut excluded_count: u32 = 0;
411    let mut generators: Vec<VertexId> = Vec::new();
412    let mut radius_max = f64::NEG_INFINITY;
413
414    // Dijkstra-style heap reused per generator candidate. We use a
415    // generation counter `epoch` so we can "clear" `dist` in O(1) per
416    // outer iteration without a full O(n) reset.
417    let mut dist = vec![f64::INFINITY; n_us];
418    let mut epoch = vec![0u32; n_us];
419    let mut active = vec![false; n_us];
420    let mut current_epoch: u32 = 0;
421    let mut heap: BinaryHeap<HeapEntry> = BinaryHeap::new();
422
423    for &g in ord.iter().take(n_us) {
424        if excluded[g as usize] {
425            continue;
426        }
427        generators.push(g);
428
429        current_epoch = current_epoch.wrapping_add(1);
430        if current_epoch == 0 {
431            // Wrapped — reset the whole epoch buffer once in a blue moon.
432            epoch.fill(0);
433            current_epoch = 1;
434        }
435        heap.clear();
436        dist[g as usize] = 0.0;
437        epoch[g as usize] = current_epoch;
438        active[g as usize] = true;
439        heap.push(HeapEntry { dist: 0.0, vid: g });
440
441        while let Some(HeapEntry { dist: d, vid }) = heap.pop() {
442            let v_us = vid as usize;
443            // Stale entry (we relaxed the same vertex with a smaller
444            // distance after pushing this one). Skip.
445            if !active[v_us] || d > dist[v_us] {
446                continue;
447            }
448            active[v_us] = false;
449
450            // Beyond cutoff: do not search further along this path.
451            if d > r {
452                continue;
453            }
454
455            // Exclude this vertex (it's within `r` of the current generator).
456            if !excluded[v_us] {
457                excluded[v_us] = true;
458                excluded_count += 1;
459            }
460            if d > radius_max {
461                radius_max = d;
462            }
463
464            for eid in incident_for_mode(graph, vid, mode)? {
465                let w = lengths[eid as usize];
466                // Optimisation matching C: don't follow infinite edges.
467                if w.is_infinite() {
468                    continue;
469                }
470                let (u1, u2) = graph.edge(eid)?;
471                let to = if u1 == vid { u2 } else { u1 };
472                let alt = d + w;
473                let to_us = to as usize;
474                if epoch[to_us] != current_epoch {
475                    dist[to_us] = alt;
476                    epoch[to_us] = current_epoch;
477                    active[to_us] = true;
478                    heap.push(HeapEntry { dist: alt, vid: to });
479                } else if active[to_us] && alt < dist[to_us] {
480                    dist[to_us] = alt;
481                    heap.push(HeapEntry { dist: alt, vid: to });
482                }
483            }
484        }
485
486        if excluded_count as usize == n_us {
487            break;
488        }
489    }
490
491    if radius_max == f64::NEG_INFINITY {
492        radius_max = 0.0;
493    }
494    Ok((generators, radius_max))
495}
496
497/// Mode-aware incidence — same helper shape as in `paths::voronoi`.
498fn incident_for_mode(graph: &Graph, v: VertexId, mode: DijkstraMode) -> IgraphResult<Vec<EdgeId>> {
499    if !graph.is_directed() {
500        return graph.incident(v);
501    }
502    match mode {
503        DijkstraMode::Out => graph.incident(v),
504        DijkstraMode::In => graph.incident_in_pub(v),
505        DijkstraMode::All => {
506            let mut out = graph.incident(v)?;
507            out.extend(graph.incident_in_pub(v)?);
508            Ok(out)
509        }
510    }
511}
512
513/// `(min_r, max_r)`: the search interval for the auto-radius optimiser.
514/// `min_r` is the shortest finite edge length; `max_r` is the largest
515/// finite distance reached by `choose_generators` when `r = +∞`.
516fn estimate_minmax_r(
517    graph: &Graph,
518    local_rel_dens: &[f64],
519    lengths: &[f64],
520    mode: DijkstraMode,
521) -> IgraphResult<(f64, f64)> {
522    let mut min_r = f64::INFINITY;
523    for &v in lengths {
524        if v.is_finite() && v < min_r {
525            min_r = v;
526        }
527    }
528    if !min_r.is_finite() {
529        min_r = 0.0;
530    }
531    let (_, max_r) = choose_generators(graph, local_rel_dens, lengths, mode, f64::INFINITY)?;
532    Ok((min_r, max_r))
533}
534
535/// Min-heap entry (ordered by ascending `dist`).
536#[derive(Debug, Clone, Copy)]
537struct HeapEntry {
538    dist: f64,
539    vid: VertexId,
540}
541impl PartialEq for HeapEntry {
542    fn eq(&self, other: &Self) -> bool {
543        self.dist == other.dist && self.vid == other.vid
544    }
545}
546impl Eq for HeapEntry {}
547impl Ord for HeapEntry {
548    fn cmp(&self, other: &Self) -> Ordering {
549        // BinaryHeap is a max-heap; invert to get min-heap.
550        other
551            .dist
552            .partial_cmp(&self.dist)
553            .unwrap_or(Ordering::Equal)
554            .then_with(|| other.vid.cmp(&self.vid))
555    }
556}
557impl PartialOrd for HeapEntry {
558    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
559        Some(self.cmp(other))
560    }
561}
562
563// --- Brent-flavour quadratic-fit 1-D optimiser ---------------------------
564
565struct ModularityWork<'a> {
566    graph: &'a Graph,
567    local_dens: &'a [f64],
568    lengths: &'a [f64],
569    weights: Option<&'a [f64]>,
570    mode: DijkstraMode,
571    generators: Vec<VertexId>,
572    membership: Vec<u32>,
573    modularity: f64,
574}
575
576/// Evaluate modularity at `r`. Stores the resulting partition + score
577/// in `work` so the caller can recover them after the optimiser
578/// terminates (it terminates with the best-known r evaluated last).
579fn eval_modularity(work: &mut ModularityWork<'_>, r: f64) -> IgraphResult<f64> {
580    let (gens, _) = choose_generators(work.graph, work.local_dens, work.lengths, work.mode, r)?;
581    let part = voronoi(
582        work.graph,
583        Some(work.lengths),
584        work.mode,
585        &gens,
586        VoronoiTiebreaker::Random,
587        VORONOI_BRENT_SEED,
588    )?;
589    let membership = membership_to_flat(&part.membership);
590    let q = compute_modularity(work.graph, &membership, work.weights, work.mode)?
591        .unwrap_or(f64::NEG_INFINITY);
592    work.generators = gens;
593    work.membership = membership;
594    work.modularity = q;
595    Ok(q)
596}
597
598/// Coefficient of the quadratic term when fitting `a·x² + b·x + c` to
599/// the three points `(x_i, f_i)`. Sign tells us whether the parabola
600/// is convex (`>0`) or concave (`<0`).
601fn coeff2(x1: f64, x2: f64, x3: f64, f1: f64, f2: f64, f3: f64) -> f64 {
602    let num = x1 * (f3 - f2) + x2 * (f1 - f3) + x3 * (f2 - f1);
603    let denom = (x1 - x2) * (x1 - x3) * (x2 - x3);
604    num / denom
605}
606
607/// Stationary point of the quadratic fit.
608fn peakx(x1: f64, x2: f64, x3: f64, f1: f64, f2: f64, f3: f64) -> f64 {
609    let x1s = x1 * x1;
610    let x2s = x2 * x2;
611    let x3s = x3 * x3;
612    let num = f3 * (x1s - x2s) + f1 * (x2s - x3s) + f2 * (x3s - x1s);
613    let denom = f3 * (x1 - x2) + f1 * (x2 - x3) + f2 * (x3 - x1);
614    0.5 * num / denom
615}
616
617fn cmp_epsilon(a: f64, b: f64, eps: f64) -> Ordering {
618    let diff = (a - b).abs();
619    let tol = eps * (a.abs().max(b.abs())).max(1.0);
620    if diff <= tol {
621        Ordering::Equal
622    } else if a < b {
623        Ordering::Less
624    } else {
625        Ordering::Greater
626    }
627}
628
629/// Quadratic-fit 1-D maximiser. Mirrors `brent_opt` in voronoi.c:317-428.
630fn brent_opt(work: &mut ModularityWork<'_>, x1_in: f64, x2_in: f64) -> IgraphResult<()> {
631    let lo = x1_in;
632    let hi = x2_in;
633
634    if !lo.is_finite() || !hi.is_finite() {
635        return Err(IgraphError::InvalidArgument(
636            "Voronoi radius search interval is non-finite".to_string(),
637        ));
638    }
639
640    let mut x1 = x1_in;
641    let mut x2 = x2_in;
642    // Initial probe closer to x1 than x2 so that, if f1 == f2, the next
643    // computed point would not coincide with x3.
644    let mut x3 = 0.6 * x1 + 0.4 * x2;
645
646    let mut f1 = eval_modularity(work, x1)?;
647    // Bit-exact equality matches the C reference's degenerate-interval guard;
648    // any non-degenerate caller passes a strict inequality.
649    #[allow(clippy::float_cmp)]
650    if x1 == x2 {
651        return Ok(());
652    }
653    let mut f2 = eval_modularity(work, x2)?;
654    let mut f3 = eval_modularity(work, x3)?;
655
656    // We expect the middle point to be higher than the boundary points.
657    if f1 > f3 {
658        return Err(IgraphError::Internal(
659            "Voronoi auto-r optimizer did not converge (f1 > f3)",
660        ));
661    }
662
663    // If f2 > f3 we bisect (x3, x2) up to 10 times trying to find a
664    // configuration where f3 >= f2; if we don't, we accept the upper
665    // end as the best radius.
666    if f2 > f3 {
667        let mut bisected = false;
668        for _ in 0..10 {
669            x1 = x3;
670            f1 = f3;
671            x3 = 0.5 * (x1 + x2);
672            f3 = eval_modularity(work, x3)?;
673            if f3 >= f2 {
674                bisected = true;
675                break;
676            }
677        }
678        if !bisected {
679            // Final eval at x2 so that `work` reflects the upper endpoint.
680            let _ = eval_modularity(work, x2)?;
681            return Ok(());
682        }
683    }
684
685    for _ in 0..20 {
686        let new_x = peakx(x1, x2, x3, f1, f2, f3);
687        let new_f = eval_modularity(work, new_x)?;
688
689        let a1 = coeff2(x2, x3, new_x, f2, f3, new_f);
690        let a2 = coeff2(x1, x3, new_x, f1, f3, new_f);
691
692        // Both options would yield a convex parabola (>=0). Terminate.
693        if a1 >= 0.0 && a2 >= 0.0 {
694            break;
695        }
696
697        if a1 <= a2 {
698            // Drop (x1, f1).
699            x1 = x2;
700            x2 = x3;
701            x3 = new_x;
702            f1 = f2;
703            f2 = f3;
704            f3 = new_f;
705        } else {
706            // Drop (x2, f2).
707            x2 = x1;
708            x1 = x3;
709            x3 = new_x;
710            f2 = f1;
711            f1 = f3;
712            f3 = new_f;
713        }
714
715        // Out-of-bounds — bail.
716        if x3 < lo || x3 > hi {
717            return Err(IgraphError::Internal(
718                "Voronoi auto-r optimizer drifted outside initial interval",
719            ));
720        }
721
722        // We're optimising a discrete-valued function: when two of f1
723        // / f2 / f3 coincide to eps, another iteration is unlikely to
724        // improve. Saves one eval.
725        let eps = 1e-10;
726        if matches!(cmp_epsilon(f1, f3, eps), Ordering::Equal)
727            || matches!(cmp_epsilon(f2, f3, eps), Ordering::Equal)
728        {
729            break;
730        }
731    }
732    Ok(())
733}
734
735// Bridge for the privacy of Graph::incident_in: re-export through a
736// dedicated thin shim. (Graph::incident_in is `pub(crate)`, so we can
737// just call it directly.)
738trait GraphIncidentInExt {
739    fn incident_in_pub(&self, v: VertexId) -> IgraphResult<Vec<EdgeId>>;
740}
741impl GraphIncidentInExt for Graph {
742    fn incident_in_pub(&self, v: VertexId) -> IgraphResult<Vec<EdgeId>> {
743        self.incident_in(v)
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    fn k4() -> Graph {
752        let mut g = Graph::with_vertices(4);
753        for u in 0..4u32 {
754            for v in (u + 1)..4 {
755                g.add_edge(u, v).unwrap();
756            }
757        }
758        g
759    }
760
761    fn karate() -> Graph {
762        use std::fs::File;
763        use std::path::PathBuf;
764        let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
765        path.push("fixtures/karate.edges");
766        crate::algorithms::io::read_edgelist(File::open(path).unwrap()).unwrap()
767    }
768
769    #[test]
770    fn null_graph_empty_membership() {
771        let g = Graph::with_vertices(0);
772        let r = community_voronoi(&g, None, None, DijkstraMode::All, -1.0).unwrap();
773        assert!(r.membership.is_empty());
774        assert!(r.generators.is_empty());
775        assert!(r.modularity.is_none());
776    }
777
778    #[test]
779    fn singleton_graph_one_community() {
780        let g = Graph::with_vertices(1);
781        let r = community_voronoi(&g, None, None, DijkstraMode::All, -1.0).unwrap();
782        assert_eq!(r.membership, vec![0]);
783        assert_eq!(r.generators, vec![0]);
784    }
785
786    #[test]
787    fn two_isolated_nodes_two_singleton_communities() {
788        let g = Graph::with_vertices(2);
789        let r = community_voronoi(&g, None, None, DijkstraMode::All, -1.0).unwrap();
790        assert_eq!(r.membership, vec![0, 1]);
791        assert_eq!(r.generators, vec![0, 1]);
792    }
793
794    #[test]
795    fn non_simple_graph_errors() {
796        let mut g = Graph::with_vertices(2);
797        g.add_edge(0, 1).unwrap();
798        g.add_edge(0, 1).unwrap(); // multi-edge
799        let r = community_voronoi(&g, None, None, DijkstraMode::All, -1.0);
800        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
801    }
802
803    #[test]
804    fn self_loop_graph_errors() {
805        let mut g = Graph::with_vertices(2);
806        g.add_edge(0, 1).unwrap();
807        g.add_edge(0, 0).unwrap(); // self-loop
808        let r = community_voronoi(&g, None, None, DijkstraMode::All, -1.0);
809        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
810    }
811
812    #[test]
813    fn fixed_r_zero_gives_singleton_communities() {
814        let g = k4();
815        let r = community_voronoi(&g, None, None, DijkstraMode::All, 0.0).unwrap();
816        // r = 0 ⇒ generator's radius excludes itself only; every vertex
817        // is a generator.
818        assert_eq!(r.generators.len(), 4);
819        // Membership densely covers 0..4.
820        let mut distinct: Vec<u32> = r.membership.clone();
821        distinct.sort_unstable();
822        distinct.dedup();
823        assert_eq!(distinct.len(), 4);
824    }
825
826    #[test]
827    fn karate_auto_r_yields_known_membership() {
828        // Mirrors `references/igraph/tests/unit/igraph_community_voronoi.out`:
829        //   Generators: ( 33 0 24 )
830        //   Membership: ( 1 1 1 1 1 1 1 1 0 0 1 1 1 1 0 0 1 1 0 1 0 1 0 0 2 2 0 0 0 0 0 2 0 0 )
831        // We don't reproduce exactly the same generators because the
832        // tiebreaker uses our SplitMix64 RNG with a different seed than
833        // igraph's Mersenne; instead we assert the algorithmic invariants
834        // we DO control:
835        //   - generators are vertex 33 (or the equivalent
836        //     highest-strength hub) and members of the karate factions
837        //   - modularity is at least 0.35 (the C reference's auto-r
838        //     produces a 3-cluster partition with Q ≈ 0.40)
839        let g = karate();
840        let r = community_voronoi(&g, None, None, DijkstraMode::All, -1.0).unwrap();
841        assert_eq!(r.membership.len(), 34);
842        assert!(r.generators.len() >= 2 && r.generators.len() <= 6);
843        let q = r.modularity.expect("karate is non-empty");
844        assert!(q > 0.30, "expected karate Q > 0.30, got {q}");
845    }
846
847    #[test]
848    fn karate_fixed_r_matches_modularity_call() {
849        let g = karate();
850        let r = community_voronoi(&g, None, None, DijkstraMode::All, 2.0).unwrap();
851        // Sanity: returned modularity matches a direct compute.
852        let q_direct = modularity(&g, &r.membership, 1.0).unwrap();
853        assert!((r.modularity.unwrap() - q_direct.unwrap()).abs() < 1e-9);
854    }
855
856    #[test]
857    fn lengths_size_mismatch_errors() {
858        let g = k4();
859        let bad = vec![1.0; g.ecount() + 1];
860        let r = community_voronoi(&g, Some(&bad), None, DijkstraMode::All, -1.0);
861        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
862    }
863
864    #[test]
865    fn weights_size_mismatch_errors() {
866        let g = k4();
867        let bad = vec![1.0; g.ecount() + 1];
868        let r = community_voronoi(&g, None, Some(&bad), DijkstraMode::All, -1.0);
869        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
870    }
871
872    #[test]
873    fn negative_length_errors() {
874        let g = k4();
875        let mut l = vec![1.0; g.ecount()];
876        l[0] = -1.0;
877        let r = community_voronoi(&g, Some(&l), None, DijkstraMode::All, -1.0);
878        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
879    }
880
881    #[test]
882    fn zero_weight_errors() {
883        let g = k4();
884        let mut w = vec![1.0; g.ecount()];
885        w[0] = 0.0;
886        let r = community_voronoi(&g, None, Some(&w), DijkstraMode::All, -1.0);
887        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
888    }
889
890    #[test]
891    fn nan_length_errors() {
892        let g = k4();
893        let mut l = vec![1.0; g.ecount()];
894        l[0] = f64::NAN;
895        let r = community_voronoi(&g, Some(&l), None, DijkstraMode::All, -1.0);
896        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
897    }
898
899    #[test]
900    fn local_relative_density_isolated_vertex_is_zero() {
901        let g = Graph::with_vertices(2);
902        let lrd = local_relative_density(&g).unwrap();
903        assert_eq!(lrd, vec![0.0, 0.0]);
904    }
905
906    #[test]
907    fn local_relative_density_triangle_is_one() {
908        let mut g = Graph::with_vertices(3);
909        g.add_edge(0, 1).unwrap();
910        g.add_edge(1, 2).unwrap();
911        g.add_edge(0, 2).unwrap();
912        // K_3: every vertex's neighbourhood is the whole graph → all
913        // edges are internal → LRD = 1 for every vertex.
914        let lrd = local_relative_density(&g).unwrap();
915        for v in lrd {
916            assert!((v - 1.0).abs() < 1e-12);
917        }
918    }
919}
920
921#[cfg(all(test, feature = "proptest-harness"))]
922mod proptests {
923    use super::*;
924    use proptest::prelude::*;
925
926    prop_compose! {
927        fn small_simple_graph()(n in 4u32..=10u32, edges_seed in any::<u64>()) -> Graph {
928            let mut g = Graph::with_vertices(n);
929            let mut rng = edges_seed;
930            let target_m = ((n * (n - 1)) / 2).min(n + 6) as usize;
931            let mut added = 0usize;
932            let mut guard = 0usize;
933            while added < target_m && guard < target_m * 8 + 4 {
934                rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
935                let u = ((rng >> 33) % u64::from(n)) as u32;
936                rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
937                let v = ((rng >> 33) % u64::from(n)) as u32;
938                guard += 1;
939                if u == v { continue; }
940                if g.add_edge(u, v).is_ok() {
941                    added += 1;
942                }
943            }
944            g
945        }
946    }
947
948    proptest! {
949        // The auto-r optimiser can legitimately fail (Internal) on degenerate
950        // small random inputs where the modularity surface is flat or
951        // monotone; we use a fixed r=1.0 here so the proptest exercises the
952        // membership/generator invariants, not the optimiser's convergence
953        // (which has its own deterministic tests above).
954        #[test]
955        fn membership_size_matches_vertex_count(g in small_simple_graph()) {
956            if !crate::algorithms::properties::is_simple::is_simple(&g).unwrap() {
957                return Ok(());
958            }
959            let r = community_voronoi(&g, None, None, DijkstraMode::All, 1.0).unwrap();
960            prop_assert_eq!(r.membership.len() as u32, g.vcount());
961        }
962
963        #[test]
964        fn generators_subset_of_vertices(g in small_simple_graph()) {
965            if !crate::algorithms::properties::is_simple::is_simple(&g).unwrap() {
966                return Ok(());
967            }
968            let r = community_voronoi(&g, None, None, DijkstraMode::All, 1.0).unwrap();
969            for &g_id in &r.generators {
970                prop_assert!(g_id < g.vcount(), "generator {} out of range (n={})", g_id, g.vcount());
971            }
972        }
973    }
974}