Skip to main content

oxiblas_lapack/evd/
schur.rs

1//! Schur decomposition.
2//!
3//! Computes the Schur decomposition A = Q T Q^T where Q is orthogonal
4//! and T is quasi-upper triangular (upper triangular with possible 2×2 blocks
5//! on the diagonal for complex eigenvalue pairs).
6//!
7//! `T` is returned in *standardized* real Schur form (as LAPACK `DHSEQR`):
8//! every entry below the first subdiagonal is zero, a subdiagonal entry is
9//! non-zero exactly where a 2×2 diagonal block starts, and every 2×2 block
10//! holds a complex-conjugate pair with equal diagonal entries and off-diagonal
11//! entries of opposite sign. The eigenvalues are listed in the order of the
12//! diagonal of `T`, a complex pair with its positive imaginary part first.
13
14mod qr;
15mod trevc;
16
17use num_traits::Float;
18use oxiblas_core::scalar::{Field, Real, Scalar};
19use oxiblas_matrix::{Mat, MatRef};
20
21use super::hessenberg::Hessenberg;
22
23pub(crate) use qr::{big_number, scale_by_pow2, scaling_exponent, small_number};
24pub(crate) use trevc::{ladiv, left_eigenvectors_raw, right_eigenvectors_raw, starts_block};
25pub use trevc::{trevc_left, trevc_right};
26
27/// Error type for Schur decomposition.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum SchurError {
30    /// Matrix is empty.
31    EmptyMatrix,
32    /// Matrix is not square.
33    NotSquare,
34    /// Algorithm did not converge.
35    NotConverged,
36}
37
38impl core::fmt::Display for SchurError {
39    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
40        match self {
41            Self::EmptyMatrix => write!(f, "Matrix is empty"),
42            Self::NotSquare => write!(f, "Matrix must be square"),
43            Self::NotConverged => write!(f, "Schur decomposition did not converge"),
44        }
45    }
46}
47
48impl std::error::Error for SchurError {}
49
50/// Represents a real or complex eigenvalue.
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub struct Eigenvalue<T> {
53    /// Real part of the eigenvalue.
54    pub real: T,
55    /// Imaginary part of the eigenvalue (zero for real eigenvalues).
56    pub imag: T,
57}
58
59impl<T: Scalar> Eigenvalue<T> {
60    /// Creates a real eigenvalue.
61    pub fn real_only(value: T) -> Self {
62        Self {
63            real: value,
64            imag: T::zero(),
65        }
66    }
67
68    /// Creates a complex eigenvalue.
69    pub fn complex(real: T, imag: T) -> Self {
70        Self { real, imag }
71    }
72
73    /// Returns true if this is a real eigenvalue.
74    pub fn is_real(&self) -> bool {
75        self.imag == T::zero()
76    }
77}
78
79/// Schur decomposition of a matrix.
80///
81/// For a matrix A, computes A = Q T Q^T where:
82/// - Q is orthogonal (Q^T Q = I)
83/// - T is quasi-upper triangular (real Schur form) in standardized form: every
84///   entry below the first subdiagonal is zero, a subdiagonal entry is
85///   non-zero exactly where a 2×2 diagonal block starts, and every 2×2 block
86///   holds a complex-conjugate pair `a ± i·sqrt(|b·c|)` as `[a b; c a]` with
87///   `b·c < 0` (as LAPACK `DHSEQR`)
88///
89/// `eigenvalues()[k]` is the eigenvalue at diagonal position `k` of `T`; a
90/// complex pair is listed with its positive imaginary part first.
91#[derive(Debug, Clone)]
92pub struct Schur<T: Scalar> {
93    /// The orthogonal matrix Q (Schur vectors).
94    q: Mat<T>,
95    /// The quasi-upper triangular matrix T.
96    t: Mat<T>,
97    /// Eigenvalues (real and complex pairs).
98    eigenvalues: Vec<Eigenvalue<T>>,
99    /// Matrix dimension.
100    n: usize,
101}
102
103impl<T: Field + Real + bytemuck::Zeroable> Schur<T> {
104    /// Maximum iterations for QR iteration.
105    const MAX_ITERATIONS: usize = 100;
106
107    /// Computes the Schur decomposition of a square matrix.
108    ///
109    /// # Example
110    ///
111    /// ```
112    /// use oxiblas_lapack::evd::Schur;
113    /// use oxiblas_matrix::Mat;
114    ///
115    /// let a = Mat::from_rows(&[
116    ///     &[1.0f64, 2.0],
117    ///     &[0.0, 3.0],
118    /// ]);
119    ///
120    /// let schur = Schur::compute(a.as_ref()).unwrap();
121    /// let eigenvalues = schur.eigenvalues();
122    ///
123    /// // Eigenvalues are 1 and 3, listed in the order of T's diagonal
124    /// let t = schur.t();
125    /// assert_eq!(eigenvalues[0].real, t[(0, 0)]);
126    /// assert!((eigenvalues[0].real - 1.0).abs() < 1e-10 || (eigenvalues[0].real - 3.0).abs() < 1e-10);
127    /// ```
128    pub fn compute(a: MatRef<'_, T>) -> Result<Self, SchurError> {
129        let m = a.nrows();
130        let n = a.ncols();
131
132        if m == 0 || n == 0 {
133            return Err(SchurError::EmptyMatrix);
134        }
135
136        if m != n {
137            return Err(SchurError::NotSquare);
138        }
139
140        // The iteration budget scales with n — `MAX_ITERATIONS` sweeps per row
141        // on average (`saturating_mul` guards the pathological huge-`n` case
142        // against usize overflow).
143        Self::compute_with_budget(a, Self::MAX_ITERATIONS.saturating_mul(n))
144    }
145
146    /// Reduces the square, non-empty `a` to standardized real Schur form:
147    /// exact power-of-two scaling into a safe range, Hessenberg reduction
148    /// (`n ≥ 3`), and the Francis double-shift QR iteration with deflation,
149    /// exceptional shifts and 2×2 block standardization (LAPACK `DLAHQR`).
150    ///
151    /// `max_total_iterations` bounds the *total* number of QR sweeps across all
152    /// deflation blocks. If that budget is exhausted while an unreduced block
153    /// of order `>= 3` remains, this returns [`SchurError::NotConverged`]
154    /// instead of silently handing back a partially reduced `T`/`Q` as if the
155    /// decomposition had succeeded — matching the `INFO > 0` failure contract
156    /// of LAPACK's DHSEQR. 1×1 and 2×2 blocks never need a sweep.
157    fn compute_with_budget(
158        a: MatRef<'_, T>,
159        max_total_iterations: usize,
160    ) -> Result<Self, SchurError> {
161        let n = a.nrows();
162
163        // Scale by an exact power of two so that max|a_ij| ∈ [1, 2): every
164        // threshold below then acts relative to the matrix norm, and the
165        // intermediate quantities can neither overflow nor underflow early.
166        let mut anrm = T::zero();
167        for j in 0..n {
168            for i in 0..n {
169                anrm = Float::max(anrm, Scalar::abs(a[(i, j)]));
170            }
171        }
172        let exponent = scaling_exponent(anrm);
173        let mut scaled = Mat::zeros(n, n);
174        for j in 0..n {
175            for i in 0..n {
176                scaled[(i, j)] = scale_by_pow2(a[(i, j)], exponent);
177            }
178        }
179
180        let (mut t, mut q) = if n >= 3 {
181            let hess = Hessenberg::compute(scaled.as_ref()).map_err(|_| SchurError::NotSquare)?;
182            let mut t = Mat::zeros(n, n);
183            let mut q = Mat::zeros(n, n);
184            let (h, qh) = (hess.h(), hess.q());
185            for j in 0..n {
186                for i in 0..n {
187                    t[(i, j)] = h[(i, j)];
188                    q[(i, j)] = qh[(i, j)];
189                }
190            }
191            (t, q)
192        } else {
193            let mut q = Mat::zeros(n, n);
194            for i in 0..n {
195                q[(i, i)] = T::one();
196            }
197            (scaled, q)
198        };
199
200        qr::hessenberg_qr(&mut t, &mut q, max_total_iterations)?;
201
202        if exponent != 0 {
203            for j in 0..n {
204                for i in 0..n {
205                    t[(i, j)] = scale_by_pow2(t[(i, j)], -exponent);
206                }
207            }
208        }
209
210        let eigenvalues = Self::extract_eigenvalues(&t);
211
212        Ok(Self {
213            q,
214            t,
215            eigenvalues,
216            n,
217        })
218    }
219
220    /// Test-only hook: runs the Schur decomposition with a custom QR iteration
221    /// budget so the [`SchurError::NotConverged`] path can be exercised
222    /// deterministically (a genuine slow-converging matrix would otherwise need to
223    /// be enormous to defeat the default `MAX_ITERATIONS * n` budget).
224    #[cfg(test)]
225    pub(crate) fn compute_with_iteration_budget(
226        a: MatRef<'_, T>,
227        max_total_iterations: usize,
228    ) -> Result<Self, SchurError> {
229        let m = a.nrows();
230        let n = a.ncols();
231
232        if m == 0 || n == 0 {
233            return Err(SchurError::EmptyMatrix);
234        }
235        if m != n {
236            return Err(SchurError::NotSquare);
237        }
238        // Tiny cases deflate without any sweep; the budget is irrelevant there.
239        Self::compute_with_budget(a, max_total_iterations)
240    }
241
242    /// Reads the eigenvalues off a standardized quasi-triangular `t`, in the
243    /// order of its diagonal: `t[k, k]` for a 1×1 block, and
244    /// `t[k, k] ± i·sqrt(|t[k, k+1]|)·sqrt(|t[k+1, k]|)` (positive imaginary
245    /// part first) for a 2×2 block at `(k, k + 1)`, exactly as LAPACK `DLANV2`
246    /// reports them.
247    fn extract_eigenvalues(t: &Mat<T>) -> Vec<Eigenvalue<T>> {
248        let n = t.nrows();
249        let mut eigenvalues = Vec::with_capacity(n);
250        let mut k = 0;
251        while k < n {
252            if starts_block(t, k) {
253                let re = t[(k, k)];
254                let im =
255                    Real::sqrt(Scalar::abs(t[(k, k + 1)])) * Real::sqrt(Scalar::abs(t[(k + 1, k)]));
256                eigenvalues.push(Eigenvalue::complex(re, im));
257                eigenvalues.push(Eigenvalue::complex(re, -im));
258                k += 2;
259            } else {
260                eigenvalues.push(Eigenvalue::real_only(t[(k, k)]));
261                k += 1;
262            }
263        }
264        eigenvalues
265    }
266
267    /// Returns the orthogonal matrix Q (Schur vectors).
268    pub fn q(&self) -> MatRef<'_, T> {
269        self.q.as_ref()
270    }
271
272    /// The Schur vectors as an owned-matrix reference (crate-internal).
273    pub(crate) fn q_mat(&self) -> &Mat<T> {
274        &self.q
275    }
276
277    /// The standardized Schur factor as an owned-matrix reference
278    /// (crate-internal).
279    pub(crate) fn t_mat(&self) -> &Mat<T> {
280        &self.t
281    }
282
283    /// Returns the quasi-upper triangular matrix T (Schur form).
284    pub fn t(&self) -> MatRef<'_, T> {
285        self.t.as_ref()
286    }
287
288    /// Returns the eigenvalues.
289    pub fn eigenvalues(&self) -> &[Eigenvalue<T>] {
290        &self.eigenvalues
291    }
292
293    /// Returns only the real parts of eigenvalues.
294    pub fn eigenvalues_real(&self) -> Vec<T> {
295        self.eigenvalues.iter().map(|e| e.real).collect()
296    }
297
298    /// Reconstructs the original matrix: A = Q T Q^T.
299    pub fn reconstruct(&self) -> Mat<T> {
300        let mut qt = Mat::zeros(self.n, self.n);
301        let mut a = Mat::zeros(self.n, self.n);
302
303        // QT = Q * T
304        for i in 0..self.n {
305            for j in 0..self.n {
306                let mut sum = T::zero();
307                for k in 0..self.n {
308                    sum = sum + self.q[(i, k)] * self.t[(k, j)];
309                }
310                qt[(i, j)] = sum;
311            }
312        }
313
314        // A = QT * Q^T
315        for i in 0..self.n {
316            for j in 0..self.n {
317                let mut sum = T::zero();
318                for k in 0..self.n {
319                    sum = sum + qt[(i, k)] * self.q[(j, k)];
320                }
321                a[(i, j)] = sum;
322            }
323        }
324
325        a
326    }
327
328    /// Computes right eigenvectors from the Schur form (LAPACK TREVC).
329    ///
330    /// Returns the right eigenvectors V such that T V = V D where D is the
331    /// diagonal matrix of eigenvalues. The eigenvectors are normalized.
332    ///
333    /// For real eigenvalues, returns real eigenvectors.
334    /// For complex conjugate pairs, returns two columns: the real and imaginary
335    /// parts of the eigenvector.
336    ///
337    /// # Example
338    ///
339    /// ```
340    /// use oxiblas_lapack::evd::Schur;
341    /// use oxiblas_matrix::Mat;
342    ///
343    /// let a = Mat::from_rows(&[
344    ///     &[1.0f64, 2.0, 3.0],
345    ///     &[0.0, 4.0, 5.0],
346    ///     &[0.0, 0.0, 6.0],
347    /// ]);
348    ///
349    /// let schur = Schur::compute(a.as_ref()).unwrap();
350    /// let vr = schur.right_eigenvectors();
351    /// // Each column of vr is a right eigenvector
352    /// ```
353    #[must_use]
354    pub fn right_eigenvectors(&self) -> Mat<T> {
355        trevc_right(&self.t)
356    }
357
358    /// Computes left eigenvectors from the Schur form (LAPACK TREVC).
359    ///
360    /// Returns the left eigenvectors U such that U^T T = D U^T where D is the
361    /// diagonal matrix of eigenvalues. The eigenvectors are normalized.
362    ///
363    /// # Example
364    ///
365    /// ```
366    /// use oxiblas_lapack::evd::Schur;
367    /// use oxiblas_matrix::Mat;
368    ///
369    /// let a = Mat::from_rows(&[
370    ///     &[1.0f64, 2.0, 3.0],
371    ///     &[0.0, 4.0, 5.0],
372    ///     &[0.0, 0.0, 6.0],
373    /// ]);
374    ///
375    /// let schur = Schur::compute(a.as_ref()).unwrap();
376    /// let vl = schur.left_eigenvectors();
377    /// ```
378    #[must_use]
379    pub fn left_eigenvectors(&self) -> Mat<T> {
380        trevc_left(&self.t)
381    }
382
383    /// Computes eigenvectors of the original matrix A = Q T Q^T.
384    ///
385    /// The eigenvectors of A are Q * V where V are the eigenvectors of T.
386    ///
387    /// # Returns
388    ///
389    /// (right_eigenvectors, left_eigenvectors) of A.
390    #[must_use]
391    pub fn eigenvectors(&self) -> (Mat<T>, Mat<T>) {
392        let vr_t = trevc_right(&self.t);
393        let vl_t = trevc_left(&self.t);
394
395        // Transform to eigenvectors of A: A_vr = Q * T_vr, A_vl = Q * T_vl
396        let mut vr_a = Mat::zeros(self.n, self.n);
397        let mut vl_a = Mat::zeros(self.n, self.n);
398
399        for i in 0..self.n {
400            for j in 0..self.n {
401                let mut sum_r = T::zero();
402                let mut sum_l = T::zero();
403                for k in 0..self.n {
404                    sum_r = sum_r + self.q[(i, k)] * vr_t[(k, j)];
405                    sum_l = sum_l + self.q[(i, k)] * vl_t[(k, j)];
406                }
407                vr_a[(i, j)] = sum_r;
408                vl_a[(i, j)] = sum_l;
409            }
410        }
411
412        (vr_a, vl_a)
413    }
414
415    /// Computes reciprocal condition numbers for eigenvalues (LAPACK DTRSNA).
416    ///
417    /// For each eigenvalue λ, the reciprocal condition number s measures how
418    /// sensitive λ is to perturbations in the matrix. A small value indicates
419    /// a poorly conditioned eigenvalue.
420    ///
421    /// The condition number is computed as:
422    /// - For simple eigenvalues: s = 1 / |y^H x| where x is the right eigenvector
423    ///   and y is the left eigenvector, both normalized to unit length.
424    /// - For complex conjugate pairs: uses the average of the pair.
425    ///
426    /// # Returns
427    ///
428    /// Vector of reciprocal condition numbers, one per eigenvalue.
429    /// Smaller values indicate more sensitive eigenvalues.
430    ///
431    /// # Example
432    ///
433    /// ```
434    /// use oxiblas_lapack::evd::Schur;
435    /// use oxiblas_matrix::Mat;
436    ///
437    /// let a = Mat::from_rows(&[
438    ///     &[1.0f64, 0.0],
439    ///     &[0.0, 1000.0],
440    /// ]);
441    ///
442    /// let schur = Schur::compute(a.as_ref()).unwrap();
443    /// let cond = schur.eigenvalue_condition_numbers();
444    /// // Both eigenvalues of a diagonal matrix are well-conditioned
445    /// assert!(cond[0] > 0.9);
446    /// assert!(cond[1] > 0.9);
447    /// ```
448    #[must_use]
449    pub fn eigenvalue_condition_numbers(&self) -> Vec<T> {
450        trsna_s(&self.t)
451    }
452
453    /// Computes reciprocal condition numbers for eigenvectors (LAPACK DTRSNA).
454    ///
455    /// For each right eigenvector x_j, computes the separation sep_j which
456    /// measures how close the eigenvalue is to the rest of the spectrum.
457    ///
458    /// # Returns
459    ///
460    /// Vector of separation values, one per eigenvalue.
461    /// Smaller values indicate more sensitive eigenvectors.
462    #[must_use]
463    pub fn eigenvector_separation(&self) -> Vec<T> {
464        trsna_sep(&self.t)
465    }
466}
467
468/// Returns the standardized form of `t` (see [`trevc_right`]); `t` itself when
469/// it is already standardized.
470fn standardized_view<T: Field + Real + bytemuck::Zeroable>(
471    t: &Mat<T>,
472) -> std::borrow::Cow<'_, Mat<T>> {
473    match trevc::standardize(t) {
474        Some((ts, _)) => std::borrow::Cow::Owned(ts),
475        None => std::borrow::Cow::Borrowed(t),
476    }
477}
478
479/// Eigenvalue `(re, im)` of the diagonal block of a standardized `t` that
480/// starts at `k` (the positive-imaginary member for a 2×2 block).
481fn block_eigenvalue<T: Field + Real>(t: &Mat<T>, k: usize) -> (T, T) {
482    if starts_block(t, k) {
483        let im = Real::sqrt(Scalar::abs(t[(k, k + 1)])) * Real::sqrt(Scalar::abs(t[(k + 1, k)]));
484        (t[(k, k)], im)
485    } else {
486        (t[(k, k)], T::zero())
487    }
488}
489
490/// Computes reciprocal condition numbers for eigenvalues (s values from LAPACK DTRSNA).
491///
492/// For each simple eigenvalue λ_j, s_j = |y_j^H * x_j| where x_j is the right
493/// eigenvector and y_j is the left eigenvector, both normalized.
494///
495/// # Arguments
496///
497/// * `t` - The quasi-upper triangular Schur matrix (see [`trevc_right`] for
498///   the accepted forms; a non-standardized 2×2 block is standardized first,
499///   which leaves the condition numbers unchanged)
500///
501/// # Returns
502///
503/// Vector of reciprocal condition numbers for each eigenvalue.
504pub fn trsna_s<T: Field + Real + bytemuck::Zeroable>(t: &Mat<T>) -> Vec<T> {
505    let n = t.nrows();
506    if n == 0 {
507        return Vec::new();
508    }
509
510    let t = standardized_view(t);
511    let t = t.as_ref();
512    let vr = trevc_right(t);
513    let vl = trevc_left(t);
514
515    let mut s = vec![T::zero(); n];
516
517    let mut j = 0;
518    while j < n {
519        if starts_block(t, j) {
520            // Complex conjugate pair: compute s = |y^H * x| using both columns
521            // For complex eigenvector stored as (real, imag) in consecutive columns:
522            // y^H * x = (yr - i*yi)^T * (xr + i*xi) = (yr^T*xr + yi^T*xi) + i*(yr^T*xi - yi^T*xr)
523            let jp1 = j + 1;
524
525            let mut prod_rr = T::zero(); // yr^T * xr
526            let mut prod_ii = T::zero(); // yi^T * xi
527            let mut prod_ri = T::zero(); // yr^T * xi
528            let mut prod_ir = T::zero(); // yi^T * xr
529
530            for k in 0..n {
531                prod_rr = prod_rr + vl[(k, j)] * vr[(k, j)];
532                prod_ii = prod_ii + vl[(k, jp1)] * vr[(k, jp1)];
533                prod_ri = prod_ri + vl[(k, j)] * vr[(k, jp1)];
534                prod_ir = prod_ir + vl[(k, jp1)] * vr[(k, j)];
535            }
536
537            let real_part = prod_rr + prod_ii;
538            let imag_part = prod_ri - prod_ir;
539            let abs_inner = Real::hypot(real_part, imag_part);
540
541            // Both eigenvalues in the pair have the same condition number
542            s[j] = abs_inner;
543            s[jp1] = abs_inner;
544
545            j += 2;
546        } else {
547            // Simple real eigenvalue: s = |y^T * x|
548            let mut inner = T::zero();
549            for k in 0..n {
550                inner = inner + vl[(k, j)] * vr[(k, j)];
551            }
552            s[j] = Scalar::abs(inner);
553            j += 1;
554        }
555    }
556
557    s
558}
559
560/// Computes separation (sep) for eigenvectors (from LAPACK DTRSNA).
561///
562/// For each eigenvalue λ_j, sep_j = σ_min(T_22 - λ_j * I) where T_22 is the
563/// (n-1)×(n-1) trailing principal submatrix with λ_j removed.
564///
565/// This measures how separated λ_j is from the rest of the spectrum.
566///
567/// # Arguments
568///
569/// * `t` - The quasi-upper triangular Schur matrix (see [`trevc_right`] for
570///   the accepted forms)
571///
572/// # Returns
573///
574/// Vector of separation values for each eigenvalue.
575pub fn trsna_sep<T: Field + Real + bytemuck::Zeroable>(t: &Mat<T>) -> Vec<T> {
576    let n = t.nrows();
577    if n == 0 {
578        return Vec::new();
579    }
580
581    let t = standardized_view(t);
582    let t = t.as_ref();
583    let mut sep = vec![T::zero(); n];
584    let eps = <T as Scalar>::epsilon();
585
586    let mut j = 0;
587    while j < n {
588        let width = if starts_block(t, j) { 2 } else { 1 };
589        let (lambda_real, lambda_imag) = block_eigenvalue(t, j);
590        let mut min_sep = T::one() / eps;
591
592        // Distance to every other eigenvalue (both members of other pairs).
593        let mut k = 0;
594        while k < n {
595            let k_width = if starts_block(t, k) { 2 } else { 1 };
596            if k != j {
597                let (other_real, other_imag) = block_eigenvalue(t, k);
598                let dr = lambda_real - other_real;
599                let dist = Real::hypot(dr, lambda_imag - other_imag);
600                if dist < min_sep && dist > T::zero() {
601                    min_sep = dist;
602                }
603                if other_imag != T::zero() {
604                    let dist_conj = Real::hypot(dr, lambda_imag + other_imag);
605                    if dist_conj < min_sep && dist_conj > T::zero() {
606                        min_sep = dist_conj;
607                    }
608                }
609            }
610            k += k_width;
611        }
612
613        for s in &mut sep[j..j + width] {
614            *s = min_sep;
615        }
616        j += width;
617    }
618
619    sep
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
627        (a - b).abs() < tol
628    }
629
630    #[test]
631    fn test_schur_upper_triangular() {
632        // Already upper triangular
633        let a = Mat::from_rows(&[&[1.0f64, 2.0], &[0.0, 3.0]]);
634
635        let schur = Schur::compute(a.as_ref()).unwrap();
636        let eigenvalues = schur.eigenvalues();
637
638        // Eigenvalues should be 1 and 3
639        let mut eigs: Vec<f64> = eigenvalues.iter().map(|e| e.real).collect();
640        eigs.sort_by(|a, b| a.partial_cmp(b).unwrap());
641        assert!(approx_eq(eigs[0], 1.0, 1e-10));
642        assert!(approx_eq(eigs[1], 3.0, 1e-10));
643    }
644
645    #[test]
646    fn test_schur_diagonal() {
647        let a = Mat::from_rows(&[&[2.0f64, 0.0, 0.0], &[0.0, 5.0, 0.0], &[0.0, 0.0, 3.0]]);
648
649        let schur = Schur::compute(a.as_ref()).unwrap();
650        let eigenvalues = schur.eigenvalues();
651
652        let mut eigs: Vec<f64> = eigenvalues.iter().map(|e| e.real).collect();
653        eigs.sort_by(|a, b| a.partial_cmp(b).unwrap());
654        assert!(approx_eq(eigs[0], 2.0, 1e-10));
655        assert!(approx_eq(eigs[1], 3.0, 1e-10));
656        assert!(approx_eq(eigs[2], 5.0, 1e-10));
657    }
658
659    #[test]
660    fn test_schur_complex_eigenvalues() {
661        // Rotation matrix - has complex eigenvalues
662        let theta = core::f64::consts::FRAC_PI_4; // 45 degrees
663        let c = theta.cos();
664        let s = theta.sin();
665        let a = Mat::from_rows(&[&[c, -s], &[s, c]]);
666
667        let schur = Schur::compute(a.as_ref()).unwrap();
668        let eigenvalues = schur.eigenvalues();
669
670        // Eigenvalues should be cos(θ) ± i*sin(θ)
671        assert_eq!(eigenvalues.len(), 2);
672        // They should be complex conjugates
673        assert!(approx_eq(eigenvalues[0].real, eigenvalues[1].real, 1e-10));
674        assert!(approx_eq(eigenvalues[0].imag, -eigenvalues[1].imag, 1e-10));
675        assert!(approx_eq(eigenvalues[0].real, c, 1e-10));
676        assert!(approx_eq(eigenvalues[0].imag.abs(), s, 1e-10));
677    }
678
679    #[test]
680    fn test_schur_reconstruction() {
681        let a = Mat::from_rows(&[&[1.0f64, 2.0, 3.0], &[4.0, 5.0, 6.0], &[7.0, 8.0, 9.0]]);
682
683        let schur = Schur::compute(a.as_ref()).unwrap();
684        let reconstructed = schur.reconstruct();
685
686        for i in 0..3 {
687            for j in 0..3 {
688                assert!(
689                    approx_eq(reconstructed[(i, j)], a[(i, j)], 1e-10),
690                    "reconstructed[{},{}] = {}, a = {}",
691                    i,
692                    j,
693                    reconstructed[(i, j)],
694                    a[(i, j)]
695                );
696            }
697        }
698    }
699
700    #[test]
701    fn test_schur_q_orthogonal() {
702        let a = Mat::from_rows(&[&[1.0f64, 2.0, 3.0], &[4.0, 5.0, 6.0], &[7.0, 8.0, 9.0]]);
703
704        let schur = Schur::compute(a.as_ref()).unwrap();
705        let q = schur.q();
706
707        // Check Q^T * Q = I
708        let n = 3;
709        for i in 0..n {
710            for j in 0..n {
711                let mut dot = 0.0;
712                for k in 0..n {
713                    dot += q[(k, i)] * q[(k, j)];
714                }
715                let expected = if i == j { 1.0 } else { 0.0 };
716                assert!(
717                    approx_eq(dot, expected, 1e-10),
718                    "Q^T*Q[{},{}] = {}, expected {}",
719                    i,
720                    j,
721                    dot,
722                    expected
723                );
724            }
725        }
726    }
727
728    #[test]
729    fn test_schur_single() {
730        let a = Mat::from_rows(&[&[5.0f64]]);
731        let schur = Schur::compute(a.as_ref()).unwrap();
732
733        assert_eq!(schur.eigenvalues().len(), 1);
734        assert!(approx_eq(schur.eigenvalues()[0].real, 5.0, 1e-10));
735    }
736
737    #[test]
738    fn test_schur_4x4() {
739        let a = Mat::from_rows(&[
740            &[4.0f64, 1.0, -2.0, 2.0],
741            &[1.0, 2.0, 0.0, 1.0],
742            &[-2.0, 0.0, 3.0, -2.0],
743            &[2.0, 1.0, -2.0, -1.0],
744        ]);
745
746        let schur = Schur::compute(a.as_ref()).unwrap();
747        let reconstructed = schur.reconstruct();
748
749        for i in 0..4 {
750            for j in 0..4 {
751                assert!(
752                    approx_eq(reconstructed[(i, j)], a[(i, j)], 1e-8),
753                    "reconstructed[{},{}] = {}, a = {}",
754                    i,
755                    j,
756                    reconstructed[(i, j)],
757                    a[(i, j)]
758                );
759            }
760        }
761    }
762
763    #[test]
764    fn test_schur_f32() {
765        let a = Mat::from_rows(&[&[1.0f32, 2.0], &[3.0, 4.0]]);
766
767        let schur = Schur::compute(a.as_ref()).unwrap();
768        let reconstructed = schur.reconstruct();
769
770        for i in 0..2 {
771            for j in 0..2 {
772                assert!(
773                    (reconstructed[(i, j)] - a[(i, j)]).abs() < 1e-4,
774                    "reconstructed[{},{}] = {}, a = {}",
775                    i,
776                    j,
777                    reconstructed[(i, j)],
778                    a[(i, j)]
779                );
780            }
781        }
782    }
783
784    #[test]
785    fn test_trevc_right_upper_triangular() {
786        // Upper triangular matrix - eigenvectors should be standard basis vectors
787        let t = Mat::from_rows(&[&[1.0f64, 2.0, 3.0], &[0.0, 4.0, 5.0], &[0.0, 0.0, 6.0]]);
788
789        let v = trevc_right(&t);
790
791        // First eigenvector for λ=1 should be proportional to [1, 0, 0]
792        assert!(v[(1, 0)].abs() < 1e-10);
793        assert!(v[(2, 0)].abs() < 1e-10);
794
795        // Third eigenvector for λ=6 should be proportional to [*, *, 1]
796        // (normalized, so third component is non-zero)
797        assert!(v[(2, 2)].abs() > 0.1);
798    }
799
800    #[test]
801    fn test_trevc_right_diagonal() {
802        // Diagonal matrix - eigenvectors are standard basis
803        let t = Mat::from_rows(&[&[2.0f64, 0.0, 0.0], &[0.0, 5.0, 0.0], &[0.0, 0.0, 3.0]]);
804
805        let v = trevc_right(&t);
806
807        // Should be identity (or close to it with normalization)
808        for i in 0..3 {
809            assert!(
810                approx_eq(v[(i, i)].abs(), 1.0, 1e-10),
811                "v[{},{}] = {}",
812                i,
813                i,
814                v[(i, i)]
815            );
816        }
817    }
818
819    #[test]
820    fn test_trevc_eigenvector_equation() {
821        // Test that T * v = λ * v for computed eigenvectors
822        let t = Mat::from_rows(&[&[1.0f64, 2.0, 3.0], &[0.0, 4.0, 5.0], &[0.0, 0.0, 6.0]]);
823
824        let v = trevc_right(&t);
825
826        // Eigenvalues are 1, 4, 6 (diagonal elements)
827        let eigenvalues = [1.0, 4.0, 6.0];
828
829        for (j, &lambda) in eigenvalues.iter().enumerate() {
830            // Compute T * v[:,j]
831            let mut tv = [0.0; 3];
832            for i in 0..3 {
833                for k in 0..3 {
834                    tv[i] += t[(i, k)] * v[(k, j)];
835                }
836            }
837
838            // Check T * v = λ * v
839            for i in 0..3 {
840                assert!(
841                    approx_eq(tv[i], lambda * v[(i, j)], 1e-10),
842                    "T*v[{}] = {}, λ*v = {}",
843                    i,
844                    tv[i],
845                    lambda * v[(i, j)]
846                );
847            }
848        }
849    }
850
851    #[test]
852    fn test_trevc_left_diagonal() {
853        let t = Mat::from_rows(&[&[2.0f64, 0.0, 0.0], &[0.0, 5.0, 0.0], &[0.0, 0.0, 3.0]]);
854
855        let u = trevc_left(&t);
856
857        // Should be identity
858        for i in 0..3 {
859            assert!(
860                approx_eq(u[(i, i)].abs(), 1.0, 1e-10),
861                "u[{},{}] = {}",
862                i,
863                i,
864                u[(i, i)]
865            );
866        }
867    }
868
869    #[test]
870    fn test_schur_eigenvectors() {
871        // Test eigenvectors through the Schur decomposition
872        let a = Mat::from_rows(&[&[1.0f64, 2.0, 3.0], &[0.0, 4.0, 5.0], &[0.0, 0.0, 6.0]]);
873
874        let schur = Schur::compute(a.as_ref()).unwrap();
875        let (vr, _vl) = schur.eigenvectors();
876
877        // For upper triangular matrix, A = T, so eigenvectors are the same
878        // Check that A * v = λ * v
879        for j in 0..3 {
880            let lambda = schur.eigenvalues()[j].real;
881
882            let mut av = [0.0; 3];
883            for i in 0..3 {
884                for k in 0..3 {
885                    av[i] += a[(i, k)] * vr[(k, j)];
886                }
887            }
888
889            for i in 0..3 {
890                assert!(
891                    approx_eq(av[i], lambda * vr[(i, j)], 1e-8),
892                    "A*v[{}] = {}, λ*v = {}",
893                    i,
894                    av[i],
895                    lambda * vr[(i, j)]
896                );
897            }
898        }
899    }
900
901    #[test]
902    fn test_trevc_2x2_block() {
903        // Create a 2x2 block with complex eigenvalues: rotation matrix
904        let theta = core::f64::consts::FRAC_PI_4;
905        let c = theta.cos();
906        let s = theta.sin();
907        let t = Mat::from_rows(&[&[c, -s], &[s, c]]);
908
909        let v = trevc_right(&t);
910
911        // For complex eigenvalues, columns should contain real and imag parts
912        // The eigenvector equation (T - λI)v = 0 where λ = c + i*s
913        // Check that both columns are normalized
914        let norm0_sq = v[(0, 0)] * v[(0, 0)] + v[(1, 0)] * v[(1, 0)];
915        let norm1_sq = v[(0, 1)] * v[(0, 1)] + v[(1, 1)] * v[(1, 1)];
916        let total_norm = (norm0_sq + norm1_sq).sqrt();
917
918        assert!(
919            approx_eq(total_norm, 1.0, 1e-10),
920            "eigenvector norm = {}",
921            total_norm
922        );
923    }
924
925    #[test]
926    fn test_trsna_s_diagonal() {
927        // Diagonal matrix: left and right eigenvectors are standard basis
928        // so s = |e_i^T e_i| = 1 for all eigenvalues
929        let t = Mat::from_rows(&[&[2.0f64, 0.0, 0.0], &[0.0, 5.0, 0.0], &[0.0, 0.0, 3.0]]);
930
931        let s = trsna_s(&t);
932
933        assert_eq!(s.len(), 3);
934        for i in 0..3 {
935            assert!(
936                approx_eq(s[i], 1.0, 1e-10),
937                "s[{}] = {}, expected 1.0",
938                i,
939                s[i]
940            );
941        }
942    }
943
944    #[test]
945    fn test_trsna_s_upper_triangular() {
946        // Upper triangular: eigenvalues are diagonal elements
947        let t = Mat::from_rows(&[&[1.0f64, 2.0, 3.0], &[0.0, 4.0, 5.0], &[0.0, 0.0, 6.0]]);
948
949        let s = trsna_s(&t);
950
951        assert_eq!(s.len(), 3);
952        // All condition numbers should be positive
953        for i in 0..3 {
954            assert!(s[i] > 0.0, "s[{}] = {} should be positive", i, s[i]);
955        }
956    }
957
958    #[test]
959    fn test_trsna_sep_diagonal() {
960        // Diagonal matrix: separation is the minimum distance to other eigenvalues
961        let t = Mat::from_rows(&[&[2.0f64, 0.0, 0.0], &[0.0, 5.0, 0.0], &[0.0, 0.0, 3.0]]);
962
963        let sep = trsna_sep(&t);
964
965        assert_eq!(sep.len(), 3);
966        // λ=2: min dist to 3,5 is 1
967        assert!(
968            approx_eq(sep[0], 1.0, 1e-10),
969            "sep[0] = {}, expected 1.0",
970            sep[0]
971        );
972        // λ=5: min dist to 2,3 is 2
973        assert!(
974            approx_eq(sep[1], 2.0, 1e-10),
975            "sep[1] = {}, expected 2.0",
976            sep[1]
977        );
978        // λ=3: min dist to 2,5 is 1
979        assert!(
980            approx_eq(sep[2], 1.0, 1e-10),
981            "sep[2] = {}, expected 1.0",
982            sep[2]
983        );
984    }
985
986    #[test]
987    fn test_trsna_sep_close_eigenvalues() {
988        // Eigenvalues very close together should have small separation
989        let t = Mat::from_rows(&[&[1.0f64, 0.0], &[0.0, 1.001]]);
990
991        let sep = trsna_sep(&t);
992
993        assert_eq!(sep.len(), 2);
994        // Both should have separation close to 0.001
995        assert!(
996            approx_eq(sep[0], 0.001, 1e-10),
997            "sep[0] = {}, expected 0.001",
998            sep[0]
999        );
1000        assert!(
1001            approx_eq(sep[1], 0.001, 1e-10),
1002            "sep[1] = {}, expected 0.001",
1003            sep[1]
1004        );
1005    }
1006
1007    #[test]
1008    fn test_schur_eigenvalue_condition_numbers() {
1009        // Test through Schur decomposition
1010        let a = Mat::from_rows(&[&[4.0f64, 0.0], &[0.0, 2.0]]);
1011
1012        let schur = Schur::compute(a.as_ref()).unwrap();
1013        let cond = schur.eigenvalue_condition_numbers();
1014
1015        assert_eq!(cond.len(), 2);
1016        // Diagonal matrix should have well-conditioned eigenvalues
1017        for i in 0..2 {
1018            assert!(
1019                cond[i] > 0.5,
1020                "cond[{}] = {} should be > 0.5 for diagonal matrix",
1021                i,
1022                cond[i]
1023            );
1024        }
1025    }
1026
1027    #[test]
1028    fn test_schur_eigenvector_separation() {
1029        // Test through Schur decomposition
1030        let a = Mat::from_rows(&[&[4.0f64, 0.0], &[0.0, 2.0]]);
1031
1032        let schur = Schur::compute(a.as_ref()).unwrap();
1033        let sep = schur.eigenvector_separation();
1034
1035        assert_eq!(sep.len(), 2);
1036        // Eigenvalues are 2 and 4, so separation should be 2
1037        for i in 0..2 {
1038            assert!(
1039                approx_eq(sep[i], 2.0, 1e-10),
1040                "sep[{}] = {}, expected 2.0",
1041                i,
1042                sep[i]
1043            );
1044        }
1045    }
1046
1047    #[test]
1048    fn test_trsna_complex_eigenvalues() {
1049        // Rotation matrix with complex eigenvalues
1050        let theta = core::f64::consts::FRAC_PI_4;
1051        let c = theta.cos();
1052        let s = theta.sin();
1053        let t = Mat::from_rows(&[&[c, -s], &[s, c]]);
1054
1055        let cond = trsna_s(&t);
1056        let sep = trsna_sep(&t);
1057
1058        assert_eq!(cond.len(), 2);
1059        assert_eq!(sep.len(), 2);
1060
1061        // Both eigenvalues in a complex pair should have the same condition
1062        assert!(
1063            approx_eq(cond[0], cond[1], 1e-10),
1064            "cond[0]={}, cond[1]={} should be equal",
1065            cond[0],
1066            cond[1]
1067        );
1068        assert!(
1069            approx_eq(sep[0], sep[1], 1e-10),
1070            "sep[0]={}, sep[1]={} should be equal",
1071            sep[0],
1072            sep[1]
1073        );
1074    }
1075
1076    /// A 5×5 non-symmetric matrix with tightly clustered eigenvalues. It is
1077    /// perfectly solvable, but a single QR sweep cannot possibly deflate it down
1078    /// to a size-2 window, so an artificially tiny iteration budget must be
1079    /// reported as a convergence failure — not silently returned as garbage.
1080    ///
1081    /// The eigenvalues cluster near 5 (this is `5*I` plus a small nilpotent-ish
1082    /// bidiagonal + coupling perturbation), which is exactly the kind of spectrum
1083    /// that makes the QR iteration work hardest.
1084    fn slow_converging_5x5() -> Mat<f64> {
1085        Mat::from_rows(&[
1086            &[5.0, 1.0, 0.2, 0.0, 0.1],
1087            &[0.3, 5.0, 1.0, 0.15, 0.0],
1088            &[0.0, 0.25, 5.0, 1.0, 0.2],
1089            &[0.1, 0.0, 0.3, 5.0, 1.0],
1090            &[0.2, 0.1, 0.0, 0.35, 5.0],
1091        ])
1092    }
1093
1094    #[test]
1095    fn test_schur_reports_non_convergence_when_budget_exhausted() {
1096        let a = slow_converging_5x5();
1097
1098        // A total budget of 1 QR sweep is nowhere near enough to reduce a 5×5
1099        // active window (which needs several sweeps and multiple deflations).
1100        // Before the fix this fell through and returned a partially-reduced T as
1101        // if valid; now it must surface NotConverged.
1102        let result = Schur::compute_with_iteration_budget(a.as_ref(), 1);
1103        assert_eq!(
1104            result.err(),
1105            Some(SchurError::NotConverged),
1106            "tiny iteration budget must report NotConverged, not fabricate a result"
1107        );
1108    }
1109
1110    #[test]
1111    fn test_schur_converges_with_full_budget() {
1112        // Control for the non-convergence test: the SAME matrix must converge
1113        // (and reconstruct as A = Q T Q^T) under the real default budget, proving
1114        // the error above is caused purely by the artificial cap, not a defect in
1115        // the matrix or the algorithm.
1116        let a = slow_converging_5x5();
1117
1118        let schur = Schur::compute(a.as_ref()).expect("full budget must converge");
1119        let rec = schur.reconstruct();
1120        for i in 0..5 {
1121            for j in 0..5 {
1122                assert!(
1123                    approx_eq(rec[(i, j)], a[(i, j)], 1e-8),
1124                    "reconstruction mismatch at ({i},{j}): {} vs {}",
1125                    rec[(i, j)],
1126                    a[(i, j)]
1127                );
1128            }
1129        }
1130
1131        // Trace is preserved by a similarity transform: sum of eigenvalue real
1132        // parts must equal the trace (= 25 here).
1133        let trace: f64 = (0..5).map(|i| a[(i, i)]).sum();
1134        let eig_sum: f64 = schur.eigenvalues().iter().map(|e| e.real).sum();
1135        assert!(
1136            approx_eq(eig_sum, trace, 1e-8),
1137            "eigenvalue real-part sum {eig_sum} != trace {trace}"
1138        );
1139    }
1140}