Skip to main content

rlevo_evolution/ops/
linalg.rs

1//! Host-side dense linear algebra for covariance-matrix strategies.
2//!
3//! CMA-ES and CMSA-ES need a symmetric eigendecomposition (for the sampling
4//! transform `B·diag(√Λ)` and the conditioning matrix `C^{-1/2}`) and a
5//! Cholesky factor (for CMSA-ES sampling). Burn 0.21 ships **no** Cholesky or
6//! eigendecomposition primitive, and the workspace deliberately avoids a
7//! `nalgebra` dependency (ADR 0021 §3 / research note
8//! `cma-es-sampling-and-numerics` §L4: the logged `nalgebra` 4×4 symmetric-eigen
9//! bug and its non-portable LAPACK path do not justify the dependency for the
10//! `D ≤ 30` regime these strategies target). Both routines therefore run on host
11//! `Vec<f32>` buffers — covariance matrices are tiny, so the device round-trip
12//! would dominate any on-device kernel anyway.
13//!
14//! All matrices are **row-major** `n × n`: entry `(i, j)` lives at index
15//! `i * n + j`.
16
17/// Hard cap on Jacobi sweeps; convergence is quadratic, so for the small `n`
18/// the covariance-matrix strategies use this is never reached in practice.
19const MAX_SWEEPS: usize = 100;
20
21/// Symmetric eigendecomposition `A = V · diag(Λ) · Vᵀ`.
22///
23/// Returned by [`jacobi_eigen`]. Packaging the two buffers in a named struct
24/// removes the positional ambiguity of a `(Vec<f32>, Vec<f32>)` pair — a caller
25/// can no longer transpose eigenvalues and eigenvectors at the destructuring
26/// site — and carries the column-layout invariant on the fields where it is
27/// used.
28#[derive(Debug, Clone)]
29pub struct SymEigen {
30    /// Eigenvalues `Λ` (unsorted), length `n`.
31    pub values: Vec<f32>,
32    /// Row-major `n × n` eigenvector matrix `V` whose **column** `k` is the
33    /// eigenvector for `values[k]`: component `i` lives at `vectors[i * n + k]`.
34    pub vectors: Vec<f32>,
35}
36
37/// Symmetric eigendecomposition via the cyclic Jacobi method.
38///
39/// `a` is an `n × n` **symmetric** matrix in row-major order. Returns a
40/// [`SymEigen`] carrying the eigenvalues (unsorted) and the row-major
41/// eigenvector matrix; see the [`SymEigen`] field docs for the column-layout
42/// invariant (`vectors` column `k` is the eigenvector for `values[k]`).
43///
44/// The eigenvector columns are orthonormal, so the input is reconstructed as
45/// `V · diag(Λ) · Vᵀ`. The classic numerically stable rotation (Golub & Van
46/// Loan, *Matrix Computations*, §8.4) is used; sweeps stop once the
47/// off-diagonal Frobenius mass is negligible or after `MAX_SWEEPS`.
48///
49/// Jacobi is the eigensolver `pycma` itself uses; it is slower than tridiagonal
50/// QR but more accurate on the small eigenvalues that govern an ill-conditioned
51/// covariance (Demmel & Veselić, 1992) — exactly the regime CMA-ES drives `C`
52/// into late in a run.
53///
54/// # Panics
55///
56/// Panics if `a.len() != n * n`.
57// Jacobi rotation uses the conventional single-letter math names (p, q for the
58// pivot pair; c, s, t for cos/sin/tan of the rotation angle).
59#[allow(clippy::many_single_char_names)]
60#[must_use]
61pub fn jacobi_eigen(a: &[f32], n: usize) -> SymEigen {
62    assert_eq!(a.len(), n * n, "matrix buffer must be n*n");
63    let mut work: Vec<f32> = a.to_vec();
64    let mut vecs: Vec<f32> = vec![0.0; n * n];
65    for i in 0..n {
66        vecs[i * n + i] = 1.0;
67    }
68    if n <= 1 {
69        return SymEigen {
70            values: work,
71            vectors: vecs,
72        };
73    }
74
75    // Off-diagonal mass below this (sum of squares) counts as converged.
76    let tol: f32 = 1e-14;
77
78    for _ in 0..MAX_SWEEPS {
79        let mut off: f32 = 0.0;
80        for p in 0..n {
81            for q in (p + 1)..n {
82                off += work[p * n + q] * work[p * n + q];
83            }
84        }
85        if off <= tol {
86            break;
87        }
88        for p in 0..n {
89            for q in (p + 1)..n {
90                let apq: f32 = work[p * n + q];
91                if apq.abs() <= f32::EPSILON {
92                    continue;
93                }
94                let app: f32 = work[p * n + p];
95                let aqq: f32 = work[q * n + q];
96                // Symmetric Schur: choose the rotation that annihilates (p, q).
97                let theta: f32 = (aqq - app) / (2.0 * apq);
98                let t: f32 = if theta >= 0.0 {
99                    1.0 / (theta + (1.0 + theta * theta).sqrt())
100                } else {
101                    -1.0 / (-theta + (1.0 + theta * theta).sqrt())
102                };
103                let c: f32 = 1.0 / (1.0 + t * t).sqrt();
104                let s: f32 = t * c;
105                // A ← Jᵀ A J, applied as a column update then a row update.
106                for r in 0..n {
107                    let arp: f32 = work[r * n + p];
108                    let arq: f32 = work[r * n + q];
109                    work[r * n + p] = c * arp - s * arq;
110                    work[r * n + q] = s * arp + c * arq;
111                }
112                for r in 0..n {
113                    let apr: f32 = work[p * n + r];
114                    let aqr: f32 = work[q * n + r];
115                    work[p * n + r] = c * apr - s * aqr;
116                    work[q * n + r] = s * apr + c * aqr;
117                }
118                // Pin the annihilated off-diagonal to exact zero / symmetry.
119                work[p * n + q] = 0.0;
120                work[q * n + p] = 0.0;
121                // Accumulate the eigenvector basis: V ← V J.
122                for r in 0..n {
123                    let vrp: f32 = vecs[r * n + p];
124                    let vrq: f32 = vecs[r * n + q];
125                    vecs[r * n + p] = c * vrp - s * vrq;
126                    vecs[r * n + q] = s * vrp + c * vrq;
127                }
128            }
129        }
130    }
131
132    let eigvals: Vec<f32> = (0..n).map(|i| work[i * n + i]).collect();
133    SymEigen {
134        values: eigvals,
135        vectors: vecs,
136    }
137}
138
139/// Lower-triangular Cholesky factor `L` with `L · Lᵀ = a`.
140///
141/// `a` is an `n × n` **symmetric positive-definite** matrix in row-major order.
142/// Returns the lower-triangular `L` (row-major `n × n`, zeros above the
143/// diagonal) or `None` if a non-positive **or non-finite** pivot is
144/// encountered. Callers recover by jittering the diagonal and retrying.
145///
146/// The pivot guard rejects any NaN-bearing (or infinite) input matrix, not just
147/// a directly-NaN diagonal: a NaN anywhere in `a` — including strictly
148/// off-diagonal — propagates into a later diagonal pivot through the
149/// `sum -= l[i*n+k] * l[j*n+k]` accumulation, so by the time a diagonal `sum` is
150/// tested it is itself NaN. Testing `sum.is_finite()` before `sum <= 0.0` is
151/// essential because `NaN <= 0.0` is `false`; without the finiteness check a NaN
152/// pivot would slip through `sqrt` and poison every entry of the returned
153/// factor, silently defeating the jitter-retry recovery in
154/// `cmsa_es::cholesky_with_jitter` (which only retries on `None`).
155///
156/// # Panics
157///
158/// Panics if `a.len() != n * n`.
159#[must_use]
160pub fn cholesky(a: &[f32], n: usize) -> Option<Vec<f32>> {
161    assert_eq!(a.len(), n * n, "matrix buffer must be n*n");
162    let mut l: Vec<f32> = vec![0.0; n * n];
163    for i in 0..n {
164        for j in 0..=i {
165            let mut sum: f32 = a[i * n + j];
166            for k in 0..j {
167                sum -= l[i * n + k] * l[j * n + k];
168            }
169            if i == j {
170                if !sum.is_finite() || sum <= 0.0 {
171                    return None;
172                }
173                l[i * n + i] = sum.sqrt();
174            } else {
175                l[i * n + j] = sum / l[j * n + j];
176            }
177        }
178    }
179    Some(l)
180}
181
182/// Matrix–vector product `y = M · x` for a row-major `n × n` matrix `M`.
183///
184/// # Panics
185///
186/// Panics if `m.len() != n * n` or `x.len() != n`.
187#[must_use]
188pub fn matvec(m: &[f32], x: &[f32], n: usize) -> Vec<f32> {
189    assert_eq!(m.len(), n * n, "matrix buffer must be n*n");
190    assert_eq!(x.len(), n, "vector length must be n");
191    let mut y: Vec<f32> = vec![0.0; n];
192    for i in 0..n {
193        let mut acc: f32 = 0.0;
194        for j in 0..n {
195            acc += m[i * n + j] * x[j];
196        }
197        y[i] = acc;
198    }
199    y
200}
201
202/// Forces the row-major `n × n` matrix `m` to be exactly symmetric in place.
203///
204/// For every `j < i`, both `(i, j)` and `(j, i)` are set to the average
205/// `0.5 * (m[i*n+j] + m[j*n+i])`; the diagonal is untouched.
206///
207/// This is **not** a fix for round-off drift in the strategy loop. The CMA-ES /
208/// CMSA-ES in-loop covariance updates preserve bit-exact symmetry on their own:
209/// IEEE-754 multiplication is commutative, and the two triangle entries `C[i,j]`
210/// and `C[j,i]` accumulate the identical rank-1 / rank-μ terms in the identical
211/// order, so they stay bit-for-bit equal without help. The helper exists as a
212/// **construction-boundary normalization** for caller-supplied covariance
213/// matrices — a state constructor handed an externally-built or deserialized
214/// `C` whose triangles may not agree — and as cheap defense-in-depth. It mirrors
215/// `pycma`, which likewise keeps `C` exactly symmetric rather than trusting the
216/// update to stay symmetric.
217///
218/// # Panics
219///
220/// Panics if `m.len() != n * n`.
221pub fn symmetrize(m: &mut [f32], n: usize) {
222    assert_eq!(m.len(), n * n, "matrix buffer must be n*n");
223    for i in 0..n {
224        for j in 0..i {
225            let avg: f32 = 0.5 * (m[i * n + j] + m[j * n + i]);
226            m[i * n + j] = avg;
227            m[j * n + i] = avg;
228        }
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    /// Reconstruct `V · diag(Λ) · Vᵀ` from an eigendecomposition.
237    fn reconstruct(eigvals: &[f32], eigvecs: &[f32], n: usize) -> Vec<f32> {
238        let mut out: Vec<f32> = vec![0.0; n * n];
239        for i in 0..n {
240            for j in 0..n {
241                let mut acc: f32 = 0.0;
242                for k in 0..n {
243                    acc += eigvecs[i * n + k] * eigvals[k] * eigvecs[j * n + k];
244                }
245                out[i * n + j] = acc;
246            }
247        }
248        out
249    }
250
251    fn assert_matrix_close(a: &[f32], b: &[f32], eps: f32) {
252        assert_eq!(a.len(), b.len());
253        for (x, y) in a.iter().zip(b.iter()) {
254            approx::assert_relative_eq!(x, y, epsilon = eps);
255        }
256    }
257
258    #[test]
259    fn eigen_diagonal_matrix() {
260        // diag(3, 5, 7): eigenvalues are the diagonal, eigenvectors the axes.
261        let a: Vec<f32> = vec![3.0, 0.0, 0.0, 0.0, 5.0, 0.0, 0.0, 0.0, 7.0];
262        let SymEigen { values, vectors } = jacobi_eigen(&a, 3);
263        let recon = reconstruct(&values, &vectors, 3);
264        assert_matrix_close(&a, &recon, 1e-5);
265    }
266
267    #[test]
268    fn eigen_known_2x2() {
269        // [[2,1],[1,2]] has eigenvalues {1, 3}.
270        let a: Vec<f32> = vec![2.0, 1.0, 1.0, 2.0];
271        let SymEigen { values, vectors } = jacobi_eigen(&a, 2);
272        let mut sorted: Vec<f32> = values.clone();
273        sorted.sort_by(f32::total_cmp);
274        approx::assert_relative_eq!(sorted[0], 1.0, epsilon = 1e-5);
275        approx::assert_relative_eq!(sorted[1], 3.0, epsilon = 1e-5);
276        let recon = reconstruct(&values, &vectors, 2);
277        assert_matrix_close(&a, &recon, 1e-5);
278    }
279
280    #[test]
281    fn eigen_3x3_reconstructs_and_is_orthonormal() {
282        // Symmetric, non-trivially coupled.
283        let a: Vec<f32> = vec![4.0, 1.0, 2.0, 1.0, 5.0, 3.0, 2.0, 3.0, 6.0];
284        let SymEigen { values, vectors } = jacobi_eigen(&a, 3);
285        let recon = reconstruct(&values, &vectors, 3);
286        assert_matrix_close(&a, &recon, 1e-4);
287        // Columns orthonormal: VᵀV ≈ I.
288        for p in 0..3 {
289            for q in 0..3 {
290                let mut dot: f32 = 0.0;
291                for i in 0..3 {
292                    dot += vectors[i * 3 + p] * vectors[i * 3 + q];
293                }
294                let expected: f32 = if p == q { 1.0 } else { 0.0 };
295                approx::assert_relative_eq!(dot, expected, epsilon = 1e-4);
296            }
297        }
298    }
299
300    #[test]
301    fn eigen_identity_is_fixed_point() {
302        let a: Vec<f32> = vec![1.0, 0.0, 0.0, 1.0];
303        let SymEigen { values, vectors } = jacobi_eigen(&a, 2);
304        for v in &values {
305            approx::assert_relative_eq!(v, &1.0, epsilon = 1e-6);
306        }
307        // Identity input: no rotation, basis stays the identity.
308        assert_matrix_close(&vectors, &[1.0, 0.0, 0.0, 1.0], 1e-6);
309    }
310
311    #[test]
312    fn cholesky_known_2x2() {
313        // [[4,2],[2,3]] = L Lᵀ with L = [[2,0],[1,√2]].
314        let a: Vec<f32> = vec![4.0, 2.0, 2.0, 3.0];
315        let l = cholesky(&a, 2).expect("matrix is positive-definite");
316        approx::assert_relative_eq!(l[0], 2.0, epsilon = 1e-6);
317        approx::assert_relative_eq!(l[1], 0.0, epsilon = 1e-6);
318        approx::assert_relative_eq!(l[2], 1.0, epsilon = 1e-6);
319        approx::assert_relative_eq!(l[3], 2.0_f32.sqrt(), epsilon = 1e-6);
320        // Round-trip: L Lᵀ ≈ A.
321        let mut recon: Vec<f32> = vec![0.0; 4];
322        for i in 0..2 {
323            for j in 0..2 {
324                let mut acc: f32 = 0.0;
325                for k in 0..2 {
326                    acc += l[i * 2 + k] * l[j * 2 + k];
327                }
328                recon[i * 2 + j] = acc;
329            }
330        }
331        assert_matrix_close(&a, &recon, 1e-6);
332    }
333
334    #[test]
335    fn cholesky_rejects_non_positive_definite() {
336        // [[1,2],[2,1]] has eigenvalues {-1, 3}: indefinite.
337        let a: Vec<f32> = vec![1.0, 2.0, 2.0, 1.0];
338        assert!(cholesky(&a, 2).is_none());
339    }
340
341    #[test]
342    fn cholesky_rejects_nan_on_diagonal() {
343        // A NaN diagonal pivot: `NaN <= 0.0` is false, so the finiteness guard
344        // is what rejects it (not the sign test).
345        let a: Vec<f32> = vec![f32::NAN, 0.0, 0.0, 1.0];
346        assert!(cholesky(&a, 2).is_none());
347    }
348
349    #[test]
350    fn cholesky_rejects_off_diagonal_only_nan() {
351        // The ONLY NaN is off-diagonal; the diagonal is finite and positive.
352        // It reaches the pivot at (1, 1) via the `sum -= l[i]·l[j]`
353        // accumulation, exercising the propagation-to-pivot path.
354        let a: Vec<f32> = vec![1.0, f32::NAN, f32::NAN, 1.0];
355        assert!(cholesky(&a, 2).is_none());
356    }
357
358    #[test]
359    fn cholesky_rejects_infinity() {
360        // An infinite entry is likewise non-finite; the pivot becomes
361        // non-finite and is rejected.
362        let a: Vec<f32> = vec![f32::INFINITY, 0.0, 0.0, 1.0];
363        assert!(cholesky(&a, 2).is_none());
364    }
365
366    #[test]
367    fn symmetrize_averages_asymmetric_and_is_idempotent() {
368        // Off-diagonal (0,1)=2, (1,0)=4 → both become 3; diagonal untouched.
369        let mut m: Vec<f32> = vec![1.0, 2.0, 4.0, 5.0];
370        symmetrize(&mut m, 2);
371        assert_matrix_close(&m, &[1.0, 3.0, 3.0, 5.0], 1e-6);
372        // Idempotent: a second pass over the now-symmetric matrix is a no-op.
373        let once: Vec<f32> = m.clone();
374        symmetrize(&mut m, 2);
375        assert_matrix_close(&m, &once, 1e-6);
376    }
377
378    #[test]
379    fn symmetrize_leaves_symmetric_unchanged() {
380        let mut m: Vec<f32> = vec![4.0, 1.0, 2.0, 1.0, 5.0, 3.0, 2.0, 3.0, 6.0];
381        let before: Vec<f32> = m.clone();
382        symmetrize(&mut m, 3);
383        assert_matrix_close(&m, &before, 1e-6);
384    }
385
386    #[test]
387    fn symmetrize_handles_scalar_and_identity() {
388        // 1×1: nothing to average, value preserved.
389        let mut one: Vec<f32> = vec![7.0];
390        symmetrize(&mut one, 1);
391        assert_eq!(one, vec![7.0]);
392        // Identity is already symmetric.
393        let mut id: Vec<f32> = vec![1.0, 0.0, 0.0, 1.0];
394        symmetrize(&mut id, 2);
395        assert_matrix_close(&id, &[1.0, 0.0, 0.0, 1.0], 1e-6);
396    }
397
398    #[test]
399    fn matvec_identity_and_general() {
400        let id: Vec<f32> = vec![1.0, 0.0, 0.0, 1.0];
401        let x: Vec<f32> = vec![3.0, -2.0];
402        assert_eq!(matvec(&id, &x, 2), vec![3.0, -2.0]);
403        let m: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0];
404        // [1 2; 3 4] · [1; 1] = [3; 7].
405        assert_eq!(matvec(&m, &[1.0, 1.0], 2), vec![3.0, 7.0]);
406    }
407}