Skip to main content

rlx_cpu/
spd.rs

1//! SPD-manifold (symmetric positive-definite) kernels — the core primitives of Riemannian
2//! machine learning on covariance matrices (EEG BCI, diffusion tensors, Gaussian embeddings).
3//!
4//! Built on the `blas::dsyevd` symmetric eigendecomposition:
5//!   - matrix functions `logm` / `expm` / `sqrtm` / `invsqrtm` (spectral: `V·diag(f(λ))·Vᵀ`),
6//!     with rayon-parallel **batched** counterparts `logm_batch` / … (thousands of small
7//!     covariances in one call — the shared seam a GPU spd backend can route through),
8//!   - the affine-invariant Riemannian metric distance `airm_dist2`,
9//!   - the Fréchet / Karcher mean `karcher_mean` and its `karcher_mean_weighted` barycentre
10//!     (iterative tangent averaging; the weighted form is the true AIRM barycentre a
11//!     barycentric-OT projection / soft-clustering / weighted-MDM needs),
12//!   - the arbitrary-base AIRM `log_map` / `exp_map` and `parallel_transport` (the
13//!     Riemannian OT / domain-adaptation primitives of Yair et al. 2019 — `geodesic_interp`
14//!     is `exp_map(A, t·log_map(A, B))`),
15//!   - and a rayon-parallel **batched** eigendecomposition `eigh_batch` (the hot path when you
16//!     have thousands of small covariances — one per band × recording).
17//!
18//! These make `rlx-spdnet` / `rlx-tsmnet` / `rlx-tensorcspnet` (and tangent-space MDM / EA
19//! alignment) fast and reusable instead of hand-rolled per project. All matrices are row-major
20//! `n×n` and symmetric (row-major == column-major, so LAPACK ingests them directly).
21
22/// Symmetric eigendecomposition: returns `(eigenvalues ascending, eigenvectors column-major)`
23/// where `evecs[k*n + i]` is component `i` of eigenvector `k`.
24pub fn eigh(a: &[f64], n: usize) -> (Vec<f64>, Vec<f64>) {
25    let mut am = a.to_vec();
26    let mut w = vec![0f64; n];
27    let _info = crate::blas::dsyevd(&mut am, &mut w, n);
28    (w, am)
29}
30
31/// Batched symmetric eigendecomposition — rayon-parallel over many small SPD matrices. This is the
32/// EEG hot path (a covariance per frequency band per recording); one call, all cores.
33pub fn eigh_batch(mats: &[Vec<f64>], n: usize) -> Vec<(Vec<f64>, Vec<f64>)> {
34    use rayon::prelude::*;
35    mats.par_iter().map(|a| eigh(a, n)).collect()
36}
37
38/// `V·diag(f(λ))·Vᵀ` — apply a scalar function to the eigenvalues of a symmetric matrix.
39pub fn matrix_fn(a: &[f64], n: usize, f: impl Fn(f64) -> f64) -> Vec<f64> {
40    let (w, v) = eigh(a, n);
41    let fl: Vec<f64> = w.iter().map(|&l| f(l)).collect();
42    let mut out = vec![0f64; n * n];
43    for k in 0..n {
44        let fk = fl[k];
45        for i in 0..n {
46            let vik = fk * v[k * n + i];
47            for j in 0..n {
48                out[i * n + j] += vik * v[k * n + j];
49            }
50        }
51    }
52    out
53}
54
55/// Matrix logarithm of an SPD matrix.
56pub fn logm(a: &[f64], n: usize) -> Vec<f64> {
57    matrix_fn(a, n, |l| l.max(1e-12).ln())
58}
59/// Matrix exponential of a symmetric matrix.
60pub fn expm(a: &[f64], n: usize) -> Vec<f64> {
61    matrix_fn(a, n, |l| l.exp())
62}
63/// Matrix square root of an SPD matrix.
64pub fn sqrtm(a: &[f64], n: usize) -> Vec<f64> {
65    matrix_fn(a, n, |l| l.max(0.0).sqrt())
66}
67/// Inverse matrix square root of an SPD matrix (`A^{-1/2}`).
68pub fn invsqrtm(a: &[f64], n: usize) -> Vec<f64> {
69    matrix_fn(a, n, |l| 1.0 / l.max(1e-12).sqrt())
70}
71
72/// `(A^{1/2}, A^{-1/2})` from a **single** eigendecomposition of SPD `A`. The
73/// AIRM maps and their VJPs need both, so sharing the `eigh` (the dominant cost)
74/// is ~a third cheaper than two separate `sqrtm`/`invsqrtm` calls.
75fn sqrt_invsqrt(a: &[f64], n: usize) -> (Vec<f64>, Vec<f64>) {
76    let (w, v) = eigh(a, n);
77    let sh: Vec<f64> = w.iter().map(|&l| l.max(0.0).sqrt()).collect();
78    let ish: Vec<f64> = w.iter().map(|&l| 1.0 / l.max(1e-12).sqrt()).collect();
79    (reconstruct(&sh, &v, n), reconstruct(&ish, &v, n))
80}
81
82/// Apply [`matrix_fn`] to a batch of symmetric matrices in parallel (rayon) — the
83/// throughput counterpart of the scalar matrix functions. Each `mats[b]` is a
84/// row-major `n×n` symmetric matrix; output order is preserved. Mirrors
85/// [`eigh_batch`]: one call amortises setup across thousands of small covariances,
86/// and the batched signature is the seam a metal/cuda/rocm spd backend routes through.
87fn matrix_fn_batch(mats: &[Vec<f64>], n: usize, f: impl Fn(f64) -> f64 + Sync) -> Vec<Vec<f64>> {
88    use rayon::prelude::*;
89    mats.par_iter().map(|a| matrix_fn(a, n, &f)).collect()
90}
91
92/// Batched [`logm`] — rayon-parallel over `covs` (each row-major `n×n` SPD).
93pub fn logm_batch(covs: &[Vec<f64>], n: usize) -> Vec<Vec<f64>> {
94    matrix_fn_batch(covs, n, |l| l.max(1e-12).ln())
95}
96/// Batched [`expm`] — rayon-parallel over `mats` (each row-major `n×n` symmetric).
97pub fn expm_batch(mats: &[Vec<f64>], n: usize) -> Vec<Vec<f64>> {
98    matrix_fn_batch(mats, n, |l| l.exp())
99}
100/// Batched [`sqrtm`] — rayon-parallel over `covs` (each row-major `n×n` SPD).
101pub fn sqrtm_batch(covs: &[Vec<f64>], n: usize) -> Vec<Vec<f64>> {
102    matrix_fn_batch(covs, n, |l| l.max(0.0).sqrt())
103}
104/// Batched [`invsqrtm`] — rayon-parallel over `covs` (each row-major `n×n` SPD).
105pub fn invsqrtm_batch(covs: &[Vec<f64>], n: usize) -> Vec<Vec<f64>> {
106    matrix_fn_batch(covs, n, |l| 1.0 / l.max(1e-12).sqrt())
107}
108
109fn matmul(a: &[f64], b: &[f64], n: usize) -> Vec<f64> {
110    let mut o = vec![0f64; n * n];
111    for i in 0..n {
112        for k in 0..n {
113            let aik = a[i * n + k];
114            if aik == 0.0 {
115                continue;
116            }
117            for j in 0..n {
118                o[i * n + j] += aik * b[k * n + j];
119            }
120        }
121    }
122    o
123}
124
125/// Squared AIRM (affine-invariant Riemannian) geodesic distance:
126/// `δ²(A,B) = ‖log(A^{-1/2} B A^{-1/2})‖_F² = Σ log²(λ_i)` of `A^{-1/2} B A^{-1/2}`.
127pub fn airm_dist2(a: &[f64], b: &[f64], n: usize) -> f64 {
128    let w = invsqrtm(a, n);
129    let m = matmul(&matmul(&w, b, n), &w, n);
130    let (evals, _) = eigh(&m, n);
131    evals.iter().map(|&l| l.max(1e-12).ln().powi(2)).sum()
132}
133
134/// Karcher / Fréchet mean of SPD matrices under the AIRM metric — iterative tangent-space
135/// averaging: `M ← M^{1/2} · exp(mean_i log(M^{-1/2} C_i M^{-1/2})) · M^{1/2}`, initialised at the
136/// arithmetic mean, until the mean tangent norm falls below `tol` (or `iters` reached).
137pub fn karcher_mean(covs: &[Vec<f64>], n: usize, iters: usize, tol: f64) -> Vec<f64> {
138    let k = covs.len().max(1) as f64;
139    let mut m = vec![0f64; n * n];
140    for c in covs {
141        for i in 0..n * n {
142            m[i] += c[i] / k;
143        }
144    }
145    for _ in 0..iters {
146        let msqrt = sqrtm(&m, n);
147        let minv = invsqrtm(&m, n);
148        let mut sbar = vec![0f64; n * n];
149        for c in covs {
150            let wcw = matmul(&matmul(&minv, c, n), &minv, n);
151            let l = logm(&wcw, n);
152            for i in 0..n * n {
153                sbar[i] += l[i] / k;
154            }
155        }
156        let e = expm(&sbar, n);
157        m = matmul(&matmul(&msqrt, &e, n), &msqrt, n);
158        let norm: f64 = sbar.iter().map(|x| x * x).sum::<f64>().sqrt();
159        if norm < tol {
160            break;
161        }
162    }
163    m
164}
165
166/// Weighted Karcher / Fréchet mean of SPD matrices under the AIRM metric — the
167/// barycentre `argmin_M Σᵢ wᵢ · δ²(M, Cᵢ)`. Same iterative tangent-space averaging
168/// as [`karcher_mean`], but each `Cᵢ` contributes with the **normalised** weight
169/// `w̄ᵢ = wᵢ / Σⱼ wⱼ` in place of `1/k`:
170///
171/// ```text
172///   M₀ = Σᵢ w̄ᵢ Cᵢ                                    (weighted arithmetic init)
173///   S̄  = Σᵢ w̄ᵢ · log(M^{-1/2} Cᵢ M^{-1/2})            (weighted tangent mean)
174///   M ← M^{1/2} · exp(S̄) · M^{1/2}                    until ‖S̄‖_F < tol (or `iters`)
175/// ```
176///
177/// This is the exact barycentre that a barycentric OT projection (the Fréchet mean
178/// of an entropic coupling), soft-clustering, or weighted MDM needs — the *true*
179/// AIRM mean, not the log-Euclidean shortcut `expm(Σᵢ w̄ᵢ logm(Cᵢ))`. With uniform
180/// weights it coincides with [`karcher_mean`]; results depend only on the weight
181/// *ratios* (an all-zero weight vector falls back to a uniform mean). `weights`
182/// must be non-negative and align 1:1 with `covs`.
183pub fn karcher_mean_weighted(
184    covs: &[Vec<f64>],
185    weights: &[f64],
186    n: usize,
187    iters: usize,
188    tol: f64,
189) -> Vec<f64> {
190    assert_eq!(
191        covs.len(),
192        weights.len(),
193        "karcher_mean_weighted: {} covs but {} weights",
194        covs.len(),
195        weights.len()
196    );
197    // Normalise to a convex combination (Σ w̄ᵢ = 1); degenerate all-zero → uniform.
198    let wsum: f64 = weights.iter().sum();
199    let wbar: Vec<f64> = if wsum.abs() < 1e-300 {
200        let u = 1.0 / covs.len().max(1) as f64;
201        vec![u; covs.len()]
202    } else {
203        weights.iter().map(|&w| w / wsum).collect()
204    };
205    // Weighted arithmetic mean as the initial guess.
206    let mut m = vec![0f64; n * n];
207    for (c, &w) in covs.iter().zip(&wbar) {
208        for i in 0..n * n {
209            m[i] += w * c[i];
210        }
211    }
212    for _ in 0..iters {
213        let msqrt = sqrtm(&m, n);
214        let minv = invsqrtm(&m, n);
215        let mut sbar = vec![0f64; n * n];
216        for (c, &w) in covs.iter().zip(&wbar) {
217            let wcw = matmul(&matmul(&minv, c, n), &minv, n);
218            let l = logm(&wcw, n);
219            for i in 0..n * n {
220                sbar[i] += w * l[i];
221            }
222        }
223        let e = expm(&sbar, n);
224        m = matmul(&matmul(&msqrt, &e, n), &msqrt, n);
225        let norm: f64 = sbar.iter().map(|x| x * x).sum::<f64>().sqrt();
226        if norm < tol {
227            break;
228        }
229    }
230    m
231}
232
233// ── SPDNet / Riemannian layer kernels ────────────────────────────
234//
235// Forward + backward for the BiMap / ReEig / LogEig layers of SPDNet
236// (Huang & Van Gool, AAAI 2017) and the SPD batch-norm transport of
237// Brooks et al. (NeurIPS 2019). These are the compute bodies the core
238// `Op::BiMap` / `Op::ReEig` / `Op::LogEig` / `Op::SpdBatchNorm` CPU
239// thunks call — kept here (host-testable, LAPACK-backed) so the thunk
240// layer stays a thin arena-offset shim.
241
242/// Transpose an `n×n` row-major matrix.
243fn transpose(a: &[f64], n: usize) -> Vec<f64> {
244    let mut t = vec![0f64; n * n];
245    for i in 0..n {
246        for j in 0..n {
247            t[j * n + i] = a[i * n + j];
248        }
249    }
250    t
251}
252
253/// Symmetrize an `n×n` matrix in place-style: `½(A + Aᵀ)`.
254fn symmetrize(a: &[f64], n: usize) -> Vec<f64> {
255    let mut o = vec![0f64; n * n];
256    for i in 0..n {
257        for j in 0..n {
258            o[i * n + j] = 0.5 * (a[i * n + j] + a[j * n + i]);
259        }
260    }
261    o
262}
263
264/// BiMap (bilinear mapping) layer forward: `Y = W · X · Wᵀ`.
265/// `W` is `[m, n]`, `X` is `[n, n]` (symmetric SPD), `Y` is `[m, m]`.
266/// Maps `SPD_n → SPD_m` when `W` has full row rank (the Stiefel /
267/// semi-orthogonality constraint on `W` is enforced by the optimizer,
268/// not here).
269pub fn bimap(w: &[f64], x: &[f64], m: usize, n: usize) -> Vec<f64> {
270    // T = W·X  [m, n]
271    let mut t = vec![0f64; m * n];
272    crate::blas::dgemm(w, x, &mut t, m, n, n);
273    // Y = T·Wᵀ  [m, m]
274    let mut wt = vec![0f64; n * m];
275    for i in 0..m {
276        for j in 0..n {
277            wt[j * m + i] = w[i * n + j];
278        }
279    }
280    let mut y = vec![0f64; m * m];
281    crate::blas::dgemm(&t, &wt, &mut y, m, n, m);
282    y
283}
284
285/// ReEig (eigenvalue rectification) forward: `Y = U · max(ε, Σ) · Uᵀ`
286/// where `X = U Σ Uᵀ`. The SPD analogue of ReLU — floors the spectrum
287/// at `eps` to keep the output SPD and well-conditioned.
288pub fn reeig(x: &[f64], n: usize, eps: f64) -> Vec<f64> {
289    matrix_fn(x, n, |l| l.max(eps))
290}
291
292/// LogEig forward: `Y = U · log(Σ) · Uᵀ = logm(X)`. Maps the SPD
293/// manifold to the tangent space at the identity (symmetric matrices)
294/// so a Euclidean classifier can consume it. `eps` floors the spectrum
295/// before the log for numerical safety.
296pub fn logeig(x: &[f64], n: usize, eps: f64) -> Vec<f64> {
297    matrix_fn(x, n, |l| l.max(eps).ln())
298}
299
300/// Adjoint (reverse-mode VJP) of a symmetric matrix function
301/// `Y = U f(Σ) Uᵀ` via the Daleckii–Krein / Loewner formula:
302///
303/// ```text
304///   (λ, U) = eigh(X)                       // U columns = eigenvectors
305///   Ḡ      = ½(dY + dYᵀ)                   // project to symmetric cotangent
306///   C      = Uᵀ · Ḡ · U
307///   P[a,b] = (f(λ_a) − f(λ_b)) / (λ_a − λ_b)  (a≠b, else f′(λ_a))   // Loewner
308///   dX     = sym( U · (P ⊙ C) · Uᵀ )
309/// ```
310///
311/// `P` is symmetric so the differential is self-adjoint; the same
312/// kernel serves ReEig (`f = max(·,ε)`), LogEig (`f = log`), and the
313/// matrix-√ used by SPD batch-norm's `G` gradient.
314pub fn spectral_backward(
315    x: &[f64],
316    dy: &[f64],
317    n: usize,
318    f: impl Fn(f64) -> f64,
319    fprime: impl Fn(f64) -> f64,
320) -> Vec<f64> {
321    let (lam, v) = eigh(x, n);
322    spectral_backward_precomputed(&lam, &v, dy, n, f, fprime)
323}
324
325/// Square `n×n` matmul via BLAS `dgemm` (Accelerate/MKL/OpenBLAS).
326fn mm(a: &[f64], b: &[f64], n: usize) -> Vec<f64> {
327    let mut o = vec![0f64; n * n];
328    crate::blas::dgemm(a, b, &mut o, n, n, n);
329    o
330}
331
332/// Reconstruct `Σ_k fk[k] · u_k u_kᵀ` from row-major eigenvectors `v`
333/// (row `k` = eigenvector `k`, matching [`eigh`]) and per-eigenvalue
334/// weights `fk`. Computes `(diag(fk)·V)ᵀ · V` via `dgemm`.
335fn reconstruct(fk: &[f64], v: &[f64], n: usize) -> Vec<f64> {
336    let mut sv = vec![0f64; n * n];
337    for k in 0..n {
338        for i in 0..n {
339            sv[k * n + i] = fk[k] * v[k * n + i];
340        }
341    }
342    mm(&transpose(&sv, n), v, n)
343}
344
345/// Forward of a symmetric matrix function `Y = U f(Σ) Uᵀ`, packed as
346/// `[Y (n²), λ (n), U (n²)]` so the backward can **reuse** the
347/// eigendecomposition instead of recomputing it. `U` is row-major
348/// (row `k` = eigenvector `k`), matching [`eigh`]. Consumed by the
349/// `Op::ReEig` / `Op::LogEig` CPU kernels.
350pub fn spectral_forward_packed(x: &[f64], n: usize, f: impl Fn(f64) -> f64) -> Vec<f64> {
351    let (lam, v) = eigh(x, n);
352    let fk: Vec<f64> = lam.iter().map(|&l| f(l)).collect();
353    let y = reconstruct(&fk, &v, n);
354    let mut out = vec![0f64; 2 * n * n + n];
355    out[..n * n].copy_from_slice(&y);
356    out[n * n..n * n + n].copy_from_slice(&lam);
357    out[n * n + n..].copy_from_slice(&v);
358    out
359}
360
361/// [`spectral_backward`] with a **precomputed** eigendecomposition
362/// (`lam`, `v` = row-major eigenvectors). This is the hot path: the
363/// forward already produced `(λ, U)`, so the backward skips a redundant
364/// `eigh`. All three matrix products go through `dgemm`.
365pub fn spectral_backward_precomputed(
366    lam: &[f64],
367    v: &[f64],
368    dy: &[f64],
369    n: usize,
370    f: impl Fn(f64) -> f64,
371    fprime: impl Fn(f64) -> f64,
372) -> Vec<f64> {
373    let g = symmetrize(dy, n);
374    let vt = transpose(v, n);
375    // C = Uᵀ·Ḡ·U = V·Ḡ·Vᵀ   (V row-major = rows-are-eigenvectors).
376    let c = mm(&mm(v, &g, n), &vt, n);
377    // Loewner-weighted middle matrix M = P ⊙ C.
378    let fl: Vec<f64> = lam.iter().map(|&l| f(l)).collect();
379    let scale = lam.iter().fold(0f64, |acc, &l| acc.max(l.abs())).max(1.0);
380    let tol = scale * 1e-9;
381    let mut mmat = vec![0f64; n * n];
382    for a in 0..n {
383        for b in 0..n {
384            let d = lam[a] - lam[b];
385            let p = if d.abs() > tol {
386                (fl[a] - fl[b]) / d
387            } else {
388                fprime(lam[a])
389            };
390            mmat[a * n + b] = p * c[a * n + b];
391        }
392    }
393    // dX = U·M·Uᵀ = Vᵀ·M·V.
394    let dx = mm(&mm(&vt, &mmat, n), v, n);
395    symmetrize(&dx, n)
396}
397
398/// ReEig backward: `dX` for `Y = U max(ε, Σ) Uᵀ`. `f(λ)=max(λ,ε)`,
399/// `f′(λ)=[λ>ε]` (the straddling case is handled exactly by the
400/// Loewner divided difference).
401pub fn reeig_backward(x: &[f64], dy: &[f64], n: usize, eps: f64) -> Vec<f64> {
402    let (lam, v) = eigh(x, n);
403    reeig_backward_precomputed(&lam, &v, dy, n, eps)
404}
405
406/// [`reeig_backward`] with a precomputed eigendecomposition.
407pub fn reeig_backward_precomputed(
408    lam: &[f64],
409    v: &[f64],
410    dy: &[f64],
411    n: usize,
412    eps: f64,
413) -> Vec<f64> {
414    spectral_backward_precomputed(
415        lam,
416        v,
417        dy,
418        n,
419        |l| l.max(eps),
420        |l| {
421            if l > eps { 1.0 } else { 0.0 }
422        },
423    )
424}
425
426/// LogEig backward: `dX` for `Y = logm(X)`. `f=log`, `f′=1/λ`.
427pub fn logeig_backward(x: &[f64], dy: &[f64], n: usize, eps: f64) -> Vec<f64> {
428    let (lam, v) = eigh(x, n);
429    logeig_backward_precomputed(&lam, &v, dy, n, eps)
430}
431
432/// [`logeig_backward`] with a precomputed eigendecomposition.
433pub fn logeig_backward_precomputed(
434    lam: &[f64],
435    v: &[f64],
436    dy: &[f64],
437    n: usize,
438    eps: f64,
439) -> Vec<f64> {
440    spectral_backward_precomputed(lam, v, dy, n, |l| l.max(eps).ln(), |l| 1.0 / l.max(eps))
441}
442
443/// SPD batch-norm transport forward. For a batch of SPD matrices
444/// `X_i` (`x` packed `[batch, n, n]`), a (detached) Fréchet-mean
445/// `mean` `[n,n]`, and a learnable SPD bias `g` `[n,n]`:
446///
447/// ```text
448///   Y_i = G^{1/2} · (M^{-1/2} X_i M^{-1/2}) · G^{1/2}
449/// ```
450///
451/// i.e. parallel-transport each `X_i` to the identity (centering by the
452/// batch mean) then bias toward `G`. `eps` floors the spectra of `M`/`G`.
453pub fn spd_bn_transport(
454    x: &[f64],
455    mean: &[f64],
456    g: &[f64],
457    batch: usize,
458    n: usize,
459    eps: f64,
460) -> Vec<f64> {
461    use rayon::prelude::*;
462    let ms = matrix_fn(mean, n, |l| 1.0 / l.max(eps).sqrt()); // M^{-1/2}
463    let gs = matrix_fn(g, n, |l| l.max(eps).sqrt()); // G^{1/2}
464    // Per-slice work is independent → rayon over the batch (the hot
465    // path: thousands of small covariances). Each slice's matmuls go
466    // through BLAS dgemm.
467    let slices: Vec<Vec<f64>> = (0..batch)
468        .into_par_iter()
469        .map(|bi| {
470            let xi = &x[bi * n * n..(bi + 1) * n * n];
471            let ci = mm(&mm(&ms, xi, n), &ms, n); // Ms Xi Ms
472            mm(&mm(&gs, &ci, n), &gs, n) // Gs Ci Gs
473        })
474        .collect();
475    slices.concat()
476}
477
478/// SPD batch-norm transport backward w.r.t. the inputs `X_i`
479/// (mean detached). With `Y_i = P X_i Pᵀ`, `P = G^{1/2} M^{-1/2}`,
480/// `dX_i = Pᵀ · Ḡ_i · P = M^{-1/2} G^{1/2} Ḡ_i G^{1/2} M^{-1/2}`.
481pub fn spd_bn_backward_x(
482    mean: &[f64],
483    g: &[f64],
484    dy: &[f64],
485    batch: usize,
486    n: usize,
487    eps: f64,
488) -> Vec<f64> {
489    use rayon::prelude::*;
490    let ms = matrix_fn(mean, n, |l| 1.0 / l.max(eps).sqrt());
491    let gs = matrix_fn(g, n, |l| l.max(eps).sqrt());
492    let q = mm(&ms, &gs, n); // Q = Ms Gs  (= Pᵀ)
493    let qt = transpose(&q, n); // Qᵀ = Gs Ms
494    let slices: Vec<Vec<f64>> = (0..batch)
495        .into_par_iter()
496        .map(|bi| {
497            let gi = &dy[bi * n * n..(bi + 1) * n * n];
498            symmetrize(&mm(&mm(&q, gi, n), &qt, n), n) // sym(Q Ḡ_i Qᵀ)
499        })
500        .collect();
501    slices.concat()
502}
503
504/// SPD batch-norm transport backward w.r.t. the learnable bias `G`
505/// (mean detached). `dL/dG^{1/2} = Σ_i (Ḡ_i G^{1/2} C_i + C_i G^{1/2} Ḡ_i)`
506/// with `C_i = M^{-1/2} X_i M^{-1/2}`, then pushed through the matrix-√
507/// VJP (`spectral_backward` with `f=√`, `f′=1/(2√)`).
508pub fn spd_bn_backward_g(
509    x: &[f64],
510    mean: &[f64],
511    g: &[f64],
512    dy: &[f64],
513    batch: usize,
514    n: usize,
515    eps: f64,
516) -> Vec<f64> {
517    use rayon::prelude::*;
518    let ms = matrix_fn(mean, n, |l| 1.0 / l.max(eps).sqrt());
519    let gs = matrix_fn(g, n, |l| l.max(eps).sqrt());
520    // Map each slice to its dL/dGs contribution, then reduce-sum.
521    let dgs = (0..batch)
522        .into_par_iter()
523        .map(|bi| {
524            let xi = &x[bi * n * n..(bi + 1) * n * n];
525            let gi = &dy[bi * n * n..(bi + 1) * n * n];
526            let ci = mm(&mm(&ms, xi, n), &ms, n); // C_i (symmetric)
527            let t1 = mm(&mm(gi, &gs, n), &ci, n); // Ḡ_i Gs C_i
528            let t2 = mm(&mm(&ci, &gs, n), gi, n); // C_i Gs Ḡ_i
529            let mut s = t1;
530            for k in 0..n * n {
531                s[k] += t2[k];
532            }
533            s
534        })
535        .reduce(
536            || vec![0f64; n * n],
537            |mut a, b| {
538                for k in 0..n * n {
539                    a[k] += b[k];
540                }
541                a
542            },
543        );
544    // Push dL/dGs through Gs = G^{1/2} (reuses eigh of G once).
545    spectral_backward(
546        g,
547        &dgs,
548        n,
549        |l| l.max(eps).sqrt(),
550        |l| 0.5 / l.max(eps).sqrt(),
551    )
552}
553
554/// Geodesic interpolation between two SPD matrices under the AIRM
555/// metric: `A #_t B = A^{1/2} (A^{-1/2} B A^{-1/2})^t A^{1/2}`. Used by
556/// the trainer to update the running Fréchet mean of SPD batch-norm
557/// (`running ← running #_momentum batch_mean`) — a non-differentiable
558/// buffer update, mirroring how Euclidean batch-norm tracks running
559/// stats outside the autodiff graph.
560pub fn geodesic_interp(a: &[f64], b: &[f64], t: f64, n: usize) -> Vec<f64> {
561    let (asqrt, ainv) = sqrt_invsqrt(a, n);
562    let m = matmul(&matmul(&ainv, b, n), &ainv, n);
563    let mt = matrix_fn(&m, n, |l| l.max(1e-12).powf(t));
564    matmul(&matmul(&asqrt, &mt, n), &asqrt, n)
565}
566
567/// AIRM Riemannian **logarithm** at base point `base`: maps the SPD point `x` to
568/// the tangent vector `V ∈ T_base` whose geodesic reaches `x`:
569///
570/// ```text
571///   Log_P(X) = P^{1/2} · logm(P^{-1/2} X P^{-1/2}) · P^{1/2}
572/// ```
573///
574/// The arbitrary-base generalisation of [`logm`] (which is `Log_I`, the
575/// log-Euclidean tangent at the identity), and the inverse of [`exp_map`]. Its
576/// AIRM norm equals the geodesic distance, `‖Log_P(X)‖_P = δ_AIRM(P, X)`. `base`
577/// and `x` are row-major `n×n` SPD; the result is symmetric.
578pub fn log_map(base: &[f64], x: &[f64], n: usize) -> Vec<f64> {
579    let (phalf, pinv) = sqrt_invsqrt(base, n);
580    let m = matmul(&matmul(&pinv, x, n), &pinv, n); // P^{-1/2} X P^{-1/2}
581    let lm = logm(&m, n);
582    matmul(&matmul(&phalf, &lm, n), &phalf, n)
583}
584
585/// AIRM Riemannian **exponential** at base point `base`: maps the tangent vector
586/// `v ∈ T_base` to the SPD point reached by the geodesic leaving `base` with
587/// initial velocity `v`:
588///
589/// ```text
590///   Exp_P(V) = P^{1/2} · expm(P^{-1/2} V P^{-1/2}) · P^{1/2}
591/// ```
592///
593/// Inverse of [`log_map`] (`Exp_P(Log_P(X)) = X`) and the arbitrary-base
594/// generalisation of [`expm`] (`Exp_I`). Note `geodesic_interp(A, B, t)` is
595/// exactly `exp_map(A, t · log_map(A, B))`. `base` is SPD and `v` symmetric
596/// (row-major `n×n`); the result is SPD.
597pub fn exp_map(base: &[f64], v: &[f64], n: usize) -> Vec<f64> {
598    let (phalf, pinv) = sqrt_invsqrt(base, n);
599    let m = matmul(&matmul(&pinv, v, n), &pinv, n); // P^{-1/2} V P^{-1/2}
600    let em = expm(&m, n);
601    matmul(&matmul(&phalf, &em, n), &phalf, n)
602}
603
604/// AIRM **parallel transport** of a tangent vector `v ∈ T_from` to `T_to` along
605/// the connecting geodesic:
606///
607/// ```text
608///   Γ_{P→Q}(V) = E · V · Eᵀ,   E = (Q P^{-1})^{1/2} = P^{1/2} (P^{-1/2} Q P^{-1/2})^{1/2} P^{-1/2}
609/// ```
610///
611/// The right-hand symmetric-SPD form is what's computed — every factor is a
612/// matrix function of an SPD matrix, so no non-symmetric eigendecomposition of
613/// `Q P^{-1}` is needed. Transport is an AIRM isometry (it preserves the metric
614/// inner product), making it the operator that moves tangent vectors between base
615/// points for Riemannian OT / domain adaptation (Yair et al., 2019) and SPD
616/// batch-norm. `from`/`to` are SPD and `v` symmetric (row-major `n×n`); the result
617/// is a symmetric tangent vector in `T_to`.
618pub fn parallel_transport(from: &[f64], to: &[f64], v: &[f64], n: usize) -> Vec<f64> {
619    let (phalf, pinv) = sqrt_invsqrt(from, n);
620    let wq = matmul(&matmul(&pinv, to, n), &pinv, n); // P^{-1/2} Q P^{-1/2}  (SPD)
621    let wq_half = sqrtm(&wq, n);
622    let e = matmul(&matmul(&phalf, &wq_half, n), &pinv, n); // E = P^{1/2} (…)^{1/2} P^{-1/2}
623    let et = transpose(&e, n);
624    let evet = matmul(&matmul(&e, v, n), &et, n);
625    symmetrize(&evet, n) // E V Eᵀ is symmetric for symmetric V — kill roundoff drift
626}
627
628// ── Riemannian map VJPs (reverse-mode) ───────────────────────────
629//
630// Analytic adjoints for `log_map` / `exp_map` / `parallel_transport`, so the
631// AIRM geometric maps are differentiable end-to-end. Every gradient reduces to
632// matmuls plus the Daleckii–Krein spectral adjoint [`spectral_backward`] of the
633// three matrix functions involved (√, ·^{-1/2}, and log or exp). Base points get
634// gradients too (they enter through `P^{1/2}` and `P^{-1/2}`), so a learned base
635// trains correctly — not just the moving argument. All are finite-difference
636// checked in the tests.
637
638/// `A + B` for two row-major `n×n` matrices.
639fn add(a: &[f64], b: &[f64]) -> Vec<f64> {
640    a.iter().zip(b).map(|(x, y)| x + y).collect()
641}
642
643// Spectral `(f, f′)` pairs used by the map VJPs — floored to match the forward
644// maps. Named fns (not closures) so a cached eigendecomposition can be reused by
645// both `reconstruct_fn` (forward) and `spectral_backward_precomputed` (adjoint).
646fn sqrt_ff(l: f64) -> f64 {
647    l.max(0.0).sqrt()
648}
649fn sqrt_fp(l: f64) -> f64 {
650    0.5 / l.max(1e-12).sqrt()
651}
652fn invsqrt_ff(l: f64) -> f64 {
653    1.0 / l.max(1e-12).sqrt()
654}
655fn invsqrt_fp(l: f64) -> f64 {
656    -0.5 * l.max(1e-12).powf(-1.5)
657}
658fn log_ff(l: f64) -> f64 {
659    l.max(1e-12).ln()
660}
661fn log_fp(l: f64) -> f64 {
662    1.0 / l.max(1e-12)
663}
664
665/// `V·diag(f(λ))·Vᵀ` from a precomputed eigendecomposition `(lam, v)` (row-major
666/// eigenvectors, as [`eigh`]) — the matrix-function partner of
667/// [`spectral_backward_precomputed`], so one cached `eigh` drives both the value
668/// and its adjoint.
669fn reconstruct_fn(lam: &[f64], v: &[f64], n: usize, f: impl Fn(f64) -> f64) -> Vec<f64> {
670    let fk: Vec<f64> = lam.iter().map(|&l| f(l)).collect();
671    reconstruct(&fk, v, n)
672}
673
674/// VJP of [`log_map`]: given the cotangent `dy` on `Log_P(X)`, returns
675/// `(∂L/∂base, ∂L/∂x)`. With `A = P^{1/2}`, `W = P^{-1/2}`, `M = W X W`:
676/// `X̄ = W · DK_log(M, A ḡ A) · W`, and the base receives the √ / ·^{-1/2}
677/// adjoints of `Ā = ḡAL + (ḡAL)ᵀ` and `W̄ = M̄WX + (M̄WX)ᵀ`. The two SPD
678/// eigendecompositions (`base`, `M`) are each computed once and reused for their
679/// matrix functions *and* Daleckii–Krein adjoints.
680pub fn log_map_backward(base: &[f64], x: &[f64], dy: &[f64], n: usize) -> (Vec<f64>, Vec<f64>) {
681    let (lam_b, v_b) = eigh(base, n);
682    let a = reconstruct_fn(&lam_b, &v_b, n, sqrt_ff); // P^{1/2}
683    let w = reconstruct_fn(&lam_b, &v_b, n, invsqrt_ff); // P^{-1/2}
684    let m = matmul(&matmul(&w, x, n), &w, n); // M = W X W
685    let (lam_m, v_m) = eigh(&m, n);
686    let g = symmetrize(dy, n);
687    let lbar = matmul(&matmul(&a, &g, n), &a, n); // L̄ = A ḡ A
688    let mbar = spectral_backward_precomputed(&lam_m, &v_m, &lbar, n, log_ff, log_fp);
689    let d_x = symmetrize(&matmul(&matmul(&w, &mbar, n), &w, n), n); // W M̄ W
690    // Base gradient: A = P^{1/2} and W = P^{-1/2} both depend on P.
691    let l = reconstruct_fn(&lam_m, &v_m, n, log_ff); // logm(M)
692    let gal = matmul(&matmul(&g, &a, n), &l, n); // ḡ A L
693    let abar = add(&gal, &transpose(&gal, n)); // Ā = ḡAL + (ḡAL)ᵀ
694    let mwx = matmul(&matmul(&mbar, &w, n), x, n); // M̄ W X
695    let wbar = add(&mwx, &transpose(&mwx, n)); // W̄ = M̄WX + (M̄WX)ᵀ
696    let d_base_a = spectral_backward_precomputed(&lam_b, &v_b, &abar, n, sqrt_ff, sqrt_fp);
697    let d_base_w = spectral_backward_precomputed(&lam_b, &v_b, &wbar, n, invsqrt_ff, invsqrt_fp);
698    let d_base = symmetrize(&add(&d_base_a, &d_base_w), n);
699    (d_base, d_x)
700}
701
702/// VJP of [`exp_map`]: given the cotangent `dy` on `Exp_P(V)`, returns
703/// `(∂L/∂base, ∂L/∂v)`. Identical structure to [`log_map_backward`] with the
704/// `exp` spectral adjoint (and `E = expm(M)` in the base term) instead of `log`.
705pub fn exp_map_backward(base: &[f64], v: &[f64], dy: &[f64], n: usize) -> (Vec<f64>, Vec<f64>) {
706    let (lam_b, v_b) = eigh(base, n);
707    let a = reconstruct_fn(&lam_b, &v_b, n, sqrt_ff);
708    let w = reconstruct_fn(&lam_b, &v_b, n, invsqrt_ff);
709    let m = matmul(&matmul(&w, v, n), &w, n); // M = W V W
710    let (lam_m, v_m) = eigh(&m, n);
711    let g = symmetrize(dy, n);
712    let ebar = matmul(&matmul(&a, &g, n), &a, n); // Ē = A ḡ A
713    let mbar = spectral_backward_precomputed(&lam_m, &v_m, &ebar, n, f64::exp, f64::exp);
714    let d_v = symmetrize(&matmul(&matmul(&w, &mbar, n), &w, n), n); // W M̄ W
715    let e = reconstruct_fn(&lam_m, &v_m, n, f64::exp); // expm(M)
716    let gae = matmul(&matmul(&g, &a, n), &e, n); // ḡ A E
717    let abar = add(&gae, &transpose(&gae, n));
718    let mwv = matmul(&matmul(&mbar, &w, n), v, n); // M̄ W V
719    let wbar = add(&mwv, &transpose(&mwv, n));
720    let d_base_a = spectral_backward_precomputed(&lam_b, &v_b, &abar, n, sqrt_ff, sqrt_fp);
721    let d_base_w = spectral_backward_precomputed(&lam_b, &v_b, &wbar, n, invsqrt_ff, invsqrt_fp);
722    let d_base = symmetrize(&add(&d_base_a, &d_base_w), n);
723    (d_base, d_v)
724}
725
726/// VJP of [`parallel_transport`]: given the cotangent `dy` on `Γ_{P→Q}(V)`,
727/// returns `(∂L/∂from, ∂L/∂to, ∂L/∂v)`. With `E = A S W`, `S = (W Q W)^{1/2}`,
728/// `A = P^{1/2}`, `W = P^{-1/2}`: `V̄ = Eᵀ ḡ E`, `Q̄ = W · DK_√(M_q, S̄) · W`,
729/// and `P̄` collects the √ / ·^{-1/2} adjoints of the `A`/`W` cotangents. The
730/// `from` and `M_q` eigendecompositions are each computed once and reused.
731pub fn parallel_transport_backward(
732    from: &[f64],
733    to: &[f64],
734    v: &[f64],
735    dy: &[f64],
736    n: usize,
737) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
738    let (lam_f, v_f) = eigh(from, n);
739    let a = reconstruct_fn(&lam_f, &v_f, n, sqrt_ff); // P^{1/2}
740    let w = reconstruct_fn(&lam_f, &v_f, n, invsqrt_ff); // P^{-1/2}
741    let mq = matmul(&matmul(&w, to, n), &w, n); // M_q = W Q W
742    let (lam_mq, v_mq) = eigh(&mq, n);
743    let s = reconstruct_fn(&lam_mq, &v_mq, n, sqrt_ff); // S = M_q^{1/2}
744    let e = matmul(&matmul(&a, &s, n), &w, n); // E = A S W
745    let et = transpose(&e, n);
746    let g = symmetrize(dy, n);
747    // grad_v = Eᵀ ḡ E.
748    let d_v = symmetrize(&matmul(&matmul(&et, &g, n), &e, n), n);
749    // Ē = 2 ḡ E V (cotangent on the non-symmetric E).
750    let gev = matmul(&matmul(&g, &e, n), v, n);
751    let ebar: Vec<f64> = gev.iter().map(|x| 2.0 * x).collect();
752    // E = A S W: split Ē across A, S, W.
753    let abar = matmul(&matmul(&ebar, &w, n), &s, n); // Ā = Ē (SW)ᵀ = Ē W S
754    let sbar = matmul(&matmul(&a, &ebar, n), &w, n); // S̄ = A Ē W
755    let wbar1 = matmul(&matmul(&s, &a, n), &ebar, n); // W̄₁ = (AS)ᵀ Ē = S A Ē
756    // S = (M_q)^{1/2}: adjoint back to M_q (reusing its eigendecomp), then Q, W.
757    let mqbar = spectral_backward_precomputed(&lam_mq, &v_mq, &sbar, n, sqrt_ff, sqrt_fp);
758    let d_to = symmetrize(&matmul(&matmul(&w, &mqbar, n), &w, n), n); // Q̄ = W M̄_q W
759    let mqwq = matmul(&matmul(&mqbar, &w, n), to, n); // M̄_q W Q
760    let wbar = add(&wbar1, &add(&mqwq, &transpose(&mqwq, n))); // W̄ = W̄₁ + (M̄_qWQ + ·ᵀ)
761    let d_from_a = spectral_backward_precomputed(&lam_f, &v_f, &abar, n, sqrt_ff, sqrt_fp);
762    let d_from_w = spectral_backward_precomputed(&lam_f, &v_f, &wbar, n, invsqrt_ff, invsqrt_fp);
763    let d_from = symmetrize(&add(&d_from_a, &d_from_w), n);
764    (d_from, d_to, d_v)
765}
766
767/// VJP of [`logm_batch`]/[`expm_batch`]/[`sqrtm_batch`]/[`invsqrtm_batch`]:
768/// per-slice Daleckii–Krein adjoint of the chosen matrix function. `x`/`dy` are
769/// `[batch, n, n]`; `(f, fprime)` is the scalar spectral function and its
770/// derivative (matching the batch forward). Rayon-parallel over the batch.
771pub fn matrix_fn_batch_backward(
772    x: &[f64],
773    dy: &[f64],
774    n: usize,
775    f: impl Fn(f64) -> f64 + Sync,
776    fprime: impl Fn(f64) -> f64 + Sync,
777) -> Vec<f64> {
778    use rayon::prelude::*;
779    let batch = x.len() / (n * n);
780    (0..batch)
781        .into_par_iter()
782        .flat_map(|bi| {
783            let xi = &x[bi * n * n..(bi + 1) * n * n];
784            let dyi = &dy[bi * n * n..(bi + 1) * n * n];
785            spectral_backward(xi, dyi, n, &f, &fprime)
786        })
787        .collect()
788}
789
790// ── Symmetric eigendecomposition as a differentiable op ──────────
791//
792// `eigh` exposed as a first-class op: forward packs `[λ ∥ U]` (ascending λ,
793// `U` with column j = eigenvector j, so `A = U diag(λ) Uᵀ`), and the reverse
794// mode is the standard symmetric-eigendecomposition adjoint. This is the
795// differentiable primitive the SPD spectral functions can be built on, and the
796// seam a native batched eigensolver (cuSOLVER `syevjBatched` &c.) slots into.
797
798/// Symmetric eigendecomposition packed as `[λ (n) ∥ U (n²)]` (row-major `U`,
799/// column `j` = eigenvector `j`, `A = U diag(λ) Uᵀ`; `λ` ascending).
800pub fn eigh_packed(a: &[f64], n: usize) -> Vec<f64> {
801    let (w, vrow) = eigh(a, n); // vrow: row k = eigenvector k  (= Uᵀ)
802    let u = transpose(&vrow, n); // column j = eigenvector j
803    let mut out = vec![0f64; n + n * n];
804    out[..n].copy_from_slice(&w);
805    out[n..].copy_from_slice(&u);
806    out
807}
808
809/// VJP of the symmetric eigendecomposition. `fwd = [λ ∥ U]` (from
810/// [`eigh_packed`]); `bar = [λ̄ ∥ Ū]` is the upstream cotangent (same layout).
811/// Returns the symmetric `Ā [n,n]`:
812///
813/// ```text
814///   Ā = sym( U (diag(λ̄) + F ∘ (Uᵀ Ū)) Uᵀ ),   F_ij = 1/(λ_j − λ_i)  (i≠j)
815/// ```
816///
817/// Degenerate pairs (`|λ_j − λ_i| ≤ tol`) zero the off-diagonal coupling — the
818/// eigenvector gradient is undefined across a repeated eigenvalue.
819pub fn eigh_backward_packed(fwd: &[f64], bar: &[f64], n: usize) -> Vec<f64> {
820    let lam = &fwd[..n];
821    let u = &fwd[n..];
822    let lbar = &bar[..n];
823    let ubar = &bar[n..];
824    let ut = transpose(u, n);
825    let c = mm(&ut, ubar, n); // C = Uᵀ Ū
826    let scale = lam.iter().fold(0f64, |a, &l| a.max(l.abs())).max(1.0);
827    let tol = scale * 1e-9;
828    let mut mid = vec![0f64; n * n];
829    for i in 0..n {
830        for j in 0..n {
831            mid[i * n + j] = if i == j {
832                lbar[i]
833            } else {
834                let d = lam[j] - lam[i];
835                if d.abs() > tol { c[i * n + j] / d } else { 0.0 }
836            };
837        }
838    }
839    let abar = mm(&mm(u, &mid, n), &ut, n); // U · mid · Uᵀ
840    symmetrize(&abar, n)
841}
842
843/// Batched [`eigh_packed`] — rayon-parallel over `mats` (each row-major `n×n`
844/// symmetric). Output slice `b` is the packed `[λ ∥ U]` for `mats[b]`.
845pub fn eigh_batch_packed(mats: &[Vec<f64>], n: usize) -> Vec<Vec<f64>> {
846    use rayon::prelude::*;
847    mats.par_iter().map(|a| eigh_packed(a, n)).collect()
848}
849
850#[cfg(test)]
851mod tests {
852    use super::*;
853
854    fn ident(n: usize) -> Vec<f64> {
855        let mut a = vec![0f64; n * n];
856        for i in 0..n {
857            a[i * n + i] = 1.0;
858        }
859        a
860    }
861
862    #[test]
863    fn logm_expm_roundtrip() {
864        // A SPD; expm(logm(A)) ≈ A.
865        let n = 4;
866        let mut a = vec![0f64; n * n];
867        for i in 0..n {
868            for j in 0..n {
869                a[i * n + j] = if i == j { 2.0 + i as f64 } else { 0.3 };
870            }
871        }
872        let back = expm(&logm(&a, n), n);
873        let err: f64 = a
874            .iter()
875            .zip(&back)
876            .map(|(x, y)| (x - y).abs())
877            .fold(0.0, f64::max);
878        assert!(err < 1e-6, "logm/expm roundtrip err {err}");
879    }
880
881    #[test]
882    fn sqrtm_squares_back() {
883        let n = 3;
884        let a = vec![4.0, 0.0, 0.0, 0.0, 9.0, 0.0, 0.0, 0.0, 16.0];
885        let s = sqrtm(&a, n);
886        assert!(
887            (s[0] - 2.0).abs() < 1e-9 && (s[4] - 3.0).abs() < 1e-9 && (s[8] - 4.0).abs() < 1e-9
888        );
889    }
890
891    #[test]
892    fn airm_dist_zero_to_self_and_invariant() {
893        let n = 3;
894        let a = vec![2.0, 0.1, 0.0, 0.1, 3.0, 0.2, 0.0, 0.2, 1.5];
895        assert!(airm_dist2(&a, &a, n) < 1e-9, "distance to self must be 0");
896        // Affine invariance: δ(A,I) = δ(kA,kI) for scalar k (both are congruence by √k·I).
897        let k = 5.0;
898        let ka: Vec<f64> = a.iter().map(|x| x * k).collect();
899        let ki: Vec<f64> = ident(n).iter().map(|x| x * k).collect();
900        let d1 = airm_dist2(&a, &ident(n), n);
901        let d2 = airm_dist2(&ka, &ki, n);
902        assert!(
903            (d1 - d2).abs() < 1e-6,
904            "AIRM not affine-invariant: {d1} vs {d2}"
905        );
906    }
907
908    #[test]
909    fn karcher_mean_of_identicals_is_the_matrix() {
910        let n = 3;
911        let a = vec![2.0, 0.1, 0.0, 0.1, 3.0, 0.2, 0.0, 0.2, 1.5];
912        let m = karcher_mean(&[a.clone(), a.clone(), a.clone()], n, 20, 1e-10);
913        let err: f64 = a
914            .iter()
915            .zip(&m)
916            .map(|(x, y)| (x - y).abs())
917            .fold(0.0, f64::max);
918        assert!(err < 1e-6, "Karcher mean of identicals err {err}");
919    }
920
921    // ── SPDNet layer kernels ─────────────────────────────────────
922
923    /// Deterministic symmetric matrix (no RNG — reproducible).
924    fn sym(n: usize, seed: f64) -> Vec<f64> {
925        let mut a = vec![0f64; n * n];
926        for i in 0..n {
927            for j in i..n {
928                let v = ((i as f64 * 3.0 + j as f64 * 1.7 + seed).sin()) * 0.5;
929                a[i * n + j] = v;
930                a[j * n + i] = v;
931            }
932        }
933        a
934    }
935
936    /// Deterministic SPD matrix `MᵀM + (n+1)·I` (well-conditioned).
937    fn spd(n: usize, seed: f64) -> Vec<f64> {
938        let m = sym(n, seed);
939        let mut a = matmul(&m, &m, n); // MᵀM = M·M (M symmetric) — PSD
940        for i in 0..n {
941            a[i * n + i] += (n + 1) as f64;
942        }
943        a
944    }
945
946    fn frob(a: &[f64], b: &[f64]) -> f64 {
947        a.iter().zip(b).map(|(x, y)| x * y).sum()
948    }
949
950    #[test]
951    fn bimap_matches_manual() {
952        let (m, n) = (2, 3);
953        // W [2,3], X [3,3] SPD.
954        let w = vec![1.0, 0.5, -0.25, 2.0, -1.0, 0.75];
955        let x = spd(n, 0.3);
956        let y = bimap(&w, &x, m, n);
957        // Manual W·X·Wᵀ.
958        let mut wx = vec![0f64; m * n];
959        for i in 0..m {
960            for j in 0..n {
961                let mut s = 0.0;
962                for k in 0..n {
963                    s += w[i * n + k] * x[k * n + j];
964                }
965                wx[i * n + j] = s;
966            }
967        }
968        let mut expect = vec![0f64; m * m];
969        for i in 0..m {
970            for j in 0..m {
971                let mut s = 0.0;
972                for k in 0..n {
973                    s += wx[i * n + k] * w[j * n + k];
974                }
975                expect[i * m + j] = s;
976            }
977        }
978        let err = y
979            .iter()
980            .zip(&expect)
981            .map(|(a, b)| (a - b).abs())
982            .fold(0.0, f64::max);
983        assert!(err < 1e-10, "bimap err {err}");
984    }
985
986    #[test]
987    fn reeig_floors_spectrum() {
988        let n = 4;
989        // A diagonal-ish SPD with a small eigenvalue.
990        let mut x = vec![0f64; n * n];
991        for i in 0..n {
992            x[i * n + i] = [0.01, 0.5, 2.0, 5.0][i];
993        }
994        let eps = 0.1;
995        let y = reeig(&x, n, eps);
996        let (evals, _) = eigh(&y, n);
997        for &l in &evals {
998            assert!(l >= eps - 1e-9, "reeig eigenvalue {l} below eps {eps}");
999        }
1000        // The 0.01 eigenvalue must be lifted to eps; others unchanged.
1001        assert!((y[0] - eps).abs() < 1e-9, "smallest not floored: {}", y[0]);
1002        assert!((y[n + 1] - 0.5).abs() < 1e-9);
1003    }
1004
1005    #[test]
1006    fn logeig_expm_roundtrip() {
1007        let n = 4;
1008        let x = spd(n, 1.1);
1009        let back = expm(&logeig(&x, n, 1e-12), n);
1010        let err = x
1011            .iter()
1012            .zip(&back)
1013            .map(|(a, b)| (a - b).abs())
1014            .fold(0.0, f64::max);
1015        assert!(err < 1e-8, "expm(logeig) roundtrip err {err}");
1016    }
1017
1018    /// Finite-difference gradient check for a symmetric matrix function.
1019    /// `L(X) = <C, layer(X)>`, so `dL/dX = layer_backward(X, C)`. Compare
1020    /// its directional derivative along a symmetric `V` to central FD.
1021    fn fd_spectral_check(
1022        layer: impl Fn(&[f64], usize) -> Vec<f64>,
1023        backward: impl Fn(&[f64], &[f64], usize) -> Vec<f64>,
1024        n: usize,
1025        seed: f64,
1026    ) {
1027        let x = spd(n, seed);
1028        let c = sym(n, seed + 10.0); // cotangent dL/dY
1029        let v = sym(n, seed + 20.0); // symmetric direction
1030        let grad = backward(&x, &c, n);
1031        let analytic = frob(&grad, &v);
1032        let h = 1e-6;
1033        let xp: Vec<f64> = x.iter().zip(&v).map(|(a, b)| a + h * b).collect();
1034        let xm: Vec<f64> = x.iter().zip(&v).map(|(a, b)| a - h * b).collect();
1035        let fd = (frob(&c, &layer(&xp, n)) - frob(&c, &layer(&xm, n))) / (2.0 * h);
1036        let err = (analytic - fd).abs() / (fd.abs() + 1e-6);
1037        assert!(
1038            err < 1e-4,
1039            "spectral backward FD mismatch: analytic {analytic}, fd {fd}, rel {err}"
1040        );
1041    }
1042
1043    #[test]
1044    fn logeig_backward_matches_fd() {
1045        fd_spectral_check(
1046            |x, n| logeig(x, n, 1e-12),
1047            |x, dy, n| logeig_backward(x, dy, n, 1e-12),
1048            5,
1049            0.7,
1050        );
1051    }
1052
1053    #[test]
1054    fn reeig_backward_matches_fd() {
1055        // eps chosen inside the spectrum so some eigenvalues are floored
1056        // and some are not (exercises both f′ branches + the straddling
1057        // divided difference).
1058        let eps = 3.0;
1059        fd_spectral_check(
1060            move |x, n| reeig(x, n, eps),
1061            move |x, dy, n| reeig_backward(x, dy, n, eps),
1062            5,
1063            0.42,
1064        );
1065    }
1066
1067    #[test]
1068    fn spd_bn_transport_normalizes() {
1069        // With mean = batch Fréchet mean and G = I, the transported
1070        // batch has Fréchet mean ≈ I (that's the point of the layer).
1071        let n = 3;
1072        let batch = 4;
1073        let mut x = vec![0f64; batch * n * n];
1074        let mut slices = Vec::new();
1075        for bi in 0..batch {
1076            let s = spd(n, bi as f64 * 0.9 + 0.2);
1077            x[bi * n * n..(bi + 1) * n * n].copy_from_slice(&s);
1078            slices.push(s);
1079        }
1080        let mean = karcher_mean(&slices, n, 50, 1e-12);
1081        let mut g = vec![0f64; n * n];
1082        for i in 0..n {
1083            g[i * n + i] = 1.0;
1084        }
1085        let y = spd_bn_transport(&x, &mean, &g, batch, n, 1e-12);
1086        let yslices: Vec<Vec<f64>> = (0..batch)
1087            .map(|bi| y[bi * n * n..(bi + 1) * n * n].to_vec())
1088            .collect();
1089        let ymean = karcher_mean(&yslices, n, 50, 1e-12);
1090        // ymean ≈ I.
1091        let mut ident = vec![0f64; n * n];
1092        for i in 0..n {
1093            ident[i * n + i] = 1.0;
1094        }
1095        let err = ymean
1096            .iter()
1097            .zip(&ident)
1098            .map(|(a, b)| (a - b).abs())
1099            .fold(0.0, f64::max);
1100        assert!(err < 1e-4, "SPD-BN did not normalize to I: err {err}");
1101    }
1102
1103    #[test]
1104    fn spd_bn_backward_x_matches_fd() {
1105        let n = 3;
1106        let batch = 2;
1107        let mean = spd(n, 5.0);
1108        let g = spd(n, 6.0);
1109        let mut x = vec![0f64; batch * n * n];
1110        for bi in 0..batch {
1111            let s = spd(n, bi as f64 + 0.3);
1112            x[bi * n * n..(bi + 1) * n * n].copy_from_slice(&s);
1113        }
1114        // cotangent (per-slice symmetric)
1115        let mut c = vec![0f64; batch * n * n];
1116        for bi in 0..batch {
1117            let s = sym(n, bi as f64 + 7.0);
1118            c[bi * n * n..(bi + 1) * n * n].copy_from_slice(&s);
1119        }
1120        let eps = 1e-12;
1121        let grad = spd_bn_backward_x(&mean, &g, &c, batch, n, eps);
1122        // symmetric direction over the whole batch
1123        let mut v = vec![0f64; batch * n * n];
1124        for bi in 0..batch {
1125            let s = sym(n, bi as f64 + 8.0);
1126            v[bi * n * n..(bi + 1) * n * n].copy_from_slice(&s);
1127        }
1128        let analytic = frob(&grad, &v);
1129        let h = 1e-6;
1130        let xp: Vec<f64> = x.iter().zip(&v).map(|(a, b)| a + h * b).collect();
1131        let xm: Vec<f64> = x.iter().zip(&v).map(|(a, b)| a - h * b).collect();
1132        let fd = (frob(&c, &spd_bn_transport(&xp, &mean, &g, batch, n, eps))
1133            - frob(&c, &spd_bn_transport(&xm, &mean, &g, batch, n, eps)))
1134            / (2.0 * h);
1135        let err = (analytic - fd).abs() / (fd.abs() + 1e-6);
1136        assert!(err < 1e-4, "SPD-BN dX FD mismatch: {analytic} vs {fd}");
1137    }
1138
1139    #[test]
1140    fn spd_bn_backward_g_matches_fd() {
1141        let n = 3;
1142        let batch = 2;
1143        let mean = spd(n, 5.0);
1144        let g = spd(n, 6.0);
1145        let mut x = vec![0f64; batch * n * n];
1146        for bi in 0..batch {
1147            let s = spd(n, bi as f64 + 0.3);
1148            x[bi * n * n..(bi + 1) * n * n].copy_from_slice(&s);
1149        }
1150        let mut c = vec![0f64; batch * n * n];
1151        for bi in 0..batch {
1152            let s = sym(n, bi as f64 + 7.0);
1153            c[bi * n * n..(bi + 1) * n * n].copy_from_slice(&s);
1154        }
1155        let eps = 1e-12;
1156        let grad = spd_bn_backward_g(&x, &mean, &g, &c, batch, n, eps);
1157        let vg = sym(n, 9.0); // symmetric direction in G
1158        let analytic = frob(&grad, &vg);
1159        let h = 1e-6;
1160        let gp: Vec<f64> = g.iter().zip(&vg).map(|(a, b)| a + h * b).collect();
1161        let gm: Vec<f64> = g.iter().zip(&vg).map(|(a, b)| a - h * b).collect();
1162        let fd = (frob(&c, &spd_bn_transport(&x, &mean, &gp, batch, n, eps))
1163            - frob(&c, &spd_bn_transport(&x, &mean, &gm, batch, n, eps)))
1164            / (2.0 * h);
1165        let err = (analytic - fd).abs() / (fd.abs() + 1e-6);
1166        assert!(err < 1e-4, "SPD-BN dG FD mismatch: {analytic} vs {fd}");
1167    }
1168
1169    #[test]
1170    fn spectral_backward_handles_degenerate_eigenvalues() {
1171        // X = 2·I + v·vᵀ has eigenvalues {2, 2, 2+‖v‖²} — a genuinely
1172        // degenerate pair (2, multiplicity n−1). The Loewner mask must
1173        // fall back to f′ on the equal-eigenvalue block; logm is smooth
1174        // there, so the analytic gradient must still match central FD.
1175        let n = 3;
1176        let vv = [1.0, 0.5, -0.3];
1177        let mut x = vec![0f64; n * n];
1178        for i in 0..n {
1179            for j in 0..n {
1180                let d = if i == j { 2.0 } else { 0.0 };
1181                x[i * n + j] = d + vv[i] * vv[j];
1182            }
1183        }
1184        let c = sym(n, 3.0);
1185        let dir = sym(n, 4.0);
1186        let grad = logeig_backward(&x, &c, n, 1e-12);
1187        let analytic = frob(&grad, &dir);
1188        let h = 1e-6;
1189        let xp: Vec<f64> = x.iter().zip(&dir).map(|(a, b)| a + h * b).collect();
1190        let xm: Vec<f64> = x.iter().zip(&dir).map(|(a, b)| a - h * b).collect();
1191        let fd = (frob(&c, &logeig(&xp, n, 1e-12)) - frob(&c, &logeig(&xm, n, 1e-12))) / (2.0 * h);
1192        let err = (analytic - fd).abs() / (fd.abs() + 1e-6);
1193        assert!(
1194            err < 1e-4,
1195            "degenerate spectral backward FD: analytic {analytic} vs fd {fd}"
1196        );
1197    }
1198
1199    #[test]
1200    fn geodesic_interp_endpoints_and_midpoint() {
1201        let n = 3;
1202        let a = spd(n, 1.0);
1203        let b = spd(n, 2.0);
1204        let maxdiff = |p: &[f64], q: &[f64]| {
1205            p.iter()
1206                .zip(q)
1207                .map(|(x, y)| (x - y).abs())
1208                .fold(0.0, f64::max)
1209        };
1210        // t=0 → A, t=1 → B.
1211        assert!(maxdiff(&geodesic_interp(&a, &b, 0.0, n), &a) < 1e-8);
1212        assert!(maxdiff(&geodesic_interp(&a, &b, 1.0, n), &b) < 1e-8);
1213        // Midpoint stays SPD (all eigenvalues > 0).
1214        let mid = geodesic_interp(&a, &b, 0.5, n);
1215        let (ev, _) = eigh(&mid, n);
1216        assert!(
1217            ev.iter().all(|&l| l > 1e-9),
1218            "geodesic midpoint not SPD: {ev:?}"
1219        );
1220    }
1221
1222    // ── Weighted Karcher mean / arbitrary-base maps / batched fns ─
1223
1224    fn maxerr(a: &[f64], b: &[f64]) -> f64 {
1225        a.iter()
1226            .zip(b)
1227            .map(|(x, y)| (x - y).abs())
1228            .fold(0.0, f64::max)
1229    }
1230
1231    /// Max |A - Aᵀ| — asymmetry of a row-major `n×n` matrix.
1232    fn asymmetry(a: &[f64], n: usize) -> f64 {
1233        let mut m = 0.0f64;
1234        for i in 0..n {
1235            for j in 0..n {
1236                m = m.max((a[i * n + j] - a[j * n + i]).abs());
1237            }
1238        }
1239        m
1240    }
1241
1242    /// AIRM tangent inner product at `P`: `⟨U, V⟩_P = tr(P^{-1} U P^{-1} V)`.
1243    fn airm_inner(p: &[f64], u: &[f64], v: &[f64], n: usize) -> f64 {
1244        let pinv = matrix_fn(p, n, |l| 1.0 / l.max(1e-12));
1245        let m = matmul(&matmul(&pinv, u, n), &pinv, n); // P^{-1} U P^{-1}
1246        let mut tr = 0.0;
1247        for i in 0..n {
1248            for j in 0..n {
1249                tr += m[i * n + j] * v[j * n + i];
1250            }
1251        }
1252        tr
1253    }
1254
1255    #[test]
1256    fn karcher_mean_weighted_uniform_matches_unweighted() {
1257        let n = 3;
1258        let covs = vec![spd(n, 0.2), spd(n, 1.3), spd(n, 2.7), spd(n, 3.1)];
1259        let unw = karcher_mean(&covs, n, 60, 1e-13);
1260        let wtd = karcher_mean_weighted(&covs, &[1.0; 4], n, 60, 1e-13);
1261        assert!(maxerr(&unw, &wtd) < 1e-9, "uniform weighted != unweighted");
1262        // Only weight *ratios* matter: scaling all weights leaves the mean fixed.
1263        let scaled = karcher_mean_weighted(&covs, &[7.5; 4], n, 60, 1e-13);
1264        assert!(maxerr(&wtd, &scaled) < 1e-12, "weight scale changed result");
1265    }
1266
1267    #[test]
1268    fn karcher_mean_weighted_dominant_weight_selects_matrix() {
1269        let n = 3;
1270        let covs = vec![spd(n, 0.5), spd(n, 1.5), spd(n, 2.5)];
1271        // Nearly all mass on covs[1] → barycentre ≈ covs[1].
1272        let m = karcher_mean_weighted(&covs, &[1e-9, 1.0, 1e-9], n, 80, 1e-14);
1273        assert!(
1274            maxerr(&covs[1], &m) < 1e-6,
1275            "dominant-weight barycentre should ≈ that cov"
1276        );
1277    }
1278
1279    #[test]
1280    fn karcher_mean_weighted_is_stationary() {
1281        // At the weighted barycentre the Riemannian gradient of
1282        // Σ wᵢ δ²(M, Cᵢ), namely -2 Σ w̄ᵢ Log_M(Cᵢ), must vanish. This
1283        // cross-validates karcher_mean_weighted *and* log_map together.
1284        let n = 4;
1285        let covs = vec![
1286            spd(n, 0.3),
1287            spd(n, 1.1),
1288            spd(n, 2.2),
1289            spd(n, 3.9),
1290            spd(n, 5.0),
1291        ];
1292        let w = [0.4, 0.1, 0.25, 0.05, 0.2];
1293        let m = karcher_mean_weighted(&covs, &w, n, 200, 1e-15);
1294        let wsum: f64 = w.iter().sum();
1295        let mut g = vec![0f64; n * n];
1296        for (c, &wi) in covs.iter().zip(&w) {
1297            let l = log_map(&m, c, n);
1298            for k in 0..n * n {
1299                g[k] += (wi / wsum) * l[k];
1300            }
1301        }
1302        let gnorm: f64 = g.iter().map(|x| x * x).sum::<f64>().sqrt();
1303        assert!(
1304            gnorm < 1e-6,
1305            "weighted barycentre not stationary: |g|={gnorm}"
1306        );
1307    }
1308
1309    #[test]
1310    fn log_exp_map_roundtrip_and_identity_base() {
1311        let n = 4;
1312        let base = spd(n, 0.7);
1313        let x = spd(n, 2.4);
1314        // Exp_P(Log_P(X)) = X.
1315        let v = log_map(&base, &x, n);
1316        let back = exp_map(&base, &v, n);
1317        assert!(maxerr(&x, &back) < 1e-8, "exp∘log roundtrip");
1318        // log_map output is a symmetric tangent vector.
1319        assert!(asymmetry(&v, n) < 1e-10, "log_map output not symmetric");
1320        // Base = I collapses to plain logm / expm.
1321        let i = ident(n);
1322        assert!(
1323            maxerr(&log_map(&i, &x, n), &logm(&x, n)) < 1e-9,
1324            "log_map(I, ·) != logm"
1325        );
1326        let s = sym(n, 3.0);
1327        assert!(
1328            maxerr(&exp_map(&i, &s, n), &expm(&s, n)) < 1e-9,
1329            "exp_map(I, ·) != expm"
1330        );
1331    }
1332
1333    #[test]
1334    fn exp_log_maps_reproduce_geodesic() {
1335        // geodesic_interp(A, B, t) == exp_map(A, t · log_map(A, B)).
1336        let n = 3;
1337        let a = spd(n, 1.0);
1338        let b = spd(n, 2.6);
1339        let v = log_map(&a, &b, n);
1340        for &t in &[0.0, 0.25, 0.5, 0.9, 1.0] {
1341            let tv: Vec<f64> = v.iter().map(|x| x * t).collect();
1342            let via_exp = exp_map(&a, &tv, n);
1343            let gi = geodesic_interp(&a, &b, t, n);
1344            assert!(
1345                maxerr(&via_exp, &gi) < 1e-8,
1346                "exp_map(t·log) != geodesic_interp at t={t}"
1347            );
1348        }
1349    }
1350
1351    #[test]
1352    fn parallel_transport_is_airm_isometry() {
1353        let n = 4;
1354        let p = spd(n, 0.9);
1355        let q = spd(n, 3.3);
1356        let v = sym(n, 1.2);
1357        let w = sym(n, 2.4);
1358        let gv = parallel_transport(&p, &q, &v, n);
1359        let gw = parallel_transport(&p, &q, &w, n);
1360        // ⟨v, w⟩_P == ⟨Γv, Γw⟩_Q  (transport preserves the metric).
1361        let before = airm_inner(&p, &v, &w, n);
1362        let after = airm_inner(&q, &gv, &gw, n);
1363        let rel = (before - after).abs() / (before.abs() + 1e-9);
1364        assert!(
1365            rel < 1e-7,
1366            "transport not an isometry: ⟨v,w⟩_P={before} vs ⟨Γv,Γw⟩_Q={after}"
1367        );
1368        // Transported vector stays symmetric; P→P is the identity map.
1369        assert!(asymmetry(&gv, n) < 1e-9, "transported vector not symmetric");
1370        let same = parallel_transport(&p, &p, &v, n);
1371        assert!(maxerr(&same, &v) < 1e-8, "transport P→P must be identity");
1372    }
1373
1374    #[test]
1375    fn batched_matrix_fns_match_scalar() {
1376        let n = 3;
1377        let covs: Vec<Vec<f64>> = (0..6).map(|i| spd(n, i as f64 * 0.6 + 0.1)).collect();
1378        let syms: Vec<Vec<f64>> = (0..6).map(|i| sym(n, i as f64 * 0.4 + 0.2)).collect();
1379        let lb = logm_batch(&covs, n);
1380        let sb = sqrtm_batch(&covs, n);
1381        let ib = invsqrtm_batch(&covs, n);
1382        for (i, c) in covs.iter().enumerate() {
1383            assert!(maxerr(&lb[i], &logm(c, n)) < 1e-12, "logm_batch[{i}]");
1384            assert!(maxerr(&sb[i], &sqrtm(c, n)) < 1e-12, "sqrtm_batch[{i}]");
1385            assert!(
1386                maxerr(&ib[i], &invsqrtm(c, n)) < 1e-12,
1387                "invsqrtm_batch[{i}]"
1388            );
1389        }
1390        let eb = expm_batch(&syms, n);
1391        for (i, s) in syms.iter().enumerate() {
1392            assert!(maxerr(&eb[i], &expm(s, n)) < 1e-12, "expm_batch[{i}]");
1393        }
1394        // Empty batch → empty output (no panic).
1395        assert!(logm_batch(&[], n).is_empty());
1396    }
1397
1398    // ── Riemannian map VJPs (finite-difference checked) ──────────
1399
1400    /// Central-FD check that `⟨grad, dir⟩ ≈ d/dh ⟨c, forward(base + h·dir)⟩`.
1401    fn fd_dir_check(
1402        grad: &[f64],
1403        dir: &[f64],
1404        c: &[f64],
1405        forward: impl Fn(&[f64]) -> Vec<f64>,
1406        at: &[f64],
1407        name: &str,
1408    ) {
1409        let h = 1e-6;
1410        let xp: Vec<f64> = at.iter().zip(dir).map(|(a, b)| a + h * b).collect();
1411        let xm: Vec<f64> = at.iter().zip(dir).map(|(a, b)| a - h * b).collect();
1412        let fd = (frob(c, &forward(&xp)) - frob(c, &forward(&xm))) / (2.0 * h);
1413        let an = frob(grad, dir);
1414        assert!(
1415            (an - fd).abs() / (fd.abs() + 1e-6) < 1e-4,
1416            "{name}: analytic {an} vs fd {fd}"
1417        );
1418    }
1419
1420    #[test]
1421    fn log_map_backward_matches_fd() {
1422        let n = 3;
1423        let base = spd(n, 0.7);
1424        let x = spd(n, 2.1);
1425        let c = sym(n, 3.0); // cotangent dL/dY
1426        let (d_base, d_x) = log_map_backward(&base, &x, &c, n);
1427        fd_dir_check(
1428            &d_x,
1429            &sym(n, 4.0),
1430            &c,
1431            |x| log_map(&base, x, n),
1432            &x,
1433            "log_map d_x",
1434        );
1435        fd_dir_check(
1436            &d_base,
1437            &sym(n, 5.0),
1438            &c,
1439            |b| log_map(b, &x, n),
1440            &base,
1441            "log_map d_base",
1442        );
1443    }
1444
1445    #[test]
1446    fn exp_map_backward_matches_fd() {
1447        let n = 3;
1448        let base = spd(n, 0.8);
1449        let v = sym(n, 1.4); // symmetric tangent (kept small → well-conditioned Exp)
1450        let c = sym(n, 3.2);
1451        let (d_base, d_v) = exp_map_backward(&base, &v, &c, n);
1452        fd_dir_check(
1453            &d_v,
1454            &sym(n, 4.1),
1455            &c,
1456            |v| exp_map(&base, v, n),
1457            &v,
1458            "exp_map d_v",
1459        );
1460        fd_dir_check(
1461            &d_base,
1462            &sym(n, 5.1),
1463            &c,
1464            |b| exp_map(b, &v, n),
1465            &base,
1466            "exp_map d_base",
1467        );
1468    }
1469
1470    #[test]
1471    fn parallel_transport_backward_matches_fd() {
1472        let n = 3;
1473        let from = spd(n, 0.6);
1474        let to = spd(n, 1.9);
1475        let v = sym(n, 2.5);
1476        let c = sym(n, 3.3);
1477        let (d_from, d_to, d_v) = parallel_transport_backward(&from, &to, &v, &c, n);
1478        fd_dir_check(
1479            &d_from,
1480            &sym(n, 4.0),
1481            &c,
1482            |p| parallel_transport(p, &to, &v, n),
1483            &from,
1484            "transport d_from",
1485        );
1486        fd_dir_check(
1487            &d_to,
1488            &sym(n, 5.0),
1489            &c,
1490            |q| parallel_transport(&from, q, &v, n),
1491            &to,
1492            "transport d_to",
1493        );
1494        fd_dir_check(
1495            &d_v,
1496            &sym(n, 6.0),
1497            &c,
1498            |vv| parallel_transport(&from, &to, vv, n),
1499            &v,
1500            "transport d_v",
1501        );
1502    }
1503
1504    #[test]
1505    fn matrix_fn_batch_backward_matches_fd() {
1506        let n = 3;
1507        let batch = 3;
1508        let mut x = vec![0.0; batch * n * n];
1509        let mut c = vec![0.0; batch * n * n];
1510        for bi in 0..batch {
1511            x[bi * n * n..(bi + 1) * n * n].copy_from_slice(&spd(n, bi as f64 * 0.5 + 0.2));
1512            c[bi * n * n..(bi + 1) * n * n].copy_from_slice(&sym(n, bi as f64 + 3.0));
1513        }
1514        // logm variant: f = ln, f' = 1/λ.
1515        let grad =
1516            matrix_fn_batch_backward(&x, &c, n, |l| l.max(1e-12).ln(), |l| 1.0 / l.max(1e-12));
1517        let mut dir = vec![0.0; batch * n * n];
1518        for bi in 0..batch {
1519            dir[bi * n * n..(bi + 1) * n * n].copy_from_slice(&sym(n, bi as f64 + 7.0));
1520        }
1521        let logm_flat = |xx: &[f64]| {
1522            let covs: Vec<Vec<f64>> = (0..batch)
1523                .map(|bi| xx[bi * n * n..(bi + 1) * n * n].to_vec())
1524                .collect();
1525            logm_batch(&covs, n).concat()
1526        };
1527        fd_dir_check(
1528            &grad,
1529            &dir,
1530            &c,
1531            logm_flat,
1532            &x,
1533            "matrix_fn_batch(logm) backward",
1534        );
1535    }
1536
1537    // ── Symmetric eigendecomposition op ──────────────────────────
1538
1539    /// SPD with well-separated eigenvalues (distinct diagonal + tiny coupling),
1540    /// so the eigenvector gradient is well-conditioned for the FD check.
1541    fn spd_sep(n: usize) -> Vec<f64> {
1542        let mut a = vec![0f64; n * n];
1543        for i in 0..n {
1544            for j in i..n {
1545                let v = if i == j {
1546                    (i as f64 + 1.0) * 2.0
1547                } else {
1548                    0.05 * ((i + j) as f64).cos()
1549                };
1550                a[i * n + j] = v;
1551                a[j * n + i] = v;
1552            }
1553        }
1554        a
1555    }
1556
1557    #[test]
1558    fn eigh_packed_reconstructs() {
1559        let n = 4;
1560        let a = spd_sep(n);
1561        let fwd = eigh_packed(&a, n);
1562        let (lam, u) = (&fwd[..n], &fwd[n..]);
1563        // A = U diag(λ) Uᵀ.
1564        let mut recon = vec![0f64; n * n];
1565        for i in 0..n {
1566            for j in 0..n {
1567                let mut s = 0.0;
1568                for k in 0..n {
1569                    s += u[i * n + k] * lam[k] * u[j * n + k];
1570                }
1571                recon[i * n + j] = s;
1572            }
1573        }
1574        assert!(maxerr(&recon, &a) < 1e-9, "eigh_packed A = U Λ Uᵀ failed");
1575        // Eigenvalues ascending.
1576        assert!(lam.windows(2).all(|w| w[0] <= w[1] + 1e-12));
1577    }
1578
1579    #[test]
1580    fn eigh_backward_matches_fd() {
1581        let n = 4;
1582        let a = spd_sep(n);
1583        let fwd = eigh_packed(&a, n);
1584        let u_base = fwd[n..].to_vec();
1585        // Arbitrary cotangents on λ and U.
1586        let lbar: Vec<f64> = (0..n).map(|i| ((i as f64 + 1.0) * 0.7).sin()).collect();
1587        let ubar = sym(n, 5.0);
1588        let mut bar = vec![0f64; n + n * n];
1589        bar[..n].copy_from_slice(&lbar);
1590        bar[n..].copy_from_slice(&ubar);
1591        let abar = eigh_backward_packed(&fwd, &bar, n);
1592        // Loss ⟨λ̄, λ⟩ + ⟨Ū, U⟩ with perturbed U sign-aligned to the base U
1593        // (eigenvectors carry an arbitrary sign the solver may flip).
1594        let loss = |mat: &[f64]| -> f64 {
1595            let fw = eigh_packed(mat, n);
1596            let (l, mut uu) = (fw[..n].to_vec(), fw[n..].to_vec());
1597            for j in 0..n {
1598                let mut dot = 0.0;
1599                for i in 0..n {
1600                    dot += uu[i * n + j] * u_base[i * n + j];
1601                }
1602                if dot < 0.0 {
1603                    for i in 0..n {
1604                        uu[i * n + j] = -uu[i * n + j];
1605                    }
1606                }
1607            }
1608            let mut s = 0.0;
1609            for i in 0..n {
1610                s += lbar[i] * l[i];
1611            }
1612            for k in 0..n * n {
1613                s += ubar[k] * uu[k];
1614            }
1615            s
1616        };
1617        // Scalar loss ⇒ central difference directly (not via fd_dir_check).
1618        let dir = sym(n, 4.0);
1619        let h = 1e-6;
1620        let ap: Vec<f64> = a.iter().zip(&dir).map(|(x, y)| x + h * y).collect();
1621        let am: Vec<f64> = a.iter().zip(&dir).map(|(x, y)| x - h * y).collect();
1622        let fd = (loss(&ap) - loss(&am)) / (2.0 * h);
1623        let an = frob(&abar, &dir);
1624        assert!(
1625            (an - fd).abs() / (fd.abs() + 1e-6) < 1e-4,
1626            "eigh backward FD: analytic {an} vs fd {fd}"
1627        );
1628    }
1629
1630    #[test]
1631    fn eigh_batch_packed_matches_scalar() {
1632        let n = 3;
1633        let mats: Vec<Vec<f64>> = (0..4).map(|i| spd(n, i as f64 * 0.4 + 0.2)).collect();
1634        let batched = eigh_batch_packed(&mats, n);
1635        for (i, m) in mats.iter().enumerate() {
1636            assert!(
1637                maxerr(&batched[i], &eigh_packed(m, n)) < 1e-12,
1638                "eigh_batch[{i}]"
1639            );
1640        }
1641    }
1642}