Skip to main content

rust_igraph/algorithms/properties/
betweenness_weighted.rs

1//! Weighted betweenness centrality (ALGO-PR-008b).
2//!
3//! Counterpart of `igraph_betweenness(_, _, vss_all(), directed,
4//! &weights)` from
5//! `references/igraph/src/centrality/betweenness.c`. Brandes' (2001)
6//! algorithm with Dijkstra in place of BFS:
7//!
8//! 1. For each source `s`, run Dijkstra. While relaxing edge
9//!    `(v, w, weight)`:
10//!    - if `d[v] + weight < d[w]`: `pred[w] = [v]`, `sigma[w] = sigma[v]`
11//!    - if `d[v] + weight == d[w]`: `pred[w].push(v)`, `sigma[w] += sigma[v]`
12//! 2. Record vertices in the order they're popped (final-distance order)
13//!    in a stack `S`.
14//! 3. Process `S` in reverse to accumulate dependencies the same way as
15//!    the unweighted variant.
16//!
17//! Phase-1 minimal slice: directed/OUT or undirected (divided by 2),
18//! raw counts. Weights must be non-negative + finite (forwarded from
19//! the Dijkstra building blocks).
20
21use std::cmp::Ordering;
22use std::collections::BinaryHeap;
23
24use crate::core::graph::EdgeId;
25use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
26
27/// Min-heap entry. Reversed so `BinaryHeap` (max-heap) pops the
28/// smallest distance first. `total_cmp` is fine because `dijkstra`-
29/// style entry points reject NaN / negative / non-finite weights.
30#[derive(Copy, Clone)]
31struct Frontier(f64, VertexId);
32
33impl PartialEq for Frontier {
34    fn eq(&self, other: &Self) -> bool {
35        self.0 == other.0 && self.1 == other.1
36    }
37}
38impl Eq for Frontier {}
39impl Ord for Frontier {
40    fn cmp(&self, other: &Self) -> Ordering {
41        other.0.total_cmp(&self.0).then(other.1.cmp(&self.1))
42    }
43}
44impl PartialOrd for Frontier {
45    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
46        Some(self.cmp(other))
47    }
48}
49
50/// Per-vertex weighted betweenness centrality.
51///
52/// Returns `Vec<f64>` of length `vcount`. Raw (unnormalised) counts:
53/// for undirected graphs the result is divided by 2 to match the
54/// unweighted variant (each unordered pair is counted once).
55///
56/// `weights[e]` is the weight of edge `e`; `weights.len()` must equal
57/// `graph.ecount()`. All weights must be `>= 0` and finite.
58///
59/// # Examples
60///
61/// ```
62/// use rust_igraph::{Graph, betweenness_weighted};
63///
64/// // Path 0-1-2-3-4, all weights 1.0 → matches unweighted result.
65/// let mut g = Graph::with_vertices(5);
66/// for i in 0..4u32 { g.add_edge(i, i + 1).unwrap(); }
67/// let b = betweenness_weighted(&g, &[1.0; 4]).unwrap();
68/// // Same as unweighted PR-008 path test.
69/// assert_eq!(b, vec![0.0, 3.0, 4.0, 3.0, 0.0]);
70/// ```
71pub fn betweenness_weighted(graph: &Graph, weights: &[f64]) -> IgraphResult<Vec<f64>> {
72    let n = graph.vcount();
73    let n_us = n as usize;
74    let mut bc = vec![0.0_f64; n_us];
75
76    if n == 0 {
77        return Ok(bc);
78    }
79
80    let m = graph.ecount();
81    if weights.len() != m {
82        return Err(IgraphError::InvalidArgument(format!(
83            "weights vector size ({}) differs from edge count ({})",
84            weights.len(),
85            m
86        )));
87    }
88    for (e, &w) in weights.iter().enumerate() {
89        if w.is_nan() || w < 0.0 || !w.is_finite() {
90            return Err(IgraphError::InvalidArgument(format!(
91                "weight at edge {e} must be non-negative and finite (got {w})"
92            )));
93        }
94    }
95
96    // Per-source state, allocated once and reused.
97    let mut sigma = vec![0.0_f64; n_us];
98    let mut dist = vec![f64::INFINITY; n_us];
99    let mut visited = vec![false; n_us];
100    let mut pred: Vec<Vec<VertexId>> = vec![Vec::new(); n_us];
101    let mut stack: Vec<VertexId> = Vec::with_capacity(n_us);
102    let mut delta = vec![0.0_f64; n_us];
103
104    // Tolerance for "equal distance" comparison: zero-weight edges can
105    // produce numerically-identical alternate paths. Brandes' algorithm
106    // is robust under exact equality; we don't add a fudge factor here.
107
108    for s in 0..n {
109        // Reset per-source state.
110        sigma.fill(0.0);
111        dist.fill(f64::INFINITY);
112        visited.fill(false);
113        for slot in &mut pred {
114            slot.clear();
115        }
116        stack.clear();
117        delta.fill(0.0);
118
119        sigma[s as usize] = 1.0;
120        dist[s as usize] = 0.0;
121        let mut heap: BinaryHeap<Frontier> = BinaryHeap::new();
122        heap.push(Frontier(0.0, s));
123
124        while let Some(Frontier(d, v)) = heap.pop() {
125            let v_us = v as usize;
126            if visited[v_us] {
127                continue;
128            }
129            visited[v_us] = true;
130            stack.push(v);
131
132            for eid in graph.incident(v)? {
133                let w_edge = weights[eid as usize];
134                let other = graph.edge_other(eid as EdgeId, v)?;
135                let other_us = other as usize;
136                let nd = d + w_edge;
137                match nd.partial_cmp(&dist[other_us]) {
138                    Some(Ordering::Less) => {
139                        dist[other_us] = nd;
140                        sigma[other_us] = sigma[v_us];
141                        pred[other_us].clear();
142                        pred[other_us].push(v);
143                        heap.push(Frontier(nd, other));
144                    }
145                    Some(Ordering::Equal) => {
146                        sigma[other_us] += sigma[v_us];
147                        pred[other_us].push(v);
148                    }
149                    _ => {} // strictly greater, or NaN (already rejected)
150                }
151            }
152        }
153
154        // Accumulate dependencies in reverse final-distance order.
155        while let Some(w) = stack.pop() {
156            let w_us = w as usize;
157            for &v in &pred[w_us] {
158                delta[v as usize] += (sigma[v as usize] / sigma[w_us]) * (1.0 + delta[w_us]);
159            }
160            if w != s {
161                bc[w_us] += delta[w_us];
162            }
163        }
164    }
165
166    if !graph.is_directed() {
167        for slot in &mut bc {
168            *slot /= 2.0;
169        }
170    }
171
172    Ok(bc)
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn close(actual: &[f64], expected: &[f64], tol: f64) {
180        assert_eq!(actual.len(), expected.len(), "length mismatch");
181        for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
182            assert!((a - e).abs() < tol, "vertex {i}: actual={a} expected={e}");
183        }
184    }
185
186    #[test]
187    fn empty_graph_yields_empty() {
188        let g = Graph::with_vertices(0);
189        assert!(betweenness_weighted(&g, &[]).unwrap().is_empty());
190    }
191
192    #[test]
193    fn isolated_vertices_all_zero() {
194        let g = Graph::with_vertices(5);
195        let b = betweenness_weighted(&g, &[]).unwrap();
196        assert_eq!(b, vec![0.0; 5]);
197    }
198
199    #[test]
200    fn unit_weights_match_unweighted_path_5() {
201        // Path 0-1-2-3-4 with unit weights → same as PR-008 path test.
202        let mut g = Graph::with_vertices(5);
203        for i in 0..4u32 {
204            g.add_edge(i, i + 1).unwrap();
205        }
206        let b = betweenness_weighted(&g, &[1.0; 4]).unwrap();
207        close(&b, &[0.0, 3.0, 4.0, 3.0, 0.0], 1e-12);
208    }
209
210    #[test]
211    fn weighted_path_swaps_route_via_higher_weight() {
212        // 3-vertex graph (0,1), (1,2), (0,2); weights make the direct
213        // (0,2) edge longer than 0->1->2: 0-1 w=1, 1-2 w=1, 0-2 w=5.
214        // Shortest path 0-2 is via 1, so vertex 1 is a betweenness
215        // intermediary. Brandes raw count: 2 (one ordered pair from
216        // each direction); undirected halve → 1.0.
217        let mut g = Graph::with_vertices(3);
218        g.add_edge(0, 1).unwrap();
219        g.add_edge(1, 2).unwrap();
220        g.add_edge(0, 2).unwrap();
221        let b = betweenness_weighted(&g, &[1.0, 1.0, 5.0]).unwrap();
222        close(&b, &[0.0, 1.0, 0.0], 1e-12);
223    }
224
225    #[test]
226    fn weighted_path_keeps_direct_when_cheaper() {
227        // Same 3-vertex graph but direct (0,2) cheaper. Vertex 1
228        // betweenness drops to 0.
229        let mut g = Graph::with_vertices(3);
230        g.add_edge(0, 1).unwrap();
231        g.add_edge(1, 2).unwrap();
232        g.add_edge(0, 2).unwrap();
233        let b = betweenness_weighted(&g, &[5.0, 5.0, 1.0]).unwrap();
234        close(&b, &[0.0, 0.0, 0.0], 1e-12);
235    }
236
237    #[test]
238    fn directed_unit_weights_match_unweighted() {
239        // 0->1->2: betweenness[1] = 1 (one ordered pair (0,2)).
240        let mut g = Graph::new(3, true).unwrap();
241        g.add_edge(0, 1).unwrap();
242        g.add_edge(1, 2).unwrap();
243        let b = betweenness_weighted(&g, &[1.0, 1.0]).unwrap();
244        close(&b, &[0.0, 1.0, 0.0], 1e-12);
245    }
246
247    #[test]
248    fn k4_complete_unit_weights_all_zero() {
249        // K4 + unit weights → no vertex on a 2-hop shortest path.
250        let mut g = Graph::with_vertices(4);
251        for u in 0..4u32 {
252            for v in (u + 1)..4 {
253                g.add_edge(u, v).unwrap();
254            }
255        }
256        let b = betweenness_weighted(&g, &[1.0; 6]).unwrap();
257        close(&b, &[0.0; 4], 1e-12);
258    }
259
260    #[test]
261    fn weights_size_mismatch_errors() {
262        let mut g = Graph::with_vertices(2);
263        g.add_edge(0, 1).unwrap();
264        assert!(betweenness_weighted(&g, &[]).is_err());
265    }
266
267    #[test]
268    fn negative_weight_errors() {
269        let mut g = Graph::with_vertices(2);
270        g.add_edge(0, 1).unwrap();
271        assert!(betweenness_weighted(&g, &[-1.0]).is_err());
272    }
273
274    #[test]
275    fn nan_weight_errors() {
276        let mut g = Graph::with_vertices(2);
277        g.add_edge(0, 1).unwrap();
278        assert!(betweenness_weighted(&g, &[f64::NAN]).is_err());
279    }
280}