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