Skip to main content

basin/core/math/
faer_backend.rs

1use faer::linalg::matmul::matmul;
2use faer::linalg::solvers::{Llt, Solve};
3use faer::{Accum, Col, Mat, Par, Side};
4use rand::{Rng, RngExt};
5use rand_distr::{Distribution, StandardNormal, uniform::SampleUniform};
6
7use super::Scalar;
8use super::cl_scaling::{
9    BoxAffineScaling, cl_scaling_pair, max_feasible_step_component,
10    project_strictly_inside_component,
11};
12use super::linalg::{
13    AddDiagonalVectorInPlace, DenseMatrixFromFn, GeneralRankOneUpdate, GramMatrix,
14    LinearSolveError, LinearSolveSpd, MatDiagonal, MatTransposeVec, MatVec, MatrixFromDiagonal,
15    MatrixIdentity, MaxDiagonal, RankOneUpdate, SymmetricEigen, SymmetricEigenError,
16};
17use super::sample::{SampleStandardNormal, SampleUniformBox, assert_finite_box};
18use super::{
19    ClampInPlace, ComponentDivAssign, ComponentMaxAssign, ComponentMulAssign, Dot,
20    FloorZerosInPlace, NegInPlace, NormInfinity, NormSquared, ScaleInPlace, ScaledAdd, VectorIndex,
21    VectorLen,
22};
23
24// The vector-tier ops used here (`Col::iter`, `Col::from_fn`, indexing, the
25// `faer::zip!` / `faer::unzip!` macros) don't require `faer_traits::ComplexField`
26// — only the linalg-tier kernels (`Col::zeros`, `matmul`, factorizations) do.
27// So `F: Scalar` is the only bound needed at these impl sites.
28
29impl<F: Scalar> ScaledAdd<F> for Col<F> {
30    fn scaled_add(&mut self, scalar: F, other: &Self) {
31        assert_eq!(self.nrows(), other.nrows(), "scaled_add: shape mismatch");
32        faer::zip!(self.as_mut(), other.as_ref())
33            .for_each(|faer::unzip!(x, y)| *x = *x + scalar * *y);
34    }
35}
36
37impl<F: Scalar> NormSquared<F> for Col<F> {
38    fn norm_squared(&self) -> F {
39        self.iter().map(|x| *x * *x).sum()
40    }
41}
42
43impl<F: Scalar> NormInfinity<F> for Col<F> {
44    fn norm_infinity(&self) -> F {
45        self.iter().map(|x| x.abs()).fold(F::zero(), F::max)
46    }
47}
48
49impl<F: Scalar> Dot<F> for Col<F> {
50    fn dot(&self, other: &Self) -> F {
51        assert_eq!(self.nrows(), other.nrows(), "dot: shape mismatch");
52        self.iter().zip(other.iter()).map(|(a, b)| *a * *b).sum()
53    }
54}
55
56impl<F: Scalar> NegInPlace for Col<F> {
57    fn neg_in_place(&mut self) {
58        faer::zip!(self.as_mut()).for_each(|faer::unzip!(x)| *x = -*x);
59    }
60}
61
62impl<F: Scalar + SampleUniform> SampleUniformBox for Col<F> {
63    fn sample_uniform_box<R: Rng + ?Sized>(lower: &Self, upper: &Self, rng: &mut R) -> Self {
64        assert_eq!(
65            lower.nrows(),
66            upper.nrows(),
67            "sample_uniform_box: bounds length mismatch"
68        );
69        assert_finite_box(lower, upper);
70        Self::from_fn(lower.nrows(), |i| rng.random_range(lower[i]..=upper[i]))
71    }
72}
73
74impl<F: Scalar> VectorLen for Col<F> {
75    fn vec_len(&self) -> usize {
76        self.nrows()
77    }
78}
79
80impl<F: Scalar> VectorIndex<F> for Col<F> {
81    fn get_scalar(&self, i: usize) -> F {
82        self[i]
83    }
84    fn set_scalar(&mut self, i: usize, value: F) {
85        self[i] = value;
86    }
87}
88
89impl<F: Scalar> SampleStandardNormal for Col<F>
90where
91    StandardNormal: Distribution<F>,
92{
93    fn sample_standard_normal<R: Rng + ?Sized>(template: &Self, rng: &mut R) -> Self {
94        Self::from_fn(template.nrows(), |_| StandardNormal.sample(rng))
95    }
96}
97
98impl<F: Scalar> ScaleInPlace<F> for Col<F> {
99    fn scale_in_place(&mut self, scalar: F) {
100        faer::zip!(self.as_mut()).for_each(|faer::unzip!(x)| *x = *x * scalar);
101    }
102}
103
104impl<F: Scalar> ComponentMulAssign for Col<F> {
105    fn component_mul_assign(&mut self, other: &Self) {
106        assert_eq!(
107            self.nrows(),
108            other.nrows(),
109            "component_mul_assign: shape mismatch"
110        );
111        faer::zip!(self.as_mut(), other.as_ref()).for_each(|faer::unzip!(x, y)| *x = *x * *y);
112    }
113}
114
115impl<F: Scalar> ComponentMaxAssign for Col<F> {
116    fn component_max_assign(&mut self, other: &Self) {
117        assert_eq!(
118            self.nrows(),
119            other.nrows(),
120            "component_max_assign: shape mismatch"
121        );
122        faer::zip!(self.as_mut(), other.as_ref()).for_each(|faer::unzip!(x, y)| *x = x.max(*y));
123    }
124}
125
126impl<F: Scalar> FloorZerosInPlace<F> for Col<F> {
127    fn floor_zeros_in_place(&mut self, value: F) {
128        faer::zip!(self.as_mut()).for_each(|faer::unzip!(x)| {
129            if *x <= F::zero() {
130                *x = value;
131            }
132        });
133    }
134}
135
136impl<F: Scalar> ComponentDivAssign for Col<F> {
137    fn component_div_assign(&mut self, other: &Self) {
138        assert_eq!(
139            self.nrows(),
140            other.nrows(),
141            "component_div_assign: shape mismatch"
142        );
143        faer::zip!(self.as_mut(), other.as_ref()).for_each(|faer::unzip!(x, y)| *x = *x / *y);
144    }
145}
146
147impl<F: Scalar> ClampInPlace for Col<F> {
148    fn clamp_in_place(&mut self, lower: &Self, upper: &Self) {
149        assert_eq!(
150            self.nrows(),
151            lower.nrows(),
152            "clamp_in_place: lower shape mismatch"
153        );
154        assert_eq!(
155            self.nrows(),
156            upper.nrows(),
157            "clamp_in_place: upper shape mismatch"
158        );
159        faer::zip!(self.as_mut(), lower.as_ref(), upper.as_ref())
160            // `Float` has no `clamp`; `max(lo).min(hi)` matches the
161            // `f64::clamp` result on finite, ordered bounds.
162            .for_each(|faer::unzip!(x, lo, hi)| *x = (*x).max(*lo).min(*hi));
163    }
164}
165
166impl<F: Scalar + faer_traits::ComplexField> BoxAffineScaling<F> for Col<F> {
167    fn compute_cl_scaling(
168        &self,
169        gradient: &Self,
170        lower: &Self,
171        upper: &Self,
172        d_sq: &mut Self,
173        c_diag: &mut Self,
174    ) {
175        let n = self.nrows();
176        assert_eq!(
177            n,
178            gradient.nrows(),
179            "compute_cl_scaling: gradient shape mismatch"
180        );
181        assert_eq!(n, lower.nrows(), "compute_cl_scaling: lower shape mismatch");
182        assert_eq!(n, upper.nrows(), "compute_cl_scaling: upper shape mismatch");
183        assert_eq!(n, d_sq.nrows(), "compute_cl_scaling: d_sq shape mismatch");
184        assert_eq!(
185            n,
186            c_diag.nrows(),
187            "compute_cl_scaling: c_diag shape mismatch"
188        );
189        // Faer's `zip!` macro caps at four operands; do an indexed loop.
190        for i in 0..n {
191            let (d_sq_i, c_i) = cl_scaling_pair::<F>(self[i], gradient[i], lower[i], upper[i]);
192            d_sq[i] = d_sq_i;
193            c_diag[i] = c_i;
194        }
195    }
196
197    fn max_feasible_step(&self, step: &Self, lower: &Self, upper: &Self) -> F {
198        let n = self.nrows();
199        assert_eq!(n, step.nrows(), "max_feasible_step: step shape mismatch");
200        assert_eq!(n, lower.nrows(), "max_feasible_step: lower shape mismatch");
201        assert_eq!(n, upper.nrows(), "max_feasible_step: upper shape mismatch");
202        let mut tau = F::infinity();
203        for i in 0..n {
204            let t = max_feasible_step_component::<F>(self[i], step[i], lower[i], upper[i]);
205            if t < tau {
206                tau = t;
207            }
208        }
209        tau
210    }
211
212    fn cl_kkt_inf_norm(&self, d_sq: &Self) -> F {
213        assert_eq!(
214            self.nrows(),
215            d_sq.nrows(),
216            "cl_kkt_inf_norm: shape mismatch"
217        );
218        self.iter()
219            .zip(d_sq.iter())
220            .map(|(&v, &d)| <F as num_traits::Float>::abs(v) / d)
221            .fold(F::zero(), |a, b| if b > a { b } else { a })
222    }
223
224    fn weighted_norm_squared(&self, weights: &Self) -> F {
225        assert_eq!(
226            self.nrows(),
227            weights.nrows(),
228            "weighted_norm_squared: shape mismatch"
229        );
230        self.iter()
231            .zip(weights.iter())
232            .map(|(&v, &w)| v * v * w)
233            .sum()
234    }
235
236    fn project_strictly_inside(&mut self, lower: &Self, upper: &Self, rstep: F) {
237        let n = self.nrows();
238        assert_eq!(
239            n,
240            lower.nrows(),
241            "project_strictly_inside: lower shape mismatch"
242        );
243        assert_eq!(
244            n,
245            upper.nrows(),
246            "project_strictly_inside: upper shape mismatch"
247        );
248        for i in 0..n {
249            self[i] = project_strictly_inside_component::<F>(self[i], lower[i], upper[i], rstep);
250        }
251    }
252}
253
254// ----------------------------------------------------------------------
255// linalg tier — dense ops on Mat<f64> with V = Col<f64>.
256// faer 0.24 has no `*` operator on Mat/Col — go through `matmul` directly.
257// ----------------------------------------------------------------------
258
259impl<F> MatVec<Col<F>> for Mat<F>
260where
261    F: Scalar + faer_traits::ComplexField,
262{
263    fn matvec(&self, x: &Col<F>) -> Col<F> {
264        assert_eq!(
265            self.ncols(),
266            x.nrows(),
267            "matvec: A.ncols ({}) != x.nrows ({})",
268            self.ncols(),
269            x.nrows()
270        );
271        let mut y = Col::<F>::zeros(self.nrows());
272        matmul(
273            y.as_mut(),
274            Accum::Replace,
275            self.as_ref(),
276            x.as_ref(),
277            F::one(),
278            Par::Seq,
279        );
280        y
281    }
282}
283
284impl<F> MatTransposeVec<Col<F>> for Mat<F>
285where
286    F: Scalar + faer_traits::ComplexField,
287{
288    fn mat_transpose_vec(&self, x: &Col<F>) -> Col<F> {
289        assert_eq!(
290            self.nrows(),
291            x.nrows(),
292            "mat_transpose_vec: A.nrows ({}) != x.nrows ({})",
293            self.nrows(),
294            x.nrows()
295        );
296        let mut y = Col::<F>::zeros(self.ncols());
297        matmul(
298            y.as_mut(),
299            Accum::Replace,
300            self.transpose(),
301            x.as_ref(),
302            F::one(),
303            Par::Seq,
304        );
305        y
306    }
307}
308
309impl<F> GramMatrix for Mat<F>
310where
311    F: Scalar + faer_traits::ComplexField,
312{
313    fn gram(&self) -> Self {
314        let n = self.ncols();
315        let mut g = Self::zeros(n, n);
316        matmul(
317            g.as_mut(),
318            Accum::Replace,
319            self.transpose(),
320            self.as_ref(),
321            F::one(),
322            Par::Seq,
323        );
324        g
325    }
326}
327
328impl<F: Scalar> MaxDiagonal<F> for Mat<F> {
329    fn max_diagonal(&self) -> F {
330        assert_eq!(
331            self.nrows(),
332            self.ncols(),
333            "max_diagonal: matrix must be square, got {}x{}",
334            self.nrows(),
335            self.ncols()
336        );
337        (0..self.nrows())
338            .map(|i| self[(i, i)])
339            .fold(F::neg_infinity(), F::max)
340    }
341}
342
343impl<F: Scalar> MatDiagonal<Col<F>> for Mat<F> {
344    fn diagonal(&self) -> Col<F> {
345        assert_eq!(
346            self.nrows(),
347            self.ncols(),
348            "diagonal: matrix must be square, got {}x{}",
349            self.nrows(),
350            self.ncols()
351        );
352        Col::from_fn(self.nrows(), |i| self[(i, i)])
353    }
354}
355
356impl<F: Scalar> AddDiagonalVectorInPlace<Col<F>> for Mat<F> {
357    fn add_diagonal_vector_in_place(&mut self, diag: &Col<F>) {
358        assert_eq!(
359            self.nrows(),
360            self.ncols(),
361            "add_diagonal_vector_in_place: matrix must be square, got {}x{}",
362            self.nrows(),
363            self.ncols()
364        );
365        assert_eq!(
366            self.nrows(),
367            diag.nrows(),
368            "add_diagonal_vector_in_place: matrix is {}x{} but diag has length {}",
369            self.nrows(),
370            self.ncols(),
371            diag.nrows()
372        );
373        for i in 0..self.nrows() {
374            let entry = &mut self[(i, i)];
375            *entry = *entry + diag[i];
376        }
377    }
378}
379
380impl<F: Scalar> ScaleInPlace<F> for Mat<F> {
381    fn scale_in_place(&mut self, scalar: F) {
382        faer::zip!(self.as_mut()).for_each(|faer::unzip!(x)| *x = *x * scalar);
383    }
384}
385
386impl<F> MatrixIdentity for Mat<F>
387where
388    F: Scalar + faer_traits::ComplexField,
389{
390    fn identity(n: usize) -> Self {
391        Self::identity(n, n)
392    }
393}
394
395impl<F: Scalar> MatrixFromDiagonal<Col<F>> for Mat<F> {
396    fn from_diagonal(diag: &Col<F>) -> Self {
397        let n = diag.nrows();
398        Self::from_fn(n, n, |i, j| if i == j { diag[i] } else { F::zero() })
399    }
400}
401
402impl<F: Scalar> DenseMatrixFromFn<F> for Col<F> {
403    type Matrix = Mat<F>;
404    fn dense_from_fn<G: FnMut(usize, usize) -> F>(rows: usize, cols: usize, f: G) -> Mat<F> {
405        Mat::from_fn(rows, cols, f)
406    }
407}
408
409impl<F> SymmetricEigen<Col<F>> for Mat<F>
410where
411    F: Scalar + faer_traits::ComplexField<Real = F>,
412{
413    fn try_eigh(&self) -> Result<(Self, Col<F>), SymmetricEigenError> {
414        assert_eq!(
415            self.nrows(),
416            self.ncols(),
417            "try_eigh: matrix must be square, got {}x{}",
418            self.nrows(),
419            self.ncols()
420        );
421        // faer takes the lower triangle as authoritative; CMA-ES's
422        // covariance is built from rank-one updates that touch both
423        // triangles symmetrically, so this assumption holds.
424        let eig = self
425            .self_adjoint_eigen(Side::Lower)
426            .map_err(|_| SymmetricEigenError::Failed)?;
427        let n = self.nrows();
428        let u_ref = eig.U();
429        let s_ref = eig.S();
430        // Materialize both as fresh, owned types so the caller doesn't
431        // hold a borrow into a transient eig wrapper.
432        let mut u_mat = Self::zeros(n, n);
433        for j in 0..n {
434            for i in 0..n {
435                u_mat[(i, j)] = u_ref[(i, j)];
436            }
437        }
438        let s_col = Col::<F>::from_fn(n, |i| s_ref[i]);
439        Ok((u_mat, s_col))
440    }
441}
442
443impl<F> RankOneUpdate<Col<F>, F> for Mat<F>
444where
445    F: Scalar + faer_traits::ComplexField,
446{
447    fn rank_one_update(&mut self, alpha: F, v: &Col<F>) {
448        assert_eq!(
449            self.nrows(),
450            self.ncols(),
451            "rank_one_update: matrix must be square, got {}x{}",
452            self.nrows(),
453            self.ncols()
454        );
455        assert_eq!(
456            self.nrows(),
457            v.nrows(),
458            "rank_one_update: matrix is {}x{} but v has length {}",
459            self.nrows(),
460            self.ncols(),
461            v.nrows()
462        );
463        // self ← self + α · v · vᵀ via matmul accumulator. v is n×1;
464        // v.transpose() is 1×n; the outer product is n×n.
465        matmul(
466            self.as_mut(),
467            Accum::Add,
468            v.as_mat(),
469            v.transpose().as_mat(),
470            alpha,
471            Par::Seq,
472        );
473    }
474}
475
476impl<F> GeneralRankOneUpdate<Col<F>, F> for Mat<F>
477where
478    F: Scalar + faer_traits::ComplexField,
479{
480    fn general_rank_one_update(&mut self, alpha: F, u: &Col<F>, v: &Col<F>) {
481        assert_eq!(
482            self.nrows(),
483            self.ncols(),
484            "general_rank_one_update: matrix must be square, got {}x{}",
485            self.nrows(),
486            self.ncols()
487        );
488        assert_eq!(
489            self.nrows(),
490            u.nrows(),
491            "general_rank_one_update: matrix is {}x{} but u has length {}",
492            self.nrows(),
493            self.ncols(),
494            u.nrows()
495        );
496        assert_eq!(
497            self.ncols(),
498            v.nrows(),
499            "general_rank_one_update: matrix is {}x{} but v has length {}",
500            self.nrows(),
501            self.ncols(),
502            v.nrows()
503        );
504        // self ← self + α · u · vᵀ via matmul accumulator. u is n×1;
505        // v.transpose() is 1×n; the outer product is n×n.
506        matmul(
507            self.as_mut(),
508            Accum::Add,
509            u.as_mat(),
510            v.transpose().as_mat(),
511            alpha,
512            Par::Seq,
513        );
514    }
515}
516
517impl<F> LinearSolveSpd<Col<F>> for Mat<F>
518where
519    F: Scalar + faer_traits::ComplexField,
520{
521    fn solve_spd(&self, b: &Col<F>) -> Result<Col<F>, LinearSolveError> {
522        assert_eq!(
523            self.nrows(),
524            self.ncols(),
525            "solve_spd: matrix must be square, got {}x{}",
526            self.nrows(),
527            self.ncols()
528        );
529        assert_eq!(
530            self.nrows(),
531            b.nrows(),
532            "solve_spd: A.nrows ({}) != b.nrows ({})",
533            self.nrows(),
534            b.nrows()
535        );
536        let llt = Llt::new(self.as_ref(), Side::Lower)
537            .map_err(|_| LinearSolveError::NotPositiveDefinite)?;
538        let mut x = b.clone();
539        llt.solve_in_place(&mut x);
540        Ok(x)
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
549        (a - b).abs() < tol
550    }
551
552    fn mat2(row0: [f64; 2], row1: [f64; 2]) -> Mat<f64> {
553        let rows = [row0, row1];
554        Mat::from_fn(2, 2, |i, j| rows[i][j])
555    }
556
557    #[test]
558    fn matvec_known_values() {
559        let a = mat2([1.0, 2.0], [3.0, 4.0]);
560        let x = Col::<f64>::from_fn(2, |i| [5.0, 6.0][i]);
561        let y = a.matvec(&x);
562        assert_eq!(y.nrows(), 2);
563        assert!(approx_eq(y[0], 17.0, 1e-12));
564        assert!(approx_eq(y[1], 39.0, 1e-12));
565    }
566
567    #[test]
568    fn mat_transpose_vec_known_values() {
569        let a = mat2([1.0, 2.0], [3.0, 4.0]);
570        let x = Col::<f64>::from_fn(2, |i| [5.0, 6.0][i]);
571        let y = a.mat_transpose_vec(&x);
572        assert_eq!(y.nrows(), 2);
573        // Aᵀ x = [1·5 + 3·6, 2·5 + 4·6] = [23, 34]
574        assert!(approx_eq(y[0], 23.0, 1e-12));
575        assert!(approx_eq(y[1], 34.0, 1e-12));
576    }
577
578    #[test]
579    fn gram_known_values() {
580        let a = mat2([1.0, 2.0], [3.0, 4.0]);
581        let g = a.gram();
582        // AᵀA = [[10, 14], [14, 20]]
583        assert_eq!(g.nrows(), 2);
584        assert_eq!(g.ncols(), 2);
585        assert!(approx_eq(g[(0, 0)], 10.0, 1e-12));
586        assert!(approx_eq(g[(0, 1)], 14.0, 1e-12));
587        assert!(approx_eq(g[(1, 0)], 14.0, 1e-12));
588        assert!(approx_eq(g[(1, 1)], 20.0, 1e-12));
589    }
590
591    #[test]
592    fn solve_spd_happy_path() {
593        let a = mat2([4.0, 1.0], [1.0, 3.0]);
594        let b = Col::<f64>::from_fn(2, |i| [1.0, 2.0][i]);
595        let x = a.solve_spd(&b).expect("SPD system must solve");
596        // Same hand-computed answer as the nalgebra test: x = [1/11, 7/11].
597        assert!(approx_eq(x[0], 1.0 / 11.0, 1e-12));
598        assert!(approx_eq(x[1], 7.0 / 11.0, 1e-12));
599    }
600
601    #[test]
602    fn solve_spd_indefinite_returns_error() {
603        let a = mat2([1.0, 2.0], [2.0, 1.0]);
604        let b = Col::<f64>::from_fn(2, |i| [1.0, 1.0][i]);
605        let err = a.solve_spd(&b).expect_err("indefinite must fail");
606        assert_eq!(err, LinearSolveError::NotPositiveDefinite);
607    }
608
609    #[test]
610    fn gram_of_rank_deficient_is_singular() {
611        let a = mat2([1.0, 2.0], [2.0, 4.0]);
612        let g = a.gram();
613        let b = Col::<f64>::from_fn(2, |i| [1.0, 1.0][i]);
614        let err = g.solve_spd(&b).expect_err("rank-deficient gram must fail");
615        assert_eq!(err, LinearSolveError::NotPositiveDefinite);
616    }
617
618    #[test]
619    fn add_diagonal_regularizes_singular_gram() {
620        let a = mat2([1.0, 2.0], [2.0, 4.0]);
621        let mut g = a.gram();
622        let b = Col::<f64>::from_fn(2, |i| [1.0, 1.0][i]);
623        assert!(g.clone().solve_spd(&b).is_err());
624        g.add_diagonal_vector_in_place(&Col::<f64>::from_fn(2, |_| 1e-3));
625        let x = g.solve_spd(&b).expect("damped gram must be SPD");
626        assert_eq!(x.nrows(), 2);
627    }
628
629    #[test]
630    fn matrix_identity_is_diagonal_ones() {
631        let i: Mat<f64> = MatrixIdentity::identity(3);
632        assert_eq!((i.nrows(), i.ncols()), (3, 3));
633        for r in 0..3 {
634            for c in 0..3 {
635                let want = if r == c { 1.0 } else { 0.0 };
636                assert!(approx_eq(i[(r, c)], want, 1e-12));
637            }
638        }
639    }
640
641    #[test]
642    fn matrix_from_diagonal_places_vector_on_diagonal() {
643        let d = Col::<f64>::from_fn(3, |i| [2.0, 3.0, 5.0][i]);
644        let m: Mat<f64> = MatrixFromDiagonal::from_diagonal(&d);
645        assert_eq!((m.nrows(), m.ncols()), (3, 3));
646        for r in 0..3 {
647            for c in 0..3 {
648                let want = if r == c { d[r] } else { 0.0 };
649                assert!(approx_eq(m[(r, c)], want, 1e-12));
650            }
651        }
652    }
653
654    #[test]
655    fn rank_one_update_outer_product() {
656        let mut a = Mat::<f64>::zeros(3, 3);
657        let v = Col::<f64>::from_fn(3, |i| [1.0, 2.0, 3.0][i]);
658        a.rank_one_update(2.0, &v);
659        assert!(approx_eq(a[(0, 0)], 2.0, 1e-12));
660        assert!(approx_eq(a[(0, 1)], 4.0, 1e-12));
661        assert!(approx_eq(a[(0, 2)], 6.0, 1e-12));
662        assert!(approx_eq(a[(1, 1)], 8.0, 1e-12));
663        assert!(approx_eq(a[(2, 2)], 18.0, 1e-12));
664    }
665
666    #[test]
667    fn symmetric_eigen_recovers_factorization() {
668        // C = [[2, 1], [1, 2]] has eigenvalues 1, 3.
669        let c = mat2([2.0, 1.0], [1.0, 2.0]);
670        let (b, lambda) = c.try_eigh().expect("eigendecomposition");
671        // Recompose: B diag(λ) Bᵀ.
672        let mut bd = b.clone();
673        for j in 0..2 {
674            for i in 0..2 {
675                bd[(i, j)] *= lambda[j];
676            }
677        }
678        let mut recomposed = Mat::<f64>::zeros(2, 2);
679        matmul(
680            recomposed.as_mut(),
681            Accum::Replace,
682            bd.as_ref(),
683            b.transpose(),
684            1.0,
685            Par::Seq,
686        );
687        for r in 0..2 {
688            for c_idx in 0..2 {
689                assert!(approx_eq(recomposed[(r, c_idx)], c[(r, c_idx)], 1e-10));
690            }
691        }
692    }
693
694    #[test]
695    fn add_diagonal_vector_in_place_adds_per_index() {
696        let mut a = Mat::<f64>::from_fn(3, 3, |i, j| (i * 3 + j + 1) as f64);
697        a.add_diagonal_vector_in_place(&Col::<f64>::from_fn(3, |i| [10.0, 100.0, 1000.0][i]));
698        // Diagonal: 1+10=11, 5+100=105, 9+1000=1009; off-diagonal untouched.
699        assert!(approx_eq(a[(0, 0)], 11.0, 1e-12));
700        assert!(approx_eq(a[(1, 1)], 105.0, 1e-12));
701        assert!(approx_eq(a[(2, 2)], 1009.0, 1e-12));
702        assert!(approx_eq(a[(0, 1)], 2.0, 1e-12));
703        assert!(approx_eq(a[(2, 1)], 8.0, 1e-12));
704    }
705}