Skip to main content

lean_ctx/core/graph_analysis/
centrality.rs

1//! Betweenness centrality (bridge nodes) via Brandes' algorithm.
2//!
3//! A bridge node lies on many shortest paths between other files — removing it
4//! would fragment the dependency graph. Computed on the undirected dependency
5//! graph (import / reexport) with the exact, unweighted Brandes algorithm
6//! (O(V·E)), which is fine for repo-sized graphs.
7
8use std::collections::{HashMap, VecDeque};
9
10use serde::Serialize;
11
12use super::dependency_edges;
13use crate::core::graph_provider::EdgeInfo;
14
15/// A file with high betweenness centrality.
16#[derive(Debug, Clone, Serialize, PartialEq)]
17pub struct BridgeNode {
18    pub path: String,
19    /// Betweenness normalized to `0.0..=1.0` (relative to the top node).
20    pub betweenness: f64,
21}
22
23/// Betweenness result with sampling provenance, so callers can disclose when the
24/// values are an estimate (large graphs) rather than the exact Brandes result.
25#[derive(Debug, Clone, Serialize, PartialEq)]
26pub struct BridgeCentrality {
27    pub nodes: Vec<BridgeNode>,
28    /// `true` when betweenness was estimated from a sampled subset of sources.
29    pub sampled: bool,
30    /// Total nodes in the dependency graph.
31    pub total_nodes: usize,
32    /// Source nodes actually used (== `total_nodes` unless sampled).
33    pub sources_used: usize,
34}
35
36/// Returns the top `limit` bridge nodes (betweenness > 0), highest first.
37/// Thin wrapper over [`compute_bridge_centrality`] for callers that don't need
38/// the sampling metadata.
39pub fn compute_bridge_nodes(edges: &[EdgeInfo], limit: usize) -> Vec<BridgeNode> {
40    compute_bridge_centrality(edges, limit).nodes
41}
42
43/// Like [`compute_bridge_nodes`] but also reports whether the result was sampled
44/// and over how many sources, for honest disclosure in reports/UI.
45pub fn compute_bridge_centrality(edges: &[EdgeInfo], limit: usize) -> BridgeCentrality {
46    let deps = dependency_edges(edges);
47    if deps.is_empty() {
48        return BridgeCentrality {
49            nodes: Vec::new(),
50            sampled: false,
51            total_nodes: 0,
52            sources_used: 0,
53        };
54    }
55
56    // Intern node names and build an undirected adjacency list.
57    let mut index_of: HashMap<&str, usize> = HashMap::new();
58    let mut names: Vec<&str> = Vec::new();
59    let mut adj: Vec<Vec<usize>> = Vec::new();
60    for (u, v) in &deps {
61        for s in [*u, *v] {
62            if !index_of.contains_key(s) {
63                index_of.insert(s, names.len());
64                names.push(s);
65                adj.push(Vec::new());
66            }
67        }
68    }
69    for (u, v) in &deps {
70        let a = index_of[*u];
71        let b = index_of[*v];
72        adj[a].push(b);
73        adj[b].push(a);
74    }
75    let n = names.len();
76
77    let mut betweenness = vec![0f64; n];
78
79    // Exact Brandes is O(V·E). Above a threshold we estimate betweenness from a
80    // deterministic sample of source nodes (Brandes–Pich): cost drops to O(k·E)
81    // and, because the result is normalized to the top node, the *relative*
82    // ranking of bridges is preserved.
83    const EXACT_SOURCE_CAP: usize = 1500;
84    const SAMPLE_SOURCES: usize = 500;
85    let sampled = n > EXACT_SOURCE_CAP;
86    let sources: Vec<usize> = if sampled {
87        let step = (n / SAMPLE_SOURCES).max(1);
88        (0..n).step_by(step).collect()
89    } else {
90        (0..n).collect()
91    };
92    let sources_used = sources.len();
93
94    for &s in &sources {
95        let mut stack: Vec<usize> = Vec::new();
96        let mut preds: Vec<Vec<usize>> = vec![Vec::new(); n];
97        let mut sigma = vec![0f64; n];
98        let mut dist = vec![-1i64; n];
99        sigma[s] = 1.0;
100        dist[s] = 0;
101
102        let mut queue: VecDeque<usize> = VecDeque::new();
103        queue.push_back(s);
104        while let Some(v) = queue.pop_front() {
105            stack.push(v);
106            for &w in &adj[v] {
107                if dist[w] < 0 {
108                    dist[w] = dist[v] + 1;
109                    queue.push_back(w);
110                }
111                if dist[w] == dist[v] + 1 {
112                    sigma[w] += sigma[v];
113                    preds[w].push(v);
114                }
115            }
116        }
117
118        let mut delta = vec![0f64; n];
119        while let Some(w) = stack.pop() {
120            let preds_w = std::mem::take(&mut preds[w]);
121            for v in preds_w {
122                if sigma[w] > 0.0 {
123                    delta[v] += (sigma[v] / sigma[w]) * (1.0 + delta[w]);
124                }
125            }
126            if w != s {
127                betweenness[w] += delta[w];
128            }
129        }
130    }
131
132    // Undirected graph: every shortest path is counted in both directions.
133    for b in &mut betweenness {
134        *b /= 2.0;
135    }
136    let max = betweenness.iter().copied().fold(0.0f64, f64::max);
137
138    let mut nodes: Vec<BridgeNode> = (0..n)
139        .map(|i| BridgeNode {
140            path: names[i].to_string(),
141            betweenness: if max > 0.0 {
142                (betweenness[i] / max * 1000.0).round() / 1000.0
143            } else {
144                0.0
145            },
146        })
147        .collect();
148
149    nodes.sort_by(|a, b| {
150        b.betweenness
151            .partial_cmp(&a.betweenness)
152            .unwrap_or(std::cmp::Ordering::Equal)
153            .then_with(|| a.path.cmp(&b.path))
154    });
155    nodes.retain(|node| node.betweenness > 0.0);
156    nodes.truncate(limit);
157    BridgeCentrality {
158        nodes,
159        sampled,
160        total_nodes: n,
161        sources_used,
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    fn e(from: &str, to: &str) -> EdgeInfo {
170        EdgeInfo {
171            from: from.into(),
172            to: to.into(),
173            kind: "import".into(),
174            weight: 1.0,
175        }
176    }
177
178    #[test]
179    fn path_graph_center_is_top_bridge() {
180        // a - b - c : b sits between a and c.
181        let edges = vec![e("a.rs", "b.rs"), e("b.rs", "c.rs")];
182        let bridges = compute_bridge_nodes(&edges, 10);
183        assert_eq!(bridges.len(), 1); // only b has betweenness > 0
184        assert_eq!(bridges[0].path, "b.rs");
185        assert_eq!(bridges[0].betweenness, 1.0);
186    }
187
188    #[test]
189    fn star_center_is_top_bridge() {
190        let edges = vec![
191            e("hub.rs", "a.rs"),
192            e("hub.rs", "b.rs"),
193            e("hub.rs", "c.rs"),
194        ];
195        let bridges = compute_bridge_nodes(&edges, 10);
196        assert_eq!(bridges[0].path, "hub.rs");
197        assert_eq!(bridges[0].betweenness, 1.0);
198    }
199
200    #[test]
201    fn fully_connected_has_no_bridges() {
202        // Triangle: no node is on a unique shortest path.
203        let edges = vec![e("a", "b"), e("b", "c"), e("a", "c")];
204        assert!(compute_bridge_nodes(&edges, 10).is_empty());
205    }
206
207    #[test]
208    fn large_graph_uses_sampling_and_still_finds_hub() {
209        // > EXACT_SOURCE_CAP nodes forces the sampled estimator; the star center
210        // must still rank as the top bridge.
211        let edges: Vec<EdgeInfo> = (0..2000)
212            .map(|i| e("hub.rs", &format!("leaf{i}.rs")))
213            .collect();
214        let bridges = compute_bridge_nodes(&edges, 10);
215        assert_eq!(bridges[0].path, "hub.rs");
216        assert_eq!(bridges[0].betweenness, 1.0);
217    }
218
219    #[test]
220    fn centrality_reports_sampling_provenance() {
221        // Small graph → exact (not sampled), every node used as a source.
222        let small = vec![e("a.rs", "b.rs"), e("b.rs", "c.rs")];
223        let bc = compute_bridge_centrality(&small, 10);
224        assert!(!bc.sampled, "small graph must be computed exactly");
225        assert_eq!(bc.sources_used, bc.total_nodes);
226
227        // Large graph → sampled, fewer sources than nodes, ranking preserved.
228        let big: Vec<EdgeInfo> = (0..2000)
229            .map(|i| e("hub.rs", &format!("leaf{i}.rs")))
230            .collect();
231        let bc = compute_bridge_centrality(&big, 10);
232        assert!(bc.sampled, "large graph must report sampling");
233        assert!(
234            bc.sources_used < bc.total_nodes,
235            "sampling must use fewer sources than nodes"
236        );
237        assert_eq!(bc.nodes[0].path, "hub.rs");
238    }
239}