Skip to main content

rust_igraph/algorithms/properties/
betweenness_cutoff.rs

1//! Range-limited betweenness centrality (ALGO-PR-049).
2//!
3//! Counterpart of `igraph_betweenness_cutoff()` from
4//! `references/igraph/src/centrality/betweenness.c:553`.
5//!
6//! Computes betweenness centrality using only shortest paths whose
7//! length does not exceed a given cutoff. Uses Brandes' algorithm
8//! with a BFS depth bound.
9
10use std::collections::VecDeque;
11
12use crate::core::{Graph, IgraphResult, VertexId};
13
14/// Range-limited betweenness centrality.
15///
16/// Computes betweenness centrality for all vertices using only shortest
17/// paths of length at most `cutoff`. Shorter cutoffs reduce computation
18/// time by limiting BFS depth, capturing local betweenness structure.
19///
20/// For undirected graphs the result is halved (each unordered pair
21/// counted once). This matches igraph's convention.
22///
23/// # Parameters
24///
25/// * `graph` — the input graph.
26/// * `cutoff` — maximum shortest-path length to consider.
27///
28/// # Examples
29///
30/// ```
31/// use rust_igraph::{Graph, betweenness_cutoff};
32///
33/// // Path 0—1—2—3—4
34/// let mut g = Graph::with_vertices(5);
35/// for i in 0..4u32 { g.add_edge(i, i + 1).unwrap(); }
36/// // With cutoff=2, vertex 2 lies on paths of length ≤2:
37/// // (1,3) only. (0,2) passes through 1, not 2.
38/// let b = betweenness_cutoff(&g, 2).unwrap();
39/// // All betweenness ≤ full betweenness
40/// assert!(b[2] <= 4.0);
41/// ```
42pub fn betweenness_cutoff(graph: &Graph, cutoff: u32) -> IgraphResult<Vec<f64>> {
43    let n = graph.vcount();
44    let n_us = n as usize;
45    let mut betweenness = vec![0.0_f64; n_us];
46
47    if n == 0 {
48        return Ok(betweenness);
49    }
50
51    let mut sigma = vec![0.0_f64; n_us];
52    let mut dist = vec![-1_i64; n_us];
53    let mut pred: Vec<Vec<VertexId>> = vec![Vec::new(); n_us];
54    let mut stack: Vec<VertexId> = Vec::with_capacity(n_us);
55    let mut delta = vec![0.0_f64; n_us];
56    let cutoff_i64 = i64::from(cutoff);
57
58    for s in 0..n {
59        sigma.fill(0.0);
60        dist.fill(-1);
61        for slot in &mut pred {
62            slot.clear();
63        }
64        stack.clear();
65        delta.fill(0.0);
66
67        sigma[s as usize] = 1.0;
68        dist[s as usize] = 0;
69        let mut queue: VecDeque<VertexId> = VecDeque::new();
70        queue.push_back(s);
71
72        while let Some(v) = queue.pop_front() {
73            stack.push(v);
74            let v_dist = dist[v as usize];
75
76            if v_dist >= cutoff_i64 {
77                continue;
78            }
79
80            for w in graph.neighbors(v)? {
81                if dist[w as usize] < 0 {
82                    queue.push_back(w);
83                    dist[w as usize] = v_dist + 1;
84                }
85                if dist[w as usize] == v_dist + 1 {
86                    sigma[w as usize] += sigma[v as usize];
87                    pred[w as usize].push(v);
88                }
89            }
90        }
91
92        while let Some(w) = stack.pop() {
93            for &v in &pred[w as usize] {
94                delta[v as usize] +=
95                    (sigma[v as usize] / sigma[w as usize]) * (1.0 + delta[w as usize]);
96            }
97            if w != s {
98                betweenness[w as usize] += delta[w as usize];
99            }
100        }
101    }
102
103    if !graph.is_directed() {
104        for slot in &mut betweenness {
105            *slot /= 2.0;
106        }
107    }
108
109    Ok(betweenness)
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    fn close(actual: &[f64], expected: &[f64], tol: f64) {
117        assert_eq!(actual.len(), expected.len(), "length mismatch");
118        for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
119            assert!((a - e).abs() < tol, "vertex {i}: actual={a} expected={e}");
120        }
121    }
122
123    #[test]
124    fn empty_graph() {
125        let g = Graph::new(0, false).unwrap();
126        let b = betweenness_cutoff(&g, 1).unwrap();
127        assert!(b.is_empty());
128    }
129
130    #[test]
131    fn single_vertex() {
132        let g = Graph::new(1, false).unwrap();
133        let b = betweenness_cutoff(&g, 1).unwrap();
134        assert_eq!(b, vec![0.0]);
135    }
136
137    #[test]
138    fn disconnected() {
139        let g = Graph::new(3, false).unwrap();
140        let b = betweenness_cutoff(&g, 5).unwrap();
141        assert_eq!(b, vec![0.0, 0.0, 0.0]);
142    }
143
144    #[test]
145    fn path_full_cutoff() {
146        // 0—1—2—3—4 with cutoff large enough → same as regular betweenness
147        let mut g = Graph::new(5, false).unwrap();
148        g.add_edges(vec![(0, 1), (1, 2), (2, 3), (3, 4)]).unwrap();
149        let b = betweenness_cutoff(&g, 100).unwrap();
150        close(&b, &[0.0, 3.0, 4.0, 3.0, 0.0], 1e-12);
151    }
152
153    #[test]
154    fn path_cutoff_1() {
155        // 0—1—2—3—4 with cutoff=1: only direct neighbors count.
156        // No vertex is on a shortest path of length 1 between two *other* vertices.
157        let mut g = Graph::new(5, false).unwrap();
158        g.add_edges(vec![(0, 1), (1, 2), (2, 3), (3, 4)]).unwrap();
159        let b = betweenness_cutoff(&g, 1).unwrap();
160        close(&b, &[0.0, 0.0, 0.0, 0.0, 0.0], 1e-12);
161    }
162
163    #[test]
164    fn path_cutoff_2() {
165        // 0—1—2—3—4 with cutoff=2
166        // Paths of length ≤ 2: (0,1),(1,2),(2,3),(3,4) length 1 +
167        //   (0,2) through 1, (1,3) through 2, (2,4) through 3
168        // Betweenness: v1 on (0,2)=1, v2 on (1,3)=1, v3 on (2,4)=1
169        let mut g = Graph::new(5, false).unwrap();
170        g.add_edges(vec![(0, 1), (1, 2), (2, 3), (3, 4)]).unwrap();
171        let b = betweenness_cutoff(&g, 2).unwrap();
172        close(&b, &[0.0, 1.0, 1.0, 1.0, 0.0], 1e-12);
173    }
174
175    #[test]
176    fn star_cutoff_2() {
177        // Star: 0 connected to 1,2,3,4
178        // With cutoff=2, paths (i,j) for i,j in {1,2,3,4} through centre = C(4,2)=6
179        let mut g = Graph::new(5, false).unwrap();
180        g.add_edges(vec![(0, 1), (0, 2), (0, 3), (0, 4)]).unwrap();
181        let b = betweenness_cutoff(&g, 2).unwrap();
182        // Centre has betweenness 6.0 (all pairs go through it)
183        close(&b, &[6.0, 0.0, 0.0, 0.0, 0.0], 1e-12);
184    }
185
186    #[test]
187    fn triangle() {
188        // Triangle 0—1—2—0: all betweenness = 0 (all paths have length 1)
189        let mut g = Graph::new(3, false).unwrap();
190        g.add_edges(vec![(0, 1), (1, 2), (2, 0)]).unwrap();
191        let b = betweenness_cutoff(&g, 10).unwrap();
192        close(&b, &[0.0, 0.0, 0.0], 1e-12);
193    }
194
195    #[test]
196    fn directed_path() {
197        // 0→1→2→3
198        let mut g = Graph::new(4, true).unwrap();
199        g.add_edges(vec![(0, 1), (1, 2), (2, 3)]).unwrap();
200        let b = betweenness_cutoff(&g, 100).unwrap();
201        // directed: vertex 1 on (0,2),(0,3) = 2; vertex 2 on (0,3),(1,3) = 2
202        close(&b, &[0.0, 2.0, 2.0, 0.0], 1e-12);
203    }
204
205    #[test]
206    fn directed_cutoff_2() {
207        // 0→1→2→3 with cutoff=2
208        // Paths of length ≤ 2: (0,1),(1,2),(2,3) len 1 + (0,2) through 1, (1,3) through 2
209        // v1 on (0,2)=1; v2 on (1,3)=1
210        let mut g = Graph::new(4, true).unwrap();
211        g.add_edges(vec![(0, 1), (1, 2), (2, 3)]).unwrap();
212        let b = betweenness_cutoff(&g, 2).unwrap();
213        close(&b, &[0.0, 1.0, 1.0, 0.0], 1e-12);
214    }
215
216    #[test]
217    fn cutoff_zero() {
218        let mut g = Graph::new(3, false).unwrap();
219        g.add_edges(vec![(0, 1), (1, 2)]).unwrap();
220        let b = betweenness_cutoff(&g, 0).unwrap();
221        // No paths of positive length → all zeros
222        close(&b, &[0.0, 0.0, 0.0], 1e-12);
223    }
224}