Skip to main content

rust_igraph/algorithms/properties/
edge_betweenness_weighted.rs

1//! Weighted edge betweenness centrality (ALGO-PR-010b).
2//!
3//! Counterpart of `igraph_edge_betweenness(_, _, all_eids, directed,
4//! &weights)` from `references/igraph/src/centrality/betweenness.c`.
5//! Same Brandes-Dijkstra framework as
6//! [`crate::betweenness_weighted`] (PR-008b) but the dependency is
7//! deposited on edges rather than vertices.
8//!
9//! Phase-1 minimal slice: undirected/directed-OUT, raw (unnormalised)
10//! counts. Weights must be non-negative + finite.
11
12use std::cmp::Ordering;
13use std::collections::BinaryHeap;
14
15use crate::core::graph::EdgeId;
16use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
17
18/// Min-heap entry. Reversed ordering so `BinaryHeap` (max-heap) pops
19/// the smallest distance first. NaN/negative weights are rejected by
20/// the public-API entry point so `total_cmp` is safe.
21#[derive(Copy, Clone)]
22struct Frontier(f64, VertexId);
23
24impl PartialEq for Frontier {
25    fn eq(&self, other: &Self) -> bool {
26        self.0 == other.0 && self.1 == other.1
27    }
28}
29impl Eq for Frontier {}
30impl Ord for Frontier {
31    fn cmp(&self, other: &Self) -> Ordering {
32        other.0.total_cmp(&self.0).then(other.1.cmp(&self.1))
33    }
34}
35impl PartialOrd for Frontier {
36    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
37        Some(self.cmp(other))
38    }
39}
40
41/// Per-edge weighted betweenness centrality (`Vec<f64>`).
42///
43/// `result[e]` is the raw count of shortest paths between all `(s, t)`
44/// pairs that use edge `e`. Undirected results are halved.
45///
46/// `weights[e]` must be non-negative and finite; `weights.len()` must
47/// equal `graph.ecount()`.
48///
49/// # Examples
50///
51/// ```
52/// use rust_igraph::{Graph, edge_betweenness_weighted};
53///
54/// // 4-path with unit weights → matches PR-010 unweighted result.
55/// let mut g = Graph::with_vertices(4);
56/// for i in 0..3u32 { g.add_edge(i, i + 1).unwrap(); }
57/// let eb = edge_betweenness_weighted(&g, &[1.0, 1.0, 1.0]).unwrap();
58/// assert_eq!(eb, vec![3.0, 4.0, 3.0]);
59/// ```
60pub fn edge_betweenness_weighted(graph: &Graph, weights: &[f64]) -> IgraphResult<Vec<f64>> {
61    let n = graph.vcount();
62    let n_us = n as usize;
63    let m = graph.ecount();
64    let mut bc = vec![0.0_f64; m];
65
66    if n == 0 || m == 0 {
67        return Ok(bc);
68    }
69
70    if weights.len() != m {
71        return Err(IgraphError::InvalidArgument(format!(
72            "weights vector size ({}) differs from edge count ({})",
73            weights.len(),
74            m
75        )));
76    }
77    for (e, &w) in weights.iter().enumerate() {
78        if w.is_nan() || w < 0.0 || !w.is_finite() {
79            return Err(IgraphError::InvalidArgument(format!(
80                "weight at edge {e} must be non-negative and finite (got {w})"
81            )));
82        }
83    }
84
85    let mut sigma = vec![0.0_f64; n_us];
86    let mut dist = vec![f64::INFINITY; n_us];
87    let mut visited = vec![false; n_us];
88    // Predecessors store (vertex, edge-id pairs along shortest paths into w).
89    let mut pred: Vec<Vec<(VertexId, EdgeId)>> = vec![Vec::new(); n_us];
90    let mut stack: Vec<VertexId> = Vec::with_capacity(n_us);
91    let mut delta_v = vec![0.0_f64; n_us];
92
93    for s in 0..n {
94        sigma.fill(0.0);
95        dist.fill(f64::INFINITY);
96        visited.fill(false);
97        for slot in &mut pred {
98            slot.clear();
99        }
100        stack.clear();
101        delta_v.fill(0.0);
102
103        sigma[s as usize] = 1.0;
104        dist[s as usize] = 0.0;
105        let mut heap: BinaryHeap<Frontier> = BinaryHeap::new();
106        heap.push(Frontier(0.0, s));
107
108        while let Some(Frontier(d, v)) = heap.pop() {
109            let v_us = v as usize;
110            if visited[v_us] {
111                continue;
112            }
113            visited[v_us] = true;
114            stack.push(v);
115
116            for eid in graph.incident(v)? {
117                let w_edge = weights[eid as usize];
118                let other = graph.edge_other(eid as EdgeId, v)?;
119                let other_us = other as usize;
120                let nd = d + w_edge;
121                match nd.partial_cmp(&dist[other_us]) {
122                    Some(Ordering::Less) => {
123                        dist[other_us] = nd;
124                        sigma[other_us] = sigma[v_us];
125                        pred[other_us].clear();
126                        pred[other_us].push((v, eid as EdgeId));
127                        heap.push(Frontier(nd, other));
128                    }
129                    Some(Ordering::Equal) => {
130                        sigma[other_us] += sigma[v_us];
131                        pred[other_us].push((v, eid as EdgeId));
132                    }
133                    _ => {}
134                }
135            }
136        }
137
138        // Reverse final-distance order; deposit dependency on edges.
139        while let Some(w) = stack.pop() {
140            let w_us = w as usize;
141            for &(v, e) in &pred[w_us] {
142                let c = (sigma[v as usize] / sigma[w_us]) * (1.0 + delta_v[w_us]);
143                delta_v[v as usize] += c;
144                bc[e as usize] += c;
145            }
146        }
147    }
148
149    if !graph.is_directed() {
150        for slot in &mut bc {
151            *slot /= 2.0;
152        }
153    }
154
155    Ok(bc)
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    fn close(actual: &[f64], expected: &[f64], tol: f64) {
163        assert_eq!(actual.len(), expected.len(), "length mismatch");
164        for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
165            assert!((a - e).abs() < tol, "edge {i}: actual={a} expected={e}");
166        }
167    }
168
169    #[test]
170    fn empty_graph_yields_empty() {
171        let g = Graph::with_vertices(0);
172        assert!(edge_betweenness_weighted(&g, &[]).unwrap().is_empty());
173    }
174
175    #[test]
176    fn no_edges_yields_empty() {
177        let g = Graph::with_vertices(5);
178        assert!(edge_betweenness_weighted(&g, &[]).unwrap().is_empty());
179    }
180
181    #[test]
182    fn unit_weights_match_unweighted_path_4() {
183        // 0-1-2-3 → expected [3, 4, 3] (matches PR-010 fixture).
184        let mut g = Graph::with_vertices(4);
185        for i in 0..3u32 {
186            g.add_edge(i, i + 1).unwrap();
187        }
188        let eb = edge_betweenness_weighted(&g, &[1.0, 1.0, 1.0]).unwrap();
189        close(&eb, &[3.0, 4.0, 3.0], 1e-12);
190    }
191
192    #[test]
193    fn weighted_triangle_swaps_route() {
194        // Triangle 0-1-2 + chord 0-2 with weights (1, 1, 5). Path 0-1-2
195        // (cost 2) is shorter than chord 0-2 (cost 5), so chord stays
196        // unused. Brandes raw count: edges (0,1) and (1,2) each pick up
197        // pair (0,2) plus their own direct pair = 2 ordered each direction,
198        // halved → 2.0. Chord = 0.0.
199        let mut g = Graph::with_vertices(3);
200        g.add_edge(0, 1).unwrap();
201        g.add_edge(1, 2).unwrap();
202        g.add_edge(0, 2).unwrap();
203        let eb = edge_betweenness_weighted(&g, &[1.0, 1.0, 5.0]).unwrap();
204        close(&eb, &[2.0, 2.0, 0.0], 1e-12);
205    }
206
207    #[test]
208    fn weighted_triangle_keeps_direct() {
209        // Same triangle but chord wins (1.0 vs 5+5). Each unordered pair
210        // sits on its single direct edge → every edge has betweenness 1.0
211        // (matches K3 unweighted PR-010 semantics).
212        let mut g = Graph::with_vertices(3);
213        g.add_edge(0, 1).unwrap();
214        g.add_edge(1, 2).unwrap();
215        g.add_edge(0, 2).unwrap();
216        let eb = edge_betweenness_weighted(&g, &[5.0, 5.0, 1.0]).unwrap();
217        close(&eb, &[1.0, 1.0, 1.0], 1e-12);
218    }
219
220    #[test]
221    fn directed_unit_weights_match_unweighted_path_3() {
222        let mut g = Graph::new(3, true).unwrap();
223        g.add_edge(0, 1).unwrap();
224        g.add_edge(1, 2).unwrap();
225        let eb = edge_betweenness_weighted(&g, &[1.0, 1.0]).unwrap();
226        close(&eb, &[2.0, 2.0], 1e-12);
227    }
228
229    #[test]
230    fn k4_unit_weights_each_edge_one() {
231        // K4 with unit weights: every direct pair sits on its own edge
232        // only → each edge betweenness = 1.0 (raw=2 over both endpoints,
233        // halved to 1.0).
234        let mut g = Graph::with_vertices(4);
235        for u in 0..4u32 {
236            for v in (u + 1)..4 {
237                g.add_edge(u, v).unwrap();
238            }
239        }
240        let eb = edge_betweenness_weighted(&g, &[1.0; 6]).unwrap();
241        close(&eb, &[1.0; 6], 1e-12);
242    }
243
244    #[test]
245    fn weights_size_mismatch_errors() {
246        let mut g = Graph::with_vertices(2);
247        g.add_edge(0, 1).unwrap();
248        assert!(edge_betweenness_weighted(&g, &[]).is_err());
249    }
250
251    #[test]
252    fn negative_weight_errors() {
253        let mut g = Graph::with_vertices(2);
254        g.add_edge(0, 1).unwrap();
255        assert!(edge_betweenness_weighted(&g, &[-1.0]).is_err());
256    }
257
258    #[test]
259    fn nan_weight_errors() {
260        let mut g = Graph::with_vertices(2);
261        g.add_edge(0, 1).unwrap();
262        assert!(edge_betweenness_weighted(&g, &[f64::NAN]).is_err());
263    }
264}