Skip to main content

ruvector_solver/
neumann.rs

1//! Jacobi-preconditioned Neumann Series iterative solver.
2//!
3//! Solves the linear system `Ax = b` by splitting `A = D - R` (where `D` is
4//! the diagonal part) and iterating:
5//!
6//! ```text
7//! x_{k+1} = x_k + D^{-1} (b - A x_k)
8//! ```
9//!
10//! This is equivalent to the Neumann series `x = sum_{k=0}^{K} M^k D^{-1} b`
11//! where `M = I - D^{-1} A`. Convergence requires `rho(M) < 1`, which is
12//! guaranteed for strictly diagonally dominant matrices.
13//!
14//! # Algorithm
15//!
16//! The iteration maintains a running solution `x` and residual `r = b - Ax`:
17//!
18//! ```text
19//! x_0 = D^{-1} b
20//! for k = 0, 1, 2, ...:
21//!     r = b - A * x_k
22//!     x_{k+1} = x_k + D^{-1} * r
23//!     if ||r|| < tolerance:
24//!         break
25//! ```
26//!
27//! # Convergence
28//!
29//! Before solving, the solver estimates `rho(I - D^{-1}A)` via a 10-step
30//! power iteration and rejects the problem with
31//! [`SolverError::SpectralRadiusExceeded`] if `rho >= 1.0`. During iteration,
32//! if the residual grows by more than 2x between consecutive steps,
33//! [`SolverError::NumericalInstability`] is returned.
34
35use std::time::Instant;
36
37use tracing::{debug, info, instrument, warn};
38
39use crate::error::{SolverError, ValidationError};
40use crate::traits::SolverEngine;
41use crate::types::{
42    Algorithm, ComplexityClass, ComplexityEstimate, ComputeBudget, ConvergenceInfo, CsrMatrix,
43    SolverResult, SparsityProfile,
44};
45
46/// Number of power-iteration steps used to estimate the spectral radius.
47const POWER_ITERATION_STEPS: usize = 10;
48
49/// If the residual grows by more than this factor in a single step, the solver
50/// declares numerical instability.
51const INSTABILITY_GROWTH_FACTOR: f64 = 2.0;
52
53// ---------------------------------------------------------------------------
54// NeumannSolver
55// ---------------------------------------------------------------------------
56
57/// Neumann Series solver for sparse linear systems.
58///
59/// Computes `x = sum_{k=0}^{K} (I - A)^k * b` by maintaining a residual
60/// vector and accumulating partial sums until convergence.
61///
62/// # Example
63///
64/// ```rust
65/// use ruvector_solver::types::CsrMatrix;
66/// use ruvector_solver::neumann::NeumannSolver;
67///
68/// // Diagonally dominant 2x2: A = [[2, -0.5], [-0.5, 2]]
69/// let a = CsrMatrix::<f32>::from_coo(2, 2, vec![
70///     (0, 0, 2.0_f32), (0, 1, -0.5_f32),
71///     (1, 0, -0.5_f32), (1, 1, 2.0_f32),
72/// ]);
73/// let b = vec![1.0_f32, 1.0];
74///
75/// let solver = NeumannSolver::new(1e-6, 500);
76/// let result = solver.solve(&a, &b).unwrap();
77/// assert!(result.residual_norm < 1e-4);
78/// ```
79#[derive(Debug, Clone)]
80pub struct NeumannSolver {
81    /// Target residual L2 norm for convergence.
82    pub tolerance: f64,
83    /// Maximum number of iterations before giving up.
84    pub max_iterations: usize,
85}
86
87impl NeumannSolver {
88    /// Create a new `NeumannSolver`.
89    ///
90    /// # Arguments
91    ///
92    /// * `tolerance` - Stop when `||r|| < tolerance`.
93    /// * `max_iterations` - Upper bound on iterations.
94    pub fn new(tolerance: f64, max_iterations: usize) -> Self {
95        Self {
96            tolerance,
97            max_iterations,
98        }
99    }
100
101    /// Estimate the spectral radius of `M = I - D^{-1}A` via 10-step power
102    /// iteration.
103    ///
104    /// Runs [`POWER_ITERATION_STEPS`] iterations of the power method on the
105    /// Jacobi iteration matrix `M = I - D^{-1}A`. Returns the Rayleigh-quotient
106    /// estimate of the dominant eigenvalue magnitude.
107    ///
108    /// # Arguments
109    ///
110    /// * `matrix` - The coefficient matrix `A` (must be square).
111    ///
112    /// # Returns
113    ///
114    /// Estimated `|lambda_max(I - D^{-1}A)|`. If this is `>= 1.0`, the
115    /// Jacobi-preconditioned Neumann series will diverge.
116    #[instrument(skip(matrix), fields(n = matrix.rows))]
117    pub fn estimate_spectral_radius(matrix: &CsrMatrix<f32>) -> f64 {
118        let n = matrix.rows;
119        if n == 0 {
120            return 0.0;
121        }
122
123        let d_inv = extract_diag_inv_f32(matrix);
124        Self::estimate_spectral_radius_with_diag(matrix, &d_inv)
125    }
126
127    /// Inner helper: estimate spectral radius using a pre-computed `d_inv`.
128    ///
129    /// This avoids recomputing the diagonal inverse when the caller already
130    /// has it (e.g. `solve()` needs `d_inv` for both the spectral check and
131    /// the Jacobi iteration).
132    fn estimate_spectral_radius_with_diag(
133        matrix: &CsrMatrix<f32>,
134        d_inv: &[f32],
135    ) -> f64 {
136        let n = matrix.rows;
137        if n == 0 {
138            return 0.0;
139        }
140
141        // Initialise with a deterministic pseudo-random unit vector.
142        let mut v: Vec<f32> = (0..n)
143            .map(|i| ((i.wrapping_mul(7).wrapping_add(13)) % 100) as f32 / 100.0)
144            .collect();
145        let norm = l2_norm_f32(&v);
146        if norm > 1e-12 {
147            scale_vec_f32(&mut v, 1.0 / norm);
148        }
149
150        let mut av = vec![0.0f32; n]; // scratch for A*v
151        let mut w = vec![0.0f32; n]; // scratch for M*v = v - D^{-1}*A*v
152        let mut eigenvalue_estimate = 0.0f64;
153
154        for _ in 0..POWER_ITERATION_STEPS {
155            // w = v - D^{-1} * A * v  (i.e. M * v)
156            matrix.spmv(&v, &mut av);
157            for j in 0..n {
158                w[j] = v[j] - d_inv[j] * av[j];
159            }
160
161            // Rayleigh quotient: lambda = v^T w  (v is unit-length).
162            let dot: f64 = v
163                .iter()
164                .zip(w.iter())
165                .map(|(&a, &b)| a as f64 * b as f64)
166                .sum();
167            eigenvalue_estimate = dot;
168
169            // Normalise w -> v for the next step.
170            let w_norm = l2_norm_f32(&w);
171            if w_norm < 1e-12 {
172                break;
173            }
174            for j in 0..n {
175                v[j] = w[j] / w_norm as f32;
176            }
177        }
178
179        let rho = eigenvalue_estimate.abs();
180        debug!(rho, "estimated spectral radius of (I - D^-1 A)");
181        rho
182    }
183
184    /// Core Jacobi-preconditioned Neumann-series solve operating on `f32`.
185    ///
186    /// Validates inputs, checks the spectral radius of `I - D^{-1}A` via
187    /// power iteration, then runs the iteration returning a [`SolverResult`].
188    ///
189    /// # Errors
190    ///
191    /// - [`SolverError::InvalidInput`] if the matrix is non-square or the RHS
192    ///   length does not match.
193    /// - [`SolverError::SpectralRadiusExceeded`] if `rho(I - D^{-1}A) >= 1`.
194    /// - [`SolverError::NumericalInstability`] if the residual grows by more
195    ///   than 2x in a single step.
196    /// - [`SolverError::NonConvergence`] if the iteration budget is exhausted.
197    #[instrument(skip(self, matrix, rhs), fields(n = matrix.rows, nnz = matrix.nnz()))]
198    pub fn solve(
199        &self,
200        matrix: &CsrMatrix<f32>,
201        rhs: &[f32],
202    ) -> Result<SolverResult, SolverError> {
203        let start = Instant::now();
204        let n = matrix.rows;
205
206        // ------------------------------------------------------------------
207        // Input validation
208        // ------------------------------------------------------------------
209        if matrix.rows != matrix.cols {
210            return Err(SolverError::InvalidInput(
211                ValidationError::DimensionMismatch(format!(
212                    "matrix must be square: got {}x{}",
213                    matrix.rows, matrix.cols,
214                )),
215            ));
216        }
217
218        if rhs.len() != n {
219            return Err(SolverError::InvalidInput(
220                ValidationError::DimensionMismatch(format!(
221                    "rhs length {} does not match matrix dimension {}",
222                    rhs.len(),
223                    n,
224                )),
225            ));
226        }
227
228        // Edge case: empty system.
229        if n == 0 {
230            return Ok(SolverResult {
231                solution: Vec::new(),
232                iterations: 0,
233                residual_norm: 0.0,
234                wall_time: start.elapsed(),
235                convergence_history: Vec::new(),
236                algorithm: Algorithm::Neumann,
237            });
238        }
239
240        // Extract D^{-1} once — reused for both the spectral radius check
241        // and the Jacobi-preconditioned iteration that follows.
242        let d_inv = extract_diag_inv_f32(matrix);
243
244        // ------------------------------------------------------------------
245        // Spectral radius pre-check (10-step power iteration on I - D^{-1}A)
246        // ------------------------------------------------------------------
247        let rho = Self::estimate_spectral_radius_with_diag(matrix, &d_inv);
248        if rho >= 1.0 {
249            warn!(rho, "spectral radius >= 1.0, Neumann series will diverge");
250            return Err(SolverError::SpectralRadiusExceeded {
251                spectral_radius: rho,
252                limit: 1.0,
253                algorithm: Algorithm::Neumann,
254            });
255        }
256        info!(rho, "spectral radius check passed");
257
258        // ------------------------------------------------------------------
259        // Jacobi-preconditioned iteration (fused kernel)
260        //
261        //   x_0 = D^{-1} * b
262        //   loop:
263        //       r = b - A * x_k            (fused with norm computation)
264        //       if ||r|| < tolerance: break
265        //       x_{k+1} = x_k + D^{-1} * r (fused with residual storage)
266        //
267        // Key optimization: uses fused_residual_norm_sq to compute
268        // r = b - Ax and ||r||^2 in a single pass, avoiding a separate
269        // spmv + subtraction + norm computation (3 memory traversals -> 1).
270        // ------------------------------------------------------------------
271        let mut x: Vec<f32> = (0..n).map(|i| d_inv[i] * rhs[i]).collect();
272        let mut r = vec![0.0f32; n]; // residual buffer (reused each iteration)
273
274        let mut convergence_history = Vec::with_capacity(self.max_iterations.min(256));
275        let mut prev_residual_norm = f64::MAX;
276        let final_residual_norm: f64;
277        let mut iterations_done: usize = 0;
278
279        for k in 0..self.max_iterations {
280            // Fused: compute r = b - Ax and ||r||^2 in one pass.
281            let residual_norm_sq = matrix.fused_residual_norm_sq(&x, rhs, &mut r);
282            let residual_norm = residual_norm_sq.sqrt();
283            iterations_done = k + 1;
284
285            convergence_history.push(ConvergenceInfo {
286                iteration: k,
287                residual_norm,
288            });
289
290            debug!(iteration = k, residual_norm, "neumann iteration");
291
292            // Convergence check.
293            if residual_norm < self.tolerance {
294                final_residual_norm = residual_norm;
295                info!(iterations = iterations_done, residual_norm, "converged");
296                return Ok(SolverResult {
297                    solution: x,
298                    iterations: iterations_done,
299                    residual_norm: final_residual_norm,
300                    wall_time: start.elapsed(),
301                    convergence_history,
302                    algorithm: Algorithm::Neumann,
303                });
304            }
305
306            // NaN / Inf guard.
307            if residual_norm.is_nan() || residual_norm.is_infinite() {
308                return Err(SolverError::NumericalInstability {
309                    iteration: k,
310                    detail: format!("residual became {residual_norm}"),
311                });
312            }
313
314            // Instability check: residual grew by > 2x.
315            if k > 0
316                && prev_residual_norm < f64::MAX
317                && prev_residual_norm > 0.0
318                && residual_norm > INSTABILITY_GROWTH_FACTOR * prev_residual_norm
319            {
320                warn!(
321                    iteration = k,
322                    prev = prev_residual_norm,
323                    current = residual_norm,
324                    "residual diverging",
325                );
326                return Err(SolverError::NumericalInstability {
327                    iteration: k,
328                    detail: format!(
329                        "residual grew from {prev_residual_norm:.6e} to \
330                         {residual_norm:.6e} (>{INSTABILITY_GROWTH_FACTOR:.0}x)",
331                    ),
332                });
333            }
334
335            // Fused update: x[j] += d_inv[j] * r[j]
336            // 4-wide unrolled for ILP.
337            let chunks = n / 4;
338            for c in 0..chunks {
339                let j = c * 4;
340                x[j] += d_inv[j] * r[j];
341                x[j + 1] += d_inv[j + 1] * r[j + 1];
342                x[j + 2] += d_inv[j + 2] * r[j + 2];
343                x[j + 3] += d_inv[j + 3] * r[j + 3];
344            }
345            for j in (chunks * 4)..n {
346                x[j] += d_inv[j] * r[j];
347            }
348
349            prev_residual_norm = residual_norm;
350        }
351
352        // Exhausted iteration budget without converging.
353        final_residual_norm = prev_residual_norm;
354        Err(SolverError::NonConvergence {
355            iterations: iterations_done,
356            residual: final_residual_norm,
357            tolerance: self.tolerance,
358        })
359    }
360}
361
362// ---------------------------------------------------------------------------
363// SolverEngine trait implementation (f64 interface)
364// ---------------------------------------------------------------------------
365
366impl SolverEngine for NeumannSolver {
367    /// Solve via the Neumann series.
368    ///
369    /// Adapts the `f64` trait interface to the internal `f32` solver by
370    /// converting the input matrix and RHS, running the solver, then
371    /// returning the `f32` solution.
372    fn solve(
373        &self,
374        matrix: &CsrMatrix<f64>,
375        rhs: &[f64],
376        budget: &ComputeBudget,
377    ) -> Result<SolverResult, SolverError> {
378        let start = Instant::now();
379
380        // Validate that f64 values fit in f32 range.
381        for (i, &v) in matrix.values.iter().enumerate() {
382            if v.is_finite() && v.abs() > f32::MAX as f64 {
383                return Err(SolverError::InvalidInput(
384                    ValidationError::NonFiniteValue(format!(
385                        "matrix value at index {i} ({v:.6e}) overflows f32"
386                    )),
387                ));
388            }
389        }
390        for (i, &v) in rhs.iter().enumerate() {
391            if v.is_finite() && v.abs() > f32::MAX as f64 {
392                return Err(SolverError::InvalidInput(
393                    ValidationError::NonFiniteValue(format!(
394                        "rhs value at index {i} ({v:.6e}) overflows f32"
395                    )),
396                ));
397            }
398        }
399
400        // Convert f64 matrix to f32 for the core solver.
401        let f32_matrix = CsrMatrix {
402            row_ptr: matrix.row_ptr.clone(),
403            col_indices: matrix.col_indices.clone(),
404            values: matrix.values.iter().map(|&v| v as f32).collect(),
405            rows: matrix.rows,
406            cols: matrix.cols,
407        };
408        let f32_rhs: Vec<f32> = rhs.iter().map(|&v| v as f32).collect();
409
410        // Use the tighter of the solver's own tolerance and the caller's budget,
411        // but no tighter than f32 precision allows (the Neumann solver operates
412        // internally in f32, so residuals below ~f32::EPSILON are unreachable).
413        let max_iters = self.max_iterations.min(budget.max_iterations);
414        let tol = self
415            .tolerance
416            .min(budget.tolerance)
417            .max(f32::EPSILON as f64 * 4.0);
418
419        let inner_solver = NeumannSolver::new(tol, max_iters);
420
421        let mut result = inner_solver.solve(&f32_matrix, &f32_rhs)?;
422
423        // Check wall-time budget.
424        if start.elapsed() > budget.max_time {
425            return Err(SolverError::BudgetExhausted {
426                reason: "wall-clock time limit exceeded".to_string(),
427                elapsed: start.elapsed(),
428            });
429        }
430
431        // Adjust wall time to include conversion overhead.
432        result.wall_time = start.elapsed();
433        Ok(result)
434    }
435
436    fn estimate_complexity(
437        &self,
438        profile: &SparsityProfile,
439        n: usize,
440    ) -> ComplexityEstimate {
441        // Estimated iterations: ceil( ln(1/tol) / |ln(rho)| )
442        let rho = profile.estimated_spectral_radius.max(0.01).min(0.999);
443        let est_iters = ((1.0 / self.tolerance).ln() / (1.0 - rho).ln().abs())
444            .ceil() as usize;
445        let est_iters = est_iters.min(self.max_iterations).max(1);
446
447        ComplexityEstimate {
448            algorithm: Algorithm::Neumann,
449            // Each iteration does one SpMV (2 * nnz flops) + O(n) vector ops.
450            estimated_flops: (est_iters as u64) * (profile.nnz as u64) * 2,
451            estimated_iterations: est_iters,
452            // Working memory: x, r, ar (3 vectors of f32).
453            estimated_memory_bytes: n * 4 * 3,
454            complexity_class: ComplexityClass::SublinearNnz,
455        }
456    }
457
458    fn algorithm(&self) -> Algorithm {
459        Algorithm::Neumann
460    }
461}
462
463// ---------------------------------------------------------------------------
464// Internal helpers
465// ---------------------------------------------------------------------------
466
467/// Extract `D^{-1}` from a CSR matrix (the reciprocal of each diagonal entry).
468///
469/// If a diagonal entry is zero or very small, uses `1.0` as a fallback to
470/// avoid division by zero.
471fn extract_diag_inv_f32(matrix: &CsrMatrix<f32>) -> Vec<f32> {
472    let n = matrix.rows;
473    let mut d_inv = vec![1.0f32; n];
474    for i in 0..n {
475        let start = matrix.row_ptr[i];
476        let end = matrix.row_ptr[i + 1];
477        for idx in start..end {
478            if matrix.col_indices[idx] == i {
479                let diag = matrix.values[idx];
480                if diag.abs() > 1e-15 {
481                    d_inv[i] = 1.0 / diag;
482                } else {
483                    warn!(
484                        row = i,
485                        diag_value = %diag,
486                        "zero or near-zero diagonal entry; substituting 1.0 — matrix may be singular"
487                    );
488                }
489                break;
490            }
491        }
492    }
493    d_inv
494}
495
496/// Compute the L2 (Euclidean) norm of a slice of `f32` values.
497///
498/// Uses `f64` accumulation to reduce catastrophic cancellation on large
499/// vectors.
500#[inline]
501fn l2_norm_f32(v: &[f32]) -> f32 {
502    let sum: f64 = v.iter().map(|&x| (x as f64) * (x as f64)).sum();
503    sum.sqrt() as f32
504}
505
506/// Scale every element of `v` by `s` in-place.
507#[inline]
508fn scale_vec_f32(v: &mut [f32], s: f32) {
509    for x in v.iter_mut() {
510        *x *= s;
511    }
512}
513
514// ---------------------------------------------------------------------------
515// Tests
516// ---------------------------------------------------------------------------
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::types::CsrMatrix;
522
523    /// Helper: build a diagonally dominant tridiagonal matrix.
524    fn tridiag_f32(n: usize, diag_val: f32, off_val: f32) -> CsrMatrix<f32> {
525        let mut entries = Vec::new();
526        for i in 0..n {
527            entries.push((i, i, diag_val));
528            if i > 0 {
529                entries.push((i, i - 1, off_val));
530            }
531            if i + 1 < n {
532                entries.push((i, i + 1, off_val));
533            }
534        }
535        CsrMatrix::<f32>::from_coo(n, n, entries)
536    }
537
538    /// Helper: build a 3x3 system whose eigenvalues are in (0, 2) so that
539    /// the Neumann series converges (rho(I - A) < 1).
540    fn test_matrix_f64() -> CsrMatrix<f64> {
541        CsrMatrix::<f64>::from_coo(
542            3,
543            3,
544            vec![
545                (0, 0, 1.0),
546                (0, 1, -0.1),
547                (1, 0, -0.1),
548                (1, 1, 1.0),
549                (1, 2, -0.1),
550                (2, 1, -0.1),
551                (2, 2, 1.0),
552            ],
553        )
554    }
555
556    #[test]
557    fn test_new() {
558        let solver = NeumannSolver::new(1e-8, 100);
559        assert_eq!(solver.tolerance, 1e-8);
560        assert_eq!(solver.max_iterations, 100);
561    }
562
563    #[test]
564    fn test_spectral_radius_identity() {
565        let identity = CsrMatrix::<f32>::identity(4);
566        let rho = NeumannSolver::estimate_spectral_radius(&identity);
567        assert!(rho < 0.1, "expected rho ~ 0 for identity, got {rho}");
568    }
569
570    #[test]
571    fn test_spectral_radius_pure_diagonal() {
572        // For a pure diagonal matrix D, D^{-1}A = I, so M = I - I = 0.
573        // The spectral radius should be ~0.
574        let a = CsrMatrix::<f32>::from_coo(
575            3, 3,
576            vec![(0, 0, 0.5_f32), (1, 1, 0.5), (2, 2, 0.5)],
577        );
578        let rho = NeumannSolver::estimate_spectral_radius(&a);
579        assert!(rho < 0.1, "expected rho ~ 0 for diagonal matrix, got {rho}");
580    }
581
582    #[test]
583    fn test_spectral_radius_empty() {
584        let empty = CsrMatrix::<f32> {
585            row_ptr: vec![0], col_indices: vec![], values: vec![],
586            rows: 0, cols: 0,
587        };
588        assert_eq!(NeumannSolver::estimate_spectral_radius(&empty), 0.0);
589    }
590
591    #[test]
592    fn test_spectral_radius_non_diag_dominant() {
593        // Matrix where off-diagonal entries dominate:
594        // [1  2]
595        // [2  1]
596        // D^{-1}A = [[1, 2], [2, 1]], so M = I - D^{-1}A = [[0, -2], [-2, 0]].
597        // Eigenvalues of M are +2 and -2, so rho(M) = 2 > 1.
598        let a = CsrMatrix::<f32>::from_coo(
599            2, 2,
600            vec![(0, 0, 1.0_f32), (0, 1, 2.0), (1, 0, 2.0), (1, 1, 1.0)],
601        );
602        let rho = NeumannSolver::estimate_spectral_radius(&a);
603        assert!(rho > 1.0, "expected rho > 1 for non-diag-dominant matrix, got {rho}");
604    }
605
606    #[test]
607    fn test_solve_identity() {
608        let identity = CsrMatrix::<f32>::identity(3);
609        let rhs = vec![1.0_f32, 2.0, 3.0];
610        let solver = NeumannSolver::new(1e-6, 100);
611        let result = solver.solve(&identity, &rhs).unwrap();
612        for (i, (&e, &a)) in rhs.iter().zip(result.solution.iter()).enumerate() {
613            assert!((e - a).abs() < 1e-4, "index {i}: expected {e}, got {a}");
614        }
615        assert!(result.residual_norm < 1e-6);
616    }
617
618    #[test]
619    fn test_solve_diagonal() {
620        let a = CsrMatrix::<f32>::from_coo(
621            3, 3,
622            vec![(0, 0, 0.5_f32), (1, 1, 0.5), (2, 2, 0.5)],
623        );
624        let rhs = vec![1.0_f32, 1.0, 1.0];
625        let solver = NeumannSolver::new(1e-6, 200);
626        let result = solver.solve(&a, &rhs).unwrap();
627        for (i, &val) in result.solution.iter().enumerate() {
628            assert!((val - 2.0).abs() < 0.01, "index {i}: expected ~2.0, got {val}");
629        }
630    }
631
632    #[test]
633    fn test_solve_tridiagonal() {
634        // diag=1.0, off=-0.1: Jacobi iteration matrix has rho ~ 0.17.
635        // Use 1e-6 tolerance since f32 accumulation limits floor.
636        let a = tridiag_f32(5, 1.0, -0.1);
637        let rhs = vec![1.0_f32, 0.0, 1.0, 0.0, 1.0];
638        let solver = NeumannSolver::new(1e-6, 1000);
639        let result = solver.solve(&a, &rhs).unwrap();
640        assert!(result.residual_norm < 1e-4);
641        assert!(result.iterations > 0);
642        assert!(!result.convergence_history.is_empty());
643    }
644
645    #[test]
646    fn test_solve_empty_system() {
647        let a = CsrMatrix::<f32> {
648            row_ptr: vec![0], col_indices: vec![], values: vec![],
649            rows: 0, cols: 0,
650        };
651        let result = NeumannSolver::new(1e-6, 10).solve(&a, &[]).unwrap();
652        assert_eq!(result.iterations, 0);
653        assert!(result.solution.is_empty());
654    }
655
656    #[test]
657    fn test_solve_dimension_mismatch() {
658        let a = CsrMatrix::<f32>::identity(3);
659        let rhs = vec![1.0_f32, 2.0];
660        let err = NeumannSolver::new(1e-6, 100).solve(&a, &rhs).unwrap_err();
661        let msg = err.to_string();
662        assert!(msg.contains("dimension") || msg.contains("mismatch"), "got: {msg}");
663    }
664
665    #[test]
666    fn test_solve_non_square() {
667        let a = CsrMatrix::<f32>::from_coo(2, 3, vec![(0, 0, 1.0_f32), (1, 1, 1.0)]);
668        let rhs = vec![1.0_f32, 1.0];
669        let err = NeumannSolver::new(1e-6, 100).solve(&a, &rhs).unwrap_err();
670        let msg = err.to_string();
671        assert!(msg.contains("square") || msg.contains("dimension"), "got: {msg}");
672    }
673
674    #[test]
675    fn test_solve_divergent_matrix() {
676        // Non-diag-dominant: off-diagonal entries larger than diagonal.
677        let a = CsrMatrix::<f32>::from_coo(
678            2, 2,
679            vec![(0, 0, 1.0_f32), (0, 1, 2.0), (1, 0, 2.0), (1, 1, 1.0)],
680        );
681        let rhs = vec![1.0_f32, 1.0];
682        let err = NeumannSolver::new(1e-6, 100).solve(&a, &rhs).unwrap_err();
683        assert!(err.to_string().contains("spectral radius"), "got: {}", err);
684    }
685
686    #[test]
687    fn test_convergence_history_monotone() {
688        let a = CsrMatrix::<f32>::identity(4);
689        let rhs = vec![1.0_f32; 4];
690        let result = NeumannSolver::new(1e-10, 50).solve(&a, &rhs).unwrap();
691        assert!(!result.convergence_history.is_empty());
692        for window in result.convergence_history.windows(2) {
693            assert!(
694                window[1].residual_norm <= window[0].residual_norm + 1e-12,
695                "residual not decreasing: {} -> {}",
696                window[0].residual_norm, window[1].residual_norm,
697            );
698        }
699    }
700
701    #[test]
702    fn test_algorithm_tag() {
703        let a = CsrMatrix::<f32>::identity(2);
704        let rhs = vec![1.0_f32; 2];
705        let result = NeumannSolver::new(1e-6, 100).solve(&a, &rhs).unwrap();
706        assert_eq!(result.algorithm, Algorithm::Neumann);
707    }
708
709    #[test]
710    fn test_solver_engine_trait_f64() {
711        let solver = NeumannSolver::new(1e-6, 200);
712        let engine: &dyn SolverEngine = &solver;
713        let a = test_matrix_f64();
714        let rhs = vec![1.0_f64, 0.0, 1.0];
715        let budget = ComputeBudget::default();
716        let result = engine.solve(&a, &rhs, &budget).unwrap();
717        assert!(result.residual_norm < 1e-4);
718        assert_eq!(result.algorithm, Algorithm::Neumann);
719    }
720
721    #[test]
722    fn test_larger_system_accuracy() {
723        let n = 50;
724        // diag=1.0, off=-0.1: Jacobi-preconditioned Neumann converges.
725        // Use 1e-6 tolerance for f32 precision headroom.
726        let a = tridiag_f32(n, 1.0, -0.1);
727        let rhs: Vec<f32> = (0..n).map(|i| (i as f32 + 1.0) / n as f32).collect();
728        let result = NeumannSolver::new(1e-6, 2000).solve(&a, &rhs).unwrap();
729        assert!(result.residual_norm < 1e-6, "residual too large: {}", result.residual_norm);
730        let mut ax = vec![0.0f32; n];
731        a.spmv(&result.solution, &mut ax);
732        for i in 0..n {
733            assert!((ax[i] - rhs[i]).abs() < 1e-4, "A*x[{i}]={} but b[{i}]={}", ax[i], rhs[i]);
734        }
735    }
736
737    #[test]
738    fn test_scalar_system() {
739        let a = CsrMatrix::<f32>::from_coo(1, 1, vec![(0, 0, 0.5_f32)]);
740        let rhs = vec![4.0_f32];
741        let result = NeumannSolver::new(1e-8, 200).solve(&a, &rhs).unwrap();
742        assert!((result.solution[0] - 8.0).abs() < 0.01, "expected ~8.0, got {}", result.solution[0]);
743    }
744
745    #[test]
746    fn test_estimate_complexity() {
747        let solver = NeumannSolver::new(1e-6, 1000);
748        let profile = SparsityProfile {
749            rows: 100, cols: 100, nnz: 500, density: 0.05,
750            is_diag_dominant: true, estimated_spectral_radius: 0.5,
751            estimated_condition: 3.0, is_symmetric_structure: true,
752            avg_nnz_per_row: 5.0, max_nnz_per_row: 8,
753        };
754        let estimate = solver.estimate_complexity(&profile, 100);
755        assert_eq!(estimate.algorithm, Algorithm::Neumann);
756        assert!(estimate.estimated_flops > 0);
757        assert!(estimate.estimated_iterations > 0);
758        assert!(estimate.estimated_memory_bytes > 0);
759        assert_eq!(estimate.complexity_class, ComplexityClass::SublinearNnz);
760    }
761}