Skip to main content

srcgraph_metrics/
betweenness.rs

1//! Betweenness centrality — Brandes' algorithm. The headline perf target
2//! versus networkx's Python-loop O(VE) implementation.
3//!
4//! Reference: Ulrik Brandes, "A Faster Algorithm for Betweenness Centrality",
5//! Journal of Mathematical Sociology 25(2):163-177, 2001.
6//!
7//! Time complexity: O(V·E) for unweighted graphs. We treat all edges as
8//! unweighted (BFS), since the visiting tool's class-dependency graphs don't
9//! carry semantically meaningful weights.
10//!
11//! See `DESIGN.md` (Phase 1) at the workspace root.
12//!
13//! @yah:ticket(R152-T4, "metrics::betweenness — Brandes' algorithm + perf benchmark vs networkx (headline win)")
14//! @yah:assignee(agent:claude)
15//! @yah:at(2026-05-12T22:14:30Z)
16//! @yah:status(review)
17//! @yah:parent(R152)
18//! @yah:verify("cargo test -p srcgraph-metrics betweenness")
19//! @yah:verify("cargo bench -p srcgraph-metrics --bench betweenness && python3 crates/srcgraph-metrics/benches/networkx_betweenness.py")
20//! @yah:handoff("Brandes' O(VE) betweenness implemented generic over N:ClassNode/E:EdgeKind; networkx-default normalization (1/((n-1)(n-2))) for directed graphs. 6 unit tests (path, hub, parallel-split-credit, isolated, empty, 3-cycle). Harness-less bench at benches/betweenness.rs writes synthetic Erdős–Rényi-ish DAGs to target/bench-graphs/, companion Python script benches/networkx_betweenness.py reads the same GraphML and times nx.betweenness_centrality. Result on aarch64: Rust 0.24/7.0/20.7/90/604 ms vs networkx 9.4/207/898/3975/28771 ms for n=100/500/1000/2000/5000 — roughly 30–48× speedup, growing with n. Headline win confirmed.")
21
22use petgraph::graph::{Graph, NodeIndex};
23use petgraph::visit::EdgeRef;
24use petgraph::Directed;
25use serde::{Deserialize, Serialize};
26use std::collections::VecDeque;
27
28use srcgraph_core::{ClassNode, EdgeKind};
29
30/// Per-node betweenness score, parallel to the input graph's node indices.
31///
32/// `scores[i]` is the centrality of `NodeIndex::new(i)`.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct BetweennessResult {
35    pub scores: Vec<f64>,
36    /// Whether `scores` were normalized to `[0, 1]` (matches networkx's default).
37    pub normalized: bool,
38}
39
40impl BetweennessResult {
41    pub fn get(&self, n: NodeIndex) -> f64 {
42        self.scores[n.index()]
43    }
44}
45
46/// Compute betweenness centrality for every node in `graph`.
47///
48/// `normalized=true` matches networkx's default behavior: scores are scaled by
49/// `1 / ((n-1)·(n-2))` for directed graphs with `n ≥ 3`. For `n < 3` scores
50/// are returned unscaled (would divide by zero).
51///
52/// Treats the graph as **directed** and unweighted. To run on an undirected
53/// view of the same data, add reverse edges before calling.
54pub fn compute_betweenness<N, E>(
55    graph: &Graph<N, E, Directed>,
56    normalized: bool,
57) -> BetweennessResult
58where
59    N: ClassNode,
60    E: EdgeKind,
61{
62    let n = graph.node_count();
63    let mut cb = vec![0.0_f64; n];
64
65    if n == 0 {
66        return BetweennessResult { scores: cb, normalized: false };
67    }
68
69    // Precompute outgoing-neighbor lists once: avoids repeated graph-edge walks
70    // inside the per-source BFS hot loop.
71    let mut out: Vec<Vec<usize>> = vec![Vec::new(); n];
72    for e in graph.edge_references() {
73        out[e.source().index()].push(e.target().index());
74    }
75
76    let mut stack: Vec<usize> = Vec::with_capacity(n);
77    let mut preds: Vec<Vec<usize>> = vec![Vec::new(); n];
78    let mut sigma = vec![0.0_f64; n];
79    let mut dist = vec![-1_i64; n];
80    let mut delta = vec![0.0_f64; n];
81    let mut queue: VecDeque<usize> = VecDeque::with_capacity(n);
82
83    for s in 0..n {
84        stack.clear();
85        for p in preds.iter_mut() {
86            p.clear();
87        }
88        for x in sigma.iter_mut() {
89            *x = 0.0;
90        }
91        for d in dist.iter_mut() {
92            *d = -1;
93        }
94        for d in delta.iter_mut() {
95            *d = 0.0;
96        }
97        queue.clear();
98
99        sigma[s] = 1.0;
100        dist[s] = 0;
101        queue.push_back(s);
102
103        while let Some(v) = queue.pop_front() {
104            stack.push(v);
105            for &w in &out[v] {
106                if dist[w] < 0 {
107                    dist[w] = dist[v] + 1;
108                    queue.push_back(w);
109                }
110                if dist[w] == dist[v] + 1 {
111                    sigma[w] += sigma[v];
112                    preds[w].push(v);
113                }
114            }
115        }
116
117        while let Some(w) = stack.pop() {
118            let coeff = (1.0 + delta[w]) / sigma[w];
119            for &v in &preds[w] {
120                delta[v] += sigma[v] * coeff;
121            }
122            if w != s {
123                cb[w] += delta[w];
124            }
125        }
126    }
127
128    if normalized && n > 2 {
129        let scale = 1.0 / ((n - 1) as f64 * (n - 2) as f64);
130        for x in cb.iter_mut() {
131            *x *= scale;
132        }
133        return BetweennessResult { scores: cb, normalized: true };
134    }
135
136    BetweennessResult { scores: cb, normalized: false }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use srcgraph_core::{EdgeType, OwnedClassNode, OwnedGraph};
143    use petgraph::Graph;
144
145    fn node(id: &str) -> OwnedClassNode {
146        OwnedClassNode {
147            id: id.to_owned(),
148            name: id.to_owned(),
149            namespace: "test".to_owned(),
150            line_count: 1,
151            method_count: 1,
152            halstead_eta1: 0,
153            halstead_eta2: 0,
154            halstead_n1: 0,
155            halstead_n2: 0,
156            method_connectivity: None,
157            method_fingerprints: None,
158            method_tokens: None,
159            call_sequences: None,
160            cyclomatic_complexity: None,
161            path_conditions: None,
162            invariants: None,
163            error_messages: None,
164            magic_numbers: None,
165            dead_code: None,
166            tenant_branches: None,
167            state_transitions: None,
168        }
169    }
170
171    fn approx(a: f64, b: f64) -> bool {
172        (a - b).abs() < 1e-9
173    }
174
175    #[test]
176    fn betweenness_path_graph_unnormalized() {
177        // A → B → C → D — directed path.
178        //   pairs through B: (A,C), (A,D)  → 2
179        //   pairs through C: (A,D), (B,D)  → 2
180        let mut g: OwnedGraph = Graph::new();
181        let a = g.add_node(node("A"));
182        let b = g.add_node(node("B"));
183        let c = g.add_node(node("C"));
184        let d = g.add_node(node("D"));
185        g.add_edge(a, b, EdgeType::MethodCall);
186        g.add_edge(b, c, EdgeType::MethodCall);
187        g.add_edge(c, d, EdgeType::MethodCall);
188
189        let r = compute_betweenness(&g, false);
190        assert!(approx(r.get(a), 0.0));
191        assert!(approx(r.get(b), 2.0), "B={}", r.get(b));
192        assert!(approx(r.get(c), 2.0), "C={}", r.get(c));
193        assert!(approx(r.get(d), 0.0));
194        assert!(!r.normalized);
195    }
196
197    #[test]
198    fn betweenness_hub_normalized() {
199        // Bidirectional star: A↔B, A↔C, A↔D. Every leaf-pair shortest path
200        // routes through A. Raw σ_A = 6 (ordered pairs); n=4 ⇒ scale=1/6 ⇒ 1.0.
201        let mut g: OwnedGraph = Graph::new();
202        let a = g.add_node(node("A"));
203        let leaves: Vec<_> = ["B", "C", "D"].iter().map(|l| g.add_node(node(l))).collect();
204        for &l in &leaves {
205            g.add_edge(a, l, EdgeType::MethodCall);
206            g.add_edge(l, a, EdgeType::MethodCall);
207        }
208
209        let r = compute_betweenness(&g, true);
210        assert!(r.normalized);
211        assert!(approx(r.get(a), 1.0), "hub={}", r.get(a));
212        for &l in &leaves {
213            assert!(approx(r.get(l), 0.0), "leaf={}", r.get(l));
214        }
215    }
216
217    #[test]
218    fn betweenness_parallel_paths_split_credit() {
219        // A → {B,C} → D — two equal-length paths A↦D.
220        // For source A, σ_D = 2; each intermediate gets 1/2.
221        let mut g: OwnedGraph = Graph::new();
222        let a = g.add_node(node("A"));
223        let b = g.add_node(node("B"));
224        let c = g.add_node(node("C"));
225        let d = g.add_node(node("D"));
226        g.add_edge(a, b, EdgeType::MethodCall);
227        g.add_edge(a, c, EdgeType::MethodCall);
228        g.add_edge(b, d, EdgeType::MethodCall);
229        g.add_edge(c, d, EdgeType::MethodCall);
230
231        let r = compute_betweenness(&g, false);
232        assert!(approx(r.get(a), 0.0));
233        assert!(approx(r.get(b), 0.5), "B={}", r.get(b));
234        assert!(approx(r.get(c), 0.5), "C={}", r.get(c));
235        assert!(approx(r.get(d), 0.0));
236    }
237
238    #[test]
239    fn betweenness_isolated_nodes_zero() {
240        let mut g: OwnedGraph = Graph::new();
241        let a = g.add_node(node("A"));
242        let b = g.add_node(node("B"));
243        let c = g.add_node(node("C"));
244        let r = compute_betweenness(&g, true);
245        for nx in [a, b, c] {
246            assert!(approx(r.get(nx), 0.0));
247        }
248    }
249
250    #[test]
251    fn betweenness_empty_graph() {
252        let g: OwnedGraph = Graph::new();
253        let r = compute_betweenness(&g, true);
254        assert!(r.scores.is_empty());
255    }
256
257    #[test]
258    fn betweenness_three_cycle_symmetric() {
259        // A→B→C→A. Each node lies on exactly one shortest path of the
260        // remaining pair (directed 2-hop), so all three scores equal 1.
261        let mut g: OwnedGraph = Graph::new();
262        let a = g.add_node(node("A"));
263        let b = g.add_node(node("B"));
264        let c = g.add_node(node("C"));
265        g.add_edge(a, b, EdgeType::MethodCall);
266        g.add_edge(b, c, EdgeType::MethodCall);
267        g.add_edge(c, a, EdgeType::MethodCall);
268
269        let r = compute_betweenness(&g, false);
270        assert!(approx(r.get(a), 1.0), "A={}", r.get(a));
271        assert!(approx(r.get(b), 1.0), "B={}", r.get(b));
272        assert!(approx(r.get(c), 1.0), "C={}", r.get(c));
273    }
274}