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}