Skip to main content

ruvector_solver/
forward_push.rs

1//! Forward Push solver for Personalized PageRank (Andersen-Chung-Lang).
2//!
3//! Computes approximate PPR from a single source vertex in O(1/epsilon) time,
4//! independent of graph size. The algorithm maintains two sparse vectors:
5//!
6//! - **estimate**: accumulated PPR values (the output).
7//! - **residual**: probability mass yet to be distributed.
8//!
9//! At each step a vertex whose residual exceeds `epsilon * degree(u)` is
10//! popped from a work-queue and its mass is "pushed" to its neighbours.
11//!
12//! # References
13//!
14//! Andersen, Chung, Lang.  *Local Graph Partitioning using PageRank Vectors.*
15//! FOCS 2006.
16
17use std::collections::VecDeque;
18
19use crate::error::SolverError;
20use crate::traits::{SolverEngine, SublinearPageRank};
21use crate::types::{
22    Algorithm, ComplexityClass, ComplexityEstimate, ComputeBudget, CsrMatrix, SolverResult,
23    SparsityProfile,
24};
25
26// ---------------------------------------------------------------------------
27// ForwardPushSolver
28// ---------------------------------------------------------------------------
29
30/// Forward Push solver for Personalized PageRank.
31///
32/// Given a graph encoded as a `CsrMatrix<f64>` (adjacency list in CSR
33/// format), computes the PPR vector from a single source vertex.
34///
35/// # Parameters
36///
37/// - `alpha` -- teleport probability (fraction absorbed per push).
38///   Default: `0.85`.
39/// - `epsilon` -- push threshold.  Vertices with
40///   `residual[u] > epsilon * degree(u)` are eligible for a push.  Smaller
41///   values yield more accurate results at the cost of more work.
42///
43/// # Complexity
44///
45/// O(1 / epsilon) pushes in total, independent of |V| or |E|.
46#[derive(Debug, Clone)]
47pub struct ForwardPushSolver {
48    /// Teleportation probability (alpha).
49    pub alpha: f64,
50    /// Approximation tolerance (epsilon).
51    pub epsilon: f64,
52}
53
54impl ForwardPushSolver {
55    /// Create a new forward-push solver.
56    ///
57    /// Parameters are validated lazily at the start of each computation
58    /// (see [`validate_params`](Self::validate_params)).
59    pub fn new(alpha: f64, epsilon: f64) -> Self {
60        Self { alpha, epsilon }
61    }
62
63    /// Validate that `alpha` and `epsilon` are within acceptable ranges.
64    ///
65    /// # Errors
66    ///
67    /// - [`SolverError::InvalidInput`] if `alpha` is not in `(0, 1)` exclusive.
68    /// - [`SolverError::InvalidInput`] if `epsilon` is not positive.
69    fn validate_params(&self) -> Result<(), SolverError> {
70        if self.alpha <= 0.0 || self.alpha >= 1.0 {
71            return Err(SolverError::InvalidInput(
72                crate::error::ValidationError::ParameterOutOfRange {
73                    name: "alpha".into(),
74                    value: self.alpha.to_string(),
75                    expected: "(0.0, 1.0) exclusive".into(),
76                },
77            ));
78        }
79        if self.epsilon <= 0.0 {
80            return Err(SolverError::InvalidInput(
81                crate::error::ValidationError::ParameterOutOfRange {
82                    name: "epsilon".into(),
83                    value: self.epsilon.to_string(),
84                    expected: "> 0.0".into(),
85                },
86            ));
87        }
88        Ok(())
89    }
90
91    /// Create a solver with default parameters (`alpha = 0.85`,
92    /// `epsilon = 1e-6`).
93    pub fn default_params() -> Self {
94        Self {
95            alpha: 0.85,
96            epsilon: 1e-6,
97        }
98    }
99
100    /// Compute PPR from `source` returning sparse `(vertex, score)` pairs
101    /// sorted by score descending.
102    ///
103    /// # Errors
104    ///
105    /// - [`SolverError::InvalidInput`] if `source >= graph.rows`.
106    /// - [`SolverError::NumericalInstability`] if the mass invariant is
107    ///   violated after convergence.
108    pub fn ppr_from_source(
109        &self,
110        graph: &CsrMatrix<f64>,
111        source: usize,
112    ) -> Result<Vec<(usize, f64)>, SolverError> {
113        self.validate_params()?;
114        validate_vertex(graph, source, "source")?;
115        self.forward_push_core(graph, &[(source, 1.0)])
116    }
117
118    /// Compute PPR from `source` and return only the top-`k` entries.
119    ///
120    /// Convenience wrapper around [`ppr_from_source`](Self::ppr_from_source).
121    pub fn top_k(
122        &self,
123        graph: &CsrMatrix<f64>,
124        source: usize,
125        k: usize,
126    ) -> Result<Vec<(usize, f64)>, SolverError> {
127        let mut result = self.ppr_from_source(graph, source)?;
128        result.truncate(k);
129        Ok(result)
130    }
131
132    // -----------------------------------------------------------------------
133    // Core push loop (Andersen-Chung-Lang)
134    // -----------------------------------------------------------------------
135
136    /// Run the forward push from a (possibly multi-seed) initial residual
137    /// distribution.
138    ///
139    /// Uses a `VecDeque` work-queue with a membership bitvec to achieve
140    /// O(1/epsilon) total work, independent of graph size.
141    /// Maximum number of graph nodes to prevent OOM DoS.
142    const MAX_GRAPH_NODES: usize = 100_000_000;
143
144    fn forward_push_core(
145        &self,
146        graph: &CsrMatrix<f64>,
147        seeds: &[(usize, f64)],
148    ) -> Result<Vec<(usize, f64)>, SolverError> {
149        self.validate_params()?;
150
151        let n = graph.rows;
152        if n > Self::MAX_GRAPH_NODES {
153            return Err(SolverError::InvalidInput(
154                crate::error::ValidationError::MatrixTooLarge {
155                    rows: n,
156                    cols: graph.cols,
157                    max_dim: Self::MAX_GRAPH_NODES,
158                },
159            ));
160        }
161
162        let mut estimate = vec![0.0f64; n];
163        let mut residual = vec![0.0f64; n];
164
165        // BFS-style work-queue with a membership bitvec.
166        let mut in_queue = vec![false; n];
167        let mut queue: VecDeque<usize> = VecDeque::new();
168
169        // Initialise residuals from seed distribution.
170        for &(v, mass) in seeds {
171            residual[v] += mass;
172            if !in_queue[v]
173                && should_push(residual[v], graph.row_degree(v), self.epsilon)
174            {
175                queue.push_back(v);
176                in_queue[v] = true;
177            }
178        }
179
180        // ----- Main push loop -----
181        while let Some(u) = queue.pop_front() {
182            in_queue[u] = false;
183
184            let r_u = residual[u];
185
186            // Re-check: the residual may have decayed since enqueue.
187            if !should_push(r_u, graph.row_degree(u), self.epsilon) {
188                continue;
189            }
190
191            // Absorb alpha fraction into the estimate.
192            estimate[u] += self.alpha * r_u;
193
194            let degree = graph.row_degree(u);
195            if degree > 0 {
196                let push_amount = (1.0 - self.alpha) * r_u / degree as f64;
197
198                // Zero out the residual at u BEFORE distributing to
199                // neighbours. This is critical for self-loops: if u has an
200                // edge to itself, the push_amount added via the self-loop
201                // must not be overwritten.
202                residual[u] = 0.0;
203
204                for (v, _weight) in graph.row_entries(u) {
205                    residual[v] += push_amount;
206
207                    if !in_queue[v]
208                        && should_push(
209                            residual[v],
210                            graph.row_degree(v),
211                            self.epsilon,
212                        )
213                    {
214                        queue.push_back(v);
215                        in_queue[v] = true;
216                    }
217                }
218            } else {
219                // Dangling vertex (degree 0): the (1-alpha) fraction cannot
220                // be distributed to neighbours.  Keep it in the residual so
221                // the mass invariant is preserved.  Re-enqueue if the
222                // leftover still exceeds the push threshold, which will
223                // converge geometrically since each push multiplies the
224                // residual by (1-alpha).
225                let leftover = (1.0 - self.alpha) * r_u;
226                residual[u] = leftover;
227
228                if !in_queue[u] && should_push(leftover, 0, self.epsilon) {
229                    queue.push_back(u);
230                    in_queue[u] = true;
231                }
232            }
233        }
234
235        // Mass invariant: sum(estimate) + sum(residual) must approximate the
236        // total initial mass.
237        let total_seed_mass: f64 = seeds.iter().map(|(_, m)| *m).sum();
238        check_mass_invariant(&estimate, &residual, total_seed_mass)?;
239
240        // Collect non-zero estimates into a sparse result, sorted descending.
241        let mut result: Vec<(usize, f64)> = estimate
242            .iter()
243            .enumerate()
244            .filter(|(_, val)| **val > 0.0)
245            .map(|(i, val)| (i, *val))
246            .collect();
247
248        result.sort_by(|a, b| {
249            b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
250        });
251
252        Ok(result)
253    }
254}
255
256/// Compute the estimate and residual vectors simultaneously.
257///
258/// Returns `(estimate, residual)` as dense `Vec<f64>` for use by hybrid
259/// random-walk algorithms that need to inspect residuals.
260pub fn forward_push_with_residuals(
261    matrix: &CsrMatrix<f64>,
262    source: usize,
263    alpha: f64,
264    epsilon: f64,
265) -> Result<(Vec<f64>, Vec<f64>), SolverError> {
266    validate_vertex(matrix, source, "source")?;
267
268    let n = matrix.rows;
269    let mut estimate = vec![0.0f64; n];
270    let mut residual = vec![0.0f64; n];
271
272    residual[source] = 1.0;
273
274    let mut in_queue = vec![false; n];
275    let mut queue: VecDeque<usize> = VecDeque::new();
276
277    if should_push(1.0, matrix.row_degree(source), epsilon) {
278        queue.push_back(source);
279        in_queue[source] = true;
280    }
281
282    while let Some(u) = queue.pop_front() {
283        in_queue[u] = false;
284        let r_u = residual[u];
285
286        if !should_push(r_u, matrix.row_degree(u), epsilon) {
287            continue;
288        }
289
290        estimate[u] += alpha * r_u;
291
292        let degree = matrix.row_degree(u);
293        if degree > 0 {
294            let push_amount = (1.0 - alpha) * r_u / degree as f64;
295            // Zero before distributing (self-loop safety).
296            residual[u] = 0.0;
297            for (v, _) in matrix.row_entries(u) {
298                residual[v] += push_amount;
299                if !in_queue[v]
300                    && should_push(residual[v], matrix.row_degree(v), epsilon)
301                {
302                    queue.push_back(v);
303                    in_queue[v] = true;
304                }
305            }
306        } else {
307            // Dangling vertex: keep (1-alpha) portion as residual.
308            let leftover = (1.0 - alpha) * r_u;
309            residual[u] = leftover;
310            if !in_queue[u] && should_push(leftover, 0, epsilon) {
311                queue.push_back(u);
312                in_queue[u] = true;
313            }
314        }
315    }
316
317    Ok((estimate, residual))
318}
319
320// ---------------------------------------------------------------------------
321// Free-standing helpers
322// ---------------------------------------------------------------------------
323
324/// Whether a vertex with the given `residual` and `degree` should be pushed.
325///
326/// For isolated vertices (degree 0) we use a fallback threshold of `epsilon`
327/// to avoid infinite loops while still absorbing meaningful residual.
328#[inline]
329fn should_push(residual: f64, degree: usize, epsilon: f64) -> bool {
330    if degree == 0 {
331        residual > epsilon
332    } else {
333        residual > epsilon * degree as f64
334    }
335}
336
337/// Validate that a vertex index is within bounds.
338fn validate_vertex(
339    graph: &CsrMatrix<f64>,
340    vertex: usize,
341    name: &str,
342) -> Result<(), SolverError> {
343    if vertex >= graph.rows {
344        return Err(SolverError::InvalidInput(
345            crate::error::ValidationError::ParameterOutOfRange {
346                name: name.into(),
347                value: vertex.to_string(),
348                expected: format!("0..{}", graph.rows),
349            },
350        ));
351    }
352    Ok(())
353}
354
355/// Verify the mass invariant: `sum(estimate) + sum(residual) ~ expected`.
356fn check_mass_invariant(
357    estimate: &[f64],
358    residual: &[f64],
359    expected_mass: f64,
360) -> Result<(), SolverError> {
361    let mass: f64 = estimate.iter().sum::<f64>() + residual.iter().sum::<f64>();
362    if (mass - expected_mass).abs() > 1e-6 {
363        return Err(SolverError::NumericalInstability {
364            iteration: 0,
365            detail: format!(
366                "mass invariant violated: sum(estimate)+sum(residual) = {mass:.10}, \
367                 expected {expected_mass:.10}",
368            ),
369        });
370    }
371    Ok(())
372}
373
374// ---------------------------------------------------------------------------
375// SolverEngine trait implementation
376// ---------------------------------------------------------------------------
377
378impl SolverEngine for ForwardPushSolver {
379    /// Adapt forward-push PPR to the generic solver interface.
380    ///
381    /// The `rhs` vector is interpreted as a source indicator: the index of
382    /// the first non-zero entry is taken as the source vertex. If `rhs` is
383    /// all zeros, vertex 0 is used. The returned `SolverResult.solution`
384    /// contains the dense PPR vector.
385    fn solve(
386        &self,
387        matrix: &CsrMatrix<f64>,
388        rhs: &[f64],
389        _budget: &ComputeBudget,
390    ) -> Result<SolverResult, SolverError> {
391        let start = std::time::Instant::now();
392
393        let source = rhs.iter().position(|&v| v != 0.0).unwrap_or(0);
394        let sparse_result = self.ppr_from_source(matrix, source)?;
395
396        let n = matrix.rows;
397        let mut solution = vec![0.0f32; n];
398        for &(idx, score) in &sparse_result {
399            solution[idx] = score as f32;
400        }
401
402        Ok(SolverResult {
403            solution,
404            iterations: sparse_result.len(),
405            residual_norm: 0.0,
406            wall_time: start.elapsed(),
407            convergence_history: Vec::new(),
408            algorithm: Algorithm::ForwardPush,
409        })
410    }
411
412    fn estimate_complexity(
413        &self,
414        _profile: &SparsityProfile,
415        _n: usize,
416    ) -> ComplexityEstimate {
417        let est_ops = (1.0 / self.epsilon).min(usize::MAX as f64) as usize;
418        ComplexityEstimate {
419            algorithm: Algorithm::ForwardPush,
420            estimated_flops: est_ops as u64 * 10,
421            estimated_iterations: est_ops,
422            estimated_memory_bytes: est_ops * 16,
423            complexity_class: ComplexityClass::SublinearNnz,
424        }
425    }
426
427    fn algorithm(&self) -> Algorithm {
428        Algorithm::ForwardPush
429    }
430}
431
432// ---------------------------------------------------------------------------
433// SublinearPageRank trait implementation
434// ---------------------------------------------------------------------------
435
436impl SublinearPageRank for ForwardPushSolver {
437    fn ppr(
438        &self,
439        matrix: &CsrMatrix<f64>,
440        source: usize,
441        alpha: f64,
442        epsilon: f64,
443    ) -> Result<Vec<(usize, f64)>, SolverError> {
444        let solver = ForwardPushSolver::new(alpha, epsilon);
445        solver.ppr_from_source(matrix, source)
446    }
447
448    fn ppr_multi_seed(
449        &self,
450        matrix: &CsrMatrix<f64>,
451        seeds: &[(usize, f64)],
452        alpha: f64,
453        epsilon: f64,
454    ) -> Result<Vec<(usize, f64)>, SolverError> {
455        for &(v, _) in seeds {
456            validate_vertex(matrix, v, "seed vertex")?;
457        }
458        let solver = ForwardPushSolver::new(alpha, epsilon);
459        solver.forward_push_core(matrix, seeds)
460    }
461}
462
463// ---------------------------------------------------------------------------
464// Unit tests
465// ---------------------------------------------------------------------------
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    /// Kahan (compensated) summation accumulator (test-only).
472    #[derive(Debug, Clone, Copy)]
473    struct KahanAccumulator {
474        sum: f64,
475        compensation: f64,
476    }
477
478    impl KahanAccumulator {
479        #[inline]
480        const fn new() -> Self {
481            Self { sum: 0.0, compensation: 0.0 }
482        }
483
484        #[inline]
485        fn add(&mut self, value: f64) {
486            let y = value - self.compensation;
487            let t = self.sum + y;
488            self.compensation = (t - self.sum) - y;
489            self.sum = t;
490        }
491
492        #[inline]
493        fn value(&self) -> f64 {
494            self.sum
495        }
496    }
497
498    /// 4-vertex graph with bidirectional edges:
499    ///   0 -- 1, 0 -- 2, 1 -- 2, 1 -- 3
500    fn triangle_graph() -> CsrMatrix<f64> {
501        CsrMatrix::<f64>::from_coo(
502            4,
503            4,
504            vec![
505                (0, 1, 1.0f64),
506                (0, 2, 1.0f64),
507                (1, 0, 1.0f64),
508                (1, 2, 1.0f64),
509                (1, 3, 1.0f64),
510                (2, 0, 1.0f64),
511                (2, 1, 1.0f64),
512                (3, 1, 1.0f64),
513            ],
514        )
515    }
516
517    /// Directed path: 0 -> 1 -> 2 -> 3
518    fn path_graph() -> CsrMatrix<f64> {
519        CsrMatrix::<f64>::from_coo(
520            4,
521            4,
522            vec![(0, 1, 1.0f64), (1, 2, 1.0f64), (2, 3, 1.0f64)],
523        )
524    }
525
526    /// Star graph centred at vertex 0 with 5 leaves, bidirectional.
527    fn star_graph() -> CsrMatrix<f64> {
528        let n = 6;
529        let mut entries = Vec::new();
530        for leaf in 1..n {
531            entries.push((0, leaf, 1.0f64));
532            entries.push((leaf, 0, 1.0f64));
533        }
534        CsrMatrix::<f64>::from_coo(n, n, entries)
535    }
536
537    #[test]
538    fn basic_ppr_triangle() {
539        let graph = triangle_graph();
540        let solver = ForwardPushSolver::default_params();
541        let result = solver.ppr_from_source(&graph, 0).unwrap();
542
543        assert!(!result.is_empty());
544        assert_eq!(result[0].0, 0, "source should be top-ranked");
545        assert!(result[0].1 > 0.0);
546
547        for &(_, score) in &result {
548            assert!(score > 0.0);
549        }
550
551        for w in result.windows(2) {
552            assert!(w[0].1 >= w[1].1, "results should be sorted descending");
553        }
554    }
555
556    #[test]
557    fn ppr_path_graph_monotone_decay() {
558        let graph = path_graph();
559        let solver = ForwardPushSolver::new(0.85, 1e-8);
560        let result = solver.ppr_from_source(&graph, 0).unwrap();
561
562        let mut scores = vec![0.0f64; 4];
563        for &(v, s) in &result {
564            scores[v] = s;
565        }
566        assert!(scores[0] > scores[1], "score[0] > score[1]");
567        assert!(scores[1] > scores[2], "score[1] > score[2]");
568        assert!(scores[2] > scores[3], "score[2] > score[3]");
569    }
570
571    #[test]
572    fn ppr_star_symmetry() {
573        let graph = star_graph();
574        let solver = ForwardPushSolver::new(0.85, 1e-8);
575        let result = solver.ppr_from_source(&graph, 0).unwrap();
576
577        let leaf_scores: Vec<f64> = result
578            .iter()
579            .filter(|(v, _)| *v != 0)
580            .map(|(_, s)| *s)
581            .collect();
582        assert_eq!(leaf_scores.len(), 5);
583
584        let mean = leaf_scores.iter().sum::<f64>() / leaf_scores.len() as f64;
585        for &s in &leaf_scores {
586            assert!(
587                (s - mean).abs() < 1e-6,
588                "leaf scores should be equal: got {s} vs mean {mean}",
589            );
590        }
591    }
592
593    #[test]
594    fn top_k_truncates() {
595        let graph = triangle_graph();
596        let solver = ForwardPushSolver::default_params();
597        let result = solver.top_k(&graph, 0, 2).unwrap();
598
599        assert!(result.len() <= 2);
600        assert_eq!(result[0].0, 0);
601    }
602
603    #[test]
604    fn mass_invariant_holds() {
605        let graph = triangle_graph();
606        let solver = ForwardPushSolver::default_params();
607        assert!(solver.ppr_from_source(&graph, 0).is_ok());
608    }
609
610    #[test]
611    fn invalid_source_errors() {
612        let graph = triangle_graph();
613        let solver = ForwardPushSolver::default_params();
614        assert!(solver.ppr_from_source(&graph, 100).is_err());
615    }
616
617    #[test]
618    fn isolated_vertex_receives_zero() {
619        // Vertex 3 has no edges.
620        let graph = CsrMatrix::<f64>::from_coo(
621            4,
622            4,
623            vec![
624                (0, 1, 1.0f64),
625                (1, 0, 1.0f64),
626                (1, 2, 1.0f64),
627                (2, 1, 1.0f64),
628            ],
629        );
630        let solver = ForwardPushSolver::default_params();
631        let result = solver.ppr_from_source(&graph, 0).unwrap();
632
633        let v3_score = result.iter().find(|(v, _)| *v == 3).map_or(0.0, |p| p.1);
634        assert!(
635            v3_score.abs() < 1e-10,
636            "isolated vertex should have ~zero PPR",
637        );
638    }
639
640    #[test]
641    fn isolated_source_converges_to_one() {
642        // An isolated vertex (degree 0) keeps pushing until residual drops
643        // below epsilon.  The estimate converges to
644        // 1 - (1-alpha)^k ~ 1.0 for small epsilon.
645        let graph = CsrMatrix::<f64>::from_coo(
646            4,
647            4,
648            vec![
649                (0, 1, 1.0f64),
650                (1, 0, 1.0f64),
651                (1, 2, 1.0f64),
652                (2, 1, 1.0f64),
653            ],
654        );
655        let solver = ForwardPushSolver::default_params();
656        let result = solver.ppr_from_source(&graph, 3).unwrap();
657
658        assert_eq!(result.len(), 1);
659        assert_eq!(result[0].0, 3);
660        // With alpha=0.85 and epsilon=1e-6, the estimate converges very
661        // close to 1.0 (within epsilon).
662        assert!(
663            (result[0].1 - 1.0).abs() < 1e-4,
664            "isolated source estimate should converge near 1.0: got {}",
665            result[0].1,
666        );
667    }
668
669    #[test]
670    fn single_vertex_graph() {
671        let graph =
672            CsrMatrix::<f64>::from_coo(1, 1, Vec::<(usize, usize, f64)>::new());
673        let solver = ForwardPushSolver::default_params();
674        let result = solver.ppr_from_source(&graph, 0).unwrap();
675
676        assert_eq!(result.len(), 1);
677        assert_eq!(result[0].0, 0);
678        // Single isolated vertex converges to ~1.0 (not 0.85) because the
679        // dangling node keeps absorbing alpha on each push iteration.
680        assert!(
681            (result[0].1 - 1.0).abs() < 1e-4,
682            "single vertex PPR should converge near 1.0: got {}",
683            result[0].1,
684        );
685    }
686
687    #[test]
688    fn solver_engine_trait() {
689        let graph = triangle_graph();
690        let solver = ForwardPushSolver::default_params();
691
692        let mut rhs = vec![0.0f64; 4];
693        rhs[1] = 1.0;
694        let budget = ComputeBudget::default();
695
696        let result = solver.solve(&graph, &rhs, &budget).unwrap();
697        assert_eq!(result.algorithm, Algorithm::ForwardPush);
698        assert_eq!(result.solution.len(), 4);
699
700        let max_idx = result
701            .solution
702            .iter()
703            .enumerate()
704            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
705            .unwrap()
706            .0;
707        assert_eq!(max_idx, 1);
708    }
709
710    #[test]
711    fn sublinear_ppr_trait() {
712        let graph = triangle_graph();
713        let solver = ForwardPushSolver::default_params();
714        let result = solver.ppr(&graph, 0, 0.85, 1e-6).unwrap();
715
716        assert!(!result.is_empty());
717        assert_eq!(result[0].0, 0, "source should rank first via ppr trait");
718    }
719
720    #[test]
721    fn multi_seed_ppr() {
722        let graph = triangle_graph();
723        let solver = ForwardPushSolver::default_params();
724
725        let seeds = vec![(0, 0.5), (1, 0.5)];
726        let result = solver
727            .ppr_multi_seed(&graph, &seeds, 0.85, 1e-6)
728            .unwrap();
729
730        assert!(!result.is_empty());
731        let has_0 = result.iter().any(|(v, _)| *v == 0);
732        let has_1 = result.iter().any(|(v, _)| *v == 1);
733        assert!(has_0 && has_1, "both seeds should appear in output");
734    }
735
736    #[test]
737    fn forward_push_with_residuals_mass_conservation() {
738        let graph = triangle_graph();
739        let (p, r) = forward_push_with_residuals(&graph, 0, 0.85, 1e-6).unwrap();
740
741        let total: f64 = p.iter().sum::<f64>() + r.iter().sum::<f64>();
742        assert!(
743            (total - 1.0).abs() < 1e-6,
744            "mass should be conserved: got {total}",
745        );
746    }
747
748    #[test]
749    fn kahan_accuracy() {
750        let mut acc = KahanAccumulator::new();
751        let n = 1_000_000;
752        let small = 1e-10;
753        for _ in 0..n {
754            acc.add(small);
755        }
756        let expected = n as f64 * small;
757        let relative_error = (acc.value() - expected).abs() / expected;
758        assert!(
759            relative_error < 1e-10,
760            "Kahan relative error {relative_error} should be tiny",
761        );
762    }
763
764    #[test]
765    fn self_loop_graph() {
766        let graph = CsrMatrix::<f64>::from_coo(
767            3,
768            3,
769            vec![
770                (0, 0, 1.0f64),
771                (0, 1, 1.0f64),
772                (1, 1, 1.0f64),
773                (1, 2, 1.0f64),
774                (2, 2, 1.0f64),
775                (2, 0, 1.0f64),
776            ],
777        );
778        let solver = ForwardPushSolver::default_params();
779        let result = solver.ppr_from_source(&graph, 0);
780        assert!(result.is_ok(), "self-loop graph failed: {:?}", result.err());
781    }
782
783    #[test]
784    fn complete_graph_symmetry() {
785        let n = 4;
786        let mut entries = Vec::new();
787        for i in 0..n {
788            for j in 0..n {
789                if i != j {
790                    entries.push((i, j, 1.0f64));
791                }
792            }
793        }
794        let graph = CsrMatrix::<f64>::from_coo(n, n, entries);
795        let solver = ForwardPushSolver::new(0.85, 1e-8);
796        let result = solver.ppr_from_source(&graph, 0).unwrap();
797
798        assert_eq!(result[0].0, 0);
799
800        let other_scores: Vec<f64> = result
801            .iter()
802            .filter(|(v, _)| *v != 0)
803            .map(|(_, s)| *s)
804            .collect();
805        assert_eq!(other_scores.len(), 3);
806        let mean = other_scores.iter().sum::<f64>() / 3.0;
807        for &s in &other_scores {
808            assert!((s - mean).abs() < 1e-6);
809        }
810    }
811
812    #[test]
813    fn estimate_complexity_sublinear() {
814        let solver = ForwardPushSolver::new(0.85, 1e-4);
815        let profile = SparsityProfile {
816            rows: 1000,
817            cols: 1000,
818            nnz: 5000,
819            density: 0.005,
820            is_diag_dominant: false,
821            estimated_spectral_radius: 0.9,
822            estimated_condition: 10.0,
823            is_symmetric_structure: true,
824            avg_nnz_per_row: 5.0,
825            max_nnz_per_row: 10,
826        };
827        let est = solver.estimate_complexity(&profile, 1000);
828        assert_eq!(est.algorithm, Algorithm::ForwardPush);
829        assert_eq!(est.complexity_class, ComplexityClass::SublinearNnz);
830        assert!(est.estimated_iterations > 0);
831    }
832}