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