Skip to main content

rust_igraph/algorithms/properties/
pagerank.rs

1//! `PageRank` centrality (ALGO-PR-011).
2//!
3//! Counterpart of `igraph_pagerank()` from
4//! `references/igraph/src/centrality/pagerank.c` (the
5//! `IGRAPH_PAGERANK_ALGO_POWER` branch). Implemented as standard power
6//! iteration:
7//!
8//! `PR_new[v] = (1 - d) / N + d * (Σ_{u → v} PR[u] / out_deg(u) +
9//!              Σ_{sink} PR[sink] / N)`
10//!
11//! where the second sum handles "dangling" vertices (out-degree 0) by
12//! distributing their rank uniformly across the graph.
13//!
14//! For undirected graphs every edge counts as bidirectional, so
15//! `out_deg(u) == deg(u)` and the in-neighbour iteration is symmetric.
16//!
17//! Phase-1 minimal slice: undirected / `IGRAPH_OUT`, unweighted, damping
18//! `0.85`, `eps = 1e-10`, `max_iter = 1000`. Configurable parameters and
19//! ARPACK-based variants ship later.
20
21use crate::core::{Graph, IgraphResult};
22
23const DEFAULT_DAMPING: f64 = 0.85;
24const DEFAULT_EPS: f64 = 1e-10;
25const DEFAULT_MAX_ITER: usize = 1000;
26
27/// `PageRank` scores via power iteration with damping `0.85`.
28///
29/// Returns a `Vec<f64>` summing approximately to 1. For graphs with
30/// `vcount = 0` returns an empty vector.
31///
32/// Counterpart of
33/// `igraph_pagerank(_, IGRAPH_PAGERANK_ALGO_POWER, _, _, vss_all(),
34/// /*directed=*/g.is_directed(), 0.85, NULL_weights, NULL_options)`
35/// with default convergence parameters.
36///
37/// # Examples
38///
39/// ```
40/// use rust_igraph::{Graph, pagerank};
41///
42/// // Triangle: every vertex has identical PageRank ≈ 1/3.
43/// let mut g = Graph::with_vertices(3);
44/// g.add_edge(0, 1).unwrap();
45/// g.add_edge(1, 2).unwrap();
46/// g.add_edge(2, 0).unwrap();
47/// let pr = pagerank(&g).unwrap();
48/// assert!((pr[0] - 1.0/3.0).abs() < 1e-10);
49/// assert!((pr[1] - 1.0/3.0).abs() < 1e-10);
50/// assert!((pr[2] - 1.0/3.0).abs() < 1e-10);
51/// ```
52pub fn pagerank(graph: &Graph) -> IgraphResult<Vec<f64>> {
53    let n = graph.vcount();
54    let n_us = n as usize;
55    if n == 0 {
56        return Ok(Vec::new());
57    }
58    if n == 1 {
59        return Ok(vec![1.0]);
60    }
61
62    let directed = graph.is_directed();
63
64    // Pre-compute per-vertex out-degree and in-neighbour adjacency:
65    //   in_adj[v] = list of (u, 1/out_deg(u)) for each in-edge u → v.
66    // For undirected graphs we use the symmetric `neighbors()` iteration
67    // (each edge contributes both directions).
68    let n_f = f64::from(n);
69    let mut out_deg = vec![0u64; n_us];
70    if directed {
71        for v in 0..n {
72            let v_us = v as usize;
73            // Count out-edges of v: use Graph's pub(crate) helper via neighbors()
74            // (which returns out-only for directed).
75            let nbrs = graph.neighbors(v)?;
76            out_deg[v_us] = nbrs.len() as u64;
77        }
78    } else {
79        for v in 0..n {
80            let v_us = v as usize;
81            let nbrs = graph.neighbors(v)?;
82            // For undirected, neighbors() returns each incident edge once
83            // (LOOPS_TWICE for self-loops). The "out degree" for PageRank
84            // is the total degree.
85            out_deg[v_us] = nbrs.len() as u64;
86        }
87    }
88
89    // Build the in-adjacency: for each edge u → v (or u-v undirected, both
90    // u→v and v→u), append u to in_adj[v].
91    let mut in_adj: Vec<Vec<u32>> = vec![Vec::new(); n_us];
92    if directed {
93        // Iterate every edge once and record u → v.
94        let m = u32::try_from(graph.ecount())
95            .map_err(|_| crate::core::IgraphError::Internal("ecount overflows u32"))?;
96        for e in 0..m {
97            let (u, v) = graph.edge(e)?;
98            in_adj[v as usize].push(u);
99        }
100    } else {
101        // Undirected: each (u, v) edge appears once; record both directions.
102        let m = u32::try_from(graph.ecount())
103            .map_err(|_| crate::core::IgraphError::Internal("ecount overflows u32"))?;
104        for e in 0..m {
105            let (u, v) = graph.edge(e)?;
106            // Self-loop in undirected has degree 2 contribution so we add
107            // both directions.
108            if u == v {
109                in_adj[v as usize].push(u);
110                in_adj[v as usize].push(u);
111            } else {
112                in_adj[u as usize].push(v);
113                in_adj[v as usize].push(u);
114            }
115        }
116    }
117
118    // Initial uniform distribution.
119    let mut pr = vec![1.0 / n_f; n_us];
120    let mut pr_new = vec![0.0_f64; n_us];
121
122    for _ in 0..DEFAULT_MAX_ITER {
123        // Compute total dangling-vertex rank for redistribution.
124        let mut dangling_sum: f64 = 0.0;
125        for v in 0..n_us {
126            if out_deg[v] == 0 {
127                dangling_sum += pr[v];
128            }
129        }
130
131        let teleport = (1.0 - DEFAULT_DAMPING) / n_f;
132        let dangling_share = DEFAULT_DAMPING * dangling_sum / n_f;
133
134        for v in 0..n_us {
135            let mut incoming: f64 = 0.0;
136            for &u in &in_adj[v] {
137                #[allow(clippy::cast_precision_loss)]
138                let denom = out_deg[u as usize] as f64;
139                if denom > 0.0 {
140                    incoming += pr[u as usize] / denom;
141                }
142            }
143            pr_new[v] = teleport + dangling_share + DEFAULT_DAMPING * incoming;
144        }
145
146        // Convergence: L1 norm of the change.
147        let mut diff: f64 = 0.0;
148        for v in 0..n_us {
149            diff += (pr_new[v] - pr[v]).abs();
150        }
151        std::mem::swap(&mut pr, &mut pr_new);
152        if diff < DEFAULT_EPS {
153            break;
154        }
155    }
156
157    Ok(pr)
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    fn close(actual: &[f64], expected: &[f64], tol: f64) {
165        assert_eq!(actual.len(), expected.len(), "length mismatch");
166        for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
167            assert!((a - e).abs() < tol, "vertex {i}: actual={a} expected={e}");
168        }
169    }
170
171    #[test]
172    fn empty_graph_yields_empty() {
173        let g = Graph::with_vertices(0);
174        assert!(pagerank(&g).unwrap().is_empty());
175    }
176
177    #[test]
178    fn singleton_yields_one() {
179        let g = Graph::with_vertices(1);
180        assert_eq!(pagerank(&g).unwrap(), vec![1.0]);
181    }
182
183    #[test]
184    fn isolated_vertices_uniform() {
185        // No edges: all vertices are dangling. The dangling-redistribution
186        // term keeps the distribution uniform → 1/n each.
187        let g = Graph::with_vertices(4);
188        let pr = pagerank(&g).unwrap();
189        close(&pr, &[0.25, 0.25, 0.25, 0.25], 1e-9);
190    }
191
192    #[test]
193    fn triangle_uniform_one_third() {
194        let mut g = Graph::with_vertices(3);
195        g.add_edge(0, 1).unwrap();
196        g.add_edge(1, 2).unwrap();
197        g.add_edge(2, 0).unwrap();
198        let pr = pagerank(&g).unwrap();
199        close(&pr, &[1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0], 1e-9);
200    }
201
202    #[test]
203    fn directed_4cycle_uniform_quarter() {
204        let mut g = Graph::new(4, true).unwrap();
205        for i in 0..4u32 {
206            g.add_edge(i, (i + 1) % 4).unwrap();
207        }
208        let pr = pagerank(&g).unwrap();
209        close(&pr, &[0.25, 0.25, 0.25, 0.25], 1e-9);
210    }
211
212    #[test]
213    fn pagerank_sums_to_one() {
214        // K4 minus an edge — verify normalization.
215        let mut g = Graph::with_vertices(4);
216        g.add_edge(0, 1).unwrap();
217        g.add_edge(0, 2).unwrap();
218        g.add_edge(1, 2).unwrap();
219        g.add_edge(1, 3).unwrap();
220        g.add_edge(2, 3).unwrap();
221        let pr = pagerank(&g).unwrap();
222        let total: f64 = pr.iter().sum();
223        assert!((total - 1.0).abs() < 1e-9, "sum {total} != 1.0");
224    }
225
226    #[test]
227    fn star_centre_has_higher_pagerank_than_leaves() {
228        let mut g = Graph::with_vertices(4);
229        for v in 1..4 {
230            g.add_edge(0, v).unwrap();
231        }
232        let pr = pagerank(&g).unwrap();
233        // Centre receives from 3 leaves, each leaf only from centre → centre > leaf.
234        for &leaf in &pr[1..4] {
235            assert!(pr[0] > leaf, "centre {} not > leaf {}", pr[0], leaf);
236        }
237        // Symmetry among leaves.
238        close(&pr[1..4], &[pr[1], pr[1], pr[1]], 1e-9);
239    }
240
241    #[test]
242    fn pagerank_dangling_node_distributes() {
243        // Directed: 0 → 1, vertex 1 is dangling. Power iteration plus
244        // dangling-redistribution keeps PR finite and summing to 1.
245        let mut g = Graph::new(2, true).unwrap();
246        g.add_edge(0, 1).unwrap();
247        let pr = pagerank(&g).unwrap();
248        let total: f64 = pr.iter().sum();
249        assert!((total - 1.0).abs() < 1e-9);
250        // Vertex 1 receives flow from 0 and the dangling-redistribution.
251        // Vertex 0 only receives the teleport + dangling-redistribution.
252        // → pr[1] > pr[0].
253        assert!(pr[1] > pr[0]);
254    }
255}