Skip to main content

rust_igraph/algorithms/paths/
voronoi.rs

1//! Voronoi graph partitioning (ALGO-SP-007).
2//!
3//! Counterpart of `igraph_voronoi` from `references/igraph/src/paths/voronoi.c`
4//! (lines 359-418, plus the BFS / Dijkstra inner helpers at lines 30-309).
5//!
6//! Given a set of *generator* vertices, assign every other vertex to the
7//! generator from which (or to which, in directed graphs with mode
8//! [`DijkstraMode::In`]) the shortest-path distance is smallest. Ties at
9//! equal distance are resolved by a caller-supplied [`VoronoiTiebreaker`]
10//! rule:
11//!
12//! - [`VoronoiTiebreaker::First`] keeps the earliest generator (in input
13//!   order) that reached the vertex.
14//! - [`VoronoiTiebreaker::Last`] always overwrites with the latest
15//!   generator.
16//! - [`VoronoiTiebreaker::Random`] picks one of the equidistant
17//!   generators uniformly at random; the choice is seeded by `seed`.
18//!
19//! Vertices not reachable from any generator are recorded as
20//! [`Option::None`] in `membership` and as [`f64::INFINITY`] in
21//! `distances`.
22//!
23//! The unweighted (BFS) and weighted (Dijkstra) inner loops share the
24//! same `mindist`-aware early-exit: when expanding from generator `g`, a
25//! vertex whose current `mindist` is already smaller than the distance
26//! it was reached at via `g` is skipped entirely — its subtree under
27//! `g`'s shortest-path tree is reachable cheaper from another generator,
28//! so we don't need to explore it. This is what gives the time
29//! complexity in the file-level docs at the bottom of this module.
30
31#![allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
32
33use std::cmp::Ordering;
34use std::collections::{BinaryHeap, VecDeque};
35
36use crate::algorithms::paths::dijkstra::DijkstraMode;
37use crate::core::graph::EdgeId;
38use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
39
40/// Tie-breaking rule used when two or more generators reach a vertex at
41/// the same distance.
42///
43/// Mirrors `igraph_voronoi_tiebreaker_t` from
44/// `references/igraph/include/igraph_paths.h`.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum VoronoiTiebreaker {
47    /// Keep the earliest generator (in input order) that reached the
48    /// vertex. Counterpart of `IGRAPH_VORONOI_FIRST`.
49    First,
50    /// Always overwrite with the latest generator. Counterpart of
51    /// `IGRAPH_VORONOI_LAST`.
52    Last,
53    /// Pick one of the equidistant generators uniformly at random.
54    /// Counterpart of `IGRAPH_VORONOI_RANDOM`. Uses a seeded
55    /// `SplitMix64` so results are reproducible per `seed` value.
56    ///
57    /// Note: as in the C reference, [`VoronoiTiebreaker::Random`] does
58    /// not guarantee that the resulting partitions are connected
59    /// subgraphs (a tie-breaker chain can leave a vertex behind in a
60    /// disconnected island of its assigned partition).
61    Random,
62}
63
64/// Result of [`voronoi`]: the Voronoi cell each vertex belongs to plus
65/// the distance to its assigned generator.
66///
67/// `membership[v]` is the *index into `generators`* of the generator
68/// vertex that `v` was assigned to (so `generators[membership[v]
69/// .unwrap()]` is the vertex id of that generator). `None` means `v` is
70/// unreachable from every generator. `distances[v]` is the shortest-path
71/// distance from `v` to its generator (under the given `mode`), or
72/// [`f64::INFINITY`] if unreachable.
73#[derive(Debug, Clone, PartialEq)]
74pub struct VoronoiPartition {
75    /// Per-vertex generator index; `None` for unreachable vertices.
76    pub membership: Vec<Option<u32>>,
77    /// Per-vertex distance to assigned generator; [`f64::INFINITY`] if
78    /// unreachable. Unweighted graphs round to integer values.
79    pub distances: Vec<f64>,
80}
81
82/// Minimal `SplitMix64` PRNG used by [`VoronoiTiebreaker::Random`].
83/// Identical to the helper inside `paths::random_walk` — kept private so
84/// each AWU's RNG choice is local.
85struct SplitMix64(u64);
86
87impl SplitMix64 {
88    fn new(seed: u64) -> Self {
89        Self(seed)
90    }
91    fn next_u64(&mut self) -> u64 {
92        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
93        let mut z = self.0;
94        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
95        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
96        z ^ (z >> 31)
97    }
98    /// Uniform float in `[0, 1)`. Uses 53 mantissa bits.
99    fn gen_unit(&mut self) -> f64 {
100        let bits = self.next_u64() >> 11;
101        #[allow(clippy::cast_precision_loss)]
102        let numerator = bits as f64;
103        numerator * (1.0_f64 / 9_007_199_254_740_992.0_f64)
104    }
105}
106
107/// Mode-aware incident edges (mirrors the helper in `dijkstra.rs`).
108fn incident_for_mode(graph: &Graph, v: VertexId, mode: DijkstraMode) -> IgraphResult<Vec<EdgeId>> {
109    if !graph.is_directed() {
110        return graph.incident(v);
111    }
112    match mode {
113        DijkstraMode::Out => graph.incident(v),
114        DijkstraMode::In => graph.incident_in(v),
115        DijkstraMode::All => {
116            let mut out = graph.incident(v)?;
117            out.extend(graph.incident_in(v)?);
118            Ok(out)
119        }
120    }
121}
122
123/// Validate `weights`: length matches `graph.ecount()`, no NaN, no
124/// negative values. [`f64::INFINITY`] is allowed and treated as a
125/// non-edge during relaxation (as in the C reference).
126fn validate_weights(graph: &Graph, weights: &[f64]) -> IgraphResult<()> {
127    let m = graph.ecount();
128    if weights.len() != m {
129        return Err(IgraphError::InvalidArgument(format!(
130            "weights vector size ({}) differs from edge count ({})",
131            weights.len(),
132            m
133        )));
134    }
135    for (e, &w) in weights.iter().enumerate() {
136        if w.is_nan() {
137            return Err(IgraphError::InvalidArgument(format!(
138                "weight at edge {e} is NaN"
139            )));
140        }
141        if w < 0.0 {
142            return Err(IgraphError::InvalidArgument(format!(
143                "weight at edge {e} is negative ({w}); Voronoi requires non-negative weights"
144            )));
145        }
146    }
147    Ok(())
148}
149
150/// Validate that every generator vertex id is in `0..vcount()` and that
151/// `generators` is non-empty when `vcount > 0`. Duplicates are allowed
152/// (and behave as expected — the last/first/random one wins per the
153/// tiebreaker rule).
154fn validate_generators(graph: &Graph, generators: &[VertexId]) -> IgraphResult<()> {
155    let n = graph.vcount();
156    for (i, &g) in generators.iter().enumerate() {
157        if g >= n {
158            return Err(IgraphError::InvalidArgument(format!(
159                "generator at index {i} is vertex {g}, out of range (vcount = {n})"
160            )));
161        }
162    }
163    Ok(())
164}
165
166/// Apply the tiebreaker rule when generator `i` reached vertex `v` at
167/// the same distance as a previously assigned generator. `tie_count[v]`
168/// counts how many equidistant generators have been seen so far
169/// (including this one).
170fn apply_tiebreaker(
171    tiebreaker: VoronoiTiebreaker,
172    v_us: usize,
173    i: u32,
174    membership: &mut [Option<u32>],
175    tie_count: &mut [u32],
176    rng: &mut SplitMix64,
177) {
178    match tiebreaker {
179        VoronoiTiebreaker::First => { /* never replace */ }
180        VoronoiTiebreaker::Last => membership[v_us] = Some(i),
181        VoronoiTiebreaker::Random => {
182            tie_count[v_us] = tie_count[v_us].saturating_add(1);
183            // Replace with probability 1/k upon seeing the k-th
184            // equidistant generator; this is the reservoir-sampling
185            // identity for a single slot.
186            let k = tie_count[v_us];
187            if rng.gen_unit() < 1.0_f64 / f64::from(k) {
188                membership[v_us] = Some(i);
189            }
190        }
191    }
192}
193
194/// BFS-multi-source Voronoi for unweighted graphs.
195fn voronoi_bfs(
196    graph: &Graph,
197    generators: &[VertexId],
198    mode: DijkstraMode,
199    tiebreaker: VoronoiTiebreaker,
200    seed: u64,
201) -> IgraphResult<VoronoiPartition> {
202    let n = graph.vcount();
203    let n_us = n as usize;
204
205    let mut membership: Vec<Option<u32>> = vec![None; n_us];
206    let mut distances: Vec<f64> = vec![f64::INFINITY; n_us];
207    let mut tie_count: Vec<u32> = vec![0; n_us];
208    let mut already_counted: Vec<u32> = vec![0; n_us];
209    let mut q: VecDeque<(VertexId, u32)> = VecDeque::new();
210    let mut rng = SplitMix64::new(seed);
211
212    for (i, &g) in generators.iter().enumerate() {
213        let i_u32 = u32::try_from(i).map_err(|_| {
214            IgraphError::InvalidArgument(format!("too many generators (overflow at index {i})"))
215        })?;
216        let stamp = i_u32.checked_add(1).ok_or_else(|| {
217            IgraphError::InvalidArgument(format!("too many generators (overflow at index {i_u32})"))
218        })?;
219
220        q.clear();
221        already_counted[g as usize] = stamp;
222        q.push_back((g, 0));
223
224        while let Some((vid, dist)) = q.pop_front() {
225            let v_us = vid as usize;
226            let md = distances[v_us];
227            let dist_f = f64::from(dist);
228
229            if dist_f > md {
230                // This subtree is already reached cheaper from another
231                // generator; skip its expansion entirely.
232                continue;
233            }
234            if dist_f < md {
235                distances[v_us] = dist_f;
236                membership[v_us] = Some(i_u32);
237                if matches!(tiebreaker, VoronoiTiebreaker::Random) {
238                    tie_count[v_us] = 1;
239                }
240            } else {
241                apply_tiebreaker(
242                    tiebreaker,
243                    v_us,
244                    i_u32,
245                    &mut membership,
246                    &mut tie_count,
247                    &mut rng,
248                );
249            }
250
251            for eid in incident_for_mode(graph, vid, mode)? {
252                let nei = graph.edge_other(eid as EdgeId, vid)?;
253                if already_counted[nei as usize] == stamp {
254                    continue;
255                }
256                already_counted[nei as usize] = stamp;
257                let next_dist = dist.checked_add(1).ok_or(IgraphError::Internal(
258                    "voronoi BFS distance overflow (graph diameter exceeds u32::MAX)",
259                ))?;
260                q.push_back((nei, next_dist));
261            }
262        }
263    }
264
265    Ok(VoronoiPartition {
266        membership,
267        distances,
268    })
269}
270
271/// Min-heap entry. `Ord` reversed so `BinaryHeap` (max-heap) pops the
272/// smallest distance first. NaN distances are forbidden by validation.
273#[derive(Copy, Clone)]
274struct Frontier(f64, VertexId);
275
276impl PartialEq for Frontier {
277    fn eq(&self, other: &Self) -> bool {
278        self.0 == other.0 && self.1 == other.1
279    }
280}
281impl Eq for Frontier {}
282impl Ord for Frontier {
283    fn cmp(&self, other: &Self) -> Ordering {
284        other.0.total_cmp(&self.0).then(other.1.cmp(&self.1))
285    }
286}
287impl PartialOrd for Frontier {
288    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
289        Some(self.cmp(other))
290    }
291}
292
293/// Dijkstra-multi-source Voronoi for weighted graphs.
294fn voronoi_dijkstra(
295    graph: &Graph,
296    weights: &[f64],
297    generators: &[VertexId],
298    mode: DijkstraMode,
299    tiebreaker: VoronoiTiebreaker,
300    seed: u64,
301) -> IgraphResult<VoronoiPartition> {
302    let n = graph.vcount();
303    let n_us = n as usize;
304
305    let mut membership: Vec<Option<u32>> = vec![None; n_us];
306    let mut distances: Vec<f64> = vec![f64::INFINITY; n_us];
307    let mut tie_count: Vec<u32> = vec![0; n_us];
308    let mut rng = SplitMix64::new(seed);
309
310    // Per-generator best-known distance reaching each vertex (the cost
311    // of the current Dijkstra source). Replaces the C 2wheap's
312    // `has_elem` / `get` / `has_active` machinery: we track our own
313    // tentative distances and skip a relax that doesn't improve.
314    let mut tentative: Vec<f64> = vec![f64::INFINITY; n_us];
315
316    for (i, &g) in generators.iter().enumerate() {
317        let i_u32 = u32::try_from(i).map_err(|_| {
318            IgraphError::InvalidArgument(format!("too many generators (overflow at index {i})"))
319        })?;
320
321        // Reset tentative for this generator: only the vertices we
322        // touched last time need clearing, but clearing all is O(n) per
323        // generator vs O(visited) per generator — for simplicity (and
324        // because the visited set can equal n in worst case) we clear
325        // the whole vector. Same asymptotic as the C 2wheap reset.
326        tentative.fill(f64::INFINITY);
327        let mut heap: BinaryHeap<Frontier> = BinaryHeap::new();
328        tentative[g as usize] = 0.0;
329        heap.push(Frontier(0.0, g));
330
331        while let Some(Frontier(dist, vid)) = heap.pop() {
332            let v_us = vid as usize;
333            // Stale heap entry (a smaller distance was already settled).
334            if dist > tentative[v_us] {
335                continue;
336            }
337            let md = distances[v_us];
338
339            // Use a small tolerance to match the C `igraph_cmp_epsilon`
340            // behavior for tie detection — without it, floating-point
341            // accumulation could classify "equal" distances as strictly
342            // less / greater.
343            let cmp = cmp_epsilon(dist, md, EPSILON);
344            if cmp == Ordering::Greater {
345                // No improvement from this generator; skip subtree.
346                continue;
347            }
348            if cmp == Ordering::Less {
349                distances[v_us] = dist;
350                membership[v_us] = Some(i_u32);
351                if matches!(tiebreaker, VoronoiTiebreaker::Random) {
352                    tie_count[v_us] = 1;
353                }
354            } else {
355                apply_tiebreaker(
356                    tiebreaker,
357                    v_us,
358                    i_u32,
359                    &mut membership,
360                    &mut tie_count,
361                    &mut rng,
362                );
363            }
364
365            for eid in incident_for_mode(graph, vid, mode)? {
366                let w = weights[eid as usize];
367                // Match the C optimization: skip infinite-weight edges.
368                if !w.is_finite() {
369                    continue;
370                }
371                let nd = dist + w;
372                let other = graph.edge_other(eid as EdgeId, vid)?;
373                let other_us = other as usize;
374                if nd < tentative[other_us] {
375                    tentative[other_us] = nd;
376                    heap.push(Frontier(nd, other));
377                }
378            }
379        }
380    }
381
382    Ok(VoronoiPartition {
383        membership,
384        distances,
385    })
386}
387
388const EPSILON: f64 = 1e-10;
389
390fn cmp_epsilon(a: f64, b: f64, eps: f64) -> Ordering {
391    let diff = a - b;
392    let abs_a = a.abs();
393    let abs_b = b.abs();
394    let denom = if abs_a > abs_b { abs_a } else { abs_b };
395    if denom == 0.0 || denom.is_infinite() {
396        // Both zero (denom == 0), or both/either infinite: fall back to
397        // total ordering. `total_cmp` matches `Ordering::Equal` only when
398        // bit-identical, which is the C reference behavior here.
399        a.total_cmp(&b)
400    } else if diff.abs() <= eps * denom {
401        Ordering::Equal
402    } else if diff < 0.0 {
403        Ordering::Less
404    } else {
405        Ordering::Greater
406    }
407}
408
409/// Voronoi partitioning of a graph from a set of generator vertices.
410///
411/// Each vertex is assigned to the generator at the smallest distance
412/// (under `mode`'s direction semantics in directed graphs). Tied
413/// distances are resolved by `tiebreaker`. Vertices unreachable from
414/// every generator have `membership[v] == None` and
415/// `distances[v] == f64::INFINITY`.
416///
417/// `weights == None` uses BFS (every edge counts as length 1); otherwise
418/// uses Dijkstra. All weights must be non-negative and not NaN; infinite
419/// weights are allowed and treated as non-edges.
420///
421/// `seed` only affects [`VoronoiTiebreaker::Random`] — [`VoronoiTiebreaker::First`]
422/// and [`VoronoiTiebreaker::Last`] are deterministic regardless of seed.
423///
424/// # Examples
425///
426/// ```
427/// use rust_igraph::{Graph, voronoi, VoronoiTiebreaker, DijkstraMode};
428///
429/// // Path 0-1-2-3-4, generators {0, 4}. The split is "halfway", with
430/// // vertex 2 equidistant (d=2 from both). With `First`, vertex 2 goes
431/// // to generator index 0 (vertex 0); with `Last`, to index 1 (vertex 4).
432/// let mut g = Graph::with_vertices(5);
433/// g.add_edge(0, 1).unwrap();
434/// g.add_edge(1, 2).unwrap();
435/// g.add_edge(2, 3).unwrap();
436/// g.add_edge(3, 4).unwrap();
437///
438/// let part = voronoi(&g, None, DijkstraMode::Out, &[0, 4], VoronoiTiebreaker::First, 0).unwrap();
439/// assert_eq!(part.membership, vec![Some(0), Some(0), Some(0), Some(1), Some(1)]);
440/// assert_eq!(part.distances,  vec![0.0,     1.0,     2.0,     1.0,     0.0]);
441///
442/// let part = voronoi(&g, None, DijkstraMode::Out, &[0, 4], VoronoiTiebreaker::Last, 0).unwrap();
443/// assert_eq!(part.membership, vec![Some(0), Some(0), Some(1), Some(1), Some(1)]);
444/// ```
445///
446/// # Errors
447/// - [`IgraphError::InvalidArgument`] if `weights.len() != graph.ecount()`,
448///   any weight is NaN or negative, or any generator index is out of
449///   range.
450/// - [`IgraphError::Internal`] on graph-diameter overflow (unweighted
451///   only — would require > 2^32 - 1 BFS layers).
452///
453/// # Complexity
454///
455/// `O(|S| · (|V| + |E|))` for unweighted graphs; `O(|S| · (|V| + |E|) ·
456/// log |V|)` for weighted graphs, where `|S|` is the number of
457/// generators.
458pub fn voronoi(
459    graph: &Graph,
460    weights: Option<&[f64]>,
461    mode: DijkstraMode,
462    generators: &[VertexId],
463    tiebreaker: VoronoiTiebreaker,
464    seed: u64,
465) -> IgraphResult<VoronoiPartition> {
466    let n = graph.vcount();
467    // Normalize mode: undirected graphs collapse every mode to All
468    // (matches the upstream contract).
469    let mode = if graph.is_directed() {
470        mode
471    } else {
472        DijkstraMode::All
473    };
474
475    validate_generators(graph, generators)?;
476    if let Some(w) = weights {
477        validate_weights(graph, w)?;
478    }
479
480    if n == 0 {
481        return Ok(VoronoiPartition {
482            membership: Vec::new(),
483            distances: Vec::new(),
484        });
485    }
486
487    if generators.is_empty() {
488        // No generators: every vertex is unreachable.
489        return Ok(VoronoiPartition {
490            membership: vec![None; n as usize],
491            distances: vec![f64::INFINITY; n as usize],
492        });
493    }
494
495    match weights {
496        None => voronoi_bfs(graph, generators, mode, tiebreaker, seed),
497        Some(w) => voronoi_dijkstra(graph, w, generators, mode, tiebreaker, seed),
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    fn path_n(n: u32) -> Graph {
506        let mut g = Graph::with_vertices(n);
507        for i in 0..n.saturating_sub(1) {
508            g.add_edge(i, i + 1).unwrap();
509        }
510        g
511    }
512
513    #[test]
514    fn empty_graph_returns_empty() {
515        let g = Graph::with_vertices(0);
516        let part = voronoi(
517            &g,
518            None,
519            DijkstraMode::Out,
520            &[],
521            VoronoiTiebreaker::First,
522            0,
523        )
524        .unwrap();
525        assert!(part.membership.is_empty());
526        assert!(part.distances.is_empty());
527    }
528
529    #[test]
530    fn no_generators_all_unreachable() {
531        let g = path_n(4);
532        let part = voronoi(
533            &g,
534            None,
535            DijkstraMode::Out,
536            &[],
537            VoronoiTiebreaker::First,
538            0,
539        )
540        .unwrap();
541        assert_eq!(part.membership, vec![None, None, None, None]);
542        assert!(part.distances.iter().all(|d| d.is_infinite()));
543    }
544
545    #[test]
546    fn single_generator_covers_connected_component() {
547        let g = path_n(5);
548        let part = voronoi(
549            &g,
550            None,
551            DijkstraMode::Out,
552            &[0],
553            VoronoiTiebreaker::First,
554            0,
555        )
556        .unwrap();
557        assert_eq!(
558            part.membership,
559            vec![Some(0), Some(0), Some(0), Some(0), Some(0)]
560        );
561        assert_eq!(part.distances, vec![0.0, 1.0, 2.0, 3.0, 4.0]);
562    }
563
564    #[test]
565    fn path_two_generators_first_keeps_tied_vertex_left() {
566        // Path 0-1-2-3-4; generators {0, 4}. Vertex 2 is equidistant
567        // (d=2). With First, the earlier generator (index 0) keeps it.
568        let g = path_n(5);
569        let part = voronoi(
570            &g,
571            None,
572            DijkstraMode::Out,
573            &[0, 4],
574            VoronoiTiebreaker::First,
575            0,
576        )
577        .unwrap();
578        assert_eq!(
579            part.membership,
580            vec![Some(0), Some(0), Some(0), Some(1), Some(1)]
581        );
582        assert_eq!(part.distances, vec![0.0, 1.0, 2.0, 1.0, 0.0]);
583    }
584
585    #[test]
586    fn path_two_generators_last_takes_tied_vertex() {
587        let g = path_n(5);
588        let part = voronoi(
589            &g,
590            None,
591            DijkstraMode::Out,
592            &[0, 4],
593            VoronoiTiebreaker::Last,
594            0,
595        )
596        .unwrap();
597        assert_eq!(
598            part.membership,
599            vec![Some(0), Some(0), Some(1), Some(1), Some(1)]
600        );
601    }
602
603    #[test]
604    fn random_tiebreaker_is_deterministic_for_fixed_seed() {
605        let g = path_n(5);
606        let part1 = voronoi(
607            &g,
608            None,
609            DijkstraMode::Out,
610            &[0, 4],
611            VoronoiTiebreaker::Random,
612            42,
613        )
614        .unwrap();
615        let part2 = voronoi(
616            &g,
617            None,
618            DijkstraMode::Out,
619            &[0, 4],
620            VoronoiTiebreaker::Random,
621            42,
622        )
623        .unwrap();
624        assert_eq!(part1, part2);
625    }
626
627    #[test]
628    fn disconnected_components_unreachable_get_none() {
629        // Two disjoint paths: 0-1-2 and 3-4-5. Generator only in left.
630        let mut g = Graph::with_vertices(6);
631        g.add_edge(0, 1).unwrap();
632        g.add_edge(1, 2).unwrap();
633        g.add_edge(3, 4).unwrap();
634        g.add_edge(4, 5).unwrap();
635        let part = voronoi(
636            &g,
637            None,
638            DijkstraMode::Out,
639            &[0],
640            VoronoiTiebreaker::First,
641            0,
642        )
643        .unwrap();
644        assert_eq!(
645            part.membership,
646            vec![Some(0), Some(0), Some(0), None, None, None]
647        );
648        assert!(part.distances[3].is_infinite());
649        assert!(part.distances[4].is_infinite());
650        assert!(part.distances[5].is_infinite());
651    }
652
653    #[test]
654    fn weighted_dijkstra_assigns_closest() {
655        // Triangle 0-1-2, weights 1-2-10. Generators {0, 2}.
656        // 0→1: direct weight 1, via 0→2→1 = 10+2 = 12. So vertex 1 goes to 0 (d=1).
657        // From generator 2: 2→1 weight 2 < 12; 2→0 weight 10 (direct) but 2→1→0 = 2+1 = 3 < 10.
658        // So d(2→0) = 3 > d(0→0) = 0, vertex 0 stays at generator 0.
659        let mut g = Graph::with_vertices(3);
660        g.add_edge(0, 1).unwrap(); // edge 0
661        g.add_edge(1, 2).unwrap(); // edge 1
662        g.add_edge(0, 2).unwrap(); // edge 2
663        let weights = [1.0, 2.0, 10.0];
664        let part = voronoi(
665            &g,
666            Some(&weights),
667            DijkstraMode::Out,
668            &[0, 2],
669            VoronoiTiebreaker::First,
670            0,
671        )
672        .unwrap();
673        assert_eq!(part.membership, vec![Some(0), Some(0), Some(1)]);
674        assert_eq!(part.distances, vec![0.0, 1.0, 0.0]);
675    }
676
677    #[test]
678    fn weighted_validates_negative_weight() {
679        let g = path_n(3);
680        let weights = [-1.0, 1.0];
681        let err = voronoi(
682            &g,
683            Some(&weights),
684            DijkstraMode::Out,
685            &[0],
686            VoronoiTiebreaker::First,
687            0,
688        )
689        .unwrap_err();
690        match err {
691            IgraphError::InvalidArgument(_) => (),
692            other => panic!("expected InvalidArgument, got {other:?}"),
693        }
694    }
695
696    #[test]
697    fn weighted_validates_nan_weight() {
698        let g = path_n(3);
699        let weights = [f64::NAN, 1.0];
700        let err = voronoi(
701            &g,
702            Some(&weights),
703            DijkstraMode::Out,
704            &[0],
705            VoronoiTiebreaker::First,
706            0,
707        )
708        .unwrap_err();
709        match err {
710            IgraphError::InvalidArgument(_) => (),
711            other => panic!("expected InvalidArgument, got {other:?}"),
712        }
713    }
714
715    #[test]
716    fn weighted_rejects_wrong_length_weight() {
717        let g = path_n(3);
718        let weights = [1.0, 1.0, 1.0]; // path has only 2 edges
719        let err = voronoi(
720            &g,
721            Some(&weights),
722            DijkstraMode::Out,
723            &[0],
724            VoronoiTiebreaker::First,
725            0,
726        )
727        .unwrap_err();
728        match err {
729            IgraphError::InvalidArgument(_) => (),
730            other => panic!("expected InvalidArgument, got {other:?}"),
731        }
732    }
733
734    #[test]
735    fn generator_out_of_range_errors() {
736        let g = path_n(3);
737        let err = voronoi(
738            &g,
739            None,
740            DijkstraMode::Out,
741            &[0, 5],
742            VoronoiTiebreaker::First,
743            0,
744        )
745        .unwrap_err();
746        match err {
747            IgraphError::InvalidArgument(_) => (),
748            other => panic!("expected InvalidArgument, got {other:?}"),
749        }
750    }
751
752    #[test]
753    fn all_vertices_as_generators_each_is_its_own_cell() {
754        let g = path_n(4);
755        let gens: Vec<VertexId> = (0..4).collect();
756        let part = voronoi(
757            &g,
758            None,
759            DijkstraMode::Out,
760            &gens,
761            VoronoiTiebreaker::First,
762            0,
763        )
764        .unwrap();
765        assert_eq!(part.membership, vec![Some(0), Some(1), Some(2), Some(3)]);
766        assert_eq!(part.distances, vec![0.0, 0.0, 0.0, 0.0]);
767    }
768
769    #[test]
770    fn directed_mode_out_vs_in() {
771        // 0 -> 1 -> 2; generators {2}.
772        // Out mode: from 2 we follow out-edges → only 2 reachable.
773        // In mode: from 2 we follow in-edges (towards 2) → 0,1,2 all reach 2.
774        let mut g = Graph::new(3, true).unwrap();
775        g.add_edge(0, 1).unwrap();
776        g.add_edge(1, 2).unwrap();
777        let part_out = voronoi(
778            &g,
779            None,
780            DijkstraMode::Out,
781            &[2],
782            VoronoiTiebreaker::First,
783            0,
784        )
785        .unwrap();
786        assert_eq!(part_out.membership, vec![None, None, Some(0)]);
787        let part_in = voronoi(
788            &g,
789            None,
790            DijkstraMode::In,
791            &[2],
792            VoronoiTiebreaker::First,
793            0,
794        )
795        .unwrap();
796        assert_eq!(part_in.membership, vec![Some(0), Some(0), Some(0)]);
797        assert_eq!(part_in.distances, vec![2.0, 1.0, 0.0]);
798    }
799
800    #[test]
801    fn weighted_infinite_edge_skipped() {
802        // Triangle 0-1, 1-2, 0-2 with weight(0-2) = +Inf.
803        // Generator {0}: 0→1=1, 0→2 either direct (Inf, rejected) or via 1: 0→1→2 = 1+2 = 3.
804        let mut g = Graph::with_vertices(3);
805        g.add_edge(0, 1).unwrap();
806        g.add_edge(1, 2).unwrap();
807        g.add_edge(0, 2).unwrap();
808        let weights = [1.0, 2.0, f64::INFINITY];
809        let part = voronoi(
810            &g,
811            Some(&weights),
812            DijkstraMode::Out,
813            &[0],
814            VoronoiTiebreaker::First,
815            0,
816        )
817        .unwrap();
818        assert_eq!(part.membership, vec![Some(0), Some(0), Some(0)]);
819        assert_eq!(part.distances, vec![0.0, 1.0, 3.0]);
820    }
821}
822
823#[cfg(all(test, feature = "proptest-harness"))]
824mod proptests {
825    use super::*;
826    use crate::algorithms::paths::distances::distances;
827    use proptest::prelude::*;
828
829    prop_compose! {
830        fn small_undirected_graph()(n in 2u32..=10u32, edges_seed in any::<u64>()) -> Graph {
831            let mut g = Graph::with_vertices(n);
832            let mut rng = edges_seed;
833            let target_m = ((n * (n - 1)) / 2).min(n + 6) as usize;
834            let mut added = 0usize;
835            let mut guard = 0usize;
836            while added < target_m && guard < target_m * 8 + 4 {
837                rng = rng
838                    .wrapping_mul(6_364_136_223_846_793_005)
839                    .wrapping_add(1_442_695_040_888_963_407);
840                let u = ((rng >> 33) % u64::from(n)) as u32;
841                rng = rng
842                    .wrapping_mul(6_364_136_223_846_793_005)
843                    .wrapping_add(1_442_695_040_888_963_407);
844                let v = ((rng >> 33) % u64::from(n)) as u32;
845                guard += 1;
846                if u == v {
847                    continue;
848                }
849                if g.add_edge(u, v).is_ok() {
850                    added += 1;
851                }
852            }
853            g
854        }
855    }
856
857    proptest! {
858        #[test]
859        fn membership_indices_in_range(
860            g in small_undirected_graph(),
861            seed in any::<u64>(),
862        ) {
863            let n = g.vcount();
864            let k = (n as usize).div_ceil(3).max(1);
865            let gens: Vec<VertexId> = (0..k as u32).collect();
866            let part = voronoi(
867                &g, None, DijkstraMode::Out, &gens,
868                VoronoiTiebreaker::Random, seed,
869            ).unwrap();
870            prop_assert_eq!(part.membership.len(), n as usize);
871            for i in part.membership.iter().flatten() {
872                prop_assert!((*i as usize) < gens.len());
873            }
874        }
875
876        #[test]
877        fn unweighted_distance_matches_min_over_generators(
878            g in small_undirected_graph(),
879            seed in any::<u64>(),
880        ) {
881            let n = g.vcount();
882            let k = ((n as usize) / 2).max(1);
883            let gens: Vec<VertexId> = (0..k as u32).collect();
884            let part = voronoi(
885                &g, None, DijkstraMode::Out, &gens,
886                VoronoiTiebreaker::First, seed,
887            ).unwrap();
888            for v in 0..n {
889                let mut best = u32::MAX;
890                for &gid in &gens {
891                    let d = distances(&g, gid).unwrap();
892                    if let Some(dv) = d[v as usize] {
893                        if dv < best {
894                            best = dv;
895                        }
896                    }
897                }
898                if best == u32::MAX {
899                    prop_assert!(part.distances[v as usize].is_infinite());
900                    prop_assert!(part.membership[v as usize].is_none());
901                } else {
902                    let got = part.distances[v as usize];
903                    prop_assert!((got - f64::from(best)).abs() < 1e-9);
904                    prop_assert!(part.membership[v as usize].is_some());
905                }
906            }
907        }
908
909        #[test]
910        fn tiebreaker_first_picks_earliest_at_ties(
911            g in small_undirected_graph(),
912            seed in any::<u64>(),
913        ) {
914            let n = g.vcount();
915            let k = ((n as usize) / 2).max(2);
916            let gens: Vec<VertexId> = (0..k as u32).collect();
917            let part = voronoi(
918                &g, None, DijkstraMode::Out, &gens,
919                VoronoiTiebreaker::First, seed,
920            ).unwrap();
921            for v in 0..n {
922                let mind = part.distances[v as usize];
923                if mind.is_infinite() {
924                    continue;
925                }
926                let assigned = part.membership[v as usize].unwrap();
927                for (i, &gid) in gens.iter().enumerate() {
928                    if i as u32 >= assigned {
929                        break;
930                    }
931                    let d = distances(&g, gid).unwrap();
932                    if let Some(dv) = d[v as usize] {
933                        prop_assert!(f64::from(dv) > mind);
934                    }
935                }
936            }
937        }
938    }
939}