Skip to main content

scivex_optim/
sparse_solve.rs

1//! Iterative solvers for sparse linear systems `Ax = b`.
2//!
3//! Provides Conjugate Gradient (CG) for symmetric positive-definite systems,
4//! BiCGSTAB for general non-symmetric systems, and a Jacobi (diagonal)
5//! preconditioner for accelerating convergence.
6
7use scivex_core::Float;
8use scivex_core::linalg::CsrMatrix;
9use scivex_core::tensor::Tensor;
10
11use crate::error::{OptimError, Result};
12
13// ======================================================================
14// Result type
15// ======================================================================
16
17/// Result of an iterative sparse solve.
18#[cfg_attr(
19    feature = "serde-support",
20    derive(serde::Serialize, serde::Deserialize)
21)]
22#[derive(Debug, Clone)]
23pub struct SparseSolveResult<T: Float> {
24    /// Solution vector.
25    pub x: Vec<T>,
26    /// Number of iterations used.
27    pub iterations: usize,
28    /// Final residual norm.
29    pub residual_norm: T,
30    /// Whether the solver converged within tolerance.
31    pub converged: bool,
32}
33
34// ======================================================================
35// Vector helper functions
36// ======================================================================
37
38/// Dot product of two slices.
39#[inline]
40fn vec_dot<T: Float>(a: &[T], b: &[T]) -> T {
41    a.iter().zip(b.iter()).map(|(&ai, &bi)| ai * bi).sum()
42}
43
44/// Euclidean norm of a slice.
45#[inline]
46fn vec_norm<T: Float>(v: &[T]) -> T {
47    vec_dot(v, v).sqrt()
48}
49
50/// y = a*x + y  (axpy in-place).
51#[inline]
52fn vec_axpy<T: Float>(alpha: T, x: &[T], y: &mut [T]) {
53    for (yi, &xi) in y.iter_mut().zip(x.iter()) {
54        *yi += alpha * xi;
55    }
56}
57
58/// Compute r = b - A*x using the sparse matvec.
59fn compute_residual<T: Float>(a: &CsrMatrix<T>, x: &[T], b: &[T]) -> Result<Vec<T>> {
60    let x_tensor = Tensor::from_vec(x.to_vec(), vec![x.len()])?;
61    let ax = a.matvec(&x_tensor)?;
62    let ax_slice = ax.as_slice();
63    let r: Vec<T> = b
64        .iter()
65        .zip(ax_slice.iter())
66        .map(|(&bi, &ai)| bi - ai)
67        .collect();
68    Ok(r)
69}
70
71/// Sparse matrix-vector multiply returning a Vec.
72fn sparse_matvec<T: Float>(a: &CsrMatrix<T>, x: &[T]) -> Result<Vec<T>> {
73    let x_tensor = Tensor::from_vec(x.to_vec(), vec![x.len()])?;
74    let result = a.matvec(&x_tensor)?;
75    Ok(result.as_slice().to_vec())
76}
77
78// ======================================================================
79// Conjugate Gradient
80// ======================================================================
81
82/// Solve `Ax = b` using the Conjugate Gradient method.
83///
84/// `A` must be symmetric positive-definite. The method iterates until the
85/// relative residual norm `||r|| / ||b||` drops below `tol`, or `max_iter`
86/// iterations are reached.
87///
88/// # Arguments
89///
90/// * `a` — Sparse coefficient matrix (must be square and SPD).
91/// * `b` — Right-hand side vector.
92/// * `x0` — Optional initial guess; zeros if `None`.
93/// * `max_iter` — Maximum number of iterations.
94/// * `tol` — Convergence tolerance on relative residual.
95///
96/// # Errors
97///
98/// Returns [`OptimError::InvalidParameter`] if dimensions are inconsistent,
99/// and [`OptimError::ConvergenceFailure`] if `max_iter` is exhausted without
100/// converging (the partial result is still returned inside the error via
101/// [`SparseSolveResult::converged`] = `false` in the `Ok` path).
102pub fn conjugate_gradient<T: Float>(
103    a: &CsrMatrix<T>,
104    b: &[T],
105    x0: Option<&[T]>,
106    max_iter: usize,
107    tol: T,
108) -> Result<SparseSolveResult<T>> {
109    let n = a.nrows();
110    if a.ncols() != n {
111        return Err(OptimError::InvalidParameter {
112            name: "a",
113            reason: "matrix must be square",
114        });
115    }
116    if b.len() != n {
117        return Err(OptimError::InvalidParameter {
118            name: "b",
119            reason: "length must match matrix dimension",
120        });
121    }
122    if x0.is_some_and(|x0v| x0v.len() != n) {
123        return Err(OptimError::InvalidParameter {
124            name: "x0",
125            reason: "length must match matrix dimension",
126        });
127    }
128
129    let b_norm = vec_norm(b);
130
131    // x = x0 or zeros
132    let mut x = x0.map_or_else(|| vec![T::zero(); n], <[T]>::to_vec);
133
134    // r = b - A*x
135    let mut r = compute_residual(a, &x, b)?;
136    // p = r
137    let mut p = r.clone();
138    // rr = r · r
139    let mut rr = vec_dot(&r, &r);
140
141    let threshold = tol * b_norm;
142
143    for k in 0..max_iter {
144        let r_norm = rr.sqrt();
145        if r_norm < threshold || (b_norm == T::zero() && r_norm < tol) {
146            return Ok(SparseSolveResult {
147                x,
148                iterations: k,
149                residual_norm: r_norm,
150                converged: true,
151            });
152        }
153
154        // Ap = A * p
155        let ap = sparse_matvec(a, &p)?;
156        let p_ap = vec_dot(&p, &ap);
157
158        if p_ap == T::zero() {
159            // Breakdown
160            return Ok(SparseSolveResult {
161                x,
162                iterations: k,
163                residual_norm: r_norm,
164                converged: false,
165            });
166        }
167
168        let alpha = rr / p_ap;
169
170        // x = x + alpha * p
171        vec_axpy(alpha, &p, &mut x);
172
173        // r_new = r - alpha * Ap
174        vec_axpy(-alpha, &ap, &mut r);
175
176        let rr_new = vec_dot(&r, &r);
177        let beta = rr_new / rr;
178
179        // p = r + beta * p
180        for (pi, &ri) in p.iter_mut().zip(r.iter()) {
181            *pi = ri + beta * *pi;
182        }
183
184        rr = rr_new;
185    }
186
187    let final_norm = rr.sqrt();
188    Ok(SparseSolveResult {
189        x,
190        iterations: max_iter,
191        residual_norm: final_norm,
192        converged: final_norm < threshold,
193    })
194}
195
196// ======================================================================
197// BiCGSTAB
198// ======================================================================
199
200/// Solve `Ax = b` using BiCGSTAB (Bi-Conjugate Gradient Stabilized).
201///
202/// Works for general (non-symmetric) sparse systems. Iterates until the
203/// relative residual drops below `tol` or `max_iter` iterations are reached.
204///
205/// # Arguments
206///
207/// * `a` — Sparse coefficient matrix (must be square).
208/// * `b` — Right-hand side vector.
209/// * `x0` — Optional initial guess; zeros if `None`.
210/// * `max_iter` — Maximum number of iterations.
211/// * `tol` — Convergence tolerance on relative residual.
212#[allow(clippy::too_many_lines)]
213pub fn bicgstab<T: Float>(
214    a: &CsrMatrix<T>,
215    b: &[T],
216    x0: Option<&[T]>,
217    max_iter: usize,
218    tol: T,
219) -> Result<SparseSolveResult<T>> {
220    let n = a.nrows();
221    if a.ncols() != n {
222        return Err(OptimError::InvalidParameter {
223            name: "a",
224            reason: "matrix must be square",
225        });
226    }
227    if b.len() != n {
228        return Err(OptimError::InvalidParameter {
229            name: "b",
230            reason: "length must match matrix dimension",
231        });
232    }
233    if x0.is_some_and(|x0v| x0v.len() != n) {
234        return Err(OptimError::InvalidParameter {
235            name: "x0",
236            reason: "length must match matrix dimension",
237        });
238    }
239
240    let b_norm = vec_norm(b);
241    let threshold = tol * b_norm;
242
243    // x = x0 or zeros
244    let mut x = match x0 {
245        Some(v) => v.to_vec(),
246        None => vec![T::zero(); n],
247    };
248
249    // r = b - A*x
250    let mut r = compute_residual(a, &x, b)?;
251    // r_hat = r (shadow residual, kept constant)
252    let r_hat = r.clone();
253
254    let mut rho = T::one();
255    let mut alpha = T::one();
256    let mut omega = T::one();
257
258    let mut v = vec![T::zero(); n];
259    let mut p = vec![T::zero(); n];
260
261    for k in 0..max_iter {
262        let r_norm = vec_norm(&r);
263        if r_norm < threshold || (b_norm == T::zero() && r_norm < tol) {
264            return Ok(SparseSolveResult {
265                x,
266                iterations: k,
267                residual_norm: r_norm,
268                converged: true,
269            });
270        }
271
272        let rho_new = vec_dot(&r_hat, &r);
273
274        if rho_new == T::zero() {
275            // Breakdown
276            return Ok(SparseSolveResult {
277                x,
278                iterations: k,
279                residual_norm: r_norm,
280                converged: false,
281            });
282        }
283
284        let beta = (rho_new / rho) * (alpha / omega);
285
286        // p = r + beta * (p - omega * v)
287        for ((pi, &ri), &vi) in p.iter_mut().zip(r.iter()).zip(v.iter()) {
288            *pi = ri + beta * (*pi - omega * vi);
289        }
290
291        // v = A * p
292        v = sparse_matvec(a, &p)?;
293
294        let r_hat_v = vec_dot(&r_hat, &v);
295        if r_hat_v == T::zero() {
296            return Ok(SparseSolveResult {
297                x,
298                iterations: k,
299                residual_norm: r_norm,
300                converged: false,
301            });
302        }
303        alpha = rho_new / r_hat_v;
304
305        // s = r - alpha * v
306        let mut s = r.clone();
307        vec_axpy(-alpha, &v, &mut s);
308
309        let s_norm = vec_norm(&s);
310        if s_norm < threshold {
311            // x = x + alpha * p
312            vec_axpy(alpha, &p, &mut x);
313            return Ok(SparseSolveResult {
314                x,
315                iterations: k + 1,
316                residual_norm: s_norm,
317                converged: true,
318            });
319        }
320
321        // t = A * s
322        let t = sparse_matvec(a, &s)?;
323
324        let t_t = vec_dot(&t, &t);
325        omega = if t_t == T::zero() {
326            T::zero()
327        } else {
328            vec_dot(&t, &s) / t_t
329        };
330
331        // x = x + alpha * p + omega * s
332        vec_axpy(alpha, &p, &mut x);
333        vec_axpy(omega, &s, &mut x);
334
335        // r = s - omega * t
336        r = s;
337        vec_axpy(-omega, &t, &mut r);
338
339        rho = rho_new;
340
341        if omega == T::zero() {
342            // Breakdown
343            let final_norm = vec_norm(&r);
344            return Ok(SparseSolveResult {
345                x,
346                iterations: k + 1,
347                residual_norm: final_norm,
348                converged: false,
349            });
350        }
351    }
352
353    let final_norm = vec_norm(&r);
354    Ok(SparseSolveResult {
355        x,
356        iterations: max_iter,
357        residual_norm: final_norm,
358        converged: final_norm < threshold,
359    })
360}
361
362// ======================================================================
363// Jacobi Preconditioner
364// ======================================================================
365
366/// Jacobi (diagonal) preconditioner for sparse iterative solvers.
367///
368/// Stores the reciprocal of each diagonal element of the matrix. When a
369/// diagonal entry is zero, the corresponding inverse is set to one (identity
370/// fallback) to avoid division by zero.
371#[cfg_attr(
372    feature = "serde-support",
373    derive(serde::Serialize, serde::Deserialize)
374)]
375#[derive(Debug, Clone)]
376pub struct JacobiPreconditioner<T: Float> {
377    inv_diag: Vec<T>,
378}
379
380impl<T: Float> JacobiPreconditioner<T> {
381    /// Create from a sparse matrix (extracts and inverts diagonal).
382    ///
383    /// Zero diagonal entries are treated as ones to prevent division by zero.
384    pub fn new(a: &CsrMatrix<T>) -> Self {
385        let n = a.nrows().min(a.ncols());
386        let mut inv_diag = Vec::with_capacity(n);
387        for i in 0..n {
388            let d = a.get(i, i).copied().unwrap_or(T::zero());
389            if d == T::zero() {
390                inv_diag.push(T::one());
391            } else {
392                inv_diag.push(T::one() / d);
393            }
394        }
395        Self { inv_diag }
396    }
397
398    /// Apply preconditioner: `z = M^{-1} r` (element-wise multiply by
399    /// inverse diagonal).
400    pub fn apply(&self, r: &[T]) -> Vec<T> {
401        r.iter()
402            .zip(self.inv_diag.iter())
403            .map(|(&ri, &di)| ri * di)
404            .collect()
405    }
406}
407
408// ======================================================================
409// Preconditioned Conjugate Gradient
410// ======================================================================
411
412/// Solve `Ax = b` using the Preconditioned Conjugate Gradient method.
413///
414/// Same algorithm as [`conjugate_gradient`] but applies the preconditioner
415/// `M^{-1}` to the residual at each step, which can significantly improve
416/// convergence for ill-conditioned systems.
417pub fn preconditioned_cg<T: Float>(
418    a: &CsrMatrix<T>,
419    b: &[T],
420    preconditioner: &JacobiPreconditioner<T>,
421    x0: Option<&[T]>,
422    max_iter: usize,
423    tol: T,
424) -> Result<SparseSolveResult<T>> {
425    let n = a.nrows();
426    if a.ncols() != n {
427        return Err(OptimError::InvalidParameter {
428            name: "a",
429            reason: "matrix must be square",
430        });
431    }
432    if b.len() != n {
433        return Err(OptimError::InvalidParameter {
434            name: "b",
435            reason: "length must match matrix dimension",
436        });
437    }
438    if x0.is_some_and(|x0v| x0v.len() != n) {
439        return Err(OptimError::InvalidParameter {
440            name: "x0",
441            reason: "length must match matrix dimension",
442        });
443    }
444
445    let b_norm = vec_norm(b);
446    let threshold = tol * b_norm;
447
448    let mut x = match x0 {
449        Some(v) => v.to_vec(),
450        None => vec![T::zero(); n],
451    };
452
453    // r = b - A*x
454    let mut r = compute_residual(a, &x, b)?;
455    // z = M^{-1} r
456    let mut z = preconditioner.apply(&r);
457    // p = z
458    let mut p = z.clone();
459    // rz = r · z
460    let mut rz = vec_dot(&r, &z);
461
462    for k in 0..max_iter {
463        let r_norm = vec_norm(&r);
464        if r_norm < threshold || (b_norm == T::zero() && r_norm < tol) {
465            return Ok(SparseSolveResult {
466                x,
467                iterations: k,
468                residual_norm: r_norm,
469                converged: true,
470            });
471        }
472
473        // Ap = A * p
474        let ap = sparse_matvec(a, &p)?;
475        let p_ap = vec_dot(&p, &ap);
476
477        if p_ap == T::zero() {
478            return Ok(SparseSolveResult {
479                x,
480                iterations: k,
481                residual_norm: r_norm,
482                converged: false,
483            });
484        }
485
486        let alpha = rz / p_ap;
487
488        // x = x + alpha * p
489        vec_axpy(alpha, &p, &mut x);
490
491        // r = r - alpha * Ap
492        vec_axpy(-alpha, &ap, &mut r);
493
494        // z = M^{-1} r
495        z = preconditioner.apply(&r);
496
497        let rz_new = vec_dot(&r, &z);
498        let beta = rz_new / rz;
499
500        // p = z + beta * p
501        for (pi, &zi) in p.iter_mut().zip(z.iter()) {
502            *pi = zi + beta * *pi;
503        }
504
505        rz = rz_new;
506    }
507
508    let final_norm = vec_norm(&r);
509    Ok(SparseSolveResult {
510        x,
511        iterations: max_iter,
512        residual_norm: final_norm,
513        converged: final_norm < threshold,
514    })
515}
516
517// ======================================================================
518// Tests
519// ======================================================================
520
521#[cfg(test)]
522#[allow(clippy::float_cmp)]
523mod tests {
524    use super::*;
525    use scivex_core::linalg::CsrMatrix;
526
527    /// Helper: build a 3x3 SPD matrix.
528    /// A = [[4, 1, 0],
529    ///      [1, 3, 1],
530    ///      [0, 1, 4]]
531    fn spd_3x3() -> CsrMatrix<f64> {
532        CsrMatrix::from_triplets(
533            3,
534            3,
535            vec![0, 0, 1, 1, 1, 2, 2],
536            vec![0, 1, 0, 1, 2, 1, 2],
537            vec![4.0, 1.0, 1.0, 3.0, 1.0, 1.0, 4.0],
538        )
539        .unwrap()
540    }
541
542    #[test]
543    fn test_cg_simple_3x3() {
544        let a = spd_3x3();
545        // b = A * [1, 2, 3] = [4+2, 1+6+3, 2+12] = [6, 10, 14]
546        let b = [6.0, 10.0, 14.0];
547        let result = conjugate_gradient(&a, &b, None, 100, 1e-10).unwrap();
548        assert!(result.converged);
549        assert!((result.x[0] - 1.0).abs() < 1e-8);
550        assert!((result.x[1] - 2.0).abs() < 1e-8);
551        assert!((result.x[2] - 3.0).abs() < 1e-8);
552    }
553
554    #[test]
555    fn test_cg_diagonal_system() {
556        // Diagonal matrix — CG should converge in 1 iteration for each
557        // distinct eigenvalue. With n distinct values it takes at most n.
558        let n = 10;
559        let mut rows = Vec::new();
560        let mut cols = Vec::new();
561        let mut vals = Vec::new();
562        let mut b = vec![0.0; n];
563        let expected: Vec<f64> = (1..=n).map(|i| i as f64).collect();
564
565        for i in 0..n {
566            rows.push(i);
567            cols.push(i);
568            vals.push((i + 1) as f64); // diag = 1,2,...,n
569            b[i] = (i + 1) as f64 * expected[i]; // b = diag * x
570        }
571
572        let a = CsrMatrix::from_triplets(n, n, rows, cols, vals).unwrap();
573        let result = conjugate_gradient(&a, &b, None, 100, 1e-12).unwrap();
574        assert!(result.converged);
575        for (i, (xi, ei)) in result.x.iter().zip(expected.iter()).enumerate() {
576            assert!((*xi - *ei).abs() < 1e-8, "x[{i}] = {xi}, expected {ei}",);
577        }
578    }
579
580    #[test]
581    fn test_bicgstab_nonsymmetric() {
582        // Non-symmetric:
583        // A = [[3, 1, 0],
584        //      [0, 4, 2],
585        //      [1, 0, 5]]
586        let a = CsrMatrix::from_triplets(
587            3,
588            3,
589            vec![0, 0, 1, 1, 2, 2],
590            vec![0, 1, 1, 2, 0, 2],
591            vec![3.0, 1.0, 4.0, 2.0, 1.0, 5.0],
592        )
593        .unwrap();
594        // x = [1, 2, 3]: b = [3+2, 8+6, 1+15] = [5, 14, 16]
595        let b = [5.0, 14.0, 16.0];
596        let result = bicgstab(&a, &b, None, 100, 1e-10).unwrap();
597        assert!(result.converged, "BiCGSTAB did not converge");
598        assert!((result.x[0] - 1.0).abs() < 1e-6);
599        assert!((result.x[1] - 2.0).abs() < 1e-6);
600        assert!((result.x[2] - 3.0).abs() < 1e-6);
601    }
602
603    #[test]
604    fn test_bicgstab_on_spd() {
605        // BiCGSTAB should also work on SPD systems.
606        let a = spd_3x3();
607        let b = [6.0, 10.0, 14.0];
608        let result = bicgstab(&a, &b, None, 100, 1e-10).unwrap();
609        assert!(result.converged);
610        assert!((result.x[0] - 1.0).abs() < 1e-6);
611        assert!((result.x[1] - 2.0).abs() < 1e-6);
612        assert!((result.x[2] - 3.0).abs() < 1e-6);
613    }
614
615    #[test]
616    fn test_jacobi_preconditioner() {
617        let a = spd_3x3();
618        let prec = JacobiPreconditioner::new(&a);
619        // Diagonal of A is [4, 3, 4], so inv_diag = [0.25, 1/3, 0.25]
620        assert!((prec.inv_diag[0] - 0.25).abs() < 1e-15);
621        assert!((prec.inv_diag[1] - 1.0 / 3.0).abs() < 1e-15);
622        assert!((prec.inv_diag[2] - 0.25).abs() < 1e-15);
623
624        let r = [4.0, 3.0, 8.0];
625        let z = prec.apply(&r);
626        assert!((z[0] - 1.0).abs() < 1e-15);
627        assert!((z[1] - 1.0).abs() < 1e-15);
628        assert!((z[2] - 2.0).abs() < 1e-15);
629    }
630
631    #[test]
632    fn test_preconditioned_cg_converges() {
633        // Use a larger ill-conditioned diagonal system where
634        // preconditioning should help.
635        let n = 20;
636        let mut rows = Vec::new();
637        let mut cols = Vec::new();
638        let mut vals = Vec::new();
639        let mut b = vec![0.0; n];
640
641        for (i, bi) in b.iter_mut().enumerate() {
642            rows.push(i);
643            cols.push(i);
644            // Condition number ~ n^2
645            let d = ((i + 1) * (i + 1)) as f64;
646            vals.push(d);
647            *bi = d; // x = [1, 1, ..., 1]
648        }
649
650        let a = CsrMatrix::from_triplets(n, n, rows, cols, vals).unwrap();
651        let prec = JacobiPreconditioner::new(&a);
652
653        let result_pcg = preconditioned_cg(&a, &b, &prec, None, 100, 1e-10).unwrap();
654        let result_cg = conjugate_gradient(&a, &b, None, 100, 1e-10).unwrap();
655
656        assert!(result_pcg.converged);
657        assert!(result_cg.converged);
658        // Preconditioned CG should converge in fewer (or equal) iterations.
659        // For a diagonal system, Jacobi preconditioner makes it trivial (1 iter).
660        assert!(
661            result_pcg.iterations <= result_cg.iterations,
662            "PCG iters {} > CG iters {}",
663            result_pcg.iterations,
664            result_cg.iterations
665        );
666    }
667
668    #[test]
669    fn test_cg_custom_initial_guess() {
670        let a = spd_3x3();
671        let b = [6.0, 10.0, 14.0];
672        // Start close to the solution
673        let x0 = [0.9, 2.1, 2.9];
674        let result = conjugate_gradient(&a, &b, Some(&x0), 100, 1e-10).unwrap();
675        assert!(result.converged);
676        assert!((result.x[0] - 1.0).abs() < 1e-8);
677        assert!((result.x[1] - 2.0).abs() < 1e-8);
678        assert!((result.x[2] - 3.0).abs() < 1e-8);
679    }
680
681    #[test]
682    fn test_convergence_failure_max_iter_too_small() {
683        let a = spd_3x3();
684        // Use a b that requires more than 0 iterations
685        let b = [6.0, 10.0, 14.0];
686        let result = conjugate_gradient(&a, &b, None, 0, 1e-10).unwrap();
687        assert!(!result.converged);
688        assert_eq!(result.iterations, 0);
689    }
690
691    #[test]
692    fn test_dimension_mismatch_errors() {
693        let a = spd_3x3();
694
695        // b too short
696        let b_short = [1.0, 2.0];
697        let err = conjugate_gradient(&a, &b_short, None, 10, 1e-10);
698        assert!(err.is_err());
699
700        // x0 wrong length
701        let b = [6.0, 10.0, 14.0];
702        let x0_bad = [1.0, 2.0];
703        let err = conjugate_gradient(&a, &b, Some(&x0_bad), 10, 1e-10);
704        assert!(err.is_err());
705
706        // Non-square matrix
707        let rect = CsrMatrix::from_triplets(2, 3, vec![0, 1], vec![0, 1], vec![1.0, 2.0]).unwrap();
708        let err = conjugate_gradient(&rect, &[1.0, 2.0], None, 10, 1e-10);
709        assert!(err.is_err());
710
711        // Same checks for bicgstab
712        let err = bicgstab(&a, &b_short, None, 10, 1e-10);
713        assert!(err.is_err());
714    }
715
716    #[test]
717    fn test_bicgstab_tridiagonal() {
718        // Tridiagonal (non-symmetric):
719        // A = [[2, -1, 0, 0],
720        //      [0,  2, -1, 0],
721        //      [0,  0,  2, -1],
722        //      [0,  0,  0,  2]]
723        // Upper triangular — clearly non-symmetric.
724        // x = [1, 1, 1, 1]
725        // b = A*x = [1, 1, 1, 2]
726        let a = CsrMatrix::from_triplets(
727            4,
728            4,
729            vec![0, 0, 1, 1, 2, 2, 3],
730            vec![0, 1, 1, 2, 2, 3, 3],
731            vec![2.0, -1.0, 2.0, -1.0, 2.0, -1.0, 2.0],
732        )
733        .unwrap();
734        let b = [1.0, 1.0, 1.0, 2.0];
735        let result = bicgstab(&a, &b, None, 100, 1e-10).unwrap();
736        assert!(
737            result.converged,
738            "BiCGSTAB did not converge on tridiagonal system"
739        );
740        for i in 0..4 {
741            assert!(
742                (result.x[i] - 1.0).abs() < 1e-6,
743                "x[{i}] = {}, expected 1.0",
744                result.x[i]
745            );
746        }
747    }
748}