Skip to main content

rust_igraph/algorithms/properties/
closeness_cutoff.rs

1//! Range-limited closeness centrality (ALGO-PR-047).
2//!
3//! Counterpart of `igraph_closeness_cutoff()` from
4//! `references/igraph/src/centrality/closeness.c:324`.
5//!
6//! Computes closeness centrality considering only shortest paths whose
7//! length is at most the given cutoff. Vertices beyond the cutoff are
8//! treated as unreachable.
9
10use crate::core::{Graph, IgraphResult, VertexId};
11use std::collections::VecDeque;
12
13/// Result of [`closeness_cutoff`].
14#[derive(Debug, Clone)]
15pub struct ClosenessCutoffResult {
16    /// Per-vertex closeness score. `None` for vertices with no reachable
17    /// neighbours within the cutoff distance.
18    pub scores: Vec<Option<f64>>,
19    /// Per-vertex count of vertices reachable within the cutoff (excluding self).
20    pub reachable_count: Vec<u32>,
21    /// Whether all vertices are reachable from every source within the cutoff.
22    pub all_reachable: bool,
23}
24
25/// Range-limited closeness centrality.
26///
27/// Computes closeness centrality for all vertices, but only considering
28/// paths of length at most `cutoff`. Shorter cutoffs make the computation
29/// faster (early BFS termination) while capturing local structure.
30///
31/// The closeness of vertex `v` is `reachable_count / sum_of_distances`
32/// when `normalized = true`, or `1.0 / sum_of_distances` when
33/// `normalized = false`. Vertices with no reachable neighbours within
34/// the cutoff get `None`.
35///
36/// # Parameters
37///
38/// * `graph` — the input graph.
39/// * `cutoff` — maximum path length to consider. Paths longer than
40///   this are ignored.
41/// * `normalized` — if `true`, returns `reach / sum_dist`; if `false`,
42///   returns `1.0 / sum_dist`.
43///
44/// # Examples
45///
46/// ```
47/// use rust_igraph::{Graph, closeness_cutoff};
48///
49/// // Path: 0—1—2—3—4
50/// let mut g = Graph::with_vertices(5);
51/// for i in 0..4 { g.add_edge(i, i + 1).unwrap(); }
52/// let r = closeness_cutoff(&g, 2, true).unwrap();
53/// // Vertex 0: reaches 1 (dist 1) and 2 (dist 2) within cutoff 2.
54/// // closeness = 2 / (1+2) = 2/3
55/// let expected = 2.0 / 3.0;
56/// assert!((r.scores[0].unwrap() - expected).abs() < 1e-12);
57/// assert_eq!(r.reachable_count[0], 2);
58/// ```
59pub fn closeness_cutoff(
60    graph: &Graph,
61    cutoff: u32,
62    normalized: bool,
63) -> IgraphResult<ClosenessCutoffResult> {
64    let n = graph.vcount();
65    let n_us = n as usize;
66    let mut scores = Vec::with_capacity(n_us);
67    let mut reachable_count = Vec::with_capacity(n_us);
68    let mut all_reachable = true;
69
70    for v in 0..n {
71        let (sum_dist, reach) = bfs_cutoff(graph, v, cutoff)?;
72
73        reachable_count.push(reach);
74
75        if reach == 0 || sum_dist == 0 {
76            scores.push(None);
77        } else if normalized {
78            #[allow(clippy::cast_precision_loss)]
79            let val = f64::from(reach) / f64::from(sum_dist);
80            scores.push(Some(val));
81        } else {
82            #[allow(clippy::cast_precision_loss)]
83            let val = 1.0 / f64::from(sum_dist);
84            scores.push(Some(val));
85        }
86
87        if reach < n.saturating_sub(1) {
88            all_reachable = false;
89        }
90    }
91
92    Ok(ClosenessCutoffResult {
93        scores,
94        reachable_count,
95        all_reachable,
96    })
97}
98
99/// BFS from `source` limited to `cutoff` hops.
100/// Returns `(sum_of_distances, reachable_count)` excluding self.
101fn bfs_cutoff(graph: &Graph, source: VertexId, cutoff: u32) -> IgraphResult<(u32, u32)> {
102    let n = graph.vcount() as usize;
103    let mut visited = vec![false; n];
104    visited[source as usize] = true;
105
106    let mut queue: VecDeque<(VertexId, u32)> = VecDeque::new();
107    queue.push_back((source, 0));
108
109    let mut sum_dist: u32 = 0;
110    let mut reach: u32 = 0;
111
112    while let Some((node, dist)) = queue.pop_front() {
113        if dist > cutoff {
114            continue;
115        }
116
117        if node != source {
118            sum_dist = sum_dist.saturating_add(dist);
119            reach = reach.saturating_add(1);
120        }
121
122        if dist == cutoff {
123            continue;
124        }
125
126        for &neighbor in &graph.neighbors(node)? {
127            if !visited[neighbor as usize] {
128                visited[neighbor as usize] = true;
129                queue.push_back((neighbor, dist.saturating_add(1)));
130            }
131        }
132    }
133
134    Ok((sum_dist, reach))
135}
136
137#[cfg(test)]
138#[allow(clippy::float_cmp)]
139mod tests {
140    use super::*;
141    use crate::core::Graph;
142
143    #[test]
144    fn empty_graph() {
145        let g = Graph::new(0, false).unwrap();
146        let r = closeness_cutoff(&g, 1, true).unwrap();
147        assert!(r.scores.is_empty());
148        assert!(r.all_reachable);
149    }
150
151    #[test]
152    fn single_vertex() {
153        let g = Graph::new(1, false).unwrap();
154        let r = closeness_cutoff(&g, 1, true).unwrap();
155        assert_eq!(r.scores[0], None);
156        assert_eq!(r.reachable_count[0], 0);
157        assert!(r.all_reachable);
158    }
159
160    #[test]
161    fn disconnected() {
162        let g = Graph::new(3, false).unwrap();
163        let r = closeness_cutoff(&g, 5, true).unwrap();
164        for s in &r.scores {
165            assert_eq!(*s, None);
166        }
167        assert!(!r.all_reachable);
168    }
169
170    #[test]
171    fn complete_graph_cutoff_1() {
172        let mut g = Graph::new(4, false).unwrap();
173        g.add_edges(vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)])
174            .unwrap();
175        let r = closeness_cutoff(&g, 1, true).unwrap();
176        // All vertices have distance 1 to all others.
177        // reach=3, sum=3 → closeness = 3/3 = 1.0
178        for s in &r.scores {
179            assert_eq!(*s, Some(1.0));
180        }
181        assert!(r.all_reachable);
182    }
183
184    #[test]
185    fn path_cutoff_1() {
186        // 0—1—2—3
187        let mut g = Graph::new(4, false).unwrap();
188        g.add_edges(vec![(0, 1), (1, 2), (2, 3)]).unwrap();
189        let r = closeness_cutoff(&g, 1, true).unwrap();
190        // Vertex 0: reaches only 1 → score = 1/1 = 1.0
191        assert_eq!(r.scores[0], Some(1.0));
192        assert_eq!(r.reachable_count[0], 1);
193        // Vertex 1: reaches 0 and 2 → reach=2, sum=2 → 2/2=1.0
194        assert_eq!(r.scores[1], Some(1.0));
195        assert_eq!(r.reachable_count[1], 2);
196        assert!(!r.all_reachable);
197    }
198
199    #[test]
200    fn path_cutoff_2() {
201        // 0—1—2—3—4
202        let mut g = Graph::new(5, false).unwrap();
203        g.add_edges(vec![(0, 1), (1, 2), (2, 3), (3, 4)]).unwrap();
204        let r = closeness_cutoff(&g, 2, true).unwrap();
205        // Vertex 0: reaches 1 (d=1), 2 (d=2) → reach=2, sum=3 → 2/3
206        let expected = 2.0 / 3.0;
207        assert!((r.scores[0].unwrap() - expected).abs() < 1e-12);
208        assert_eq!(r.reachable_count[0], 2);
209        assert!(!r.all_reachable);
210    }
211
212    #[test]
213    fn large_cutoff_equals_closeness() {
214        // With cutoff larger than diameter, results should equal regular closeness.
215        let mut g = Graph::new(4, false).unwrap();
216        g.add_edges(vec![(0, 1), (1, 2), (2, 3)]).unwrap();
217        let r = closeness_cutoff(&g, 100, true).unwrap();
218        assert!(r.all_reachable);
219        // Vertex 0: reaches 3, sum = 1+2+3=6 → 3/6 = 0.5
220        assert!((r.scores[0].unwrap() - 0.5).abs() < 1e-12);
221        // Vertex 1: reaches 3, sum = 1+1+2=4 → 3/4 = 0.75
222        assert!((r.scores[1].unwrap() - 0.75).abs() < 1e-12);
223    }
224
225    #[test]
226    fn normalized_false() {
227        // 0—1—2
228        let mut g = Graph::new(3, false).unwrap();
229        g.add_edges(vec![(0, 1), (1, 2)]).unwrap();
230        let r = closeness_cutoff(&g, 10, false).unwrap();
231        // Vertex 0: sum = 1+2=3 → 1/3
232        let expected = 1.0 / 3.0;
233        assert!((r.scores[0].unwrap() - expected).abs() < 1e-12);
234        // Vertex 1: sum = 1+1=2 → 1/2
235        assert!((r.scores[1].unwrap() - 0.5).abs() < 1e-12);
236    }
237
238    #[test]
239    fn cutoff_zero() {
240        let mut g = Graph::new(3, false).unwrap();
241        g.add_edges(vec![(0, 1), (1, 2)]).unwrap();
242        let r = closeness_cutoff(&g, 0, true).unwrap();
243        // With cutoff 0, no vertex can reach any other.
244        for s in &r.scores {
245            assert_eq!(*s, None);
246        }
247    }
248
249    #[test]
250    fn directed_graph() {
251        // 0→1→2
252        let mut g = Graph::new(3, true).unwrap();
253        g.add_edges(vec![(0, 1), (1, 2)]).unwrap();
254        let r = closeness_cutoff(&g, 10, true).unwrap();
255        // Vertex 0: reaches 1 (d=1), 2 (d=2) → reach=2, sum=3 → 2/3
256        let expected = 2.0 / 3.0;
257        assert!((r.scores[0].unwrap() - expected).abs() < 1e-12);
258        // Vertex 2: can't reach anyone in directed mode
259        assert_eq!(r.scores[2], None);
260    }
261
262    #[test]
263    fn star_cutoff_1() {
264        // Star: 0 connected to 1,2,3,4
265        let mut g = Graph::new(5, false).unwrap();
266        g.add_edges(vec![(0, 1), (0, 2), (0, 3), (0, 4)]).unwrap();
267        let r = closeness_cutoff(&g, 1, true).unwrap();
268        // Centre reaches all 4 at distance 1: reach=4, sum=4 → 1.0
269        assert_eq!(r.scores[0], Some(1.0));
270        assert_eq!(r.reachable_count[0], 4);
271        // Leaves reach only centre at distance 1: reach=1, sum=1 → 1.0
272        assert_eq!(r.scores[1], Some(1.0));
273        assert_eq!(r.reachable_count[1], 1);
274        assert!(!r.all_reachable);
275    }
276}