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