Skip to main content

rust_igraph/algorithms/properties/
harmonic_cutoff.rs

1//! Range-limited harmonic centrality (ALGO-PR-048).
2//!
3//! Counterpart of `igraph_harmonic_centrality_cutoff()` from
4//! `references/igraph/src/centrality/closeness.c:722`.
5//!
6//! Computes harmonic centrality considering only shortest paths whose
7//! length is at most the given cutoff. The inverse distance to vertices
8//! not reachable within the cutoff is considered to be zero.
9
10use crate::core::{Graph, IgraphResult, VertexId};
11use std::collections::VecDeque;
12
13/// Range-limited harmonic centrality.
14///
15/// Computes the sum (or mean) of inverse distances to vertices reachable
16/// within `cutoff` hops. Vertices beyond the cutoff contribute 0, same as
17/// unreachable vertices.
18///
19/// When `normalized = true`, returns `(1/(n-1)) * sum(1/d)` (mean inverse
20/// distance). When `normalized = false`, returns the raw `sum(1/d)`.
21///
22/// For graphs with `vcount < 2`, returns zeros (the normalization factor
23/// `n-1` would be 0).
24///
25/// # Parameters
26///
27/// * `graph` — the input graph.
28/// * `cutoff` — maximum path length to consider.
29/// * `normalized` — if `true`, divides sum by `n - 1`.
30///
31/// # Examples
32///
33/// ```
34/// use rust_igraph::{Graph, harmonic_centrality_cutoff};
35///
36/// // Path: 0—1—2—3—4
37/// let mut g = Graph::with_vertices(5);
38/// for i in 0..4 { g.add_edge(i, i + 1).unwrap(); }
39/// let h = harmonic_centrality_cutoff(&g, 2, true).unwrap();
40/// // Vertex 0: reaches 1 (d=1) and 2 (d=2) within cutoff 2.
41/// // sum_inv = 1/1 + 1/2 = 1.5, normalized by (5-1)=4 → 0.375
42/// let expected = 1.5 / 4.0;
43/// assert!((h[0] - expected).abs() < 1e-12);
44/// ```
45pub fn harmonic_centrality_cutoff(
46    graph: &Graph,
47    cutoff: u32,
48    normalized: bool,
49) -> IgraphResult<Vec<f64>> {
50    let n = graph.vcount();
51    let n_us = n as usize;
52
53    if n < 2 {
54        return Ok(vec![0.0; n_us]);
55    }
56
57    let denom = f64::from(n - 1);
58    let mut out = Vec::with_capacity(n_us);
59
60    for v in 0..n {
61        let sum_inv = bfs_harmonic_cutoff(graph, v, cutoff)?;
62        if normalized {
63            out.push(sum_inv / denom);
64        } else {
65            out.push(sum_inv);
66        }
67    }
68
69    Ok(out)
70}
71
72/// BFS from `source` limited to `cutoff` hops.
73/// Returns sum of `1/distance` for all reachable vertices (excluding self).
74fn bfs_harmonic_cutoff(graph: &Graph, source: VertexId, cutoff: u32) -> IgraphResult<f64> {
75    let n = graph.vcount() as usize;
76    let mut visited = vec![false; n];
77    visited[source as usize] = true;
78
79    let mut queue: VecDeque<(VertexId, u32)> = VecDeque::new();
80    queue.push_back((source, 0));
81
82    let mut sum_inv: f64 = 0.0;
83
84    while let Some((node, dist)) = queue.pop_front() {
85        if dist > cutoff {
86            continue;
87        }
88
89        if node != source && dist > 0 {
90            sum_inv += 1.0 / f64::from(dist);
91        }
92
93        if dist == cutoff {
94            continue;
95        }
96
97        for &neighbor in &graph.neighbors(node)? {
98            if !visited[neighbor as usize] {
99                visited[neighbor as usize] = true;
100                queue.push_back((neighbor, dist.saturating_add(1)));
101            }
102        }
103    }
104
105    Ok(sum_inv)
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::core::Graph;
112
113    #[test]
114    fn empty_graph() {
115        let g = Graph::new(0, false).unwrap();
116        let h = harmonic_centrality_cutoff(&g, 1, true).unwrap();
117        assert!(h.is_empty());
118    }
119
120    #[test]
121    fn single_vertex() {
122        let g = Graph::new(1, false).unwrap();
123        let h = harmonic_centrality_cutoff(&g, 1, true).unwrap();
124        assert_eq!(h, vec![0.0]);
125    }
126
127    #[test]
128    fn disconnected() {
129        let g = Graph::new(3, false).unwrap();
130        let h = harmonic_centrality_cutoff(&g, 5, true).unwrap();
131        assert_eq!(h, vec![0.0, 0.0, 0.0]);
132    }
133
134    #[test]
135    fn complete_graph_cutoff_1() {
136        let mut g = Graph::new(4, false).unwrap();
137        g.add_edges(vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)])
138            .unwrap();
139        let h = harmonic_centrality_cutoff(&g, 1, true).unwrap();
140        // All vertices at distance 1 → sum_inv=3, normalized by 3 → 1.0
141        for &val in &h {
142            assert!((val - 1.0).abs() < 1e-12);
143        }
144    }
145
146    #[test]
147    fn path_cutoff_1() {
148        // 0—1—2—3
149        let mut g = Graph::new(4, false).unwrap();
150        g.add_edges(vec![(0, 1), (1, 2), (2, 3)]).unwrap();
151        let h = harmonic_centrality_cutoff(&g, 1, true).unwrap();
152        // Vertex 0: reaches 1 at d=1 → sum_inv=1.0, /3 = 1/3
153        let expected = 1.0 / 3.0;
154        assert!((h[0] - expected).abs() < 1e-12);
155        // Vertex 1: reaches 0,2 at d=1 → sum_inv=2.0, /3 = 2/3
156        let expected1 = 2.0 / 3.0;
157        assert!((h[1] - expected1).abs() < 1e-12);
158    }
159
160    #[test]
161    fn path_cutoff_2() {
162        // 0—1—2—3—4
163        let mut g = Graph::new(5, false).unwrap();
164        g.add_edges(vec![(0, 1), (1, 2), (2, 3), (3, 4)]).unwrap();
165        let h = harmonic_centrality_cutoff(&g, 2, true).unwrap();
166        // Vertex 0: reaches 1(d=1), 2(d=2) → sum_inv=1+0.5=1.5, /4=0.375
167        let expected = 1.5 / 4.0;
168        assert!((h[0] - expected).abs() < 1e-12);
169    }
170
171    #[test]
172    fn large_cutoff_matches_harmonic() {
173        // With cutoff > diameter, should equal regular harmonic centrality.
174        // Path 0—1—2
175        let mut g = Graph::new(3, false).unwrap();
176        g.add_edges(vec![(0, 1), (1, 2)]).unwrap();
177        let h = harmonic_centrality_cutoff(&g, 100, true).unwrap();
178        // Vertex 0: 1/1 + 1/2 = 1.5, /2 = 0.75
179        assert!((h[0] - 0.75).abs() < 1e-12);
180        // Vertex 1: 1/1 + 1/1 = 2.0, /2 = 1.0
181        assert!((h[1] - 1.0).abs() < 1e-12);
182    }
183
184    #[test]
185    fn normalized_false() {
186        // 0—1—2
187        let mut g = Graph::new(3, false).unwrap();
188        g.add_edges(vec![(0, 1), (1, 2)]).unwrap();
189        let h = harmonic_centrality_cutoff(&g, 10, false).unwrap();
190        // Vertex 0: 1/1 + 1/2 = 1.5
191        assert!((h[0] - 1.5).abs() < 1e-12);
192        // Vertex 1: 1/1 + 1/1 = 2.0
193        assert!((h[1] - 2.0).abs() < 1e-12);
194    }
195
196    #[test]
197    fn cutoff_zero() {
198        let mut g = Graph::new(3, false).unwrap();
199        g.add_edges(vec![(0, 1), (1, 2)]).unwrap();
200        let h = harmonic_centrality_cutoff(&g, 0, true).unwrap();
201        // No vertices reachable → all zeros
202        assert_eq!(h, vec![0.0, 0.0, 0.0]);
203    }
204
205    #[test]
206    fn directed_graph() {
207        // 0→1→2
208        let mut g = Graph::new(3, true).unwrap();
209        g.add_edges(vec![(0, 1), (1, 2)]).unwrap();
210        let h = harmonic_centrality_cutoff(&g, 10, true).unwrap();
211        // Vertex 0: reaches 1(d=1), 2(d=2) → (1+0.5)/2 = 0.75
212        assert!((h[0] - 0.75).abs() < 1e-12);
213        // Vertex 2: can't reach anyone → 0.0
214        assert!((h[2] - 0.0).abs() < 1e-12);
215    }
216
217    #[test]
218    fn star_cutoff_1() {
219        // Star: 0 connected to 1,2,3,4
220        let mut g = Graph::new(5, false).unwrap();
221        g.add_edges(vec![(0, 1), (0, 2), (0, 3), (0, 4)]).unwrap();
222        let h = harmonic_centrality_cutoff(&g, 1, true).unwrap();
223        // Centre: reaches 4 vertices at d=1 → sum=4, /4 = 1.0
224        assert!((h[0] - 1.0).abs() < 1e-12);
225        // Leaf: reaches only centre at d=1 → sum=1, /4 = 0.25
226        assert!((h[1] - 0.25).abs() < 1e-12);
227    }
228}