Skip to main content

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}