rust_igraph/algorithms/properties/harmonic.rs
1//! Harmonic centrality (ALGO-PR-009).
2//!
3//! Counterpart of `igraph_harmonic_centrality()` from
4//! `references/igraph/src/centrality/closeness.c:800-805` (and the
5//! underlying `igraph_harmonic_centrality_cutoff`). Defined as the mean
6//! inverse distance to all other vertices, with `1/inf == 0` for
7//! unreachable pairs.
8//!
9//! Phase-1 minimal slice: undirected / `IGRAPH_OUT`, unweighted, normalized.
10//! Returns `f64` per vertex (no `Option`); a vertex with no reachable
11//! neighbours simply gets 0.0 (matching python-igraph's behaviour).
12
13use crate::algorithms::paths::distances::distances;
14use crate::core::{Graph, IgraphResult};
15
16/// Per-vertex harmonic centrality.
17///
18/// `result[v] = (1/(n-1)) * sum_{u != v, reachable} 1/d(v, u)`. The
19/// inverse distance to unreachable vertices is taken as 0, so harmonic
20/// centrality is well-defined on disconnected graphs (unlike closeness).
21///
22/// For graphs with `vcount < 2` returns an empty / single-zero vector
23/// (the normalization factor `n - 1` is 0 in that case; we report 0
24/// rather than NaN to match python-igraph 0.11.x).
25///
26/// Counterpart of
27/// `igraph_harmonic_centrality(_, _, vss_all(), IGRAPH_OUT, NULL, /*normalized=*/true)`.
28///
29/// # Examples
30///
31/// ```
32/// use rust_igraph::{Graph, harmonic_centrality};
33///
34/// // Path 0-1-2: vertex 1 (centre) has harmonic 1.0; ends 0.75.
35/// let mut g = Graph::with_vertices(3);
36/// g.add_edge(0, 1).unwrap();
37/// g.add_edge(1, 2).unwrap();
38/// let h = harmonic_centrality(&g).unwrap();
39/// assert_eq!(h, vec![0.75, 1.0, 0.75]);
40/// ```
41pub fn harmonic_centrality(graph: &Graph) -> IgraphResult<Vec<f64>> {
42 let n = graph.vcount();
43 let n_us = n as usize;
44 if n < 2 {
45 return Ok(vec![0.0; n_us]);
46 }
47 let mut out = Vec::with_capacity(n_us);
48 let denom = f64::from(n - 1);
49 for v in 0..n {
50 let d = distances(graph, v)?;
51 let mut sum_inv: f64 = 0.0;
52 let v_us = v as usize;
53 for (target, &val) in d.iter().enumerate() {
54 if target == v_us {
55 continue;
56 }
57 if let Some(dist) = val {
58 if dist > 0 {
59 sum_inv += 1.0 / f64::from(dist);
60 }
61 }
62 }
63 out.push(sum_inv / denom);
64 }
65 Ok(out)
66}
67
68#[cfg(test)]
69mod tests {
70 use super::*;
71
72 fn close(a: f64, b: f64, tol: f64) {
73 assert!((a - b).abs() < tol, "{a} vs {b}");
74 }
75
76 #[test]
77 fn empty_graph_yields_empty() {
78 let g = Graph::with_vertices(0);
79 assert!(harmonic_centrality(&g).unwrap().is_empty());
80 }
81
82 #[test]
83 fn singleton_yields_zero() {
84 let g = Graph::with_vertices(1);
85 assert_eq!(harmonic_centrality(&g).unwrap(), vec![0.0]);
86 }
87
88 #[test]
89 fn isolated_vertices_all_zero() {
90 let g = Graph::with_vertices(3);
91 assert_eq!(harmonic_centrality(&g).unwrap(), vec![0.0, 0.0, 0.0]);
92 }
93
94 #[test]
95 fn path_3_centre_one_ends_three_quarters() {
96 // 0-1-2: centre has 1/1 + 1/1 = 2; / (3-1) = 1.0.
97 // End: 1/1 + 1/2 = 1.5; / 2 = 0.75.
98 let mut g = Graph::with_vertices(3);
99 g.add_edge(0, 1).unwrap();
100 g.add_edge(1, 2).unwrap();
101 let h = harmonic_centrality(&g).unwrap();
102 assert_eq!(h, vec![0.75, 1.0, 0.75]);
103 }
104
105 #[test]
106 fn k3_uniform_one() {
107 let mut g = Graph::with_vertices(3);
108 g.add_edge(0, 1).unwrap();
109 g.add_edge(1, 2).unwrap();
110 g.add_edge(2, 0).unwrap();
111 let h = harmonic_centrality(&g).unwrap();
112 for &x in &h {
113 close(x, 1.0, 1e-12);
114 }
115 }
116
117 #[test]
118 fn star_centre_one_leaves_two_thirds() {
119 // Centre: 3 leaves at distance 1 → sum = 3, /3 = 1.0.
120 // Leaf: centre at 1 + 2 leaves at 2 → 1 + 0.5 + 0.5 = 2; /3 = 2/3.
121 let mut g = Graph::with_vertices(4);
122 for v in 1..4 {
123 g.add_edge(0, v).unwrap();
124 }
125 let h = harmonic_centrality(&g).unwrap();
126 close(h[0], 1.0, 1e-12);
127 for &leaf in &h[1..4] {
128 close(leaf, 2.0 / 3.0, 1e-12);
129 }
130 }
131
132 #[test]
133 fn disconnected_components_well_defined() {
134 // {0-1-2} and {3} (isolated). Vertex 0: reach {1,2} sum=1+0.5 = 1.5; /3 = 0.5.
135 // Vertex 3: no reach → 0/3 = 0.
136 // Unlike closeness, harmonic is defined here.
137 let mut g = Graph::with_vertices(4);
138 g.add_edge(0, 1).unwrap();
139 g.add_edge(1, 2).unwrap();
140 let h = harmonic_centrality(&g).unwrap();
141 close(h[0], 0.5, 1e-12);
142 close(h[1], 2.0 / 3.0, 1e-12); // 1/1 + 1/1 = 2; /3.
143 close(h[2], 0.5, 1e-12);
144 close(h[3], 0.0, 1e-12);
145 }
146
147 #[test]
148 fn directed_path_uses_out_edges() {
149 // 0->1->2: from 0 reach {1, 2}, from 1 reach {2}, from 2 reach {} (= 0).
150 let mut g = Graph::new(3, true).unwrap();
151 g.add_edge(0, 1).unwrap();
152 g.add_edge(1, 2).unwrap();
153 let h = harmonic_centrality(&g).unwrap();
154 // 0: 1/1 + 1/2 = 1.5; / 2 = 0.75.
155 close(h[0], 0.75, 1e-12);
156 // 1: 1/1 = 1; / 2 = 0.5.
157 close(h[1], 0.5, 1e-12);
158 // 2: 0; / 2 = 0.
159 close(h[2], 0.0, 1e-12);
160 }
161}