Skip to main content

trueno_graph/algorithms/
pagerank.rs

1//! `PageRank` algorithm via aprender sparse matrix operations
2//!
3//! Based on Page et al. (1999) "The `PageRank` Citation Ranking: Bringing Order to the Web"
4//! Implementation uses power iteration via aprender's sparse matrix multiplication.
5
6use crate::storage::CsrGraph;
7use anyhow::Result;
8
9/// Damping factor for `PageRank` (Google standard)
10const DAMPING_FACTOR: f32 = 0.85;
11
12/// Compute `PageRank` scores for all nodes in the graph
13///
14/// Uses power iteration algorithm with damping factor 0.85 (Google standard).
15///
16/// # Arguments
17///
18/// * `graph` - CSR graph representation
19/// * `max_iterations` - Maximum number of power iterations (typically 20-50)
20/// * `tolerance` - Convergence threshold (default: 1e-6)
21///
22/// # Returns
23///
24/// Vector of `PageRank` scores (one per node, sum = 1.0)
25///
26/// # Algorithm
27///
28/// `PageRank` formula:
29/// ```text
30/// PR(u) = (1-d)/N + d * Σ(PR(v) / outdegree(v))
31/// ```
32///
33/// Where:
34/// - d = 0.85 (damping factor)
35/// - N = total number of nodes
36/// - v = nodes with edges to u
37///
38/// # Errors
39///
40/// This function does not return errors in the current implementation.
41/// The Result type is used for future extensibility (e.g., invalid graph states).
42///
43/// # Example
44///
45/// ```
46/// use trueno_graph::{CsrGraph, NodeId, pagerank};
47///
48/// let mut graph = CsrGraph::new();
49/// graph.add_edge(NodeId(0), NodeId(1), 1.0).unwrap();
50/// graph.add_edge(NodeId(1), NodeId(2), 1.0).unwrap();
51/// graph.add_edge(NodeId(2), NodeId(0), 1.0).unwrap(); // Cycle
52///
53/// let scores = pagerank(&graph, 20, 1e-6).unwrap();
54/// assert_eq!(scores.len(), 3);
55/// assert!((scores.iter().sum::<f32>() - 1.0).abs() < 1e-5); // Sum = 1.0
56/// ```
57#[allow(clippy::cast_precision_loss)] // Graphs >16M nodes unlikely
58#[allow(clippy::cast_possible_truncation)] // Graphs with >4B edges unlikely
59pub fn pagerank(graph: &CsrGraph, max_iterations: usize, tolerance: f32) -> Result<Vec<f32>> {
60    let n = graph.num_nodes();
61
62    if n == 0 {
63        return Ok(Vec::new());
64    }
65
66    let teleport = (1.0 - DAMPING_FACTOR) / n as f32;
67
68    // Initialize: uniform distribution
69    let mut ranks = vec![1.0 / n as f32; n];
70    let mut new_ranks = vec![0.0; n];
71
72    // Get CSR components for iteration
73    let (row_offsets, col_indices, _edge_weights) = graph.csr_components();
74
75    // Compute out-degrees for normalization
76    let mut out_degrees = vec![0_u32; n];
77    for node in 0..n {
78        let start = row_offsets[node] as usize;
79        let end = row_offsets[node + 1] as usize;
80        out_degrees[node] = (end - start) as u32;
81    }
82
83    // Power iteration
84    #[allow(unused_variables)] // iteration only used in test builds
85    for iteration in 0..max_iterations {
86        // Reset new ranks to teleport value
87        new_ranks.fill(teleport);
88
89        // Distribute rank from each node to its neighbors
90        for node in 0..n {
91            let start = row_offsets[node] as usize;
92            let end = row_offsets[node + 1] as usize;
93
94            if out_degrees[node] > 0 {
95                let rank_contribution = DAMPING_FACTOR * ranks[node] / out_degrees[node] as f32;
96
97                for &target in &col_indices[start..end] {
98                    new_ranks[target as usize] += rank_contribution;
99                }
100            } else {
101                // Dangling node: distribute rank equally to all nodes
102                let dangling_contribution = DAMPING_FACTOR * ranks[node] / n as f32;
103                for r in &mut new_ranks {
104                    *r += dangling_contribution;
105                }
106            }
107        }
108
109        // Check convergence (L1 norm)
110        let mut diff = 0.0;
111        for i in 0..n {
112            diff += (new_ranks[i] - ranks[i]).abs();
113        }
114
115        // Swap buffers
116        std::mem::swap(&mut ranks, &mut new_ranks);
117
118        if diff < tolerance {
119            #[cfg(test)]
120            eprintln!("PageRank converged after {} iterations (diff={:.2e})", iteration + 1, diff);
121            break;
122        }
123    }
124
125    Ok(ranks)
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::NodeId;
132
133    #[test]
134    fn test_pagerank_simple_chain() {
135        // Linear chain: 0 → 1 → 2
136        let edges = vec![(NodeId(0), NodeId(1), 1.0), (NodeId(1), NodeId(2), 1.0)];
137        let graph = CsrGraph::from_edge_list(&edges).unwrap();
138
139        let scores = pagerank(&graph, 20, 1e-6).unwrap();
140
141        // Verify properties
142        assert_eq!(scores.len(), 3);
143
144        // Sum should be 1.0
145        let sum: f32 = scores.iter().sum();
146        assert!((sum - 1.0).abs() < 1e-5, "Sum = {sum}");
147
148        // In a chain, last node gets highest score (sink node)
149        assert!(scores[2] > scores[1], "Node 2 should have higher score than 1");
150        assert!(scores[1] > scores[0], "Node 1 should have higher score than 0");
151    }
152
153    #[test]
154    fn test_pagerank_cycle() {
155        // Cycle: 0 → 1 → 2 → 0
156        let edges = vec![
157            (NodeId(0), NodeId(1), 1.0),
158            (NodeId(1), NodeId(2), 1.0),
159            (NodeId(2), NodeId(0), 1.0),
160        ];
161        let graph = CsrGraph::from_edge_list(&edges).unwrap();
162
163        let scores = pagerank(&graph, 50, 1e-6).unwrap();
164
165        // In a symmetric cycle, all nodes should have equal rank
166        assert_eq!(scores.len(), 3);
167
168        let sum: f32 = scores.iter().sum();
169        assert!((sum - 1.0).abs() < 1e-5);
170
171        // All scores should be approximately 1/3
172        for score in &scores {
173            assert!((*score - 1.0 / 3.0).abs() < 0.01, "Score = {score}");
174        }
175    }
176
177    #[test]
178    fn test_pagerank_star() {
179        // Star: 0 ← 1, 0 ← 2, 0 ← 3 (all point to center)
180        let edges = vec![
181            (NodeId(1), NodeId(0), 1.0),
182            (NodeId(2), NodeId(0), 1.0),
183            (NodeId(3), NodeId(0), 1.0),
184        ];
185        let graph = CsrGraph::from_edge_list(&edges).unwrap();
186
187        let scores = pagerank(&graph, 20, 1e-6).unwrap();
188
189        // Center node (0) should have highest score
190        assert!(scores[0] > scores[1]);
191        assert!(scores[0] > scores[2]);
192        assert!(scores[0] > scores[3]);
193
194        // Peripheral nodes should have similar scores
195        assert!((scores[1] - scores[2]).abs() < 0.01);
196        assert!((scores[2] - scores[3]).abs() < 0.01);
197    }
198
199    #[test]
200    fn test_pagerank_empty_graph() {
201        let graph = CsrGraph::new();
202        let scores = pagerank(&graph, 20, 1e-6).unwrap();
203        assert_eq!(scores.len(), 0);
204    }
205
206    #[test]
207    fn test_pagerank_single_node() {
208        let mut graph = CsrGraph::new();
209        graph.add_edge(NodeId(0), NodeId(0), 1.0).unwrap(); // Self-loop
210
211        let scores = pagerank(&graph, 20, 1e-6).unwrap();
212        assert_eq!(scores.len(), 1);
213        assert!((scores[0] - 1.0).abs() < 1e-5); // Single node gets all rank
214    }
215
216    #[test]
217    fn test_pagerank_convergence() {
218        // Large cycle to test convergence
219        let mut edges = Vec::new();
220        for i in 0..10 {
221            edges.push((NodeId(i), NodeId((i + 1) % 10), 1.0));
222        }
223        let graph = CsrGraph::from_edge_list(&edges).unwrap();
224
225        let scores = pagerank(&graph, 100, 1e-6).unwrap();
226
227        // Should converge to uniform distribution
228        for score in &scores {
229            assert!((*score - 0.1).abs() < 0.01, "Score = {score}");
230        }
231    }
232}