Skip to main content

rust_igraph/algorithms/properties/
centralization.rs

1//! Graph-level centralization indices (ALGO-PR-033 + ALGO-PR-043).
2//!
3//! Counterpart of `igraph_centralization*` from
4//! `references/igraph/src/centrality/centralization.c` (723 lines).
5//!
6//! Centralization measures how much a graph's structure revolves
7//! around a single vertex. Given per-vertex centrality scores, the
8//! graph-level centralization is:
9//!
10//! ```text
11//! C = Σ_v (max_u c_u - c_v)
12//! ```
13//!
14//! Optionally divided by the theoretical maximum for the most
15//! centralized structure (usually a star graph) of the same size.
16//!
17//! ## Convenience wrappers (ALGO-PR-043)
18//!
19//! [`centralization_degree_wrapper`], [`centralization_betweenness_wrapper`],
20//! [`centralization_closeness_wrapper`], and
21//! [`centralization_eigenvector_wrapper`] compose the per-vertex score
22//! functions with the tmax + centralization formula into single calls.
23
24use super::betweenness::betweenness;
25use super::closeness::closeness;
26use super::degree::DegreeMode;
27use super::eigenvector::{EigenvectorMode, eigenvector_centrality_full};
28use super::strength::{StrengthMode, strength_with_mode};
29use crate::core::{Graph, IgraphResult};
30
31/// How loops are counted when computing degree centralization.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum LoopMode {
34    /// Ignore loop edges entirely.
35    NoLoops,
36    /// Count each loop edge once.
37    LoopsOnce,
38    /// Count each loop edge twice (undirected) or once (directed).
39    LoopsTwice,
40}
41
42/// Whether centralization considers in-degree, out-degree, or total.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum CentralizationMode {
45    In,
46    Out,
47    All,
48}
49
50/// Compute the graph-level centralization score from per-vertex scores.
51///
52/// Returns `C = n * max(scores) - sum(scores)` when `normalized` is
53/// `false`, or `C / theoretical_max` when `normalized` is `true`.
54///
55/// Returns `NaN` for empty score vectors.
56///
57/// # Examples
58///
59/// ```
60/// use rust_igraph::centralization;
61///
62/// // Star graph degree scores: center=4, leaves=1,1,1,1
63/// let scores = [4.0, 1.0, 1.0, 1.0, 1.0];
64/// let c = centralization(&scores, 12.0, true);
65/// assert!((c - 1.0).abs() < 1e-9);
66/// ```
67#[allow(clippy::cast_precision_loss)]
68pub fn centralization(scores: &[f64], theoretical_max: f64, normalized: bool) -> f64 {
69    if scores.is_empty() {
70        return f64::NAN;
71    }
72
73    let max_score = scores.iter().copied().fold(f64::NEG_INFINITY, f64::max);
74    let n = scores.len() as f64;
75    let sum: f64 = scores.iter().sum();
76    let cent = n * max_score - sum;
77
78    if normalized {
79        cent / theoretical_max
80    } else {
81        cent
82    }
83}
84
85/// Theoretical maximum degree centralization for a star graph.
86///
87/// Returns `NaN` for `n == 0`.
88///
89/// # Examples
90///
91/// ```
92/// use rust_igraph::{CentralizationMode, LoopMode, centralization_degree_tmax};
93///
94/// // Undirected, no loops, 5 vertices: (5-1)*(5-2) = 12
95/// let tmax = centralization_degree_tmax(5, false, CentralizationMode::All, LoopMode::NoLoops);
96/// assert!((tmax - 12.0).abs() < 1e-9);
97/// ```
98#[allow(clippy::cast_precision_loss)]
99pub fn centralization_degree_tmax(
100    n: u32,
101    directed: bool,
102    mode: CentralizationMode,
103    loops: LoopMode,
104) -> f64 {
105    if n == 0 {
106        return f64::NAN;
107    }
108
109    let nf = f64::from(n);
110
111    if directed {
112        match mode {
113            CentralizationMode::In | CentralizationMode::Out => {
114                if matches!(loops, LoopMode::NoLoops) {
115                    (nf - 1.0) * (nf - 1.0)
116                } else {
117                    (nf - 1.0) * nf
118                }
119            }
120            CentralizationMode::All => {
121                if matches!(loops, LoopMode::NoLoops) {
122                    2.0 * (nf - 1.0) * (nf - 2.0)
123                } else {
124                    2.0 * (nf - 1.0) * (nf - 1.0)
125                }
126            }
127        }
128    } else {
129        match loops {
130            LoopMode::NoLoops => (nf - 1.0) * (nf - 2.0),
131            LoopMode::LoopsOnce => (nf - 1.0) * (nf - 1.0),
132            LoopMode::LoopsTwice => (nf - 1.0) * nf,
133        }
134    }
135}
136
137/// Theoretical maximum betweenness centralization for a star graph.
138///
139/// Returns `NaN` for `n == 0`.
140///
141/// # Examples
142///
143/// ```
144/// use rust_igraph::centralization_betweenness_tmax;
145///
146/// // Undirected, 5 vertices: (5-1)*(5-1)*(5-2)/2 = 24
147/// let tmax = centralization_betweenness_tmax(5, false);
148/// assert!((tmax - 24.0).abs() < 1e-9);
149/// ```
150pub fn centralization_betweenness_tmax(n: u32, directed: bool) -> f64 {
151    if n == 0 {
152        return f64::NAN;
153    }
154
155    let nf = f64::from(n);
156
157    if directed {
158        (nf - 1.0) * (nf - 1.0) * (nf - 2.0)
159    } else {
160        (nf - 1.0) * (nf - 1.0) * (nf - 2.0) / 2.0
161    }
162}
163
164/// Theoretical maximum closeness centralization for a star graph.
165///
166/// For directed graphs, `mode` distinguishes IN/OUT from ALL.
167/// For undirected, `mode` should be `All`.
168///
169/// Returns `NaN` for `n == 0`.
170///
171/// # Examples
172///
173/// ```
174/// use rust_igraph::{CentralizationMode, centralization_closeness_tmax};
175///
176/// // Undirected, 5 vertices: (5-1)*(5-2)/(2*5-3) = 12/7
177/// let tmax = centralization_closeness_tmax(5, CentralizationMode::All);
178/// assert!((tmax - 12.0 / 7.0).abs() < 1e-9);
179/// ```
180pub fn centralization_closeness_tmax(n: u32, mode: CentralizationMode) -> f64 {
181    if n == 0 {
182        return f64::NAN;
183    }
184
185    let nf = f64::from(n);
186
187    match mode {
188        CentralizationMode::In | CentralizationMode::Out => (nf - 1.0) * (1.0 - 1.0 / nf),
189        CentralizationMode::All => (nf - 1.0) * (nf - 2.0) / (2.0 * nf - 3.0),
190    }
191}
192
193/// Theoretical maximum eigenvector centralization for a star graph.
194///
195/// For directed graphs, `mode` distinguishes IN/OUT from ALL.
196/// For undirected, `mode` should be `All`.
197///
198/// Returns `NaN` for `n == 0`, `0.0` for `n == 1`.
199///
200/// # Examples
201///
202/// ```
203/// use rust_igraph::{CentralizationMode, centralization_eigenvector_tmax};
204///
205/// // Undirected, 5 vertices: 5-2 = 3
206/// let tmax = centralization_eigenvector_tmax(5, CentralizationMode::All);
207/// assert!((tmax - 3.0).abs() < 1e-9);
208/// ```
209pub fn centralization_eigenvector_tmax(n: u32, mode: CentralizationMode) -> f64 {
210    if n == 0 {
211        return f64::NAN;
212    }
213    if n == 1 {
214        return 0.0;
215    }
216
217    let nf = f64::from(n);
218
219    match mode {
220        CentralizationMode::In | CentralizationMode::Out => nf - 1.0,
221        CentralizationMode::All => nf - 2.0,
222    }
223}
224
225/// Result of a centralization convenience wrapper.
226#[derive(Debug, Clone, PartialEq)]
227pub struct CentralizationResult {
228    /// Per-vertex centrality scores.
229    pub scores: Vec<f64>,
230    /// Graph-level centralization value.
231    pub centralization: f64,
232    /// Theoretical maximum for the most centralized graph of this size.
233    pub theoretical_max: f64,
234}
235
236fn degree_to_strength_mode(mode: DegreeMode) -> StrengthMode {
237    match mode {
238        DegreeMode::Out => StrengthMode::Out,
239        DegreeMode::In => StrengthMode::In,
240        DegreeMode::All => StrengthMode::All,
241    }
242}
243
244fn degree_to_cent_mode(mode: DegreeMode) -> CentralizationMode {
245    match mode {
246        DegreeMode::Out => CentralizationMode::Out,
247        DegreeMode::In => CentralizationMode::In,
248        DegreeMode::All => CentralizationMode::All,
249    }
250}
251
252/// Degree centralization: compute per-vertex degree scores and the
253/// graph-level centralization in one call.
254///
255/// `mode` selects in-/out-/total degree for directed graphs (ignored
256/// for undirected). `loops` controls whether self-loops are counted.
257///
258/// Counterpart of `igraph_centralization_degree()`.
259///
260/// # Examples
261///
262/// ```
263/// use rust_igraph::{Graph, DegreeMode, centralization_degree_wrapper};
264///
265/// // Star K_{1,4}: normalized degree centralization = 1.0.
266/// let mut g = Graph::with_vertices(5);
267/// for v in 1..5u32 { g.add_edge(0, v).unwrap(); }
268/// let r = centralization_degree_wrapper(&g, DegreeMode::All, false, true).unwrap();
269/// assert!((r.centralization - 1.0).abs() < 1e-9);
270/// assert!((r.scores[0] - 4.0).abs() < 1e-9);
271/// ```
272pub fn centralization_degree_wrapper(
273    graph: &Graph,
274    mode: DegreeMode,
275    loops: bool,
276    normalized: bool,
277) -> IgraphResult<CentralizationResult> {
278    let n = graph.vcount();
279    let ecount = graph.ecount();
280
281    let scores = if ecount == 0 {
282        vec![0.0_f64; n as usize]
283    } else {
284        let unit_w = vec![1.0_f64; ecount];
285        strength_with_mode(graph, &unit_w, degree_to_strength_mode(mode), loops)?
286    };
287
288    let loop_mode = if loops {
289        LoopMode::LoopsTwice
290    } else {
291        LoopMode::NoLoops
292    };
293    let tmax =
294        centralization_degree_tmax(n, graph.is_directed(), degree_to_cent_mode(mode), loop_mode);
295    let cent = centralization(&scores, tmax, normalized);
296
297    Ok(CentralizationResult {
298        scores,
299        centralization: cent,
300        theoretical_max: tmax,
301    })
302}
303
304/// Betweenness centralization: compute per-vertex betweenness scores
305/// and the graph-level centralization in one call.
306///
307/// Directedness is derived from `graph.is_directed()`.
308///
309/// Counterpart of `igraph_centralization_betweenness()`.
310///
311/// # Examples
312///
313/// ```
314/// use rust_igraph::{Graph, centralization_betweenness_wrapper};
315///
316/// // Star K_{1,4}: normalized betweenness centralization = 1.0.
317/// let mut g = Graph::with_vertices(5);
318/// for v in 1..5u32 { g.add_edge(0, v).unwrap(); }
319/// let r = centralization_betweenness_wrapper(&g, true).unwrap();
320/// assert!((r.centralization - 1.0).abs() < 1e-9);
321/// ```
322pub fn centralization_betweenness_wrapper(
323    graph: &Graph,
324    normalized: bool,
325) -> IgraphResult<CentralizationResult> {
326    let scores = betweenness(graph)?;
327    let directed = graph.is_directed();
328    let tmax = centralization_betweenness_tmax(graph.vcount(), directed);
329    let cent = centralization(&scores, tmax, normalized);
330
331    Ok(CentralizationResult {
332        scores,
333        centralization: cent,
334        theoretical_max: tmax,
335    })
336}
337
338/// Closeness centralization: compute per-vertex closeness scores and
339/// the graph-level centralization in one call.
340///
341/// Vertices with no reachable neighbors produce `NaN` scores; if any
342/// vertex is unreachable the centralization itself will be `NaN`
343/// (matching igraph C semantics for disconnected graphs).
344///
345/// Counterpart of `igraph_centralization_closeness()`.
346///
347/// # Examples
348///
349/// ```
350/// use rust_igraph::{Graph, centralization_closeness_wrapper};
351///
352/// // Star K_{1,4}: normalized closeness centralization = 1.0.
353/// let mut g = Graph::with_vertices(5);
354/// for v in 1..5u32 { g.add_edge(0, v).unwrap(); }
355/// let r = centralization_closeness_wrapper(&g, true).unwrap();
356/// assert!((r.centralization - 1.0).abs() < 1e-9);
357/// ```
358pub fn centralization_closeness_wrapper(
359    graph: &Graph,
360    normalized: bool,
361) -> IgraphResult<CentralizationResult> {
362    let raw = closeness(graph)?;
363    let scores: Vec<f64> = raw.into_iter().map(|v| v.unwrap_or(f64::NAN)).collect();
364
365    let cent_mode = if graph.is_directed() {
366        CentralizationMode::Out
367    } else {
368        CentralizationMode::All
369    };
370    let tmax = centralization_closeness_tmax(graph.vcount(), cent_mode);
371    let cent = centralization(&scores, tmax, normalized);
372
373    Ok(CentralizationResult {
374        scores,
375        centralization: cent,
376        theoretical_max: tmax,
377    })
378}
379
380/// Eigenvector centralization: compute per-vertex eigenvector centrality
381/// scores and the graph-level centralization in one call.
382///
383/// For directed graphs, uses `EigenvectorMode::Out` (the standard
384/// convention matching upstream). The eigenvector is max-1 normalised.
385///
386/// Counterpart of `igraph_centralization_eigenvector_centrality()`.
387///
388/// # Examples
389///
390/// ```
391/// use rust_igraph::{Graph, centralization_eigenvector_wrapper};
392///
393/// // Star K_{1,4}: center has eigenvector centrality 1.0, leaves ≈ 0.5.
394/// let mut g = Graph::with_vertices(5);
395/// for v in 1..5u32 { g.add_edge(0, v).unwrap(); }
396/// let r = centralization_eigenvector_wrapper(&g, true).unwrap();
397/// assert!(r.centralization > 0.0);
398/// assert!((r.scores[0] - 1.0).abs() < 1e-9);
399/// ```
400pub fn centralization_eigenvector_wrapper(
401    graph: &Graph,
402    normalized: bool,
403) -> IgraphResult<CentralizationResult> {
404    let eig_mode = if graph.is_directed() {
405        EigenvectorMode::Out
406    } else {
407        EigenvectorMode::All
408    };
409    let eig = eigenvector_centrality_full(graph, eig_mode, None)?;
410    let scores = eig.vector;
411
412    let cent_mode = if graph.is_directed() {
413        CentralizationMode::Out
414    } else {
415        CentralizationMode::All
416    };
417    let tmax = centralization_eigenvector_tmax(graph.vcount(), cent_mode);
418    let cent = centralization(&scores, tmax, normalized);
419
420    Ok(CentralizationResult {
421        scores,
422        centralization: cent,
423        theoretical_max: tmax,
424    })
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    fn approx_eq(a: f64, b: f64) -> bool {
432        (a - b).abs() < 1e-9
433    }
434
435    #[test]
436    fn centralization_empty() {
437        let c = centralization(&[], 1.0, false);
438        assert!(c.is_nan());
439    }
440
441    #[test]
442    fn centralization_single() {
443        let c = centralization(&[5.0], 1.0, false);
444        assert!(approx_eq(c, 0.0));
445    }
446
447    #[test]
448    fn centralization_star_degree() {
449        let scores = [4.0, 1.0, 1.0, 1.0, 1.0];
450        let c = centralization(&scores, 12.0, false);
451        assert!(approx_eq(c, 12.0));
452
453        let c_norm = centralization(&scores, 12.0, true);
454        assert!(approx_eq(c_norm, 1.0));
455    }
456
457    #[test]
458    fn centralization_uniform() {
459        let scores = [3.0, 3.0, 3.0, 3.0];
460        let c = centralization(&scores, 6.0, false);
461        assert!(approx_eq(c, 0.0));
462    }
463
464    #[test]
465    fn centralization_normalized() {
466        let scores = [4.0, 2.0, 1.0];
467        let unnorm = centralization(&scores, 1.0, false);
468        let norm = centralization(&scores, 6.0, true);
469        assert!(approx_eq(unnorm, 5.0));
470        assert!(approx_eq(norm, 5.0 / 6.0));
471    }
472
473    #[test]
474    fn degree_tmax_zero() {
475        assert!(
476            centralization_degree_tmax(0, false, CentralizationMode::All, LoopMode::NoLoops)
477                .is_nan()
478        );
479    }
480
481    #[test]
482    fn degree_tmax_undirected_no_loops() {
483        assert!(approx_eq(
484            centralization_degree_tmax(5, false, CentralizationMode::All, LoopMode::NoLoops),
485            12.0
486        ));
487        assert!(approx_eq(
488            centralization_degree_tmax(10, false, CentralizationMode::All, LoopMode::NoLoops),
489            72.0
490        ));
491    }
492
493    #[test]
494    fn degree_tmax_undirected_loops_once() {
495        assert!(approx_eq(
496            centralization_degree_tmax(5, false, CentralizationMode::All, LoopMode::LoopsOnce),
497            16.0
498        ));
499    }
500
501    #[test]
502    fn degree_tmax_undirected_loops_twice() {
503        assert!(approx_eq(
504            centralization_degree_tmax(5, false, CentralizationMode::All, LoopMode::LoopsTwice),
505            20.0
506        ));
507    }
508
509    #[test]
510    fn degree_tmax_directed_in_no_loops() {
511        assert!(approx_eq(
512            centralization_degree_tmax(5, true, CentralizationMode::In, LoopMode::NoLoops),
513            16.0
514        ));
515    }
516
517    #[test]
518    fn degree_tmax_directed_all_no_loops() {
519        assert!(approx_eq(
520            centralization_degree_tmax(5, true, CentralizationMode::All, LoopMode::NoLoops),
521            24.0
522        ));
523    }
524
525    #[test]
526    fn degree_tmax_directed_in_with_loops() {
527        assert!(approx_eq(
528            centralization_degree_tmax(5, true, CentralizationMode::In, LoopMode::LoopsTwice),
529            20.0
530        ));
531    }
532
533    #[test]
534    fn degree_tmax_directed_all_with_loops() {
535        assert!(approx_eq(
536            centralization_degree_tmax(5, true, CentralizationMode::All, LoopMode::LoopsTwice),
537            32.0
538        ));
539    }
540
541    #[test]
542    fn betweenness_tmax_zero() {
543        assert!(centralization_betweenness_tmax(0, false).is_nan());
544    }
545
546    #[test]
547    fn betweenness_tmax_undirected() {
548        assert!(approx_eq(centralization_betweenness_tmax(5, false), 24.0));
549        assert!(approx_eq(centralization_betweenness_tmax(3, false), 2.0));
550    }
551
552    #[test]
553    fn betweenness_tmax_directed() {
554        assert!(approx_eq(centralization_betweenness_tmax(5, true), 48.0));
555        assert!(approx_eq(centralization_betweenness_tmax(3, true), 4.0));
556    }
557
558    #[test]
559    fn closeness_tmax_zero() {
560        assert!(centralization_closeness_tmax(0, CentralizationMode::All).is_nan());
561    }
562
563    #[test]
564    fn closeness_tmax_undirected() {
565        let tmax = centralization_closeness_tmax(5, CentralizationMode::All);
566        assert!(approx_eq(tmax, 12.0 / 7.0));
567    }
568
569    #[test]
570    fn closeness_tmax_directed() {
571        let tmax = centralization_closeness_tmax(5, CentralizationMode::In);
572        assert!(approx_eq(tmax, 4.0 * (1.0 - 1.0 / 5.0)));
573    }
574
575    #[test]
576    fn eigenvector_tmax_zero() {
577        assert!(centralization_eigenvector_tmax(0, CentralizationMode::All).is_nan());
578    }
579
580    #[test]
581    fn eigenvector_tmax_one() {
582        assert!(approx_eq(
583            centralization_eigenvector_tmax(1, CentralizationMode::All),
584            0.0
585        ));
586    }
587
588    #[test]
589    fn eigenvector_tmax_undirected() {
590        assert!(approx_eq(
591            centralization_eigenvector_tmax(5, CentralizationMode::All),
592            3.0
593        ));
594    }
595
596    #[test]
597    fn eigenvector_tmax_directed() {
598        assert!(approx_eq(
599            centralization_eigenvector_tmax(5, CentralizationMode::In),
600            4.0
601        ));
602    }
603
604    #[test]
605    fn star5_degree_centralization() {
606        let scores = [4.0, 1.0, 1.0, 1.0, 1.0];
607        let tmax = centralization_degree_tmax(5, false, CentralizationMode::All, LoopMode::NoLoops);
608        let c = centralization(&scores, tmax, true);
609        assert!(approx_eq(c, 1.0), "star degree centralization = {c}");
610    }
611
612    #[test]
613    fn ring_degree_centralization() {
614        let scores = [2.0, 2.0, 2.0, 2.0, 2.0];
615        let tmax = centralization_degree_tmax(5, false, CentralizationMode::All, LoopMode::NoLoops);
616        let c = centralization(&scores, tmax, true);
617        assert!(approx_eq(c, 0.0), "ring degree centralization = {c}");
618    }
619
620    #[test]
621    fn star_betweenness_centralization() {
622        let n = 5u32;
623        let scores = [6.0, 0.0, 0.0, 0.0, 0.0];
624        let tmax = centralization_betweenness_tmax(n, false);
625        let c = centralization(&scores, tmax, true);
626        assert!(approx_eq(c, 1.0), "star betweenness centralization = {c}");
627    }
628
629    // ── wrapper tests ────────────────────────────────────────────────
630
631    fn make_star(n: u32) -> Graph {
632        let mut g = Graph::with_vertices(n);
633        for v in 1..n {
634            g.add_edge(0, v).unwrap();
635        }
636        g
637    }
638
639    fn make_ring(n: u32) -> Graph {
640        let mut g = Graph::with_vertices(n);
641        for v in 0..n {
642            g.add_edge(v, (v + 1) % n).unwrap();
643        }
644        g
645    }
646
647    fn make_path(n: u32) -> Graph {
648        let mut g = Graph::with_vertices(n);
649        for v in 0..n - 1 {
650            g.add_edge(v, v + 1).unwrap();
651        }
652        g
653    }
654
655    // -- degree wrapper --
656
657    #[test]
658    fn wrapper_degree_star5() {
659        let g = make_star(5);
660        let r = centralization_degree_wrapper(&g, DegreeMode::All, false, true).unwrap();
661        assert!(approx_eq(r.centralization, 1.0), "got {}", r.centralization);
662        assert!(approx_eq(r.scores[0], 4.0));
663        for i in 1..5 {
664            assert!(approx_eq(r.scores[i], 1.0));
665        }
666    }
667
668    #[test]
669    fn wrapper_degree_ring5() {
670        let g = make_ring(5);
671        let r = centralization_degree_wrapper(&g, DegreeMode::All, false, true).unwrap();
672        assert!(approx_eq(r.centralization, 0.0), "got {}", r.centralization);
673    }
674
675    #[test]
676    fn wrapper_degree_empty() {
677        let g = Graph::with_vertices(0);
678        let r = centralization_degree_wrapper(&g, DegreeMode::All, false, true).unwrap();
679        assert!(r.scores.is_empty());
680        assert!(r.centralization.is_nan());
681    }
682
683    #[test]
684    fn wrapper_degree_single_vertex() {
685        let g = Graph::with_vertices(1);
686        let r = centralization_degree_wrapper(&g, DegreeMode::All, false, false).unwrap();
687        assert_eq!(r.scores.len(), 1);
688        assert!(approx_eq(r.scores[0], 0.0));
689        assert!(approx_eq(r.centralization, 0.0));
690    }
691
692    #[test]
693    fn wrapper_degree_unnormalized() {
694        let g = make_star(5);
695        let r = centralization_degree_wrapper(&g, DegreeMode::All, false, false).unwrap();
696        assert!(approx_eq(r.centralization, 12.0));
697        assert!(approx_eq(r.theoretical_max, 12.0));
698    }
699
700    #[test]
701    fn wrapper_degree_with_self_loops() {
702        let mut g = Graph::with_vertices(3);
703        g.add_edge(0, 0).unwrap(); // self-loop
704        g.add_edge(0, 1).unwrap();
705        g.add_edge(1, 2).unwrap();
706        let r_loops = centralization_degree_wrapper(&g, DegreeMode::All, true, false).unwrap();
707        let r_no_loops = centralization_degree_wrapper(&g, DegreeMode::All, false, false).unwrap();
708        // With loops: vertex 0 degree = 2+1 = 3 (self-loop twice + edge)
709        assert!(r_loops.scores[0] > r_no_loops.scores[0]);
710    }
711
712    // -- betweenness wrapper --
713
714    #[test]
715    fn wrapper_betweenness_star5() {
716        let g = make_star(5);
717        let r = centralization_betweenness_wrapper(&g, true).unwrap();
718        assert!(approx_eq(r.centralization, 1.0), "got {}", r.centralization);
719    }
720
721    #[test]
722    fn wrapper_betweenness_path5() {
723        let g = make_path(5);
724        let r = centralization_betweenness_wrapper(&g, false).unwrap();
725        assert!(r.centralization > 0.0);
726        // Center vertex (2) should have highest betweenness
727        assert!(r.scores[2] > r.scores[0]);
728        assert!(r.scores[2] > r.scores[4]);
729    }
730
731    #[test]
732    fn wrapper_betweenness_ring() {
733        let g = make_ring(5);
734        let r = centralization_betweenness_wrapper(&g, true).unwrap();
735        assert!(approx_eq(r.centralization, 0.0), "got {}", r.centralization);
736    }
737
738    #[test]
739    fn wrapper_betweenness_empty() {
740        let g = Graph::with_vertices(0);
741        let r = centralization_betweenness_wrapper(&g, true).unwrap();
742        assert!(r.scores.is_empty());
743    }
744
745    // -- closeness wrapper --
746
747    #[test]
748    fn wrapper_closeness_star5() {
749        let g = make_star(5);
750        let r = centralization_closeness_wrapper(&g, true).unwrap();
751        assert!(approx_eq(r.centralization, 1.0), "got {}", r.centralization);
752        // Center has closeness 1.0
753        assert!(approx_eq(r.scores[0], 1.0));
754    }
755
756    #[test]
757    fn wrapper_closeness_ring() {
758        let g = make_ring(5);
759        let r = centralization_closeness_wrapper(&g, true).unwrap();
760        assert!(approx_eq(r.centralization, 0.0), "got {}", r.centralization);
761    }
762
763    #[test]
764    fn wrapper_closeness_disconnected_is_nan() {
765        // Two isolated vertices → closeness is None → centralization is NaN
766        let g = Graph::with_vertices(2);
767        let r = centralization_closeness_wrapper(&g, true).unwrap();
768        assert!(r.scores[0].is_nan());
769        assert!(r.scores[1].is_nan());
770        assert!(r.centralization.is_nan());
771    }
772
773    // -- eigenvector wrapper --
774
775    #[test]
776    fn wrapper_eigenvector_star5() {
777        let g = make_star(5);
778        let r = centralization_eigenvector_wrapper(&g, true).unwrap();
779        assert!(r.centralization > 0.0);
780        // Center should have highest score (1.0)
781        assert!(approx_eq(r.scores[0], 1.0));
782        // Leaves should have equal score
783        for i in 1..5 {
784            assert!(approx_eq(r.scores[i], r.scores[1]));
785        }
786    }
787
788    #[test]
789    fn wrapper_eigenvector_ring() {
790        let g = make_ring(5);
791        let r = centralization_eigenvector_wrapper(&g, true).unwrap();
792        // Regular graph: all vertices have same eigenvector centrality
793        assert!(approx_eq(r.centralization, 0.0), "got {}", r.centralization);
794    }
795
796    #[test]
797    fn wrapper_eigenvector_empty() {
798        let g = Graph::with_vertices(0);
799        let r = centralization_eigenvector_wrapper(&g, true).unwrap();
800        assert!(r.scores.is_empty());
801    }
802}
803
804#[cfg(all(test, feature = "proptest-harness"))]
805mod proptests {
806    use super::*;
807    use proptest::prelude::*;
808
809    proptest! {
810        #[test]
811        fn centralization_non_negative(
812            scores in proptest::collection::vec(0.0f64..100.0, 1..20)
813        ) {
814            let c = centralization(&scores, 1.0, false);
815            prop_assert!(c >= 0.0, "centralization should be >= 0, got {c}");
816        }
817
818        #[test]
819        fn centralization_uniform_is_zero(val in 0.0f64..100.0, n in 1usize..20) {
820            let scores = vec![val; n];
821            let c = centralization(&scores, 1.0, false);
822            prop_assert!(
823                c.abs() < 1e-9,
824                "uniform scores should give centralization 0, got {c}"
825            );
826        }
827
828        #[test]
829        fn tmax_non_negative(n in 3u32..100) {
830            let deg = centralization_degree_tmax(n, false, CentralizationMode::All, LoopMode::NoLoops);
831            let bet = centralization_betweenness_tmax(n, false);
832            let clo = centralization_closeness_tmax(n, CentralizationMode::All);
833            let eig = centralization_eigenvector_tmax(n, CentralizationMode::All);
834            prop_assert!(deg > 0.0, "degree tmax should be > 0 for n={n}");
835            prop_assert!(bet > 0.0, "betweenness tmax should be > 0 for n={n}");
836            prop_assert!(clo > 0.0, "closeness tmax should be > 0 for n={n}");
837            prop_assert!(eig > 0.0, "eigenvector tmax should be > 0 for n={n}");
838        }
839    }
840}