rust_igraph/algorithms/properties/closeness.rs
1//! Closeness centrality (ALGO-PR-007).
2//!
3//! Counterpart of `igraph_closeness()` from
4//! `references/igraph/src/centrality/closeness.c:33+`. The closeness
5//! of vertex `v` is the inverse of its mean distance to all reachable
6//! vertices.
7//!
8//! Phase-1 minimal slice: undirected/IGRAPH_OUT-mode unweighted
9//! closeness with `normalized = true` (per the upstream default).
10//! Reuses the existing [`distances`] primitive for BFS-from-each-vertex.
11//!
12//! For an isolated vertex (no reachable vertices besides itself) the
13//! definition is undefined → returns `None`. For graphs with multiple
14//! components, the score is computed within each vertex's reachable
15//! set (matches upstream's `unconn = true` semantics).
16
17use crate::algorithms::paths::distances::distances;
18use crate::core::{Graph, IgraphResult};
19
20/// Per-vertex closeness centrality (`Vec<Option<f64>>`).
21///
22/// `result[v]` is `Some(reachable_count / sum_of_distances)` if vertex
23/// `v` has at least one reachable neighbour; `None` for isolated
24/// vertices (matches upstream's `IGRAPH_NAN`).
25///
26/// Edge directions follow [`distances`]: out-edges for directed
27/// graphs (`IGRAPH_OUT`), full undirected reachability otherwise.
28///
29/// # Examples
30///
31/// ```
32/// use rust_igraph::{Graph, closeness};
33///
34/// // Star with centre 0 and 3 leaves: centre has the highest closeness.
35/// let mut g = Graph::with_vertices(4);
36/// for v in 1..4 { g.add_edge(0, v).unwrap(); }
37/// let c = closeness(&g).unwrap();
38/// // Centre: 3 reachable, sum_dist = 3 → 3/3 = 1.0.
39/// assert_eq!(c[0], Some(1.0));
40/// // Leaves: 3 reachable (centre + 2 other leaves), sum = 1 + 2 + 2 = 5 → 3/5 = 0.6.
41/// for leaf in 1..4 {
42/// assert_eq!(c[leaf], Some(0.6));
43/// }
44/// ```
45pub fn closeness(graph: &Graph) -> IgraphResult<Vec<Option<f64>>> {
46 let n = graph.vcount();
47 let n_us = n as usize;
48 let mut out = Vec::with_capacity(n_us);
49 for v in 0..n {
50 let d = distances(graph, v)?;
51 let mut sum_dist: u64 = 0;
52 let mut reach: u64 = 0;
53 let v_us = v as usize;
54 for (target, &val) in d.iter().enumerate() {
55 if target == v_us {
56 continue;
57 }
58 if let Some(dist) = val {
59 sum_dist += u64::from(dist);
60 reach += 1;
61 }
62 }
63 if reach == 0 || sum_dist == 0 {
64 // Isolated (no reachable vertices) — upstream returns NaN.
65 // sum_dist == 0 with reach > 0 only happens for degenerate
66 // self-loop graphs which we treat as isolated for closeness.
67 out.push(None);
68 } else {
69 // Counts fit in u64; ratio fits in f64 mantissa for any
70 // graph that survives u32 vertex ids.
71 #[allow(clippy::cast_precision_loss)]
72 let val = (reach as f64) / (sum_dist as f64);
73 out.push(Some(val));
74 }
75 }
76 Ok(out)
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82
83 fn approx_eq(a: Option<f64>, b: Option<f64>, tol: f64) {
84 match (a, b) {
85 (Some(x), Some(y)) => {
86 assert!((x - y).abs() < tol, "{x} vs {y}");
87 }
88 (None, None) => {}
89 _ => panic!("{a:?} vs {b:?}"),
90 }
91 }
92
93 #[test]
94 fn empty_graph_yields_empty() {
95 let g = Graph::with_vertices(0);
96 assert!(closeness(&g).unwrap().is_empty());
97 }
98
99 #[test]
100 fn isolated_vertices_all_none() {
101 let g = Graph::with_vertices(3);
102 let c = closeness(&g).unwrap();
103 assert_eq!(c, vec![None, None, None]);
104 }
105
106 #[test]
107 fn singleton_is_none() {
108 let g = Graph::with_vertices(1);
109 assert_eq!(closeness(&g).unwrap(), vec![None]);
110 }
111
112 #[test]
113 fn k3_triangle_uniform_one() {
114 // Triangle: every vertex has 2 reachable, sum_dist = 1+1 = 2 → 2/2 = 1.0.
115 let mut g = Graph::with_vertices(3);
116 g.add_edge(0, 1).unwrap();
117 g.add_edge(1, 2).unwrap();
118 g.add_edge(2, 0).unwrap();
119 let c = closeness(&g).unwrap();
120 for &val in &c {
121 approx_eq(val, Some(1.0), 1e-12);
122 }
123 }
124
125 #[test]
126 fn star_centre_has_highest() {
127 let mut g = Graph::with_vertices(4);
128 for v in 1..4 {
129 g.add_edge(0, v).unwrap();
130 }
131 let c = closeness(&g).unwrap();
132 // Centre: 3 reachable, sum=3 → 1.0.
133 approx_eq(c[0], Some(1.0), 1e-12);
134 // Leaves: 3 reachable (centre at 1, two leaves at 2 each), sum=5 → 0.6.
135 for &leaf_val in &c[1..4] {
136 approx_eq(leaf_val, Some(0.6), 1e-12);
137 }
138 }
139
140 #[test]
141 fn path_5_endpoints_lower_than_centre() {
142 // 0-1-2-3-4: centre at 2 has min mean distance.
143 let mut g = Graph::with_vertices(5);
144 for i in 0..4 {
145 g.add_edge(i, i + 1).unwrap();
146 }
147 let c = closeness(&g).unwrap();
148 // Vertex 0: reach=4, sum=1+2+3+4=10 → 0.4
149 approx_eq(c[0], Some(0.4), 1e-12);
150 // Vertex 1: reach=4, sum=1+1+2+3=7 → 4/7
151 approx_eq(c[1], Some(4.0 / 7.0), 1e-12);
152 // Vertex 2 (centre): reach=4, sum=2+1+1+2=6 → 4/6
153 approx_eq(c[2], Some(4.0 / 6.0), 1e-12);
154 // Symmetric for 3 and 4.
155 approx_eq(c[3], Some(4.0 / 7.0), 1e-12);
156 approx_eq(c[4], Some(0.4), 1e-12);
157 }
158
159 #[test]
160 fn disconnected_components_within_component_only() {
161 // {0-1-2} and {3}: vertex 3 isolated → None.
162 let mut g = Graph::with_vertices(4);
163 g.add_edge(0, 1).unwrap();
164 g.add_edge(1, 2).unwrap();
165 let c = closeness(&g).unwrap();
166 // 0: reach=2 (1,2), sum=1+2=3 → 2/3
167 approx_eq(c[0], Some(2.0 / 3.0), 1e-12);
168 // 1: reach=2 (0,2), sum=1+1=2 → 1.0
169 approx_eq(c[1], Some(1.0), 1e-12);
170 approx_eq(c[2], Some(2.0 / 3.0), 1e-12);
171 // 3: isolated.
172 assert_eq!(c[3], None);
173 }
174
175 #[test]
176 fn directed_path_uses_out_edges() {
177 // 0->1->2: from 0 reach {1,2} sum=1+2=3 → 2/3; from 1 reach {2} sum=1 → 1.0;
178 // from 2 reach {} → None.
179 let mut g = Graph::new(3, true).unwrap();
180 g.add_edge(0, 1).unwrap();
181 g.add_edge(1, 2).unwrap();
182 let c = closeness(&g).unwrap();
183 approx_eq(c[0], Some(2.0 / 3.0), 1e-12);
184 approx_eq(c[1], Some(1.0), 1e-12);
185 assert_eq!(c[2], None);
186 }
187}