Skip to main content

oxicuda_graph/centrality/
pagerank.rs

1//! PageRank centrality (Brin & Page 1998).
2//!
3//! PageRank ranks the nodes of a directed graph by the stationary
4//! distribution of a random walk that, with probability `damping`, follows an
5//! out-edge chosen uniformly at random and, with probability `1 - damping`,
6//! teleports to a uniformly random node.
7//!
8//! The score vector `r` is the fixed point of
9//! ```text
10//! r[i] = (1 - d) / n  +  d * ( Σ_{j → i} r[j] / outdeg[j]  +  (1/n) Σ_{k dangling} r[k] )
11//! ```
12//! where the second inner sum redistributes the mass held by *dangling* nodes
13//! (nodes with no out-edges) uniformly across the graph, which keeps the total
14//! probability mass conserved at 1.
15//!
16//! The fixed point is approximated by **power iteration**: starting from the
17//! uniform distribution `r = 1/n`, the update above is applied until the
18//! L1 change between successive iterates falls below `tol` (convergence) or
19//! `max_iter` iterations have elapsed.
20//!
21//! Reference: S. Brin and L. Page, "The anatomy of a large-scale hypertextual
22//! Web search engine", Computer Networks and ISDN Systems, 1998.
23
24use crate::error::{GraphError, GraphResult};
25
26/// Hyper-parameters for the PageRank power iteration.
27#[derive(Debug, Clone)]
28pub struct PageRankConfig {
29    /// Damping factor `d` (probability of following an out-edge rather than
30    /// teleporting). The classic value is `0.85`. Must lie in `[0, 1]`.
31    pub damping: f64,
32    /// Maximum number of power-iteration sweeps.
33    pub max_iter: usize,
34    /// Convergence tolerance on the L1 distance between successive iterates.
35    pub tol: f64,
36}
37
38impl Default for PageRankConfig {
39    fn default() -> Self {
40        Self {
41            damping: 0.85,
42            max_iter: 100,
43            tol: 1e-9,
44        }
45    }
46}
47
48/// Compute the PageRank score of every node in a directed graph.
49///
50/// `adj[j]` lists the heads of the out-edges of node `j` (i.e. `j → adj[j][*]`).
51/// The returned vector has length `n_nodes`, holds non-negative scores that sum
52/// to `1`, and is ordered so that `result[i]` is the score of node `i`.
53///
54/// # Errors
55/// - [`GraphError::EmptyGraph`] when `n_nodes == 0`.
56/// - [`GraphError::InvalidPlan`] when `damping` is outside `[0, 1]` or when an
57///   adjacency entry references a node `>= n_nodes`.
58pub fn pagerank(adj: &[Vec<usize>], n_nodes: usize, cfg: &PageRankConfig) -> GraphResult<Vec<f64>> {
59    if n_nodes == 0 {
60        return Err(GraphError::EmptyGraph);
61    }
62    if !(0.0..=1.0).contains(&cfg.damping) || !cfg.damping.is_finite() {
63        return Err(GraphError::InvalidPlan(format!(
64            "damping must be in [0, 1], got {}",
65            cfg.damping
66        )));
67    }
68    // Validate adjacency references. Nodes beyond `adj.len()` are treated as
69    // isolated (no out-edges), which is permitted.
70    for (j, heads) in adj.iter().enumerate() {
71        for &i in heads {
72            if i >= n_nodes {
73                return Err(GraphError::InvalidPlan(format!(
74                    "adjacency edge {j} -> {i} references node >= n_nodes={n_nodes}"
75                )));
76            }
77        }
78    }
79
80    let n_f = n_nodes as f64;
81    let teleport = (1.0 - cfg.damping) / n_f;
82
83    // Out-degree of each node (0 for dangling / out-of-range nodes).
84    let mut outdeg = vec![0usize; n_nodes];
85    for (j, heads) in adj.iter().enumerate().take(n_nodes) {
86        outdeg[j] = heads.len();
87    }
88
89    let mut r = vec![1.0 / n_f; n_nodes];
90    let mut next = vec![0.0; n_nodes];
91
92    for _ in 0..cfg.max_iter {
93        // Mass currently held by dangling nodes is redistributed uniformly.
94        let mut dangling_mass = 0.0;
95        for i in 0..n_nodes {
96            if outdeg[i] == 0 {
97                dangling_mass += r[i];
98            }
99        }
100        let base = teleport + cfg.damping * dangling_mass / n_f;
101        for slot in next.iter_mut() {
102            *slot = base;
103        }
104        // Push each node's rank to its out-neighbours.
105        for (j, heads) in adj.iter().enumerate().take(n_nodes) {
106            if heads.is_empty() {
107                continue;
108            }
109            let share = cfg.damping * r[j] / heads.len() as f64;
110            for &i in heads {
111                next[i] += share;
112            }
113        }
114
115        // L1 convergence check.
116        let mut delta = 0.0;
117        for i in 0..n_nodes {
118            delta += (next[i] - r[i]).abs();
119        }
120        std::mem::swap(&mut r, &mut next);
121        if delta < cfg.tol {
122            break;
123        }
124    }
125
126    // Renormalise to guard against accumulated floating-point drift.
127    let sum: f64 = r.iter().sum();
128    if sum > 0.0 {
129        for v in &mut r {
130            *v /= sum;
131        }
132    }
133    Ok(r)
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    fn cfg() -> PageRankConfig {
141        PageRankConfig::default()
142    }
143
144    // 1. Scores sum to 1.
145    #[test]
146    fn sums_to_1() {
147        let adj = vec![vec![1, 2], vec![2], vec![0]];
148        let r = pagerank(&adj, 3, &cfg()).expect("value should be present");
149        let s: f64 = r.iter().sum();
150        assert!((s - 1.0).abs() < 1e-9, "sum = {s}");
151    }
152
153    // 2. All scores strictly positive (teleport guarantees this).
154    #[test]
155    fn all_positive() {
156        let adj = vec![vec![1], vec![2], vec![0]];
157        let r = pagerank(&adj, 3, &cfg()).expect("value should be present");
158        for &v in &r {
159            assert!(v > 0.0, "non-positive score {v}");
160        }
161    }
162
163    // 3. n_nodes == 0 errors.
164    #[test]
165    fn n_nodes_0_error() {
166        let adj: Vec<Vec<usize>> = vec![];
167        let err = pagerank(&adj, 0, &cfg());
168        assert!(matches!(err, Err(GraphError::EmptyGraph)), "got {err:?}");
169    }
170
171    // 4. A hub pointed at by many nodes earns a higher rank.
172    #[test]
173    fn hub_higher_rank() {
174        // Nodes 1,2,3 all point to node 0; node 0 points back to 1.
175        let adj = vec![vec![1], vec![0], vec![0], vec![0]];
176        let r = pagerank(&adj, 4, &cfg()).expect("value should be present");
177        assert!(
178            r[0] > r[1] && r[0] > r[2] && r[0] > r[3],
179            "hub {} should exceed {:?}",
180            r[0],
181            &r[1..]
182        );
183    }
184
185    // 5. A symmetric (vertex-transitive) ring yields a uniform distribution.
186    #[test]
187    fn symmetric_graph_uniform() {
188        // Bidirectional 4-cycle: every node has identical structure.
189        let adj = vec![vec![1, 3], vec![0, 2], vec![1, 3], vec![2, 0]];
190        let r = pagerank(&adj, 4, &cfg()).expect("value should be present");
191        for &v in &r {
192            assert!((v - 0.25).abs() < 1e-6, "score {v} not ~0.25");
193        }
194    }
195
196    // 6. A dangling node (no out-edges) is handled without losing mass.
197    #[test]
198    fn dangling_node_handled() {
199        // Node 2 is dangling.
200        let adj = vec![vec![1], vec![2], vec![]];
201        let r = pagerank(&adj, 3, &cfg()).expect("value should be present");
202        let s: f64 = r.iter().sum();
203        assert!((s - 1.0).abs() < 1e-9, "sum = {s}");
204        for &v in &r {
205            assert!(v > 0.0, "score {v} should be positive");
206        }
207    }
208
209    // 7. damping == 0 collapses to the uniform teleport distribution.
210    #[test]
211    fn damping_0_uniform() {
212        let adj = vec![vec![1], vec![0], vec![0], vec![1]];
213        let c = PageRankConfig {
214            damping: 0.0,
215            ..cfg()
216        };
217        let r = pagerank(&adj, 4, &c).expect("pagerank should succeed");
218        for &v in &r {
219            assert!((v - 0.25).abs() < 1e-9, "score {v} not uniform");
220        }
221    }
222
223    // 8. Power iteration converges (running with a tight tolerance succeeds and
224    //    re-running with more iterations does not change the result materially).
225    #[test]
226    fn converges() {
227        let adj = vec![vec![1, 2], vec![2], vec![0], vec![0, 1]];
228        let r1 = pagerank(&adj, 4, &cfg()).expect("value should be present");
229        let c2 = PageRankConfig {
230            max_iter: 1000,
231            tol: 1e-12,
232            ..cfg()
233        };
234        let r2 = pagerank(&adj, 4, &c2).expect("pagerank should succeed");
235        for i in 0..4 {
236            assert!(
237                (r1[i] - r2[i]).abs() < 1e-6,
238                "node {i}: {} vs {}",
239                r1[i],
240                r2[i]
241            );
242        }
243    }
244
245    // 9. A single node receives the entire mass.
246    #[test]
247    fn single_node() {
248        let adj = vec![vec![]];
249        let r = pagerank(&adj, 1, &cfg()).expect("value should be present");
250        assert_eq!(r.len(), 1);
251        assert!((r[0] - 1.0).abs() < 1e-12, "score {}", r[0]);
252    }
253
254    // 10. Out-of-range adjacency reference errors.
255    #[test]
256    fn out_of_range_edge_error() {
257        let adj = vec![vec![5]]; // node 5 >= n_nodes = 2
258        let err = pagerank(&adj, 2, &cfg());
259        assert!(
260            matches!(err, Err(GraphError::InvalidPlan(_))),
261            "got {err:?}"
262        );
263    }
264
265    // 11. Invalid damping errors.
266    #[test]
267    fn invalid_damping_error() {
268        let adj = vec![vec![1], vec![0]];
269        let c = PageRankConfig {
270            damping: 1.5,
271            ..cfg()
272        };
273        let err = pagerank(&adj, 2, &c);
274        assert!(
275            matches!(err, Err(GraphError::InvalidPlan(_))),
276            "got {err:?}"
277        );
278    }
279}