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