Skip to main content

facett_graphview/analysis/
centrality.rs

1//! **Centrality** — who matters in the graph, five ways. All return a `Vec<f32>`
2//! indexed by node (`0..n`). Deterministic; no RNG.
3
4use std::collections::VecDeque;
5
6use super::Adjacency;
7
8/// **Degree centrality** — undirected degree normalised by `n - 1` (the fraction of
9/// other nodes a node is adjacent to). `0.0` for `n ≤ 1`.
10#[must_use]
11pub fn degree(g: &Adjacency) -> Vec<f32> {
12    let denom = (g.n.saturating_sub(1)).max(1) as f32;
13    (0..g.n).map(|i| g.degree(i) as f32 / denom).collect()
14}
15
16/// **In-degree** and **out-degree** centrality (directed), normalised by `n - 1`.
17#[must_use]
18pub fn in_out_degree(g: &Adjacency) -> (Vec<f32>, Vec<f32>) {
19    let denom = (g.n.saturating_sub(1)).max(1) as f32;
20    let ins = (0..g.n).map(|i| g.in_degree(i) as f32 / denom).collect();
21    let outs = (0..g.n).map(|i| g.out_degree(i) as f32 / denom).collect();
22    (ins, outs)
23}
24
25/// **Closeness centrality** (undirected) — `(reachable-1) / Σ dist`, scaled by the
26/// reachable fraction so nodes in small components aren't over-rated (Wasserman–Faust).
27/// `0.0` for isolated nodes. BFS from each node (unweighted).
28#[must_use]
29pub fn closeness(g: &Adjacency) -> Vec<f32> {
30    let n = g.n;
31    (0..n)
32        .map(|s| {
33            let dist = bfs_dist(g, s);
34            let mut sum = 0.0f32;
35            let mut reach = 0usize;
36            for (i, &d) in dist.iter().enumerate() {
37                if i != s && d >= 0 {
38                    sum += d as f32;
39                    reach += 1;
40                }
41            }
42            if sum <= 0.0 || n <= 1 {
43                0.0
44            } else {
45                // (reach / sum) · (reach / (n-1)) — harmonic-ish, component-scaled.
46                (reach as f32 / sum) * (reach as f32 / (n - 1) as f32)
47            }
48        })
49        .collect()
50}
51
52/// **Betweenness centrality** (Brandes' algorithm, undirected, unweighted). The
53/// fraction of shortest paths through each node; normalised by `(n-1)(n-2)` so it
54/// lands in `[0, 1]`. `O(n·m)`.
55#[must_use]
56pub fn betweenness(g: &Adjacency) -> Vec<f32> {
57    let n = g.n;
58    let mut bc = vec![0.0f32; n];
59    for s in 0..n {
60        // Single-source shortest-path counting (Brandes).
61        let mut stack = Vec::new();
62        let mut pred: Vec<Vec<usize>> = vec![Vec::new(); n];
63        let mut sigma = vec![0.0f32; n];
64        let mut dist = vec![-1i32; n];
65        sigma[s] = 1.0;
66        dist[s] = 0;
67        let mut q = VecDeque::new();
68        q.push_back(s);
69        while let Some(v) = q.pop_front() {
70            stack.push(v);
71            for &(w, _) in &g.und[v] {
72                if dist[w] < 0 {
73                    dist[w] = dist[v] + 1;
74                    q.push_back(w);
75                }
76                if dist[w] == dist[v] + 1 {
77                    sigma[w] += sigma[v];
78                    pred[w].push(v);
79                }
80            }
81        }
82        // Accumulate dependencies back-to-front.
83        let mut delta = vec![0.0f32; n];
84        while let Some(w) = stack.pop() {
85            for &v in &pred[w] {
86                if sigma[w] > 0.0 {
87                    delta[v] += (sigma[v] / sigma[w]) * (1.0 + delta[w]);
88                }
89            }
90            if w != s {
91                bc[w] += delta[w];
92            }
93        }
94    }
95    // Undirected: each pair counted twice → halve. Normalise by (n-1)(n-2).
96    let norm = if n > 2 { ((n - 1) * (n - 2)) as f32 } else { 1.0 };
97    for x in &mut bc {
98        *x = (*x * 0.5) / norm;
99    }
100    bc
101}
102
103/// **PageRank** (directed) with damping `d` (typically `0.85`) for `iters` power
104/// iterations. Dangling nodes (no out-edges) redistribute their mass uniformly.
105/// Returns a probability distribution summing to ≈1.
106#[must_use]
107pub fn pagerank(g: &Adjacency, d: f32, iters: usize) -> Vec<f32> {
108    let n = g.n;
109    if n == 0 {
110        return Vec::new();
111    }
112    let base = (1.0 - d) / n as f32;
113    let mut rank = vec![1.0f32 / n as f32; n];
114    // Out-weight sums for normalising contributions.
115    let out_sum: Vec<f32> = (0..n).map(|i| g.out[i].iter().map(|(_, w)| *w).sum::<f32>()).collect();
116    for _ in 0..iters {
117        let mut next = vec![base; n];
118        // Dangling mass (nodes with no out-edges) spread uniformly.
119        let dangling: f32 = (0..n).filter(|&i| g.out[i].is_empty()).map(|i| rank[i]).sum();
120        let dangle_share = d * dangling / n as f32;
121        for v in &mut next {
122            *v += dangle_share;
123        }
124        for i in 0..n {
125            if out_sum[i] <= 0.0 {
126                continue;
127            }
128            for &(j, w) in &g.out[i] {
129                next[j] += d * rank[i] * (w / out_sum[i]);
130            }
131        }
132        rank = next;
133    }
134    // Renormalise against tiny drift.
135    let total: f32 = rank.iter().sum();
136    if total > 0.0 {
137        for v in &mut rank {
138            *v /= total;
139        }
140    }
141    rank
142}
143
144/// **Eigenvector centrality** (undirected) via power iteration for `iters` steps.
145/// Iterates `(A + I)` rather than `A` — the identity shift makes every eigenvalue
146/// positive, curing the **bipartite oscillation** that traps plain `A·x` (a star /
147/// path never converges because `+λ` and `−λ` have equal magnitude). L2-normalised
148/// each step. `0.0` everywhere for an edgeless graph (centrality is undefined there).
149#[must_use]
150pub fn eigenvector(g: &Adjacency, iters: usize) -> Vec<f32> {
151    let n = g.n;
152    if n == 0 {
153        return Vec::new();
154    }
155    // Edgeless ⇒ no meaningful centrality.
156    if (0..n).all(|i| g.und[i].is_empty()) {
157        return vec![0.0; n];
158    }
159    let mut x = vec![1.0f32 / (n as f32).sqrt(); n];
160    for _ in 0..iters {
161        // next = (A + I)·x — the +I shift breaks bipartite oscillation.
162        let mut next = x.clone();
163        for i in 0..n {
164            for &(j, w) in &g.und[i] {
165                next[i] += w * x[j];
166            }
167        }
168        let norm = next.iter().map(|v| v * v).sum::<f32>().sqrt();
169        if norm <= f32::EPSILON {
170            return vec![0.0; n];
171        }
172        for v in &mut next {
173            *v /= norm;
174        }
175        x = next;
176    }
177    // Fix the sign so the vector is non-negative (Perron–Frobenius).
178    if x.iter().sum::<f32>() < 0.0 {
179        for v in &mut x {
180            *v = -*v;
181        }
182    }
183    x
184}
185
186/// BFS distances from `s` over the **undirected** view; `-1` for unreachable.
187fn bfs_dist(g: &Adjacency, s: usize) -> Vec<i32> {
188    let mut dist = vec![-1i32; g.n];
189    let mut q = VecDeque::new();
190    dist[s] = 0;
191    q.push_back(s);
192    while let Some(v) = q.pop_front() {
193        for &(w, _) in &g.und[v] {
194            if dist[w] < 0 {
195                dist[w] = dist[v] + 1;
196                q.push_back(w);
197            }
198        }
199    }
200    dist
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    /// A 5-node path `0-1-2-3-4` — the classic betweenness/closeness fixture.
208    fn path5() -> Adjacency {
209        Adjacency::from_edges(5, &[(0, 1), (1, 2), (2, 3), (3, 4)])
210    }
211
212    /// A star: centre 0 connected to 1,2,3,4.
213    fn star5() -> Adjacency {
214        Adjacency::from_edges(5, &[(0, 1), (0, 2), (0, 3), (0, 4)])
215    }
216
217    #[test]
218    fn degree_peaks_at_the_star_centre() {
219        let d = degree(&star5());
220        assert!((d[0] - 1.0).abs() < 1e-6, "the hub is adjacent to everyone");
221        for i in 1..5 {
222            assert!((d[i] - 0.25).abs() < 1e-6, "a leaf touches only the hub");
223        }
224    }
225
226    #[test]
227    fn betweenness_peaks_at_the_path_middle() {
228        let b = betweenness(&path5());
229        // The middle node (2) lies on the most shortest paths; the ends on none.
230        assert!(b[2] > b[1] && b[1] > b[0], "betweenness rises toward the centre: {b:?}");
231        assert!((b[0]).abs() < 1e-6 && (b[4]).abs() < 1e-6, "endpoints are on no through-path");
232    }
233
234    #[test]
235    fn closeness_peaks_at_the_star_centre() {
236        let c = closeness(&star5());
237        for i in 1..5 {
238            assert!(c[0] > c[i], "the hub is closest to all: {c:?}");
239        }
240    }
241
242    #[test]
243    fn pagerank_is_a_distribution_and_favours_a_sink() {
244        // 1→0, 2→0, 3→0: everyone points at 0, so 0 collects the rank.
245        let g = Adjacency::from_edges(4, &[(1, 0), (2, 0), (3, 0)]);
246        let pr = pagerank(&g, 0.85, 50);
247        let sum: f32 = pr.iter().sum();
248        assert!((sum - 1.0).abs() < 1e-3, "pagerank sums to 1 (got {sum})");
249        assert!(pr[0] > pr[1] && pr[0] > pr[2] && pr[0] > pr[3], "the sink ranks highest: {pr:?}");
250    }
251
252    #[test]
253    fn eigenvector_peaks_at_the_star_centre() {
254        let e = eigenvector(&star5(), 100);
255        for i in 1..5 {
256            assert!(e[0] > e[i], "the hub dominates the eigenvector: {e:?}");
257        }
258        // Non-negative (Perron–Frobenius sign fix).
259        assert!(e.iter().all(|&v| v >= -1e-6));
260    }
261
262    #[test]
263    fn edgeless_graphs_do_not_panic() {
264        let g = Adjacency::from_edges(3, &[]);
265        assert_eq!(betweenness(&g), vec![0.0; 3]);
266        assert_eq!(closeness(&g), vec![0.0; 3]);
267        assert_eq!(eigenvector(&g, 10), vec![0.0; 3]);
268        let pr = pagerank(&g, 0.85, 10);
269        assert!((pr.iter().sum::<f32>() - 1.0).abs() < 1e-3);
270    }
271}