1use 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#[derive(Debug, Clone)]
47pub struct ForwardPushSolver {
48 pub alpha: f64,
50 pub epsilon: f64,
52}
53
54impl ForwardPushSolver {
55 pub fn new(alpha: f64, epsilon: f64) -> Self {
60 Self { alpha, epsilon }
61 }
62
63 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 pub fn default_params() -> Self {
94 Self {
95 alpha: 0.85,
96 epsilon: 1e-6,
97 }
98 }
99
100 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 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 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 let mut in_queue = vec![false; n];
167 let mut queue: VecDeque<usize> = VecDeque::new();
168
169 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 while let Some(u) = queue.pop_front() {
182 in_queue[u] = false;
183
184 let r_u = residual[u];
185
186 if !should_push(r_u, graph.row_degree(u), self.epsilon) {
188 continue;
189 }
190
191 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 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 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 let total_seed_mass: f64 = seeds.iter().map(|(_, m)| *m).sum();
238 check_mass_invariant(&estimate, &residual, total_seed_mass)?;
239
240 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
256pub 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 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 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#[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
337fn 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
355fn 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
374impl SolverEngine for ForwardPushSolver {
379 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
432impl 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#[cfg(test)]
468mod tests {
469 use super::*;
470
471 #[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 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 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 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 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 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 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 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}