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}