Skip to main content

rust_igraph/algorithms/properties/
pagerank_linsys.rs

1//! `PageRank` via GMRES on the linear-system formulation (ALGO-PR-011c).
2//!
3//! Second backend for [`crate::pagerank`]. Where PR-011 runs power
4//! iteration on the Google matrix `G = (1 - α)/N · 1·1ᵀ + α · M`,
5//! this AWU casts the same fixed point as a non-singular linear system
6//! and solves it with restarted GMRES:
7//!
8//! ```text
9//!   pr = (1 - α)/N · 1 + α · Mᵀ · pr
10//!   ⟺ (I - α · Mᵀ) · pr = (1 - α)/N · 1
11//! ```
12//!
13//! where `Mᵀ[v, u] = 1/out_deg(u)` for every directed edge `u → v` (and
14//! both directions per edge for undirected graphs), with the
15//! "dangling-vertex" correction baked in: a vertex `s` with
16//! `out_deg(s) = 0` distributes its rank uniformly across all `N`
17//! vertices, so the matvec `(Mᵀ · pr)[v]` picks up an extra
18//! `Σ_{s dangling} pr[s] / N`.
19//!
20//! Because `α < 1`, the spectral radius of `α · Mᵀ` is bounded by `α`,
21//! so `I - α · Mᵀ` is non-singular and GMRES converges in at most
22//! `O(spectral_gap)` Arnoldi steps. The operator is applied **without
23//! materialising any matrix** — one O(|V| + |E|) sweep per matvec,
24//! reusing the same `in_adj` + `out_deg` machinery as PR-011.
25//!
26//! Numerical contract: parity with [`crate::pagerank`] to `1e-9`
27//! elementwise on every fixture under `tests/conformance/c/pagerank/`.
28//! GMRES is run with a tight residual tolerance (`1e-13`) so its
29//! result is essentially exact (≤ machine precision against the true
30//! fixed point); the parity budget is set by PR-011's looser
31//! power-iter stopping rule (`eps = 1e-10`), so individual entries can
32//! differ by `O(eps)` between the two backends. Both backends still
33//! match python-igraph's ARPACK output within `1e-6` on the shared
34//! fixtures.
35//!
36//! Performance notes: the Krylov basis lives in a single
37//! `(restart_dim + 1) · |V|` flat `Vec<f64>` (no per-step
38//! re-allocation), and the matvec uses a pre-computed `inv_out_deg`
39//! lookup so the inner sweep is a single fused multiply-add per edge.
40//! That keeps the per-restart cost down to one large allocation rather
41//! than ~90 small ones.
42//!
43//! Scope: **unweighted only**. A weighted GMRES backend can be added
44//! later as PR-011d.
45
46use crate::core::{Graph, IgraphResult};
47
48/// Damping factor (matches PR-011).
49const ALPHA: f64 = 0.85;
50/// GMRES restart dimension — m in the `GMRES(m)` literature.
51const RESTART_DIM: usize = 30;
52/// Maximum number of restart cycles before giving up.
53const MAX_RESTARTS: usize = 50;
54/// Relative residual tolerance — stop when `‖r‖₂ / ‖b‖₂ < TOL`.
55/// `1e-13` is tight enough to deliver `1e-12` parity vs. PR-011 on all
56/// fixtures, given `‖x_true − x‖ ≤ ‖A⁻¹‖ · ‖r‖` with
57/// `‖A⁻¹‖ ≤ 1/(1 − α) ≈ 6.67`.
58const TOL: f64 = 1e-13;
59
60/// `PageRank` via GMRES on `(I - α · Mᵀ) · pr = (1 - α)/N · 1`.
61///
62/// Returns a `Vec<f64>` of length `vcount()` summing approximately to
63/// `1.0`, agreeing with [`crate::pagerank`] to `1e-9` elementwise on
64/// the shared conformance fixtures (the limit comes from PR-011's
65/// `eps = 1e-10` stopping rule; GMRES itself reaches the true fixed
66/// point to ≤ machine precision). Empty graphs return an empty vector;
67/// singleton graphs return `vec![1.0]` — matching the PR-011 edge-case
68/// contract.
69///
70/// Internally uses restarted GMRES with `restart_dim = 30`,
71/// `max_restarts = 50`, relative residual tolerance `1e-13`. No
72/// `unsafe`, no external linear-algebra deps; the Arnoldi + Givens +
73/// back-sub routine is implemented in this module.
74///
75/// # Errors
76///
77/// Returns [`crate::IgraphError`] only via the underlying graph
78/// accessors (e.g. an `Internal` error if `ecount()` overflows `u32`).
79/// GMRES non-convergence after `max_restarts` returns the best iterate
80/// it had — no error, mirroring PR-011's power-iter "fall through after
81/// `max_iter`" behaviour.
82///
83/// # Examples
84///
85/// ```
86/// use rust_igraph::{Graph, pagerank, pagerank_linsys};
87///
88/// // Triangle: both backends agree on uniform 1/3.
89/// let mut g = Graph::with_vertices(3);
90/// g.add_edge(0, 1).unwrap();
91/// g.add_edge(1, 2).unwrap();
92/// g.add_edge(2, 0).unwrap();
93/// let a = pagerank(&g).unwrap();
94/// let b = pagerank_linsys(&g).unwrap();
95/// for v in 0..3 {
96///     assert!((a[v] - b[v]).abs() < 1e-12);
97/// }
98/// ```
99pub fn pagerank_linsys(graph: &Graph) -> IgraphResult<Vec<f64>> {
100    let n = graph.vcount();
101    let n_us = n as usize;
102    if n == 0 {
103        return Ok(Vec::new());
104    }
105    if n == 1 {
106        return Ok(vec![1.0]);
107    }
108
109    let n_f = f64::from(n);
110    let adj = build_adjacency(graph)?;
111
112    // RHS b = (1 - α)/N · 1.
113    let rhs_val = (1.0 - ALPHA) / n_f;
114    let rhs_norm = rhs_val * (n_f.sqrt());
115    let stop = TOL * rhs_norm.max(1e-300);
116
117    // Initial guess: uniform 1/N — the same starting point as PR-011.
118    let mut x_iter = vec![1.0 / n_f; n_us];
119
120    // All scratch buffers allocated once.
121    let mut residual = vec![0.0_f64; n_us];
122    let mut ax = vec![0.0_f64; n_us];
123    let mut basis_flat = vec![0.0_f64; (RESTART_DIM + 1) * n_us];
124    let mut hcols_flat = vec![0.0_f64; RESTART_DIM * (RESTART_DIM + 1)];
125    let mut w_scratch = vec![0.0_f64; n_us];
126    let mut cos_v = vec![0.0_f64; RESTART_DIM];
127    let mut sin_v = vec![0.0_f64; RESTART_DIM];
128    let mut g_rot = vec![0.0_f64; RESTART_DIM + 1];
129    let mut y_solve = vec![0.0_f64; RESTART_DIM];
130
131    for _restart in 0..MAX_RESTARTS {
132        apply_a(&x_iter, &mut ax, &adj, n_f);
133        for i in 0..n_us {
134            residual[i] = rhs_val - ax[i];
135        }
136        let beta = norm2(&residual);
137        if beta < stop {
138            break;
139        }
140        let converged = gmres_inner(
141            &mut x_iter,
142            &residual,
143            beta,
144            stop,
145            &adj,
146            n_f,
147            &mut basis_flat,
148            &mut hcols_flat,
149            &mut w_scratch,
150            &mut cos_v,
151            &mut sin_v,
152            &mut g_rot,
153            &mut y_solve,
154        );
155        if converged {
156            break;
157        }
158    }
159
160    Ok(x_iter)
161}
162
163struct Adj {
164    incoming: Vec<Vec<u32>>,
165    inv_out_deg: Vec<f64>,
166    dangling_ids: Vec<u32>,
167}
168
169fn build_adjacency(graph: &Graph) -> IgraphResult<Adj> {
170    let n = graph.vcount();
171    let n_us = n as usize;
172    let directed = graph.is_directed();
173
174    let mut out_deg = vec![0u64; n_us];
175    for vid in 0..n {
176        let nbrs = graph.neighbors(vid)?;
177        out_deg[vid as usize] = nbrs.len() as u64;
178    }
179
180    let mut in_adj: Vec<Vec<u32>> = vec![Vec::new(); n_us];
181    let edge_count = u32::try_from(graph.ecount())
182        .map_err(|_| crate::core::IgraphError::Internal("ecount overflows u32"))?;
183    if directed {
184        for eid in 0..edge_count {
185            let (src, dst) = graph.edge(eid)?;
186            in_adj[dst as usize].push(src);
187        }
188    } else {
189        for eid in 0..edge_count {
190            let (src, dst) = graph.edge(eid)?;
191            if src == dst {
192                in_adj[dst as usize].push(src);
193                in_adj[dst as usize].push(src);
194            } else {
195                in_adj[src as usize].push(dst);
196                in_adj[dst as usize].push(src);
197            }
198        }
199    }
200
201    // Pre-compute 1/out_deg so the matvec's inner loop is fmadd-only.
202    // Dangling vertices keep `0.0` here — they never appear in any
203    // in_adj entry so the value is never read.
204    let mut inv_out_deg = vec![0.0_f64; n_us];
205    for v in 0..n_us {
206        if out_deg[v] > 0 {
207            #[allow(clippy::cast_precision_loss)]
208            let d = out_deg[v] as f64;
209            inv_out_deg[v] = 1.0 / d;
210        }
211    }
212
213    let dangling_ids: Vec<u32> = (0..n).filter(|&v| out_deg[v as usize] == 0).collect();
214    Ok(Adj {
215        incoming: in_adj,
216        inv_out_deg,
217        dangling_ids,
218    })
219}
220
221fn apply_a(x: &[f64], y: &mut [f64], adj: &Adj, n_f: f64) {
222    let mut dangling_sum = 0.0_f64;
223    for &sid in &adj.dangling_ids {
224        dangling_sum += x[sid as usize];
225    }
226    let dangling_share = dangling_sum / n_f;
227    let off = ALPHA * dangling_share;
228    for v in 0..y.len() {
229        let mut acc = 0.0_f64;
230        for &u in &adj.incoming[v] {
231            // `inv_out_deg[u]` is `0.0` for dangling u, but dangling u
232            // never appears in any in_adj entry, so the multiplication
233            // is always meaningful.
234            acc += x[u as usize] * adj.inv_out_deg[u as usize];
235        }
236        y[v] = x[v] - ALPHA * acc - off;
237    }
238}
239
240/// One restart cycle of GMRES(m). Returns true if the residual estimate
241/// dropped below `stop` inside the cycle (so the outer loop can exit).
242#[allow(clippy::too_many_arguments)]
243fn gmres_inner(
244    x_iter: &mut [f64],
245    r0: &[f64],
246    beta: f64,
247    stop: f64,
248    adj: &Adj,
249    n_f: f64,
250    basis_flat: &mut [f64],
251    hcols_flat: &mut [f64],
252    w: &mut [f64],
253    cos_v: &mut [f64],
254    sin_v: &mut [f64],
255    g_rot: &mut [f64],
256    y_solve: &mut [f64],
257) -> bool {
258    let n_us = x_iter.len();
259    let inv_beta = 1.0 / beta;
260
261    // v_0 = r0 / beta
262    for i in 0..n_us {
263        basis_flat[i] = r0[i] * inv_beta;
264    }
265
266    for g in g_rot.iter_mut() {
267        *g = 0.0;
268    }
269    g_rot[0] = beta;
270
271    let col_stride = RESTART_DIM + 1;
272    let mut steps = 0_usize;
273    let mut converged = false;
274
275    for j in 0..RESTART_DIM {
276        // w = A · v_j
277        let (vj, _rest) = basis_flat[j * n_us..].split_at(n_us);
278        apply_a(vj, w, adj, n_f);
279
280        // Modified Gram-Schmidt against v_0..v_j (stored in basis_flat).
281        let h_col_start = j * col_stride;
282        for i in 0..=j {
283            let vi = &basis_flat[i * n_us..(i + 1) * n_us];
284            let h_ij = dot(w, vi);
285            hcols_flat[h_col_start + i] = h_ij;
286            for k in 0..n_us {
287                w[k] -= h_ij * vi[k];
288            }
289        }
290        let h_next = norm2(w);
291        hcols_flat[h_col_start + j + 1] = h_next;
292
293        // Apply prior Givens rotations to the new column.
294        for i in 0..j {
295            let a = hcols_flat[h_col_start + i];
296            let b = hcols_flat[h_col_start + i + 1];
297            hcols_flat[h_col_start + i] = cos_v[i] * a + sin_v[i] * b;
298            hcols_flat[h_col_start + i + 1] = -sin_v[i] * a + cos_v[i] * b;
299        }
300
301        // New Givens for (h_col[j], h_col[j+1]).
302        let diag = hcols_flat[h_col_start + j];
303        let subdiag = hcols_flat[h_col_start + j + 1];
304        let denom = (diag * diag + subdiag * subdiag).sqrt();
305        let (c_new, s_new) = if denom > 0.0 {
306            (diag / denom, subdiag / denom)
307        } else {
308            (1.0_f64, 0.0_f64)
309        };
310        cos_v[j] = c_new;
311        sin_v[j] = s_new;
312
313        hcols_flat[h_col_start + j] = c_new * diag + s_new * subdiag;
314        hcols_flat[h_col_start + j + 1] = 0.0;
315        let g_temp = c_new * g_rot[j] + s_new * g_rot[j + 1];
316        g_rot[j + 1] = -s_new * g_rot[j] + c_new * g_rot[j + 1];
317        g_rot[j] = g_temp;
318
319        steps = j + 1;
320
321        if g_rot[j + 1].abs() < stop {
322            converged = true;
323            break;
324        }
325        if h_next.abs() <= 1e-300 {
326            break;
327        }
328        if j + 1 < RESTART_DIM {
329            let inv_h_next = 1.0 / h_next;
330            let dst_start = (j + 1) * n_us;
331            for k in 0..n_us {
332                basis_flat[dst_start + k] = w[k] * inv_h_next;
333            }
334        }
335    }
336
337    // Back-sub R · y = g_rot[0..steps] where R[i, k] = hcols_flat[k*col_stride + i].
338    for i in (0..steps).rev() {
339        let mut sum = g_rot[i];
340        for k in (i + 1)..steps {
341            sum -= hcols_flat[k * col_stride + i] * y_solve[k];
342        }
343        // Diagonal is non-zero — Givens left it ≥ |g_rot[i+1]| > stop,
344        // otherwise we already broke via the convergence check above.
345        y_solve[i] = sum / hcols_flat[i * col_stride + i];
346    }
347
348    // x ← x + V · y
349    for k in 0..steps {
350        let yk = y_solve[k];
351        let vk = &basis_flat[k * n_us..(k + 1) * n_us];
352        for i in 0..n_us {
353            x_iter[i] += yk * vk[i];
354        }
355    }
356
357    converged
358}
359
360#[inline]
361fn dot(a: &[f64], b: &[f64]) -> f64 {
362    let mut s = 0.0_f64;
363    for i in 0..a.len() {
364        s += a[i] * b[i];
365    }
366    s
367}
368
369#[inline]
370fn norm2(a: &[f64]) -> f64 {
371    dot(a, a).sqrt()
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    fn close(actual: &[f64], expected: &[f64], tol: f64) {
379        assert_eq!(actual.len(), expected.len(), "length mismatch");
380        for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
381            assert!((a - e).abs() < tol, "vertex {i}: actual={a} expected={e}");
382        }
383    }
384
385    #[test]
386    fn empty_graph_yields_empty() {
387        let g = Graph::with_vertices(0);
388        assert!(pagerank_linsys(&g).unwrap().is_empty());
389    }
390
391    #[test]
392    fn singleton_yields_one() {
393        let g = Graph::with_vertices(1);
394        assert_eq!(pagerank_linsys(&g).unwrap(), vec![1.0]);
395    }
396
397    #[test]
398    fn isolated_vertices_uniform() {
399        let g = Graph::with_vertices(4);
400        let pr = pagerank_linsys(&g).unwrap();
401        close(&pr, &[0.25, 0.25, 0.25, 0.25], 1e-12);
402    }
403
404    #[test]
405    fn triangle_uniform_one_third() {
406        let mut g = Graph::with_vertices(3);
407        g.add_edge(0, 1).unwrap();
408        g.add_edge(1, 2).unwrap();
409        g.add_edge(2, 0).unwrap();
410        let pr = pagerank_linsys(&g).unwrap();
411        close(&pr, &[1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0], 1e-12);
412    }
413
414    #[test]
415    fn directed_4cycle_uniform_quarter() {
416        let mut g = Graph::new(4, true).unwrap();
417        for i in 0..4u32 {
418            g.add_edge(i, (i + 1) % 4).unwrap();
419        }
420        let pr = pagerank_linsys(&g).unwrap();
421        close(&pr, &[0.25, 0.25, 0.25, 0.25], 1e-12);
422    }
423
424    #[test]
425    fn matches_power_iter_k4_minus_edge() {
426        use crate::pagerank;
427        let mut g = Graph::with_vertices(4);
428        g.add_edge(0, 1).unwrap();
429        g.add_edge(0, 2).unwrap();
430        g.add_edge(1, 2).unwrap();
431        g.add_edge(1, 3).unwrap();
432        g.add_edge(2, 3).unwrap();
433        let a = pagerank(&g).unwrap();
434        let b = pagerank_linsys(&g).unwrap();
435        for i in 0..4 {
436            assert!((a[i] - b[i]).abs() < 1e-9, "i={i}: {} vs {}", a[i], b[i]);
437        }
438    }
439
440    #[test]
441    fn matches_power_iter_directed_dangling() {
442        use crate::pagerank;
443        let mut g = Graph::new(2, true).unwrap();
444        g.add_edge(0, 1).unwrap();
445        let a = pagerank(&g).unwrap();
446        let b = pagerank_linsys(&g).unwrap();
447        for i in 0..2 {
448            assert!((a[i] - b[i]).abs() < 1e-9, "i={i}: {} vs {}", a[i], b[i]);
449        }
450    }
451
452    #[test]
453    fn matches_power_iter_star() {
454        use crate::pagerank;
455        let mut g = Graph::with_vertices(8);
456        for v in 1..8u32 {
457            g.add_edge(0, v).unwrap();
458        }
459        let a = pagerank(&g).unwrap();
460        let b = pagerank_linsys(&g).unwrap();
461        for i in 0..8 {
462            assert!((a[i] - b[i]).abs() < 1e-9, "i={i}: {} vs {}", a[i], b[i]);
463        }
464    }
465
466    #[test]
467    fn sums_to_one() {
468        let mut g = Graph::with_vertices(6);
469        g.add_edge(0, 1).unwrap();
470        g.add_edge(1, 2).unwrap();
471        g.add_edge(2, 3).unwrap();
472        g.add_edge(3, 4).unwrap();
473        g.add_edge(4, 5).unwrap();
474        g.add_edge(5, 0).unwrap();
475        let pr = pagerank_linsys(&g).unwrap();
476        let total: f64 = pr.iter().sum();
477        assert!((total - 1.0).abs() < 1e-9, "sum {total} != 1.0");
478    }
479}