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}