Skip to main content

qualia_core_db/solvers/linear_algebra/
eigen.rs

1//! Symmetric eigendecomposition — the engine's single home for eigenvalues of a
2//! symmetric matrix.
3//!
4//! Before this, the same math lived in two silos: `specialized_libs/linear_algebra`
5//! had a cyclic-Jacobi `eigen_symmetric`, and `specialized_libs/engineering_analysis`
6//! had a closed-form 3×3 principal-stress solver — two implementations of one
7//! operation. Both now route here.
8//!
9//! Two entry points, same modality:
10//! - [`symmetric_eigen_3x3`] — closed-form (Smith's algorithm) for the symmetric
11//!   3×3 case; zero-heap, no iteration, eigenvalues sorted descending.
12//! - [`symmetric_eigen`] — cyclic-Jacobi for general `n×n`; in-place on a
13//!   caller-owned buffer, also yields eigenvectors. Zero-heap.
14
15use crate::solvers::SolversError;
16
17/// Eigenvalues of a **symmetric 3×3** matrix `a` (row-major, length 9) by Smith's
18/// closed-form algorithm — no iteration, no allocation. Returns the three
19/// eigenvalues **sorted descending** (`e[0] ≥ e[1] ≥ e[2]`), the convention used
20/// for principal stresses/strains.
21///
22/// Only the symmetric part is used (off-diagonals read from the upper triangle:
23/// `(0,1)`, `(0,2)`, `(1,2)`), so a numerically-symmetric `a` need not be exact in
24/// its lower half.
25pub fn symmetric_eigen_3x3(a: &[f64; 9]) -> [f64; 3] {
26    let (sxx, syy, szz) = (a[0], a[4], a[8]);
27    // Upper-triangle off-diagonals: sxy=(0,1), szx=(0,2), syz=(1,2).
28    let (sxy, szx, syz) = (a[1], a[2], a[5]);
29    let p1 = sxy * sxy + syz * syz + szx * szx;
30    let q = (sxx + syy + szz) / 3.0;
31    if p1 <= 1e-18 {
32        // Already diagonal — eigenvalues are the diagonal entries.
33        let mut e = [sxx, syy, szz];
34        e.sort_by(|a, b| b.partial_cmp(a).unwrap_or(core::cmp::Ordering::Equal));
35        return e;
36    }
37    let p2 = (sxx - q).powi(2) + (syy - q).powi(2) + (szz - q).powi(2) + 2.0 * p1;
38    let p = (p2 / 6.0).sqrt();
39    // B = (1/p)·(A − qI); r = det(B)/2 ∈ [−1, 1].
40    let (b00, b11, b22) = ((sxx - q) / p, (syy - q) / p, (szz - q) / p);
41    let (b01, b12, b02) = (sxy / p, syz / p, szx / p);
42    let det_b = b00 * (b11 * b22 - b12 * b12) - b01 * (b01 * b22 - b12 * b02)
43        + b02 * (b01 * b12 - b11 * b02);
44    let r = (det_b / 2.0).clamp(-1.0, 1.0);
45    let phi = r.acos() / 3.0;
46    let e1 = q + 2.0 * p * phi.cos();
47    let e3 = q + 2.0 * p * (phi + 2.0 * core::f64::consts::PI / 3.0).cos();
48    let e2 = 3.0 * q - e1 - e3; // trace is invariant: e1+e2+e3 = 3q
49    [e1, e2, e3]
50}
51
52/// Eigendecomposition of a **symmetric `n×n`** matrix by cyclic Jacobi rotations.
53///
54/// On entry `a` (row-major, length `n*n`) holds the symmetric matrix; it is
55/// **overwritten** — on return its diagonal `a[i*n+i]` holds the eigenvalues (in
56/// Jacobi's natural order, not sorted). `eigvecs` (length `n*n`) receives the
57/// orthonormal eigenvectors as **columns**: column `j` is the unit eigenvector for
58/// the eigenvalue at `a[j*n+j]`. Zero allocation — both buffers are caller-owned.
59///
60/// Returns [`SolversError::InvalidDimension`] on a shape mismatch, or
61/// [`SolversError::InvalidParameters`] if `a` is not (within tolerance) symmetric.
62pub fn symmetric_eigen(n: usize, a: &mut [f64], eigvecs: &mut [f64]) -> Result<(), SolversError> {
63    if n == 0 || a.len() != n * n || eigvecs.len() != n * n {
64        return Err(SolversError::InvalidDimension);
65    }
66    let scale = a.iter().fold(0.0_f64, |m, &v| m.max(v.abs())).max(1.0);
67    // Symmetry check.
68    for i in 0..n {
69        for j in (i + 1)..n {
70            if (a[i * n + j] - a[j * n + i]).abs() > 1e-9 * scale {
71                return Err(SolversError::InvalidParameters);
72            }
73        }
74    }
75    // eigvecs starts as the identity.
76    for x in eigvecs.iter_mut() {
77        *x = 0.0;
78    }
79    for i in 0..n {
80        eigvecs[i * n + i] = 1.0;
81    }
82
83    const MAX_SWEEPS: usize = 100;
84    for _ in 0..MAX_SWEEPS {
85        // Off-diagonal Frobenius norm; stop when negligible.
86        let mut off = 0.0_f64;
87        for p in 0..n {
88            for q in (p + 1)..n {
89                off += a[p * n + q] * a[p * n + q];
90            }
91        }
92        if off.sqrt() <= 1e-15 * scale {
93            break;
94        }
95        for p in 0..n {
96            for q in (p + 1)..n {
97                let apq = a[p * n + q];
98                if apq == 0.0 {
99                    continue;
100                }
101                let app = a[p * n + p];
102                let aqq = a[q * n + q];
103                let theta = (aqq - app) / (2.0 * apq);
104                let sign = if theta >= 0.0 { 1.0 } else { -1.0 };
105                let t = sign / (theta.abs() + (theta * theta + 1.0).sqrt());
106                let c = 1.0 / (t * t + 1.0).sqrt();
107                let s = t * c;
108                // Rotate columns p,q of A.
109                for k in 0..n {
110                    let akp = a[k * n + p];
111                    let akq = a[k * n + q];
112                    a[k * n + p] = c * akp - s * akq;
113                    a[k * n + q] = s * akp + c * akq;
114                }
115                // Rotate rows p,q of A.
116                for k in 0..n {
117                    let apk = a[p * n + k];
118                    let aqk = a[q * n + k];
119                    a[p * n + k] = c * apk - s * aqk;
120                    a[q * n + k] = s * apk + c * aqk;
121                }
122                // Accumulate the rotation into the eigenvector matrix.
123                for k in 0..n {
124                    let vkp = eigvecs[k * n + p];
125                    let vkq = eigvecs[k * n + q];
126                    eigvecs[k * n + p] = c * vkp - s * vkq;
127                    eigvecs[k * n + q] = s * vkp + c * vkq;
128                }
129            }
130        }
131    }
132    Ok(())
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn approx(a: f64, b: f64, tol: f64) {
140        assert!((a - b).abs() < tol, "{a} != {b} (tol {tol})");
141    }
142
143    #[test]
144    fn closed_form_diagonal() {
145        // Diagonal matrix → eigenvalues are the diagonal, sorted descending.
146        let a = [3.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 2.0];
147        let e = symmetric_eigen_3x3(&a);
148        approx(e[0], 3.0, 1e-12);
149        approx(e[1], 2.0, 1e-12);
150        approx(e[2], 1.0, 1e-12);
151    }
152
153    #[test]
154    fn closed_form_known_symmetric() {
155        // [[2,0,0],[0,3,4],[0,4,9]] — block eigenvalues of [[3,4],[4,9]] are 1 and 11,
156        // plus the isolated 2 → {11, 2, 1} descending.
157        let a = [2.0, 0.0, 0.0, 0.0, 3.0, 4.0, 0.0, 4.0, 9.0];
158        let e = symmetric_eigen_3x3(&a);
159        approx(e[0], 11.0, 1e-9);
160        approx(e[1], 2.0, 1e-9);
161        approx(e[2], 1.0, 1e-9);
162        // Trace invariant.
163        approx(e[0] + e[1] + e[2], 14.0, 1e-9);
164    }
165
166    #[test]
167    fn closed_form_matches_jacobi() {
168        let a = [4.0, 1.0, 2.0, 1.0, 5.0, 3.0, 2.0, 3.0, 6.0];
169        let closed = symmetric_eigen_3x3(&a);
170        let mut work = a;
171        let mut v = [0.0; 9];
172        symmetric_eigen(3, &mut work, &mut v).unwrap();
173        let mut jac = [work[0], work[4], work[8]];
174        jac.sort_by(|x, y| y.partial_cmp(x).unwrap());
175        for i in 0..3 {
176            approx(closed[i], jac[i], 1e-7);
177        }
178    }
179
180    #[test]
181    fn jacobi_eigenvectors_reconstruct() {
182        // A·v_j == λ_j·v_j for each eigenpair.
183        let a0 = [4.0, 1.0, 2.0, 1.0, 5.0, 3.0, 2.0, 3.0, 6.0];
184        let mut a = a0;
185        let mut v = [0.0; 9];
186        symmetric_eigen(3, &mut a, &mut v).unwrap();
187        for j in 0..3 {
188            let lambda = a[j * 3 + j];
189            // column j of v
190            let vj = [v[j], v[3 + j], v[6 + j]];
191            // A·vj
192            for i in 0..3 {
193                let mut s = 0.0;
194                for k in 0..3 {
195                    s += a0[i * 3 + k] * vj[k];
196                }
197                approx(s, lambda * vj[i], 1e-7);
198            }
199        }
200    }
201
202    #[test]
203    fn jacobi_rejects_asymmetric() {
204        let mut a = [1.0, 2.0, 3.0, 4.0]; // not symmetric (2 != 3)
205        let mut v = [0.0; 4];
206        assert_eq!(
207            symmetric_eigen(2, &mut a, &mut v),
208            Err(SolversError::InvalidParameters)
209        );
210    }
211
212    #[test]
213    fn jacobi_rejects_bad_dims() {
214        let mut a = [1.0, 2.0, 3.0];
215        let mut v = [0.0; 4];
216        assert_eq!(
217            symmetric_eigen(2, &mut a, &mut v),
218            Err(SolversError::InvalidDimension)
219        );
220    }
221}