Skip to main content

oxiblas_ndarray/
lapack.rs

1//! LAPACK decompositions and operations on ndarray types.
2//!
3//! This module provides LAPACK decompositions (LU, QR, SVD, EVD, Cholesky)
4//! directly on ndarray types, using OxiBLAS-LAPACK as the backend.
5
6use crate::conversions::{array2_to_mat, mat_ref_to_array2, mat_to_array2};
7use ndarray::{Array1, Array2};
8use oxiblas_core::scalar::Field;
9use oxiblas_lapack::{cholesky, evd, lu, qr, solve, svd};
10use oxiblas_matrix::Mat;
11
12// Re-export useful types
13pub use evd::Eigenvalue;
14
15// =============================================================================
16// Error Types
17// =============================================================================
18
19/// Error type for LAPACK operations on ndarray.
20#[derive(Debug, Clone)]
21pub enum LapackError {
22    /// Matrix is singular or nearly singular
23    Singular(String),
24    /// Matrix is not positive definite
25    NotPositiveDefinite(String),
26    /// Dimension mismatch
27    DimensionMismatch(String),
28    /// Decomposition did not converge
29    NotConverged(String),
30    /// Other error
31    Other(String),
32}
33
34impl std::fmt::Display for LapackError {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            Self::Singular(msg) => write!(f, "Singular matrix: {msg}"),
38            Self::NotPositiveDefinite(msg) => write!(f, "Not positive definite: {msg}"),
39            Self::DimensionMismatch(msg) => write!(f, "Dimension mismatch: {msg}"),
40            Self::NotConverged(msg) => write!(f, "Did not converge: {msg}"),
41            Self::Other(msg) => write!(f, "LAPACK error: {msg}"),
42        }
43    }
44}
45
46impl std::error::Error for LapackError {}
47
48/// Result type for LAPACK operations.
49pub type LapackResult<T> = Result<T, LapackError>;
50
51// =============================================================================
52// LU Decomposition
53// =============================================================================
54
55/// Result of LU decomposition.
56#[derive(Debug, Clone)]
57pub struct LuResult<T> {
58    /// L factor (lower triangular with unit diagonal)
59    pub l: Array2<T>,
60    /// U factor (upper triangular)
61    pub u: Array2<T>,
62    /// Permutation vector
63    pub perm: Vec<usize>,
64}
65
66impl<T: Field + Clone> LuResult<T>
67where
68    T: bytemuck::Zeroable,
69{
70    /// Solves Ax = b using the LU decomposition.
71    pub fn solve(&self, b: &Array1<T>) -> Array1<T> {
72        let n = self.l.dim().0;
73        assert_eq!(b.len(), n, "b length must match matrix dimension");
74
75        // Apply the row permutation P to b, forming Pb.
76        //
77        // `perm` is LAPACK's pivot *sequence*, not a destination-index
78        // permutation array: at factorization step k, row k was interchanged
79        // with row `perm[k]`. The interchanges must therefore be *replayed in
80        // ascending k* (exactly as LAPACK's DLASWP does) so that a chain of
81        // pivots — e.g. 0->2 followed by 1->2 — is composed correctly. The old
82        // `pb[i] = b[perm[i]]` treated the sequence as a final permutation and
83        // returned wrong solutions whenever any row interchange occurred.
84        let mut pb: Vec<T> = b.iter().cloned().collect();
85        for k in 0..n {
86            let pk = self.perm[k];
87            if k != pk {
88                pb.swap(k, pk);
89            }
90        }
91
92        // Forward substitution: L * y = pb
93        let mut y: Vec<T> = vec![T::zero(); n];
94        for i in 0..n {
95            let mut sum = pb[i];
96            for j in 0..i {
97                sum -= self.l[[i, j]] * y[j];
98            }
99            y[i] = sum;
100        }
101
102        // Back substitution: U * x = y
103        let mut x: Vec<T> = vec![T::zero(); n];
104        for i in (0..n).rev() {
105            let mut sum = y[i];
106            for j in (i + 1)..n {
107                sum -= self.u[[i, j]] * x[j];
108            }
109            x[i] = sum / self.u[[i, i]];
110        }
111
112        Array1::from_vec(x)
113    }
114
115    /// Computes the determinant.
116    pub fn det(&self) -> T {
117        let n = self.l.dim().0;
118        let mut det = T::one();
119
120        // Product of U diagonal elements
121        for i in 0..n {
122            det *= self.u[[i, i]];
123        }
124
125        // Account for the permutation sign.
126        //
127        // `perm` is LAPACK's pivot *sequence* (a sequence of transpositions),
128        // not a destination-index permutation array. The number of actual row
129        // interchanges performed during factorization is exactly the count of
130        // positions k with `perm[k] != k` — each such step swapped one pair of
131        // rows once — so the determinant sign is (-1)^num_swaps. Cycle-
132        // decomposing `perm` as if it were a permutation array (the previous
133        // approach) yields the wrong sign as soon as two or more swaps occur.
134        let num_swaps = self
135            .perm
136            .iter()
137            .enumerate()
138            .filter(|&(k, &pk)| k != pk)
139            .count();
140
141        if num_swaps % 2 == 1 {
142            det = T::zero() - det;
143        }
144
145        det
146    }
147}
148
149/// Computes the LU decomposition of a matrix.
150///
151/// A = P * L * U
152///
153/// # Arguments
154/// * `a` - The input matrix (m×n)
155///
156/// # Returns
157/// LU decomposition with L, U, and permutation
158pub fn lu_ndarray<T: Field + Clone>(a: &Array2<T>) -> LapackResult<LuResult<T>>
159where
160    T: bytemuck::Zeroable,
161{
162    let mat = array2_to_mat(a);
163
164    match lu::Lu::compute(mat.as_ref()) {
165        Ok(lu_decomp) => {
166            // Extract L and U factors
167            let l = mat_to_array2(&lu_decomp.l_factor());
168            let u = mat_to_array2(&lu_decomp.u_factor());
169
170            // Get permutation
171            let perm = lu_decomp.pivot().to_vec();
172
173            Ok(LuResult { l, u, perm })
174        }
175        Err(e) => Err(LapackError::Singular(format!("{e:?}"))),
176    }
177}
178
179// =============================================================================
180// QR Decomposition
181// =============================================================================
182
183/// Result of QR decomposition.
184#[derive(Debug, Clone)]
185pub struct QrResult<T> {
186    /// Q factor (orthogonal/unitary)
187    pub q: Array2<T>,
188    /// R factor (upper triangular)
189    pub r: Array2<T>,
190}
191
192impl<T: Field + Clone> QrResult<T> {
193    /// Solves the least squares problem min ||Ax - b||.
194    pub fn solve_least_squares(&self, b: &Array1<T>) -> Array1<T> {
195        let (m, n) = (self.q.dim().0, self.r.dim().1);
196        assert_eq!(b.len(), m, "b length must match matrix rows");
197
198        // Compute Q^T * b (or Q^H for complex)
199        let mut qtb: Array1<T> = Array1::from_vec(vec![T::zero(); n]);
200        for j in 0..n {
201            let mut sum = T::zero();
202            for i in 0..m {
203                sum += self.q[[i, j]].conj() * b[i];
204            }
205            qtb[j] = sum;
206        }
207
208        // Back substitution: R * x = Q^T * b
209        let mut x: Array1<T> = Array1::from_vec(vec![T::zero(); n]);
210        for i in (0..n).rev() {
211            let mut sum = qtb[i];
212            for j in (i + 1)..n {
213                sum -= self.r[[i, j]] * x[j];
214            }
215            x[i] = sum / self.r[[i, i]];
216        }
217
218        x
219    }
220}
221
222/// Computes the QR decomposition of a matrix.
223///
224/// A = Q * R
225///
226/// # Arguments
227/// * `a` - The input matrix (m×n)
228///
229/// # Returns
230/// QR decomposition with Q and R
231pub fn qr_ndarray<T: Field + Clone>(a: &Array2<T>) -> LapackResult<QrResult<T>>
232where
233    T: bytemuck::Zeroable + oxiblas_core::scalar::Real,
234{
235    let mat = array2_to_mat(a);
236
237    match qr::Qr::compute(mat.as_ref()) {
238        Ok(qr_decomp) => {
239            let q = mat_to_array2(&qr_decomp.q());
240            let r = mat_to_array2(&qr_decomp.r());
241
242            Ok(QrResult { q, r })
243        }
244        Err(e) => Err(LapackError::Other(format!("{e:?}"))),
245    }
246}
247
248// =============================================================================
249// Singular Value Decomposition
250// =============================================================================
251
252/// Result of SVD decomposition.
253#[derive(Debug, Clone)]
254pub struct SvdResult<T> {
255    /// Left singular vectors U (m×k where k = min(m,n))
256    pub u: Array2<T>,
257    /// Singular values σ (sorted in descending order)
258    pub s: Array1<T>,
259    /// Right singular vectors V^T (k×n)
260    pub vt: Array2<T>,
261}
262
263impl<T: Field + Clone> SvdResult<T> {
264    /// Returns the rank based on a tolerance.
265    pub fn rank(&self, tol: T) -> usize {
266        self.s.iter().filter(|&s| s.abs() > tol.abs()).count()
267    }
268}
269
270/// Computes the SVD of a matrix.
271///
272/// A = U * Σ * V^T
273///
274/// # Arguments
275/// * `a` - The input matrix (m×n)
276///
277/// # Returns
278/// SVD with U, S (singular values), and V^T
279pub fn svd_ndarray<T>(a: &Array2<T>) -> LapackResult<SvdResult<T>>
280where
281    T: Field + Clone + bytemuck::Zeroable + oxiblas_core::scalar::Real,
282{
283    let mat = array2_to_mat(a);
284
285    match svd::Svd::compute(mat.as_ref()) {
286        Ok(svd_decomp) => {
287            let u = mat_ref_to_array2(svd_decomp.u());
288            let s = Array1::from_vec(svd_decomp.singular_values().to_vec());
289            let vt = mat_ref_to_array2(svd_decomp.vt());
290
291            Ok(SvdResult { u, s, vt })
292        }
293        Err(e) => Err(LapackError::NotConverged(format!("{e:?}"))),
294    }
295}
296
297/// Computes the truncated SVD of a matrix.
298///
299/// Returns only the top k singular values and vectors.
300pub fn svd_truncated<T>(a: &Array2<T>, k: usize) -> LapackResult<SvdResult<T>>
301where
302    T: Field + Clone + bytemuck::Zeroable + oxiblas_core::scalar::Real,
303{
304    let svd_result = svd_ndarray(a)?;
305
306    let actual_k = k.min(svd_result.s.len());
307
308    // Truncate to k components
309    let u = svd_result.u.slice(ndarray::s![.., ..actual_k]).to_owned();
310    let s = svd_result.s.slice(ndarray::s![..actual_k]).to_owned();
311    let vt = svd_result.vt.slice(ndarray::s![..actual_k, ..]).to_owned();
312
313    Ok(SvdResult { u, s, vt })
314}
315
316// =============================================================================
317// Eigenvalue Decomposition (Symmetric)
318// =============================================================================
319
320/// Result of symmetric eigenvalue decomposition.
321#[derive(Debug, Clone)]
322pub struct SymEvdResult<T> {
323    /// Eigenvalues (sorted in ascending order)
324    pub eigenvalues: Array1<T>,
325    /// Eigenvectors (columns are eigenvectors)
326    pub eigenvectors: Array2<T>,
327}
328
329/// Computes the eigenvalue decomposition of a symmetric matrix.
330///
331/// A * V = V * Λ where Λ = diag(eigenvalues)
332///
333/// # Arguments
334/// * `a` - The input symmetric matrix (n×n)
335///
336/// # Returns
337/// Eigenvalues and eigenvectors
338pub fn eig_symmetric<T>(a: &Array2<T>) -> LapackResult<SymEvdResult<T>>
339where
340    T: Field + Clone + bytemuck::Zeroable + oxiblas_core::scalar::Real,
341{
342    let (m, n) = a.dim();
343    if m != n {
344        return Err(LapackError::DimensionMismatch(
345            "Matrix must be square".to_string(),
346        ));
347    }
348
349    let mat = array2_to_mat(a);
350
351    match evd::SymmetricEvd::compute(mat.as_ref()) {
352        Ok(evd_result) => {
353            let eigenvalues = Array1::from_vec(evd_result.eigenvalues().to_vec());
354            // Convert MatRef to Array2
355            let evec_ref = evd_result.eigenvectors();
356            let (rows, cols) = (evec_ref.nrows(), evec_ref.ncols());
357            let eigenvectors = Array2::from_shape_fn((rows, cols), |(i, j)| evec_ref[(i, j)]);
358
359            Ok(SymEvdResult {
360                eigenvalues,
361                eigenvectors,
362            })
363        }
364        Err(e) => Err(LapackError::NotConverged(format!("{e:?}"))),
365    }
366}
367
368/// Computes only the eigenvalues of a symmetric matrix.
369pub fn eigvals_symmetric<T>(a: &Array2<T>) -> LapackResult<Array1<T>>
370where
371    T: Field + Clone + bytemuck::Zeroable + oxiblas_core::scalar::Real,
372{
373    eig_symmetric(a).map(|result| result.eigenvalues)
374}
375
376// =============================================================================
377// Complex-Specific Functions (for Complex<f64>, Complex<f32>)
378// =============================================================================
379
380/// Result of complex SVD decomposition.
381#[derive(Debug, Clone)]
382pub struct ComplexSvdResult<T>
383where
384    T: oxiblas_core::Scalar,
385{
386    /// Left singular vectors U (m×k, complex unitary)
387    pub u: Array2<T>,
388    /// Singular values σ (real, sorted in descending order)
389    pub s: Array1<T::Real>,
390    /// Right singular vectors V^H (k×n, complex unitary)
391    pub vt: Array2<T>,
392}
393
394/// Computes the SVD of a complex matrix using ComplexSvd algorithm.
395///
396/// For complex matrices, this function uses the one-sided Jacobi algorithm
397/// specifically designed for complex numbers.
398///
399/// # Arguments
400/// * `a` - The input complex matrix (m×n)
401///
402/// # Returns
403/// U, singular values (real), and V^H
404pub fn svd_complex_ndarray<T>(a: &Array2<T>) -> LapackResult<ComplexSvdResult<T>>
405where
406    T: Field + oxiblas_core::scalar::ComplexScalar + Clone + bytemuck::Zeroable,
407    T::Real: oxiblas_core::scalar::Real,
408{
409    let mat = array2_to_mat(a);
410
411    match svd::ComplexSvd::compute(mat.as_ref()) {
412        Ok(svd_decomp) => {
413            let u = mat_ref_to_array2(svd_decomp.u().as_ref());
414            let s = Array1::from_vec(svd_decomp.singular_values().to_vec());
415            let vh = mat_ref_to_array2(svd_decomp.vh().as_ref());
416
417            Ok(ComplexSvdResult { u, s, vt: vh })
418        }
419        Err(e) => Err(LapackError::NotConverged(format!("{e:?}"))),
420    }
421}
422
423/// Computes the QR decomposition of a complex matrix.
424///
425/// # Arguments
426/// * `a` - The input complex matrix (m×n)
427///
428/// # Returns
429/// Q (unitary) and R (upper triangular)
430pub fn qr_complex_ndarray<T>(a: &Array2<T>) -> LapackResult<QrResult<T>>
431where
432    T: Field + oxiblas_core::scalar::ComplexScalar + Clone + bytemuck::Zeroable,
433    T::Real: oxiblas_core::scalar::Real,
434{
435    let mat = array2_to_mat(a);
436
437    match qr::UnitaryQr::compute(mat.as_ref()) {
438        Ok(qr_decomp) => {
439            let q_mat = qr_decomp.q();
440            let r_mat = qr_decomp.r();
441            let q = mat_to_array2(&q_mat);
442            let r = mat_to_array2(&r_mat);
443            Ok(QrResult { q, r })
444        }
445        Err(e) => Err(LapackError::NotConverged(format!("{e:?}"))),
446    }
447}
448
449/// Computes the Cholesky decomposition of a Hermitian positive definite matrix.
450///
451/// # Arguments
452/// * `a` - The input Hermitian positive definite matrix (n×n)
453///
454/// # Returns
455/// Lower triangular factor L such that A = LL^H
456pub fn cholesky_hermitian_ndarray<T>(a: &Array2<T>) -> LapackResult<CholeskyResult<T>>
457where
458    T: Field + oxiblas_core::scalar::ComplexScalar + Clone + bytemuck::Zeroable,
459    T::Real: oxiblas_core::scalar::Real,
460{
461    let (m, n) = a.dim();
462    if m != n {
463        return Err(LapackError::DimensionMismatch(
464            "Matrix must be square".to_string(),
465        ));
466    }
467
468    let mat = array2_to_mat(a);
469
470    match cholesky::HermitianCholesky::compute(mat.as_ref()) {
471        Ok(chol) => {
472            let l_mat = chol.l_factor();
473            let l = mat_to_array2(&l_mat);
474            Ok(CholeskyResult { l })
475        }
476        Err(e) => Err(LapackError::NotPositiveDefinite(format!("{e:?}"))),
477    }
478}
479
480/// Result of Hermitian eigenvalue decomposition for complex matrices.
481#[derive(Debug, Clone)]
482pub struct HermitianEvdResult<T>
483where
484    T: oxiblas_core::Scalar,
485{
486    /// Eigenvalues (real, sorted in ascending order)
487    pub eigenvalues: Array1<T>,
488    /// Eigenvectors (complex columns)
489    pub eigenvectors: Array2<T>,
490}
491
492/// Computes the eigenvalue decomposition of a Hermitian matrix.
493///
494/// For Hermitian matrices (A = A^H), all eigenvalues are real but eigenvectors are complex.
495///
496/// # Arguments
497/// * `a` - The input Hermitian matrix (n×n, only upper triangle is used)
498///
499/// # Returns
500/// Eigenvalues (real, sorted in ascending order) and eigenvectors (complex columns)
501pub fn eig_hermitian_ndarray<T>(a: &Array2<T>) -> LapackResult<(Array1<T::Real>, Array2<T>)>
502where
503    T: Field + oxiblas_core::scalar::ComplexScalar + Clone + bytemuck::Zeroable,
504    T::Real: oxiblas_core::scalar::Real + Clone + bytemuck::Zeroable,
505{
506    let (m, n) = a.dim();
507    if m != n {
508        return Err(LapackError::DimensionMismatch(
509            "Matrix must be square".to_string(),
510        ));
511    }
512
513    let mat = array2_to_mat(a);
514
515    match evd::HermitianEvd::compute(mat.as_ref()) {
516        Ok(evd_result) => {
517            let eigenvalues = Array1::from_vec(evd_result.eigenvalues().to_vec());
518
519            // Convert eigenvectors MatRef<T> to Array2<T>
520            let evec_ref = evd_result.eigenvectors();
521            let eigenvectors = mat_ref_to_array2(evec_ref);
522
523            Ok((eigenvalues, eigenvectors))
524        }
525        Err(e) => Err(LapackError::NotConverged(format!("{e:?}"))),
526    }
527}
528
529// =============================================================================
530// Cholesky Decomposition
531// =============================================================================
532
533/// Result of Cholesky decomposition.
534#[derive(Debug, Clone)]
535pub struct CholeskyResult<T> {
536    /// Lower triangular factor L such that A = L * L^T
537    pub l: Array2<T>,
538}
539
540impl<T: Field + Clone> CholeskyResult<T> {
541    /// Solves Ax = b using the Cholesky decomposition.
542    pub fn solve(&self, b: &Array1<T>) -> Array1<T> {
543        let n = self.l.dim().0;
544        assert_eq!(b.len(), n, "b length must match matrix dimension");
545
546        // Forward substitution: L * y = b
547        let mut y: Array1<T> = Array1::from_vec(vec![T::zero(); n]);
548        for i in 0..n {
549            let mut sum = b[i];
550            for j in 0..i {
551                sum -= self.l[[i, j]] * y[j];
552            }
553            y[i] = sum / self.l[[i, i]];
554        }
555
556        // Back substitution: L^T * x = y
557        let mut x: Array1<T> = Array1::from_vec(vec![T::zero(); n]);
558        for i in (0..n).rev() {
559            let mut sum = y[i];
560            for j in (i + 1)..n {
561                sum -= self.l[[j, i]].conj() * x[j];
562            }
563            x[i] = sum / self.l[[i, i]].conj();
564        }
565
566        x
567    }
568
569    /// Computes the determinant.
570    pub fn det(&self) -> T {
571        let n = self.l.dim().0;
572        let mut det = T::one();
573        for i in 0..n {
574            let diag = self.l[[i, i]];
575            det = det * diag * diag;
576        }
577        det
578    }
579}
580
581/// Computes the Cholesky decomposition of a positive definite matrix.
582///
583/// A = L * L^T
584///
585/// # Arguments
586/// * `a` - The input symmetric positive definite matrix (n×n)
587///
588/// # Returns
589/// Cholesky decomposition with lower triangular factor L
590pub fn cholesky_ndarray<T>(a: &Array2<T>) -> LapackResult<CholeskyResult<T>>
591where
592    T: Field + Clone + bytemuck::Zeroable + oxiblas_core::scalar::Real,
593{
594    let (m, n) = a.dim();
595    if m != n {
596        return Err(LapackError::DimensionMismatch(
597            "Matrix must be square".to_string(),
598        ));
599    }
600
601    let mat = array2_to_mat(a);
602
603    match cholesky::Cholesky::compute(mat.as_ref()) {
604        Ok(chol) => {
605            let l = mat_to_array2(&chol.l_factor());
606            Ok(CholeskyResult { l })
607        }
608        Err(e) => Err(LapackError::NotPositiveDefinite(format!("{e:?}"))),
609    }
610}
611
612// =============================================================================
613// Linear Solve
614// =============================================================================
615
616/// Solves the linear system Ax = b.
617///
618/// # Arguments
619/// * `a` - The coefficient matrix (n×n)
620/// * `b` - The right-hand side vector (n)
621///
622/// # Returns
623/// The solution vector x
624pub fn solve_ndarray<T>(a: &Array2<T>, b: &Array1<T>) -> LapackResult<Array1<T>>
625where
626    T: Field + Clone + bytemuck::Zeroable,
627{
628    let (m, n) = a.dim();
629    if m != n {
630        return Err(LapackError::DimensionMismatch(
631            "Matrix must be square".to_string(),
632        ));
633    }
634    if b.len() != n {
635        return Err(LapackError::DimensionMismatch(
636            "b length must match matrix dimension".to_string(),
637        ));
638    }
639
640    let a_mat = array2_to_mat(a);
641    // Convert b to a column vector matrix
642    let mut b_mat: Mat<T> = Mat::zeros(n, 1);
643    for i in 0..n {
644        b_mat[(i, 0)] = b[i];
645    }
646
647    match solve::solve(a_mat.as_ref(), b_mat.as_ref()) {
648        Ok(x_mat) => {
649            // Extract column vector from result matrix
650            let x: Vec<T> = (0..n).map(|i| x_mat[(i, 0)]).collect();
651            Ok(Array1::from_vec(x))
652        }
653        Err(e) => Err(LapackError::Singular(format!("{e:?}"))),
654    }
655}
656
657/// Solves multiple linear systems AX = B.
658///
659/// # Arguments
660/// * `a` - The coefficient matrix (n×n)
661/// * `b` - The right-hand side matrix (n×k)
662///
663/// # Returns
664/// The solution matrix X (n×k)
665pub fn solve_multiple_ndarray<T>(a: &Array2<T>, b: &Array2<T>) -> LapackResult<Array2<T>>
666where
667    T: Field + Clone + bytemuck::Zeroable,
668{
669    let (m, n) = a.dim();
670    let (b_rows, _b_cols) = b.dim();
671
672    if m != n {
673        return Err(LapackError::DimensionMismatch(
674            "Matrix must be square".to_string(),
675        ));
676    }
677    if b_rows != n {
678        return Err(LapackError::DimensionMismatch(
679            "b rows must match matrix dimension".to_string(),
680        ));
681    }
682
683    let a_mat = array2_to_mat(a);
684    let b_mat = array2_to_mat(b);
685
686    match solve::solve_multiple(a_mat.as_ref(), b_mat.as_ref()) {
687        Ok(x_mat) => Ok(mat_to_array2(&x_mat)),
688        Err(e) => Err(LapackError::Singular(format!("{e:?}"))),
689    }
690}
691
692/// Solves the least squares problem min ||Ax - b||.
693pub fn lstsq_ndarray<T>(a: &Array2<T>, b: &Array1<T>) -> LapackResult<Array1<T>>
694where
695    T: Field + Clone + bytemuck::Zeroable + oxiblas_core::scalar::Real,
696{
697    let m = a.dim().0;
698    let a_mat = array2_to_mat(a);
699    // Convert b to a column vector matrix
700    let mut b_mat: Mat<T> = Mat::zeros(m, 1);
701    for i in 0..m {
702        b_mat[(i, 0)] = b[i];
703    }
704
705    match solve::lstsq(a_mat.as_ref(), b_mat.as_ref()) {
706        Ok(result) => {
707            // Extract solution column vector
708            let n = result.solution.nrows();
709            let x: Vec<T> = (0..n).map(|i| result.solution[(i, 0)]).collect();
710            Ok(Array1::from_vec(x))
711        }
712        Err(e) => Err(LapackError::NotConverged(format!("{e:?}"))),
713    }
714}
715
716// =============================================================================
717// Matrix Inverse
718// =============================================================================
719
720/// Computes the inverse of a matrix.
721pub fn inv_ndarray<T>(a: &Array2<T>) -> LapackResult<Array2<T>>
722where
723    T: Field + Clone + bytemuck::Zeroable,
724{
725    let (m, n) = a.dim();
726    if m != n {
727        return Err(LapackError::DimensionMismatch(
728            "Matrix must be square".to_string(),
729        ));
730    }
731
732    let a_mat = array2_to_mat(a);
733
734    match oxiblas_lapack::utils::inv(a_mat.as_ref()) {
735        Ok(inv_mat) => Ok(mat_to_array2(&inv_mat)),
736        Err(e) => Err(LapackError::Singular(format!("{e:?}"))),
737    }
738}
739
740/// Computes the Moore-Penrose pseudo-inverse.
741pub fn pinv_ndarray<T>(a: &Array2<T>) -> LapackResult<Array2<T>>
742where
743    T: Field + Clone + bytemuck::Zeroable + oxiblas_core::scalar::Real,
744{
745    let a_mat = array2_to_mat(a);
746
747    match oxiblas_lapack::utils::pinv_default(a_mat.as_ref()) {
748        Ok(result) => Ok(mat_to_array2(&result.pinv)),
749        Err(e) => Err(LapackError::NotConverged(format!("{e:?}"))),
750    }
751}
752
753// =============================================================================
754// Determinant
755// =============================================================================
756
757/// Computes the determinant of a matrix.
758pub fn det_ndarray<T>(a: &Array2<T>) -> LapackResult<T>
759where
760    T: Field + Clone + bytemuck::Zeroable,
761{
762    let (m, n) = a.dim();
763    if m != n {
764        return Err(LapackError::DimensionMismatch(
765            "Matrix must be square".to_string(),
766        ));
767    }
768
769    let a_mat = array2_to_mat(a);
770
771    match oxiblas_lapack::utils::det(a_mat.as_ref()) {
772        Ok(d) => Ok(d),
773        Err(e) => Err(LapackError::Other(format!("{e:?}"))),
774    }
775}
776
777// =============================================================================
778// Condition Number
779// =============================================================================
780
781/// Computes the condition number of a matrix (using 2-norm).
782pub fn cond_ndarray<T>(a: &Array2<T>) -> LapackResult<T>
783where
784    T: Field + Clone + bytemuck::Zeroable + oxiblas_core::scalar::Real,
785{
786    let svd_result = svd_ndarray(a)?;
787    let n = svd_result.s.len();
788
789    if n == 0 {
790        return Ok(T::one());
791    }
792
793    let sigma_max = svd_result.s[0];
794    let sigma_min = svd_result.s[n - 1];
795
796    // Check if sigma_min is very small
797    if sigma_min == T::zero() {
798        // Return a large number to indicate ill-conditioning
799        Ok(T::from_f64(1e15).unwrap_or(T::one()))
800    } else {
801        Ok(sigma_max / sigma_min)
802    }
803}
804
805// =============================================================================
806// Rank
807// =============================================================================
808
809/// Computes the numerical rank of a matrix.
810pub fn rank_ndarray<T>(a: &Array2<T>) -> LapackResult<usize>
811where
812    T: Field + Clone + bytemuck::Zeroable + oxiblas_core::scalar::Real,
813{
814    let (m, n) = a.dim();
815    let svd_result = svd_ndarray(a)?;
816
817    if svd_result.s.is_empty() {
818        return Ok(0);
819    }
820
821    // Default tolerance: max(m,n) * eps * sigma_max
822    let sigma_max = svd_result.s[0];
823    let eps = T::from_f64(1e-14).unwrap_or(T::zero());
824    let dim_scale = T::from_f64(m.max(n) as f64).unwrap_or(T::one());
825    let tol = dim_scale * eps * sigma_max;
826
827    Ok(svd_result.rank(tol))
828}
829
830// =============================================================================
831// Randomized SVD
832// =============================================================================
833
834/// Result of randomized SVD.
835#[derive(Debug, Clone)]
836pub struct RandomizedSvdResult<T> {
837    /// Left singular vectors U (m × k)
838    pub u: Array2<T>,
839    /// Singular values σ (k elements, sorted descending)
840    pub s: Array1<T>,
841    /// Right singular vectors V (n × k), NOT V^T
842    pub v: Array2<T>,
843}
844
845/// Computes a randomized SVD approximation of a matrix.
846///
847/// Uses randomized projections to compute a rank-k approximation efficiently,
848/// particularly useful for large matrices where only the top singular values
849/// are needed.
850///
851/// # Arguments
852/// * `a` - The input matrix (m×n)
853/// * `k` - Target rank (number of singular values to compute)
854///
855/// # Returns
856/// Truncated SVD with k singular values and vectors
857///
858/// # Algorithm
859/// Uses the Halko-Martinsson-Tropp randomized algorithm:
860/// 1. Generate random test matrix Ω
861/// 2. Compute Y = A × Ω to sample column space
862/// 3. QR factorize Y to get orthonormal basis Q
863/// 4. Project B = Q^T × A
864/// 5. Compute full SVD of B
865/// 6. Recover U = Q × Ũ
866pub fn rsvd_ndarray<T>(a: &Array2<T>, k: usize) -> LapackResult<RandomizedSvdResult<T>>
867where
868    T: Field + Clone + bytemuck::Zeroable + oxiblas_core::scalar::Real,
869{
870    let mat = array2_to_mat(a);
871
872    match svd::RandomizedSvd::compute(mat.as_ref(), k) {
873        Ok(rsvd) => {
874            let u = mat_ref_to_array2(rsvd.u());
875            let s = Array1::from_vec(rsvd.singular_values().to_vec());
876            let v = mat_ref_to_array2(rsvd.v());
877
878            Ok(RandomizedSvdResult { u, s, v })
879        }
880        Err(e) => Err(LapackError::Other(format!("{e:?}"))),
881    }
882}
883
884/// Computes randomized SVD with power iteration for improved accuracy.
885///
886/// Power iteration emphasizes dominant singular values and improves accuracy
887/// for matrices with slowly decaying singular values.
888///
889/// # Arguments
890/// * `a` - The input matrix (m×n)
891/// * `k` - Target rank
892/// * `power_iterations` - Number of power iterations (typically 1-3)
893pub fn rsvd_power_ndarray<T>(
894    a: &Array2<T>,
895    k: usize,
896    power_iterations: usize,
897) -> LapackResult<RandomizedSvdResult<T>>
898where
899    T: Field + Clone + bytemuck::Zeroable + oxiblas_core::scalar::Real,
900{
901    let mat = array2_to_mat(a);
902
903    let config = svd::RandomizedSvdConfig::new(k).with_power_iterations(power_iterations);
904
905    match svd::RandomizedSvd::compute_with_config(mat.as_ref(), config) {
906        Ok(rsvd) => {
907            let u = mat_ref_to_array2(rsvd.u());
908            let s = Array1::from_vec(rsvd.singular_values().to_vec());
909            let v = mat_ref_to_array2(rsvd.v());
910
911            Ok(RandomizedSvdResult { u, s, v })
912        }
913        Err(e) => Err(LapackError::Other(format!("{e:?}"))),
914    }
915}
916
917// =============================================================================
918// Schur Decomposition
919// =============================================================================
920
921/// Result of Schur decomposition.
922#[derive(Debug, Clone)]
923pub struct SchurResult<T> {
924    /// Orthogonal matrix Q (Schur vectors)
925    pub q: Array2<T>,
926    /// Quasi-upper triangular matrix T (Schur form)
927    pub t: Array2<T>,
928    /// Eigenvalues (real and complex pairs)
929    pub eigenvalues: Vec<Eigenvalue<T>>,
930}
931
932/// Computes the real Schur decomposition of a square matrix.
933///
934/// A = Q T Q^T where:
935/// - Q is orthogonal (Q^T Q = I)
936/// - T is quasi-upper triangular (upper triangular with possible 2×2 blocks
937///   on the diagonal for complex eigenvalue pairs)
938///
939/// # Arguments
940/// * `a` - The input square matrix (n×n)
941///
942/// # Returns
943/// Schur decomposition with Q, T, and eigenvalues
944pub fn schur_ndarray<T>(a: &Array2<T>) -> LapackResult<SchurResult<T>>
945where
946    T: Field + Clone + bytemuck::Zeroable + oxiblas_core::scalar::Real,
947{
948    let (m, n) = a.dim();
949    if m != n {
950        return Err(LapackError::DimensionMismatch(
951            "Matrix must be square".to_string(),
952        ));
953    }
954
955    let mat = array2_to_mat(a);
956
957    match evd::Schur::compute(mat.as_ref()) {
958        Ok(schur) => {
959            let q = mat_ref_to_array2(schur.q());
960            let t = mat_ref_to_array2(schur.t());
961            let eigenvalues = schur.eigenvalues().to_vec();
962
963            Ok(SchurResult { q, t, eigenvalues })
964        }
965        Err(e) => Err(LapackError::NotConverged(format!("{e:?}"))),
966    }
967}
968
969// =============================================================================
970// General Eigenvalue Decomposition
971// =============================================================================
972
973/// Result of general eigenvalue decomposition.
974#[derive(Debug, Clone)]
975pub struct GeneralEvdResult<T> {
976    /// Eigenvalues (real and imaginary parts)
977    pub eigenvalues: Vec<Eigenvalue<T>>,
978    /// Right eigenvectors (real parts), if computed
979    pub eigenvectors_real: Option<Array2<T>>,
980    /// Right eigenvectors (imaginary parts), if computed
981    pub eigenvectors_imag: Option<Array2<T>>,
982    /// Left eigenvectors (real parts), if computed
983    pub left_eigenvectors_real: Option<Array2<T>>,
984    /// Left eigenvectors (imaginary parts), if computed
985    pub left_eigenvectors_imag: Option<Array2<T>>,
986}
987
988/// Computes eigenvalues of a general (non-symmetric) matrix.
989///
990/// For a real matrix, eigenvalues may be complex. They are returned as
991/// real/imaginary pairs. Eigenvectors are also split into real and
992/// imaginary parts.
993///
994/// # Arguments
995/// * `a` - The input square matrix (n×n)
996///
997/// # Returns
998/// Eigenvalues and eigenvectors (split into real/imaginary parts)
999pub fn eig_ndarray<T>(a: &Array2<T>) -> LapackResult<GeneralEvdResult<T>>
1000where
1001    T: Field + Clone + bytemuck::Zeroable + oxiblas_core::scalar::Real,
1002{
1003    let (m, n) = a.dim();
1004    if m != n {
1005        return Err(LapackError::DimensionMismatch(
1006            "Matrix must be square".to_string(),
1007        ));
1008    }
1009
1010    let mat = array2_to_mat(a);
1011
1012    match evd::GeneralEvd::compute(mat.as_ref()) {
1013        Ok(evd_result) => {
1014            let eigenvalues = evd_result.eigenvalues().to_vec();
1015
1016            // Get right eigenvectors (real and imaginary parts)
1017            let eigenvectors_real = evd_result
1018                .eigenvectors_real()
1019                .map(|vr| mat_ref_to_array2(vr));
1020
1021            let eigenvectors_imag = evd_result
1022                .eigenvectors_imag()
1023                .map(|vi| mat_ref_to_array2(vi));
1024
1025            // Get left eigenvectors (real and imaginary parts)
1026            let left_eigenvectors_real = evd_result
1027                .left_eigenvectors_real()
1028                .map(|vl| mat_ref_to_array2(vl));
1029
1030            let left_eigenvectors_imag = evd_result
1031                .left_eigenvectors_imag()
1032                .map(|vl| mat_ref_to_array2(vl));
1033
1034            Ok(GeneralEvdResult {
1035                eigenvalues,
1036                eigenvectors_real,
1037                eigenvectors_imag,
1038                left_eigenvectors_real,
1039                left_eigenvectors_imag,
1040            })
1041        }
1042        Err(e) => Err(LapackError::NotConverged(format!("{e:?}"))),
1043    }
1044}
1045
1046/// Computes only the eigenvalues of a general matrix.
1047pub fn eigvals_ndarray<T>(a: &Array2<T>) -> LapackResult<Vec<Eigenvalue<T>>>
1048where
1049    T: Field + Clone + bytemuck::Zeroable + oxiblas_core::scalar::Real,
1050{
1051    let (m, n) = a.dim();
1052    if m != n {
1053        return Err(LapackError::DimensionMismatch(
1054            "Matrix must be square".to_string(),
1055        ));
1056    }
1057
1058    let mat = array2_to_mat(a);
1059
1060    match evd::GeneralEvd::eigenvalues_only(mat.as_ref()) {
1061        Ok(evd_result) => Ok(evd_result.eigenvalues().to_vec()),
1062        Err(e) => Err(LapackError::NotConverged(format!("{e:?}"))),
1063    }
1064}
1065
1066// =============================================================================
1067// Tridiagonal Solvers
1068// =============================================================================
1069
1070/// Solves a tridiagonal system of equations.
1071///
1072/// Solves T x = b where T is a tridiagonal matrix.
1073///
1074/// # Arguments
1075/// * `dl` - Lower diagonal (n-1 elements)
1076/// * `d` - Main diagonal (n elements)
1077/// * `du` - Upper diagonal (n-1 elements)
1078/// * `b` - Right-hand side vector (n elements)
1079///
1080/// # Returns
1081/// The solution vector x
1082pub fn tridiag_solve_ndarray<T>(
1083    dl: &Array1<T>,
1084    d: &Array1<T>,
1085    du: &Array1<T>,
1086    b: &Array1<T>,
1087) -> LapackResult<Array1<T>>
1088where
1089    T: Field + Clone + bytemuck::Zeroable + oxiblas_core::scalar::Real,
1090{
1091    let n = d.len();
1092
1093    // Empty system: guard the `n - 1` arithmetic below, which would otherwise
1094    // underflow (usize) and panic on debug builds. The only consistent inputs
1095    // are empty off-diagonals and an empty right-hand side; the solution is the
1096    // empty vector.
1097    if n == 0 {
1098        if !dl.is_empty() || !du.is_empty() || !b.is_empty() {
1099            return Err(LapackError::DimensionMismatch(
1100                "Tridiagonal dimensions must be consistent".to_string(),
1101            ));
1102        }
1103        return Ok(Array1::from_vec(Vec::new()));
1104    }
1105
1106    if dl.len() != n - 1 || du.len() != n - 1 || b.len() != n {
1107        return Err(LapackError::DimensionMismatch(
1108            "Tridiagonal dimensions must be consistent".to_string(),
1109        ));
1110    }
1111
1112    let dl_vec: Vec<T> = dl.iter().cloned().collect();
1113    let d_vec: Vec<T> = d.iter().cloned().collect();
1114    let du_vec: Vec<T> = du.iter().cloned().collect();
1115    let b_vec: Vec<T> = b.iter().cloned().collect();
1116
1117    match solve::tridiag_solve(&dl_vec, &d_vec, &du_vec, &b_vec) {
1118        Ok(x) => Ok(Array1::from_vec(x)),
1119        Err(e) => Err(LapackError::Singular(format!("{e:?}"))),
1120    }
1121}
1122
1123/// Solves a symmetric positive definite tridiagonal system.
1124///
1125/// Solves T x = b where T is symmetric positive definite and tridiagonal.
1126/// Uses specialized algorithm that's more efficient for SPD matrices.
1127///
1128/// # Arguments
1129/// * `d` - Main diagonal (n elements, positive)
1130/// * `e` - Off-diagonal (n-1 elements)
1131/// * `b` - Right-hand side vector (n elements)
1132///
1133/// # Returns
1134/// The solution vector x
1135pub fn tridiag_solve_spd_ndarray<T>(
1136    d: &Array1<T>,
1137    e: &Array1<T>,
1138    b: &Array1<T>,
1139) -> LapackResult<Array1<T>>
1140where
1141    T: Field + Clone + bytemuck::Zeroable + oxiblas_core::scalar::Real,
1142{
1143    let n = d.len();
1144
1145    // Empty system: guard the `n - 1` arithmetic below, which would otherwise
1146    // underflow (usize) and panic on debug builds. An empty SPD tridiagonal
1147    // system has an empty off-diagonal and right-hand side; its solution is the
1148    // empty vector.
1149    if n == 0 {
1150        if !e.is_empty() || !b.is_empty() {
1151            return Err(LapackError::DimensionMismatch(
1152                "Tridiagonal dimensions must be consistent".to_string(),
1153            ));
1154        }
1155        return Ok(Array1::from_vec(Vec::new()));
1156    }
1157
1158    if e.len() != n - 1 || b.len() != n {
1159        return Err(LapackError::DimensionMismatch(
1160            "Tridiagonal dimensions must be consistent".to_string(),
1161        ));
1162    }
1163
1164    let d_vec: Vec<T> = d.iter().cloned().collect();
1165    let e_vec: Vec<T> = e.iter().cloned().collect();
1166    let b_vec: Vec<T> = b.iter().cloned().collect();
1167
1168    match solve::tridiag_solve_spd(&d_vec, &e_vec, &b_vec) {
1169        Ok(x) => Ok(Array1::from_vec(x)),
1170        Err(e) => Err(LapackError::NotPositiveDefinite(format!("{e:?}"))),
1171    }
1172}
1173
1174/// Solves multiple tridiagonal systems with the same matrix.
1175///
1176/// # Arguments
1177/// * `dl` - Lower diagonal (n-1 elements)
1178/// * `d` - Main diagonal (n elements)
1179/// * `du` - Upper diagonal (n-1 elements)
1180/// * `b` - Right-hand side matrix (n × nrhs)
1181///
1182/// # Returns
1183/// The solution matrix X (n × nrhs)
1184pub fn tridiag_solve_multiple_ndarray<T>(
1185    dl: &Array1<T>,
1186    d: &Array1<T>,
1187    du: &Array1<T>,
1188    b: &Array2<T>,
1189) -> LapackResult<Array2<T>>
1190where
1191    T: Field + Clone + bytemuck::Zeroable + oxiblas_core::scalar::Real,
1192{
1193    let n = d.len();
1194    let (b_rows, b_cols) = b.dim();
1195
1196    // Empty system: guard the `n - 1` arithmetic below, which would otherwise
1197    // underflow (usize) and panic on debug builds. With zero equations the
1198    // solution is an empty (0 × nrhs) matrix.
1199    if n == 0 {
1200        if !dl.is_empty() || !du.is_empty() || b_rows != 0 {
1201            return Err(LapackError::DimensionMismatch(
1202                "Tridiagonal dimensions must be consistent".to_string(),
1203            ));
1204        }
1205        return Ok(Array2::zeros((0, b_cols)));
1206    }
1207
1208    if dl.len() != n - 1 || du.len() != n - 1 || b_rows != n {
1209        return Err(LapackError::DimensionMismatch(
1210            "Tridiagonal dimensions must be consistent".to_string(),
1211        ));
1212    }
1213
1214    let dl_vec: Vec<T> = dl.iter().cloned().collect();
1215    let d_vec: Vec<T> = d.iter().cloned().collect();
1216    let du_vec: Vec<T> = du.iter().cloned().collect();
1217    let b_mat = array2_to_mat(b);
1218
1219    match solve::tridiag_solve_multiple(&dl_vec, &d_vec, &du_vec, b_mat.as_ref()) {
1220        Ok(x_mat) => Ok(mat_to_array2(&x_mat)),
1221        Err(e) => Err(LapackError::Singular(format!("{e:?}"))),
1222    }
1223}
1224
1225// =============================================================================
1226// Low-Rank Approximation
1227// =============================================================================
1228
1229/// Computes a low-rank approximation of a matrix.
1230///
1231/// Returns A_k = U_k Σ_k V_k^T, the best rank-k approximation in Frobenius norm.
1232///
1233/// # Arguments
1234/// * `a` - The input matrix (m×n)
1235/// * `k` - Target rank
1236///
1237/// # Returns
1238/// The rank-k approximation as a matrix
1239pub fn low_rank_approx_ndarray<T>(a: &Array2<T>, k: usize) -> LapackResult<Array2<T>>
1240where
1241    T: Field + Clone + bytemuck::Zeroable + oxiblas_core::scalar::Real,
1242{
1243    let mat = array2_to_mat(a);
1244
1245    match svd::low_rank_approximation(mat.as_ref(), k) {
1246        Ok(approx) => Ok(mat_to_array2(&approx)),
1247        Err(e) => Err(LapackError::Other(format!("{e:?}"))),
1248    }
1249}
1250
1251#[cfg(test)]
1252mod tests {
1253    use super::*;
1254    use ndarray::array;
1255
1256    #[test]
1257    fn test_lu_decomposition() {
1258        let a = array![[2.0f64, 1.0], [1.0, 3.0]];
1259        let lu = lu_ndarray(&a).unwrap();
1260
1261        // Verify L * U ≈ P * A
1262        let n = a.dim().0;
1263        for i in 0..n {
1264            for j in 0..n {
1265                let mut sum = 0.0f64;
1266                for k in 0..n {
1267                    sum += lu.l[[i, k]] * lu.u[[k, j]];
1268                }
1269                let perm_i = lu.perm.iter().position(|&p| p == i).unwrap();
1270                assert!((sum - a[[perm_i, j]]).abs() < 1e-10);
1271            }
1272        }
1273    }
1274
1275    #[test]
1276    fn test_lu_determinant() {
1277        let a = array![[2.0f64, 1.0], [1.0, 3.0]];
1278        let lu = lu_ndarray(&a).unwrap();
1279        let det = lu.det();
1280        // det = 2*3 - 1*1 = 5
1281        assert!((det - 5.0).abs() < 1e-10);
1282    }
1283
1284    #[test]
1285    fn test_lu_solve() {
1286        let a = array![[2.0f64, 1.0], [1.0, 3.0]];
1287        let b = array![5.0f64, 7.0];
1288        let lu = lu_ndarray(&a).unwrap();
1289        let x = lu.solve(&b);
1290
1291        // Verify A * x ≈ b
1292        let ax0 = a[[0, 0]] * x[0] + a[[0, 1]] * x[1];
1293        let ax1 = a[[1, 0]] * x[0] + a[[1, 1]] * x[1];
1294        assert!((ax0 - b[0]).abs() < 1e-10);
1295        assert!((ax1 - b[1]).abs() < 1e-10);
1296    }
1297
1298    /// Regression for the pivot-sequence bug in `LuResult::solve` / `det`.
1299    ///
1300    /// The matrix `[[2,1,1],[4,3,3],[8,7,9]]` forces partial pivoting to perform
1301    /// **two** row interchanges: the pivot sequence is `perm = [2, 2, 2]` (swap
1302    /// rows 0<->2 at step 0, then rows 1<->2 at step 1). This is exactly the
1303    /// case the old code mishandled:
1304    ///
1305    /// * `solve` computed `pb[i] = b[perm[i]]`, i.e. `[b[2], b[2], b[2]]`, so
1306    ///   every entry of the permuted RHS collapsed to `b[2]` — a wildly wrong
1307    ///   solution.
1308    /// * `det` cycle-decomposed `perm = [2,2,2]` into a single 2-cycle and
1309    ///   reported an *odd* number of sign changes, negating the determinant: it
1310    ///   returned `-4` for a matrix whose true determinant is `+4`.
1311    ///
1312    /// Independently verified references (by hand):
1313    ///   det(A) = 2(27-21) - 1(36-24) + 1(28-24) = 12 - 12 + 4 = +4,
1314    ///   and A * [1,2,3]^T = [7, 19, 49]^T.
1315    #[test]
1316    fn test_lu_solve_and_det_with_two_pivot_swaps() {
1317        let a = array![[2.0f64, 1.0, 1.0], [4.0, 3.0, 3.0], [8.0, 7.0, 9.0]];
1318
1319        let lu = lu_ndarray(&a).unwrap();
1320
1321        // The factorization must genuinely require >= 2 interchanges, otherwise
1322        // this test would not exercise the composed-permutation path at all.
1323        let num_swaps = lu
1324            .perm
1325            .iter()
1326            .enumerate()
1327            .filter(|&(k, &pk)| k != pk)
1328            .count();
1329        assert!(
1330            num_swaps >= 2,
1331            "test matrix must force at least two row swaps, got perm = {:?}",
1332            lu.perm
1333        );
1334
1335        // Determinant: true value is +4. The buggy sign logic returned -4.
1336        let det = lu.det();
1337        assert!(
1338            (det - 4.0).abs() < 1e-10,
1339            "det = {det}, expected +4 (sign must be positive for an even swap count)"
1340        );
1341
1342        // Solve A x = b with a known solution x_true = [1, 2, 3].
1343        let x_true = [1.0f64, 2.0, 3.0];
1344        let b = array![7.0f64, 19.0, 49.0];
1345        let x = lu.solve(&b);
1346        for i in 0..3 {
1347            assert!(
1348                (x[i] - x_true[i]).abs() < 1e-10,
1349                "x[{i}] = {}, expected {}",
1350                x[i],
1351                x_true[i]
1352            );
1353        }
1354
1355        // Cross-check by residual: A x must reproduce b.
1356        for i in 0..3 {
1357            let axi = a[[i, 0]] * x[0] + a[[i, 1]] * x[1] + a[[i, 2]] * x[2];
1358            assert!(
1359                (axi - b[i]).abs() < 1e-10,
1360                "residual row {i}: {axi} != {}",
1361                b[i]
1362            );
1363        }
1364    }
1365
1366    /// Empty tridiagonal systems must not panic on the `n - 1` arithmetic and
1367    /// must return empty results (regression for the usize subtract-overflow).
1368    #[test]
1369    fn test_tridiag_empty_inputs_no_overflow() {
1370        let empty1: Array1<f64> = Array1::from_vec(Vec::new());
1371
1372        let x = tridiag_solve_ndarray(&empty1, &empty1, &empty1, &empty1).unwrap();
1373        assert_eq!(x.len(), 0);
1374
1375        let x_spd = tridiag_solve_spd_ndarray(&empty1, &empty1, &empty1).unwrap();
1376        assert_eq!(x_spd.len(), 0);
1377
1378        let b_empty: Array2<f64> = Array2::zeros((0, 3));
1379        let x_multi = tridiag_solve_multiple_ndarray(&empty1, &empty1, &empty1, &b_empty).unwrap();
1380        assert_eq!(x_multi.dim(), (0, 3));
1381    }
1382
1383    #[test]
1384    fn test_qr_decomposition() {
1385        let a = array![[1.0f64, 2.0], [3.0, 4.0], [5.0, 6.0]];
1386        let qr = qr_ndarray(&a).unwrap();
1387
1388        // Q should be orthogonal: Q^T * Q = I
1389        let qt = qr.q.t();
1390        let qtq = crate::blas::matmul(&qt.to_owned(), &qr.q);
1391        for i in 0..qtq.dim().0 {
1392            for j in 0..qtq.dim().1 {
1393                let expected = if i == j { 1.0 } else { 0.0 };
1394                assert!(
1395                    (qtq[[i, j]] - expected).abs() < 1e-10,
1396                    "Q^T Q[{},{}] = {}, expected {}",
1397                    i,
1398                    j,
1399                    qtq[[i, j]],
1400                    expected
1401                );
1402            }
1403        }
1404
1405        // Q * R should equal A
1406        let qr_product = crate::blas::matmul(&qr.q, &qr.r);
1407        for i in 0..a.dim().0 {
1408            for j in 0..a.dim().1 {
1409                assert!(
1410                    (qr_product[[i, j]] - a[[i, j]]).abs() < 1e-10,
1411                    "QR[{},{}] = {}, A = {}",
1412                    i,
1413                    j,
1414                    qr_product[[i, j]],
1415                    a[[i, j]]
1416                );
1417            }
1418        }
1419    }
1420
1421    #[test]
1422    fn test_svd() {
1423        let a = array![[1.0f64, 2.0], [3.0, 4.0], [5.0, 6.0]];
1424        let svd = svd_ndarray(&a).unwrap();
1425
1426        // Reconstruct A from SVD: U * S * V^T
1427        let (m, n) = a.dim();
1428        let k = svd.s.len();
1429
1430        for i in 0..m {
1431            for j in 0..n {
1432                let mut sum = 0.0f64;
1433                for l in 0..k {
1434                    sum += svd.u[[i, l]] * svd.s[l] * svd.vt[[l, j]];
1435                }
1436                assert!(
1437                    (sum - a[[i, j]]).abs() < 1e-10,
1438                    "Reconstructed[{},{}] = {}, A = {}",
1439                    i,
1440                    j,
1441                    sum,
1442                    a[[i, j]]
1443                );
1444            }
1445        }
1446    }
1447
1448    #[test]
1449    fn test_symmetric_evd() {
1450        // Symmetric matrix
1451        let a = array![[4.0f64, 1.0], [1.0, 3.0]];
1452        let evd = eig_symmetric(&a).unwrap();
1453
1454        // Eigenvalues should be real and positive for this matrix
1455        assert!(evd.eigenvalues.len() == 2);
1456
1457        // Verify A * V = V * Λ for each eigenvalue/eigenvector pair
1458        for (idx, &lambda) in evd.eigenvalues.iter().enumerate() {
1459            let v = evd.eigenvectors.column(idx);
1460            let av = crate::blas::matvec(&a, &v.to_owned());
1461            let lambda_v: Array1<f64> = v.iter().map(|&x| lambda * x).collect();
1462
1463            for i in 0..2 {
1464                assert!(
1465                    (av[i] - lambda_v[i]).abs() < 1e-10,
1466                    "Av[{}] = {}, λv[{}] = {}",
1467                    i,
1468                    av[i],
1469                    i,
1470                    lambda_v[i]
1471                );
1472            }
1473        }
1474    }
1475
1476    #[test]
1477    fn test_cholesky() {
1478        // Positive definite matrix
1479        let a = array![[4.0f64, 2.0], [2.0, 5.0]];
1480        let chol = cholesky_ndarray(&a).unwrap();
1481
1482        // Verify L * L^T = A
1483        let lt = chol.l.t();
1484        let llt = crate::blas::matmul(&chol.l, &lt.to_owned());
1485
1486        for i in 0..2 {
1487            for j in 0..2 {
1488                assert!(
1489                    (llt[[i, j]] - a[[i, j]]).abs() < 1e-10,
1490                    "LLT[{},{}] = {}, A = {}",
1491                    i,
1492                    j,
1493                    llt[[i, j]],
1494                    a[[i, j]]
1495                );
1496            }
1497        }
1498    }
1499
1500    #[test]
1501    fn test_solve() {
1502        let a = array![[2.0f64, 1.0], [1.0, 3.0]];
1503        let b = array![5.0f64, 7.0];
1504        let x = solve_ndarray(&a, &b).unwrap();
1505
1506        // Verify A * x = b
1507        let ax = crate::blas::matvec(&a, &x);
1508        assert!((ax[0] - b[0]).abs() < 1e-10);
1509        assert!((ax[1] - b[1]).abs() < 1e-10);
1510    }
1511
1512    #[test]
1513    fn test_inverse() {
1514        let a = array![[4.0f64, 7.0], [2.0, 6.0]];
1515        let a_inv = inv_ndarray(&a).unwrap();
1516
1517        // A * A^-1 = I
1518        let product = crate::blas::matmul(&a, &a_inv);
1519        for i in 0..2 {
1520            for j in 0..2 {
1521                let expected = if i == j { 1.0 } else { 0.0 };
1522                assert!(
1523                    (product[[i, j]] - expected).abs() < 1e-10,
1524                    "A*A^-1[{},{}] = {}, expected {}",
1525                    i,
1526                    j,
1527                    product[[i, j]],
1528                    expected
1529                );
1530            }
1531        }
1532    }
1533
1534    #[test]
1535    fn test_determinant() {
1536        let a = array![[2.0f64, 1.0], [1.0, 3.0]];
1537        let det = det_ndarray(&a).unwrap();
1538        // det = 2*3 - 1*1 = 5
1539        assert!((det - 5.0).abs() < 1e-10);
1540    }
1541
1542    #[test]
1543    fn test_condition_number() {
1544        let a = array![[1.0f64, 0.0], [0.0, 1.0]];
1545        let cond = cond_ndarray(&a).unwrap();
1546        // Identity matrix has condition number 1
1547        assert!((cond - 1.0).abs() < 1e-10);
1548    }
1549
1550    #[test]
1551    fn test_rank() {
1552        // Full rank matrix
1553        let a = array![[1.0f64, 2.0], [3.0, 4.0]];
1554        let r = rank_ndarray(&a).unwrap();
1555        assert_eq!(r, 2);
1556
1557        // Rank deficient matrix
1558        let b = array![[1.0f64, 2.0], [2.0, 4.0]];
1559        let r2 = rank_ndarray(&b).unwrap();
1560        assert_eq!(r2, 1);
1561    }
1562
1563    // =========================================================================
1564    // Randomized SVD Tests
1565    // =========================================================================
1566
1567    #[test]
1568    fn test_rsvd_basic() {
1569        // Create a low-rank matrix
1570        let a = array![
1571            [1.0f64, 2.0, 3.0, 4.0],
1572            [5.0, 6.0, 7.0, 8.0],
1573            [9.0, 10.0, 11.0, 12.0]
1574        ];
1575
1576        let rsvd = rsvd_ndarray(&a, 2).unwrap();
1577
1578        // Should have 2 singular values
1579        assert_eq!(rsvd.s.len(), 2);
1580
1581        // Singular values should be positive and in descending order
1582        assert!(rsvd.s[0] > rsvd.s[1]);
1583        assert!(rsvd.s[1] >= 0.0);
1584
1585        // U should be m×k
1586        assert_eq!(rsvd.u.dim(), (3, 2));
1587
1588        // V should be n×k
1589        assert_eq!(rsvd.v.dim(), (4, 2));
1590    }
1591
1592    #[test]
1593    fn test_rsvd_approximation_quality() {
1594        // Create a matrix with clear rank structure
1595        let a = Array2::from_shape_fn((10, 8), |(i, j)| (i as f64) * 0.1 + (j as f64) * 0.2);
1596
1597        let rsvd = rsvd_ndarray(&a, 2).unwrap();
1598
1599        // Reconstruct: A ≈ U * S * V^T
1600        let (m, n) = a.dim();
1601        let k = rsvd.s.len();
1602
1603        let mut approx: Array2<f64> = Array2::zeros((m, n));
1604        for i in 0..m {
1605            for j in 0..n {
1606                for l in 0..k {
1607                    approx[[i, j]] += rsvd.u[[i, l]] * rsvd.s[l] * rsvd.v[[j, l]];
1608                }
1609            }
1610        }
1611
1612        // The approximation should capture most of the matrix (rank-1 for this matrix)
1613        let mut diff_norm = 0.0f64;
1614        for i in 0..m {
1615            for j in 0..n {
1616                let diff = a[[i, j]] - approx[[i, j]];
1617                diff_norm += diff.powi(2);
1618            }
1619        }
1620        diff_norm = diff_norm.sqrt();
1621
1622        // Should be reasonably small
1623        assert!(diff_norm < 1e-10, "Reconstruction error = {}", diff_norm);
1624    }
1625
1626    #[test]
1627    fn test_rsvd_power_iteration() {
1628        let a = Array2::from_shape_fn((20, 15), |(i, j)| ((i * j) as f64).sin() + 0.1 * (i as f64));
1629
1630        let rsvd = rsvd_power_ndarray(&a, 3, 2).unwrap();
1631
1632        assert_eq!(rsvd.s.len(), 3);
1633        assert!(rsvd.s[0] >= rsvd.s[1]);
1634        assert!(rsvd.s[1] >= rsvd.s[2]);
1635    }
1636
1637    // =========================================================================
1638    // Schur Decomposition Tests
1639    // =========================================================================
1640
1641    #[test]
1642    fn test_schur_triangular() {
1643        // Already upper triangular matrix
1644        let a = array![[1.0f64, 2.0], [0.0, 3.0]];
1645
1646        let schur = schur_ndarray(&a).unwrap();
1647
1648        // Eigenvalues should be 1 and 3
1649        assert_eq!(schur.eigenvalues.len(), 2);
1650
1651        let evs: Vec<f64> = schur.eigenvalues.iter().map(|e| e.real).collect();
1652        assert!(evs.contains(&1.0) || evs.iter().any(|&x| (x - 1.0).abs() < 1e-10));
1653        assert!(evs.contains(&3.0) || evs.iter().any(|&x| (x - 3.0).abs() < 1e-10));
1654    }
1655
1656    #[test]
1657    fn test_schur_reconstruction() {
1658        let a = array![[4.0f64, 1.0], [2.0, 3.0]];
1659
1660        let schur = schur_ndarray(&a).unwrap();
1661
1662        // Verify A = Q * T * Q^T
1663        let qt = schur.q.t();
1664        let qt_owned = qt.to_owned();
1665        let qr_temp = crate::blas::matmul(&schur.q, &schur.t);
1666        let reconstructed = crate::blas::matmul(&qr_temp, &qt_owned);
1667
1668        for i in 0..2 {
1669            for j in 0..2 {
1670                assert!(
1671                    (reconstructed[[i, j]] - a[[i, j]]).abs() < 1e-10,
1672                    "Reconstruction failed at [{},{}]: {} vs {}",
1673                    i,
1674                    j,
1675                    reconstructed[[i, j]],
1676                    a[[i, j]]
1677                );
1678            }
1679        }
1680    }
1681
1682    #[test]
1683    fn test_schur_orthogonality() {
1684        let a = array![[1.0f64, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 10.0]];
1685
1686        let schur = schur_ndarray(&a).unwrap();
1687
1688        // Q should be orthogonal: Q^T * Q = I
1689        let qt = schur.q.t();
1690        let qtq = crate::blas::matmul(&qt.to_owned(), &schur.q);
1691
1692        for i in 0..3 {
1693            for j in 0..3 {
1694                let expected = if i == j { 1.0 } else { 0.0 };
1695                assert!(
1696                    (qtq[[i, j]] - expected).abs() < 1e-10,
1697                    "Q^T Q[{},{}] = {}, expected {}",
1698                    i,
1699                    j,
1700                    qtq[[i, j]],
1701                    expected
1702                );
1703            }
1704        }
1705    }
1706
1707    // =========================================================================
1708    // General Eigenvalue Decomposition Tests
1709    // =========================================================================
1710
1711    #[test]
1712    fn test_eig_real_eigenvalues() {
1713        // Symmetric matrix has real eigenvalues
1714        let a = array![[4.0f64, 1.0], [1.0, 3.0]];
1715
1716        let evd = eig_ndarray(&a).unwrap();
1717
1718        assert_eq!(evd.eigenvalues.len(), 2);
1719
1720        // All eigenvalues should be real (imaginary part ≈ 0)
1721        for ev in &evd.eigenvalues {
1722            assert!(
1723                ev.imag.abs() < 1e-10,
1724                "Expected real eigenvalue, got imag = {}",
1725                ev.imag
1726            );
1727        }
1728    }
1729
1730    #[test]
1731    fn test_eig_complex_eigenvalues() {
1732        // Rotation matrix has complex eigenvalues (±i)
1733        let a = array![[0.0f64, -1.0], [1.0, 0.0]];
1734
1735        let evd = eig_ndarray(&a).unwrap();
1736
1737        assert_eq!(evd.eigenvalues.len(), 2);
1738
1739        // Should have eigenvalues with nonzero imaginary parts
1740        let has_complex = evd.eigenvalues.iter().any(|e| e.imag.abs() > 0.5);
1741        assert!(has_complex, "Expected complex eigenvalues");
1742
1743        // Real parts should be close to 0
1744        for ev in &evd.eigenvalues {
1745            assert!(ev.real.abs() < 1e-10, "Expected real part ≈ 0");
1746        }
1747    }
1748
1749    #[test]
1750    fn test_eigvals_only() {
1751        let a = array![[1.0f64, 2.0], [0.0, 3.0]];
1752
1753        let evs = eigvals_ndarray(&a).unwrap();
1754
1755        assert_eq!(evs.len(), 2);
1756
1757        // Eigenvalues of upper triangular matrix are diagonal elements
1758        let reals: Vec<f64> = evs.iter().map(|e| e.real).collect();
1759        assert!(reals.iter().any(|&x| (x - 1.0).abs() < 1e-10));
1760        assert!(reals.iter().any(|&x| (x - 3.0).abs() < 1e-10));
1761    }
1762
1763    // =========================================================================
1764    // Tridiagonal Solver Tests
1765    // =========================================================================
1766
1767    #[test]
1768    fn test_tridiag_solve() {
1769        // Tridiagonal matrix:
1770        // [2  -1  0 ]   [x0]   [1]
1771        // [-1  2 -1 ] * [x1] = [0]
1772        // [0  -1  2 ]   [x2]   [1]
1773        let dl = array![-1.0f64, -1.0];
1774        let d = array![2.0f64, 2.0, 2.0];
1775        let du = array![-1.0f64, -1.0];
1776        let b = array![1.0f64, 0.0, 1.0];
1777
1778        let x = tridiag_solve_ndarray(&dl, &d, &du, &b).unwrap();
1779
1780        assert_eq!(x.len(), 3);
1781
1782        // Verify solution: T * x ≈ b
1783        let tx0 = d[0] * x[0] + du[0] * x[1];
1784        let tx1 = dl[0] * x[0] + d[1] * x[1] + du[1] * x[2];
1785        let tx2 = dl[1] * x[1] + d[2] * x[2];
1786
1787        assert!((tx0 - b[0]).abs() < 1e-10);
1788        assert!((tx1 - b[1]).abs() < 1e-10);
1789        assert!((tx2 - b[2]).abs() < 1e-10);
1790    }
1791
1792    #[test]
1793    fn test_tridiag_solve_spd() {
1794        // SPD tridiagonal matrix:
1795        // [4 1 0]
1796        // [1 4 1]
1797        // [0 1 4]
1798        // This is diagonally dominant -> SPD
1799        let d = array![4.0f64, 4.0, 4.0];
1800        let e = array![1.0f64, 1.0]; // Off-diagonal elements
1801        let b = array![5.0f64, 6.0, 5.0];
1802
1803        let x = tridiag_solve_spd_ndarray(&d, &e, &b).unwrap();
1804
1805        assert_eq!(x.len(), 3);
1806
1807        // Verify solution: T * x = b where T is symmetric with d on diagonal, e on off-diagonals
1808        let tx0 = d[0] * x[0] + e[0] * x[1];
1809        let tx1 = e[0] * x[0] + d[1] * x[1] + e[1] * x[2];
1810        let tx2 = e[1] * x[1] + d[2] * x[2];
1811
1812        assert!((tx0 - b[0]).abs() < 1e-10, "tx0 = {}, b[0] = {}", tx0, b[0]);
1813        assert!((tx1 - b[1]).abs() < 1e-10, "tx1 = {}, b[1] = {}", tx1, b[1]);
1814        assert!((tx2 - b[2]).abs() < 1e-10, "tx2 = {}, b[2] = {}", tx2, b[2]);
1815    }
1816
1817    #[test]
1818    fn test_tridiag_solve_multiple() {
1819        let dl = array![-1.0f64, -1.0];
1820        let d = array![2.0f64, 2.0, 2.0];
1821        let du = array![-1.0f64, -1.0];
1822        let b = array![[1.0f64, 0.0], [0.0, 1.0], [1.0, 0.0]];
1823
1824        let x = tridiag_solve_multiple_ndarray(&dl, &d, &du, &b).unwrap();
1825
1826        assert_eq!(x.dim(), (3, 2));
1827
1828        // Each column should be the solution to T * x_j = b_j
1829        for j in 0..2 {
1830            let tx0 = d[0] * x[[0, j]] + du[0] * x[[1, j]];
1831            let tx1 = dl[0] * x[[0, j]] + d[1] * x[[1, j]] + du[1] * x[[2, j]];
1832            let tx2 = dl[1] * x[[1, j]] + d[2] * x[[2, j]];
1833
1834            assert!((tx0 - b[[0, j]]).abs() < 1e-10);
1835            assert!((tx1 - b[[1, j]]).abs() < 1e-10);
1836            assert!((tx2 - b[[2, j]]).abs() < 1e-10);
1837        }
1838    }
1839
1840    // =========================================================================
1841    // Low-Rank Approximation Tests
1842    // =========================================================================
1843
1844    #[test]
1845    fn test_low_rank_approx() {
1846        // Create a rank-1 matrix: outer product of two vectors
1847        let u = array![1.0f64, 2.0, 3.0];
1848        let v = array![4.0, 5.0, 6.0, 7.0];
1849
1850        let mut a = Array2::zeros((3, 4));
1851        for i in 0..3 {
1852            for j in 0..4 {
1853                a[[i, j]] = u[i] * v[j];
1854            }
1855        }
1856
1857        // Rank-1 approximation should be exact
1858        let approx = low_rank_approx_ndarray(&a, 1).unwrap();
1859
1860        assert_eq!(approx.dim(), a.dim());
1861
1862        for i in 0..3 {
1863            for j in 0..4 {
1864                assert!(
1865                    (approx[[i, j]] - a[[i, j]]).abs() < 1e-10,
1866                    "Approximation failed at [{},{}]",
1867                    i,
1868                    j
1869                );
1870            }
1871        }
1872    }
1873
1874    #[test]
1875    fn test_low_rank_approx_truncation() {
1876        let a = array![
1877            [1.0f64, 2.0, 3.0],
1878            [4.0, 5.0, 6.0],
1879            [7.0, 8.0, 9.0],
1880            [10.0, 11.0, 12.0]
1881        ];
1882
1883        let approx = low_rank_approx_ndarray(&a, 2).unwrap();
1884
1885        assert_eq!(approx.dim(), (4, 3));
1886
1887        // The approximation should not equal the original (rank 2 < rank A)
1888        // but should be close
1889        let mut diff_norm = 0.0f64;
1890        let mut orig_norm = 0.0f64;
1891        for i in 0..4 {
1892            for j in 0..3 {
1893                diff_norm += (a[[i, j]] - approx[[i, j]]).powi(2);
1894                orig_norm += a[[i, j]].powi(2);
1895            }
1896        }
1897
1898        // Relative error should be small (this matrix has rank 2)
1899        let rel_error = diff_norm.sqrt() / orig_norm.sqrt();
1900        assert!(rel_error < 0.1, "Relative error = {}", rel_error);
1901    }
1902}