Skip to main content

oxiblas_ndarray/
sparse.rs

1//! Sparse matrix integration with ndarray types.
2//!
3//! This module provides conversion functions between ndarray dense matrices
4//! and OxiBLAS sparse matrix formats (CSR, CSC), plus sparse linear algebra
5//! operations that accept and return ndarray types.
6//!
7//! # Features
8//!
9//! - **Conversions**: Dense (Array2) to/from CSR and CSC sparse formats
10//! - **SpMV**: Sparse matrix-vector multiplication returning Array1
11//! - **Sparse Solve**: Solve sparse linear systems using CG, returning Array1
12//!
13//! # Example
14//!
15//! ```
16//! # #[cfg(feature = "sparse")] {
17//! use ndarray::array;
18//! use oxiblas_ndarray::sparse::{array2_to_csr, spmv_ndarray};
19//!
20//! let dense = array![[1.0, 0.0, 2.0], [0.0, 3.0, 0.0], [4.0, 0.0, 5.0]];
21//! let csr = array2_to_csr(&dense);
22//! assert_eq!(csr.nnz(), 5);
23//!
24//! let x = array![1.0, 1.0, 1.0];
25//! let y = spmv_ndarray(&csr, &x);
26//! assert_eq!(y, array![3.0, 3.0, 9.0]);
27//! # }
28//! ```
29
30use ndarray::{Array1, Array2};
31use num_traits::Float;
32use oxiblas_core::scalar::{Field, Scalar};
33use oxiblas_sparse::csc::CscMatrix;
34use oxiblas_sparse::csr::CsrMatrix;
35
36// =============================================================================
37// Dense to Sparse Conversions
38// =============================================================================
39
40/// Decides whether a dense entry must be retained when converting to a
41/// sparse format.
42///
43/// `NaN` entries are **always** retained: a `NaN` is technically a non-zero
44/// value (it never compares equal to zero, or to anything else), and
45/// silently discarding it during a dense-to-sparse conversion would corrupt
46/// the represented data. This holds regardless of the sparsification mode.
47///
48/// Otherwise:
49/// - `tolerance == None` — **exact-zero sparsification** (the default):
50///   an entry is retained unless it compares exactly equal to zero
51///   (`value != 0`). This never changes the numerical content of the
52///   matrix: any nonzero value, however small in magnitude (e.g.
53///   `1e-300`), is a distinct number from zero and is preserved.
54/// - `tolerance == Some(tol)` — explicit, user-opted-in **approximate**
55///   sparsification: an entry is retained iff `abs(value) > tol`.
56#[inline]
57fn retain_entry<T: Scalar>(val: T, tolerance: Option<<T as Scalar>::Real>) -> bool {
58    // A NaN component (real or imaginary) makes this entry impossible to
59    // classify as "zero" - never silently drop it.
60    if val.real().is_nan() || val.imag().is_nan() {
61        return true;
62    }
63
64    match tolerance {
65        Some(tol) => Scalar::abs(val) > tol,
66        None => val != T::zero(),
67    }
68}
69
70/// Converts a dense Array2 to CSR (Compressed Sparse Row) format using
71/// **exact-zero** sparsification.
72///
73/// An entry is dropped only if it compares exactly equal to zero
74/// (`value == 0`). This is the mathematically correct default: a value of
75/// `1e-300` is not the same as a mathematical zero, so it is always kept,
76/// and the numerical content of the matrix is never silently altered by
77/// this conversion.
78///
79/// `NaN` entries are never silently dropped: because a `NaN` never compares
80/// equal to zero (or to anything else), `NaN`-containing entries are always
81/// treated as non-zero and stored explicitly in the sparse result.
82///
83/// If you instead want tolerance-based (approximate) sparsification - e.g.
84/// to prune round-off noise below some magnitude - use
85/// [`array2_to_csr_with_tolerance`] and pass an explicit tolerance. This
86/// crate never applies a silent tolerance-based default.
87///
88/// # Arguments
89/// * `arr` - Dense 2D array
90///
91/// # Returns
92/// CSR matrix containing every entry that is not exactly zero
93///
94/// # Example
95/// ```
96/// # #[cfg(feature = "sparse")] {
97/// use ndarray::array;
98/// use oxiblas_ndarray::sparse::array2_to_csr;
99///
100/// let a = array![[1.0, 0.0], [0.0, 2.0]];
101/// let csr = array2_to_csr(&a);
102/// assert_eq!(csr.nnz(), 2);
103/// # }
104/// ```
105pub fn array2_to_csr<T: Scalar + Clone + Field>(arr: &Array2<T>) -> CsrMatrix<T> {
106    array2_to_csr_with_tolerance(arr, None)
107}
108
109/// Converts a dense Array2 to CSR (Compressed Sparse Row) format, with an
110/// explicit, opt-in tolerance for approximate sparsification.
111///
112/// # Arguments
113/// * `arr` - Dense 2D array
114/// * `tolerance` - `None` selects exact-zero sparsification (see
115///   [`array2_to_csr`]). `Some(tol)` explicitly opts into dropping any
116///   entry with `abs(value) <= tol`; this must be a deliberate,
117///   user-supplied choice, never a silent default, because it changes the
118///   numerical content of the matrix.
119///
120/// `NaN` entries are **always** retained (stored explicitly) regardless of
121/// `tolerance`, since a `NaN` is technically non-zero and silently
122/// discarding it would be data corruption.
123///
124/// # Returns
125/// CSR matrix containing the retained entries
126pub fn array2_to_csr_with_tolerance<T: Scalar + Clone + Field>(
127    arr: &Array2<T>,
128    tolerance: Option<<T as Scalar>::Real>,
129) -> CsrMatrix<T> {
130    let (nrows, ncols) = arr.dim();
131
132    let mut row_ptrs = Vec::with_capacity(nrows + 1);
133    let mut col_indices = Vec::new();
134    let mut values = Vec::new();
135
136    row_ptrs.push(0);
137
138    for i in 0..nrows {
139        for j in 0..ncols {
140            let val = arr[[i, j]];
141            if retain_entry(val, tolerance) {
142                col_indices.push(j);
143                values.push(val);
144            }
145        }
146        row_ptrs.push(values.len());
147    }
148
149    // Safety: we construct valid CSR arrays by design:
150    // - row_ptrs has length nrows + 1
151    // - row_ptrs is monotonically increasing
152    // - all col_indices are < ncols (from the loop bounds)
153    // - values.len() == col_indices.len()
154    unsafe { CsrMatrix::new_unchecked(nrows, ncols, row_ptrs, col_indices, values) }
155}
156
157/// Converts a CSR matrix back to a dense Array2.
158///
159/// # Arguments
160/// * `csr` - Sparse CSR matrix
161///
162/// # Returns
163/// Dense 2D array with all elements (including zeros)
164pub fn csr_to_array2<T: Scalar + Clone + Field>(csr: &CsrMatrix<T>) -> Array2<T> {
165    let (nrows, ncols) = csr.shape();
166    let mut result = Array2::zeros((nrows, ncols));
167
168    for i in 0..nrows {
169        for (col, val) in csr.row_iter(i) {
170            result[[i, col]] = *val;
171        }
172    }
173
174    result
175}
176
177/// Converts a dense Array2 to CSC (Compressed Sparse Column) format using
178/// **exact-zero** sparsification.
179///
180/// An entry is dropped only if it compares exactly equal to zero
181/// (`value == 0`). This is the mathematically correct default: a value of
182/// `1e-300` is not the same as a mathematical zero, so it is always kept,
183/// and the numerical content of the matrix is never silently altered by
184/// this conversion.
185///
186/// `NaN` entries are never silently dropped: because a `NaN` never compares
187/// equal to zero (or to anything else), `NaN`-containing entries are always
188/// treated as non-zero and stored explicitly in the sparse result.
189///
190/// If you instead want tolerance-based (approximate) sparsification - e.g.
191/// to prune round-off noise below some magnitude - use
192/// [`array2_to_csc_with_tolerance`] and pass an explicit tolerance. This
193/// crate never applies a silent tolerance-based default.
194///
195/// # Arguments
196/// * `arr` - Dense 2D array
197///
198/// # Returns
199/// CSC matrix containing every entry that is not exactly zero
200pub fn array2_to_csc<T: Scalar + Clone + Field>(arr: &Array2<T>) -> CscMatrix<T> {
201    array2_to_csc_with_tolerance(arr, None)
202}
203
204/// Converts a dense Array2 to CSC (Compressed Sparse Column) format, with an
205/// explicit, opt-in tolerance for approximate sparsification.
206///
207/// # Arguments
208/// * `arr` - Dense 2D array
209/// * `tolerance` - `None` selects exact-zero sparsification (see
210///   [`array2_to_csc`]). `Some(tol)` explicitly opts into dropping any
211///   entry with `abs(value) <= tol`; this must be a deliberate,
212///   user-supplied choice, never a silent default, because it changes the
213///   numerical content of the matrix.
214///
215/// `NaN` entries are **always** retained (stored explicitly) regardless of
216/// `tolerance`, since a `NaN` is technically non-zero and silently
217/// discarding it would be data corruption.
218///
219/// # Returns
220/// CSC matrix containing the retained entries
221pub fn array2_to_csc_with_tolerance<T: Scalar + Clone + Field>(
222    arr: &Array2<T>,
223    tolerance: Option<<T as Scalar>::Real>,
224) -> CscMatrix<T> {
225    let (nrows, ncols) = arr.dim();
226
227    let mut col_ptrs = Vec::with_capacity(ncols + 1);
228    let mut row_indices = Vec::new();
229    let mut values = Vec::new();
230
231    col_ptrs.push(0);
232
233    for j in 0..ncols {
234        for i in 0..nrows {
235            let val = arr[[i, j]];
236            if retain_entry(val, tolerance) {
237                row_indices.push(i);
238                values.push(val);
239            }
240        }
241        col_ptrs.push(values.len());
242    }
243
244    // Safety: we construct valid CSC arrays by design
245    unsafe { CscMatrix::new_unchecked(nrows, ncols, col_ptrs, row_indices, values) }
246}
247
248/// Converts a CSC matrix back to a dense Array2.
249///
250/// # Arguments
251/// * `csc` - Sparse CSC matrix
252///
253/// # Returns
254/// Dense 2D array with all elements (including zeros)
255pub fn csc_to_array2<T: Scalar + Clone + Field>(csc: &CscMatrix<T>) -> Array2<T> {
256    let (nrows, ncols) = csc.shape();
257    let mut result = Array2::zeros((nrows, ncols));
258
259    for j in 0..ncols {
260        for (row, val) in csc.col_iter(j) {
261            result[[row, j]] = *val;
262        }
263    }
264
265    result
266}
267
268// =============================================================================
269// Sparse Matrix-Vector Multiplication
270// =============================================================================
271
272/// Sparse matrix-vector multiplication: y = A * x
273///
274/// Computes the product of a CSR sparse matrix with a dense vector,
275/// returning a new dense Array1.
276///
277/// # Arguments
278/// * `a` - Sparse CSR matrix (m x n)
279/// * `x` - Dense input vector (length n)
280///
281/// # Returns
282/// Dense output vector (length m)
283///
284/// # Panics
285/// Panics if the vector length does not match the number of matrix columns.
286pub fn spmv_ndarray<T: Scalar + Clone + Field>(a: &CsrMatrix<T>, x: &Array1<T>) -> Array1<T> {
287    assert_eq!(
288        x.len(),
289        a.ncols(),
290        "Vector length {} must match matrix columns {}",
291        x.len(),
292        a.ncols()
293    );
294
295    let x_vec: Vec<T> = x.iter().cloned().collect();
296    let mut y_vec = vec![T::zero(); a.nrows()];
297
298    oxiblas_sparse::ops::spmv(T::one(), a, &x_vec, T::zero(), &mut y_vec);
299
300    Array1::from_vec(y_vec)
301}
302
303/// Sparse matrix-vector multiplication with scaling: y = alpha * A * x + beta * y
304///
305/// General form of SpMV that supports scaling factors.
306///
307/// # Arguments
308/// * `alpha` - Scalar multiplier for A * x
309/// * `a` - Sparse CSR matrix (m x n)
310/// * `x` - Dense input vector (length n)
311/// * `beta` - Scalar multiplier for existing y
312/// * `y` - Dense output vector (length m), modified in place
313///
314/// # Panics
315/// Panics if dimensions do not match.
316pub fn spmv_full_ndarray<T: Scalar + Clone + Field>(
317    alpha: T,
318    a: &CsrMatrix<T>,
319    x: &Array1<T>,
320    beta: T,
321    y: &mut Array1<T>,
322) {
323    assert_eq!(x.len(), a.ncols(), "x length must match matrix columns");
324    assert_eq!(y.len(), a.nrows(), "y length must match matrix rows");
325
326    let x_vec: Vec<T> = x.iter().cloned().collect();
327
328    if let Some(y_slice) = y.as_slice_mut() {
329        oxiblas_sparse::ops::spmv(alpha, a, &x_vec, beta, y_slice);
330    } else {
331        let mut y_vec: Vec<T> = y.iter().cloned().collect();
332        oxiblas_sparse::ops::spmv(alpha, a, &x_vec, beta, &mut y_vec);
333        for (yi, val) in y.iter_mut().zip(y_vec) {
334            *yi = val;
335        }
336    }
337}
338
339// =============================================================================
340// Sparse Linear Solve
341// =============================================================================
342
343/// Error type for sparse ndarray operations.
344#[derive(Debug, Clone)]
345pub enum SparseNdarrayError {
346    /// The matrix is not square.
347    NotSquare {
348        /// Number of rows.
349        nrows: usize,
350        /// Number of columns.
351        ncols: usize,
352    },
353    /// Dimension mismatch between matrix and vector.
354    DimensionMismatch {
355        /// Matrix dimension.
356        matrix_dim: usize,
357        /// Vector length.
358        vector_len: usize,
359    },
360    /// The iterative solver did not converge.
361    NotConverged {
362        /// Number of iterations performed.
363        iterations: usize,
364        /// Final residual norm.
365        residual_norm: f64,
366    },
367    /// Solver encountered an error.
368    SolverError(String),
369}
370
371impl core::fmt::Display for SparseNdarrayError {
372    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
373        match self {
374            Self::NotSquare { nrows, ncols } => {
375                write!(f, "Matrix must be square: got {nrows}x{ncols}")
376            }
377            Self::DimensionMismatch {
378                matrix_dim,
379                vector_len,
380            } => {
381                write!(
382                    f,
383                    "Dimension mismatch: matrix dim={matrix_dim}, vector len={vector_len}"
384                )
385            }
386            Self::NotConverged {
387                iterations,
388                residual_norm,
389            } => {
390                write!(
391                    f,
392                    "CG did not converge after {iterations} iterations (residual={residual_norm})"
393                )
394            }
395            Self::SolverError(msg) => write!(f, "Solver error: {msg}"),
396        }
397    }
398}
399
400impl std::error::Error for SparseNdarrayError {}
401
402/// Solve a sparse linear system A * x = b using Conjugate Gradient.
403///
404/// This function solves the system using the CG iterative method, which
405/// requires A to be symmetric positive definite (SPD). For non-SPD matrices,
406/// consider using other solvers.
407///
408/// # Arguments
409/// * `a` - Sparse CSR matrix (n x n), must be SPD
410/// * `b` - Right-hand side vector (length n)
411///
412/// # Returns
413/// Solution vector x, or an error if the solver fails
414///
415/// # Errors
416/// Returns `SparseNdarrayError` if:
417/// - Matrix is not square
418/// - Dimensions don't match
419/// - CG solver does not converge
420pub fn sparse_solve_ndarray(
421    a: &CsrMatrix<f64>,
422    b: &Array1<f64>,
423) -> Result<Array1<f64>, SparseNdarrayError> {
424    let (nrows, ncols) = a.shape();
425
426    if nrows != ncols {
427        return Err(SparseNdarrayError::NotSquare { nrows, ncols });
428    }
429
430    if b.len() != nrows {
431        return Err(SparseNdarrayError::DimensionMismatch {
432            matrix_dim: nrows,
433            vector_len: b.len(),
434        });
435    }
436
437    let b_vec: Vec<f64> = b.iter().copied().collect();
438    let x0 = vec![0.0f64; nrows];
439
440    let tol = 1e-10;
441    let max_iter = nrows * 2 + 100;
442
443    match oxiblas_sparse::linalg::cg(a, &b_vec, &x0, tol, max_iter) {
444        Ok(result) => {
445            if result.converged {
446                Ok(Array1::from_vec(result.x))
447            } else {
448                Err(SparseNdarrayError::NotConverged {
449                    iterations: result.iterations,
450                    residual_norm: result.residual_norm,
451                })
452            }
453        }
454        Err(e) => Err(SparseNdarrayError::SolverError(e.to_string())),
455    }
456}
457
458/// Solve a sparse linear system with custom tolerance and max iterations.
459///
460/// # Arguments
461/// * `a` - Sparse CSR matrix (n x n), must be SPD
462/// * `b` - Right-hand side vector (length n)
463/// * `tol` - Convergence tolerance (relative to norm of b)
464/// * `max_iter` - Maximum number of CG iterations
465///
466/// # Returns
467/// Solution vector x, or an error if the solver fails
468///
469/// # Errors
470/// Returns `SparseNdarrayError` if:
471/// - Matrix is not square
472/// - Dimensions don't match
473/// - CG solver does not converge within max_iter
474pub fn sparse_solve_ndarray_with_options(
475    a: &CsrMatrix<f64>,
476    b: &Array1<f64>,
477    tol: f64,
478    max_iter: usize,
479) -> Result<Array1<f64>, SparseNdarrayError> {
480    let (nrows, ncols) = a.shape();
481
482    if nrows != ncols {
483        return Err(SparseNdarrayError::NotSquare { nrows, ncols });
484    }
485
486    if b.len() != nrows {
487        return Err(SparseNdarrayError::DimensionMismatch {
488            matrix_dim: nrows,
489            vector_len: b.len(),
490        });
491    }
492
493    let b_vec: Vec<f64> = b.iter().copied().collect();
494    let x0 = vec![0.0f64; nrows];
495
496    match oxiblas_sparse::linalg::cg(a, &b_vec, &x0, tol, max_iter) {
497        Ok(result) => {
498            if result.converged {
499                Ok(Array1::from_vec(result.x))
500            } else {
501                Err(SparseNdarrayError::NotConverged {
502                    iterations: result.iterations,
503                    residual_norm: result.residual_norm,
504                })
505            }
506        }
507        Err(e) => Err(SparseNdarrayError::SolverError(e.to_string())),
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use ndarray::array;
515
516    // =========================================================================
517    // Conversion Tests
518    // =========================================================================
519
520    #[test]
521    fn test_array2_to_csr_basic() {
522        let a = array![[1.0f64, 0.0, 2.0], [0.0, 3.0, 0.0], [4.0, 0.0, 5.0]];
523        let csr = array2_to_csr(&a);
524
525        assert_eq!(csr.nrows(), 3);
526        assert_eq!(csr.ncols(), 3);
527        assert_eq!(csr.nnz(), 5);
528
529        assert_eq!(csr.get(0, 0), Some(&1.0));
530        assert_eq!(csr.get(0, 2), Some(&2.0));
531        assert_eq!(csr.get(1, 1), Some(&3.0));
532        assert_eq!(csr.get(2, 0), Some(&4.0));
533        assert_eq!(csr.get(2, 2), Some(&5.0));
534
535        // Zero elements
536        assert_eq!(csr.get(0, 1), None);
537        assert_eq!(csr.get(1, 0), None);
538    }
539
540    #[test]
541    fn test_csr_to_array2_basic() {
542        let values = vec![1.0f64, 2.0, 3.0, 4.0, 5.0];
543        let col_indices = vec![0, 2, 1, 0, 2];
544        let row_ptrs = vec![0, 2, 3, 5];
545        let csr = CsrMatrix::new(3, 3, row_ptrs, col_indices, values)
546            .expect("Failed to create CSR matrix");
547
548        let arr = csr_to_array2(&csr);
549        assert_eq!(arr.dim(), (3, 3));
550        assert!((arr[[0, 0]] - 1.0).abs() < 1e-15);
551        assert!((arr[[0, 1]]).abs() < 1e-15);
552        assert!((arr[[0, 2]] - 2.0).abs() < 1e-15);
553        assert!((arr[[1, 1]] - 3.0).abs() < 1e-15);
554        assert!((arr[[2, 0]] - 4.0).abs() < 1e-15);
555        assert!((arr[[2, 2]] - 5.0).abs() < 1e-15);
556    }
557
558    #[test]
559    fn test_roundtrip_csr() {
560        let original = array![
561            [1.0f64, 0.0, 3.0, 0.0],
562            [0.0, 5.0, 0.0, 7.0],
563            [9.0, 0.0, 11.0, 0.0]
564        ];
565
566        let csr = array2_to_csr(&original);
567        let recovered = csr_to_array2(&csr);
568
569        assert_eq!(original.dim(), recovered.dim());
570        for i in 0..3 {
571            for j in 0..4 {
572                assert!(
573                    (original[[i, j]] - recovered[[i, j]]).abs() < 1e-15,
574                    "Mismatch at ({}, {})",
575                    i,
576                    j
577                );
578            }
579        }
580    }
581
582    #[test]
583    fn test_array2_to_csc_basic() {
584        let a = array![[1.0f64, 0.0, 2.0], [0.0, 3.0, 0.0], [4.0, 0.0, 5.0]];
585        let csc = array2_to_csc(&a);
586
587        assert_eq!(csc.nrows(), 3);
588        assert_eq!(csc.ncols(), 3);
589        assert_eq!(csc.nnz(), 5);
590
591        assert_eq!(csc.get(0, 0), Some(&1.0));
592        assert_eq!(csc.get(0, 2), Some(&2.0));
593        assert_eq!(csc.get(1, 1), Some(&3.0));
594        assert_eq!(csc.get(2, 0), Some(&4.0));
595        assert_eq!(csc.get(2, 2), Some(&5.0));
596    }
597
598    #[test]
599    fn test_csc_to_array2_basic() {
600        let values = vec![1.0f64, 4.0, 3.0, 2.0, 5.0];
601        let row_indices = vec![0, 2, 1, 0, 2];
602        let col_ptrs = vec![0, 2, 3, 5];
603        let csc = CscMatrix::new(3, 3, col_ptrs, row_indices, values)
604            .expect("Failed to create CSC matrix");
605
606        let arr = csc_to_array2(&csc);
607        assert_eq!(arr.dim(), (3, 3));
608        assert!((arr[[0, 0]] - 1.0).abs() < 1e-15);
609        assert!((arr[[2, 0]] - 4.0).abs() < 1e-15);
610        assert!((arr[[1, 1]] - 3.0).abs() < 1e-15);
611        assert!((arr[[0, 2]] - 2.0).abs() < 1e-15);
612        assert!((arr[[2, 2]] - 5.0).abs() < 1e-15);
613    }
614
615    #[test]
616    fn test_roundtrip_csc() {
617        let original = array![
618            [0.0f64, 2.0, 0.0],
619            [4.0, 0.0, 6.0],
620            [0.0, 8.0, 0.0],
621            [10.0, 0.0, 12.0]
622        ];
623
624        let csc = array2_to_csc(&original);
625        let recovered = csc_to_array2(&csc);
626
627        assert_eq!(original.dim(), recovered.dim());
628        for i in 0..4 {
629            for j in 0..3 {
630                assert!(
631                    (original[[i, j]] - recovered[[i, j]]).abs() < 1e-15,
632                    "Mismatch at ({}, {})",
633                    i,
634                    j
635                );
636            }
637        }
638    }
639
640    #[test]
641    fn test_empty_matrix_csr() {
642        let a: Array2<f64> = Array2::zeros((3, 4));
643        let csr = array2_to_csr(&a);
644        assert_eq!(csr.nnz(), 0);
645        assert_eq!(csr.shape(), (3, 4));
646
647        let recovered = csr_to_array2(&csr);
648        for i in 0..3 {
649            for j in 0..4 {
650                assert!(recovered[[i, j]].abs() < 1e-15);
651            }
652        }
653    }
654
655    #[test]
656    fn test_dense_matrix_csr() {
657        let a = array![[1.0f64, 2.0], [3.0, 4.0]];
658        let csr = array2_to_csr(&a);
659        assert_eq!(csr.nnz(), 4);
660    }
661
662    #[test]
663    fn test_array2_to_csr_exact_zero_sparsification_retains_tiny_values() {
664        // A value far below machine epsilon must still be retained: it is
665        // not the mathematical zero, and exact-zero sparsification must
666        // never silently discard it.
667        let tiny = 1e-300f64;
668        let a = array![[tiny, 0.0], [0.0, 1.0]];
669        let csr = array2_to_csr(&a);
670        assert_eq!(csr.nnz(), 2);
671        assert_eq!(csr.get(0, 0), Some(&tiny));
672    }
673
674    #[test]
675    fn test_array2_to_csc_exact_zero_sparsification_retains_tiny_values() {
676        let tiny = 1e-300f64;
677        let a = array![[tiny, 0.0], [0.0, 1.0]];
678        let csc = array2_to_csc(&a);
679        assert_eq!(csc.nnz(), 2);
680        assert_eq!(csc.get(0, 0), Some(&tiny));
681    }
682
683    #[test]
684    fn test_array2_to_csr_never_drops_nan() {
685        let a = array![[f64::NAN, 0.0], [0.0, 1.0]];
686        let csr = array2_to_csr(&a);
687        // NaN is technically non-zero and must be stored, not discarded.
688        assert_eq!(csr.nnz(), 2);
689        let stored = csr.get(0, 0).copied().expect("NaN entry must be stored");
690        assert!(stored.is_nan());
691    }
692
693    #[test]
694    fn test_array2_to_csc_never_drops_nan() {
695        let a = array![[f64::NAN, 0.0], [0.0, 1.0]];
696        let csc = array2_to_csc(&a);
697        assert_eq!(csc.nnz(), 2);
698        let stored = csc.get(0, 0).copied().expect("NaN entry must be stored");
699        assert!(stored.is_nan());
700    }
701
702    #[test]
703    fn test_array2_to_csr_exact_zero_drops_exact_zero_only() {
704        // -0.0 compares equal to 0.0 and must still be dropped.
705        let a = array![[0.0f64, -0.0], [1.0, 0.0]];
706        let csr = array2_to_csr(&a);
707        assert_eq!(csr.nnz(), 1);
708    }
709
710    #[test]
711    fn test_array2_to_csr_with_tolerance_is_opt_in() {
712        // Without an explicit tolerance, a small-but-nonzero entry is kept.
713        let small = 1e-10f64;
714        let a = array![[small, 1.0]];
715        let default_csr = array2_to_csr(&a);
716        assert_eq!(default_csr.nnz(), 2);
717
718        // With an explicit tolerance, the caller may opt into dropping it.
719        let tol_csr = array2_to_csr_with_tolerance(&a, Some(1e-6));
720        assert_eq!(tol_csr.nnz(), 1);
721        assert_eq!(tol_csr.get(0, 1), Some(&1.0));
722    }
723
724    #[test]
725    fn test_array2_to_csc_with_tolerance_is_opt_in() {
726        let small = 1e-10f64;
727        let a = array![[small, 1.0]];
728        let default_csc = array2_to_csc(&a);
729        assert_eq!(default_csc.nnz(), 2);
730
731        let tol_csc = array2_to_csc_with_tolerance(&a, Some(1e-6));
732        assert_eq!(tol_csc.nnz(), 1);
733        assert_eq!(tol_csc.get(0, 1), Some(&1.0));
734    }
735
736    #[test]
737    fn test_array2_to_csr_with_tolerance_still_never_drops_nan() {
738        // Even with an explicit tolerance, NaN must never be silently
739        // dropped: it cannot be meaningfully compared against a tolerance.
740        let a = array![[f64::NAN, 1.0]];
741        let tol_csr = array2_to_csr_with_tolerance(&a, Some(1e-6));
742        assert_eq!(tol_csr.nnz(), 2);
743        let stored = tol_csr
744            .get(0, 0)
745            .copied()
746            .expect("NaN entry must be stored even with tolerance");
747        assert!(stored.is_nan());
748    }
749
750    #[test]
751    fn test_identity_csr() {
752        let n = 5;
753        let mut a = Array2::<f64>::zeros((n, n));
754        for i in 0..n {
755            a[[i, i]] = 1.0;
756        }
757
758        let csr = array2_to_csr(&a);
759        assert_eq!(csr.nnz(), n);
760
761        for i in 0..n {
762            assert_eq!(csr.get(i, i), Some(&1.0));
763        }
764    }
765
766    #[test]
767    fn test_f32_conversions() {
768        let a = array![[1.0f32, 0.0, 2.0], [0.0, 3.0, 0.0]];
769        let csr = array2_to_csr(&a);
770        assert_eq!(csr.nnz(), 3);
771
772        let recovered = csr_to_array2(&csr);
773        assert!((recovered[[0, 0]] - 1.0f32).abs() < 1e-6);
774        assert!((recovered[[0, 2]] - 2.0f32).abs() < 1e-6);
775        assert!((recovered[[1, 1]] - 3.0f32).abs() < 1e-6);
776    }
777
778    // =========================================================================
779    // SpMV Tests
780    // =========================================================================
781
782    #[test]
783    fn test_spmv_ndarray_basic() {
784        let a = array![[1.0f64, 0.0, 2.0], [0.0, 3.0, 0.0], [4.0, 0.0, 5.0]];
785        let csr = array2_to_csr(&a);
786        let x = array![1.0f64, 1.0, 1.0];
787
788        let y = spmv_ndarray(&csr, &x);
789
790        // y[0] = 1*1 + 0*1 + 2*1 = 3
791        // y[1] = 0*1 + 3*1 + 0*1 = 3
792        // y[2] = 4*1 + 0*1 + 5*1 = 9
793        assert!((y[0] - 3.0).abs() < 1e-10);
794        assert!((y[1] - 3.0).abs() < 1e-10);
795        assert!((y[2] - 9.0).abs() < 1e-10);
796    }
797
798    #[test]
799    fn test_spmv_ndarray_identity() {
800        let n = 10;
801        let csr: CsrMatrix<f64> = CsrMatrix::eye(n);
802        let x = Array1::from_shape_fn(n, |i| (i + 1) as f64);
803
804        let y = spmv_ndarray(&csr, &x);
805
806        for i in 0..n {
807            assert!((y[i] - x[i]).abs() < 1e-15);
808        }
809    }
810
811    #[test]
812    fn test_spmv_full_ndarray() {
813        let a = array![[2.0f64, 0.0], [0.0, 3.0]];
814        let csr = array2_to_csr(&a);
815        let x = array![1.0f64, 2.0];
816        let mut y = array![10.0f64, 20.0];
817
818        // y = 2.0 * A * x + 0.5 * y
819        // y[0] = 2.0 * (2*1 + 0*2) + 0.5 * 10 = 4 + 5 = 9
820        // y[1] = 2.0 * (0*1 + 3*2) + 0.5 * 20 = 12 + 10 = 22
821        spmv_full_ndarray(2.0, &csr, &x, 0.5, &mut y);
822
823        assert!((y[0] - 9.0).abs() < 1e-10);
824        assert!((y[1] - 22.0).abs() < 1e-10);
825    }
826
827    // =========================================================================
828    // Sparse Solve Tests
829    // =========================================================================
830
831    #[test]
832    fn test_sparse_solve_identity() {
833        let n = 5;
834        let csr: CsrMatrix<f64> = CsrMatrix::eye(n);
835        let b = Array1::from_shape_fn(n, |i| (i + 1) as f64);
836
837        let x = sparse_solve_ndarray(&csr, &b).expect("Solve should succeed for identity");
838
839        for i in 0..n {
840            assert!(
841                (x[i] - b[i]).abs() < 1e-8,
842                "Mismatch at {}: got {}, expected {}",
843                i,
844                x[i],
845                b[i]
846            );
847        }
848    }
849
850    #[test]
851    fn test_sparse_solve_spd() {
852        // SPD tridiagonal: [4 -1 0; -1 4 -1; 0 -1 4]
853        let values = vec![4.0, -1.0, -1.0, 4.0, -1.0, -1.0, 4.0];
854        let col_indices = vec![0, 1, 0, 1, 2, 1, 2];
855        let row_ptrs = vec![0, 2, 5, 7];
856        let csr = CsrMatrix::new(3, 3, row_ptrs, col_indices, values)
857            .expect("Failed to create CSR matrix");
858
859        let b = array![3.0f64, 2.0, 3.0];
860        let x = sparse_solve_ndarray(&csr, &b).expect("Solve should succeed for SPD matrix");
861
862        // Verify A * x = b
863        let residual = spmv_ndarray(&csr, &x);
864        for i in 0..3 {
865            assert!(
866                (residual[i] - b[i]).abs() < 1e-8,
867                "Residual mismatch at {}: got {}, expected {}",
868                i,
869                residual[i],
870                b[i]
871            );
872        }
873    }
874
875    #[test]
876    fn test_sparse_solve_larger_spd() {
877        let n = 20;
878        let mut values = Vec::new();
879        let mut col_indices = Vec::new();
880        let mut row_ptrs = vec![0usize];
881
882        for i in 0..n {
883            if i > 0 {
884                values.push(-1.0f64);
885                col_indices.push(i - 1);
886            }
887            values.push(4.0f64);
888            col_indices.push(i);
889            if i < n - 1 {
890                values.push(-1.0f64);
891                col_indices.push(i + 1);
892            }
893            row_ptrs.push(values.len());
894        }
895
896        let csr = CsrMatrix::new(n, n, row_ptrs, col_indices, values)
897            .expect("Failed to create CSR matrix");
898
899        let b = Array1::from_shape_fn(n, |i| (i + 1) as f64);
900        let x = sparse_solve_ndarray(&csr, &b).expect("Solve should succeed for larger SPD");
901
902        let residual = spmv_ndarray(&csr, &x);
903        for i in 0..n {
904            assert!(
905                (residual[i] - b[i]).abs() < 1e-6,
906                "Residual mismatch at {}: got {}, expected {}",
907                i,
908                residual[i],
909                b[i]
910            );
911        }
912    }
913
914    #[test]
915    fn test_sparse_solve_not_square() {
916        let csr: CsrMatrix<f64> = CsrMatrix::zeros(3, 4);
917        let b = array![1.0f64, 2.0, 3.0];
918        let result = sparse_solve_ndarray(&csr, &b);
919        assert!(result.is_err());
920    }
921
922    #[test]
923    fn test_sparse_solve_dimension_mismatch() {
924        let csr: CsrMatrix<f64> = CsrMatrix::eye(3);
925        let b = array![1.0f64, 2.0]; // Wrong length
926        let result = sparse_solve_ndarray(&csr, &b);
927        assert!(result.is_err());
928    }
929
930    #[test]
931    fn test_sparse_solve_with_options() {
932        let csr: CsrMatrix<f64> = CsrMatrix::eye(3);
933        let b = array![1.0f64, 2.0, 3.0];
934
935        let x = sparse_solve_ndarray_with_options(&csr, &b, 1e-12, 100)
936            .expect("Solve with options should succeed");
937
938        for i in 0..3 {
939            assert!((x[i] - b[i]).abs() < 1e-10);
940        }
941    }
942}