Skip to main content

optirs_core/second_order/kfac/
utils.rs

1// Utility functions for K-FAC optimization
2//
3// This module contains helper functions and utilities used throughout
4// the K-FAC implementation, including layer-specific operations and
5// mathematical utilities.
6
7use crate::error::{OptimError, Result};
8use scirs2_core::ndarray::{Array1, Array2};
9use scirs2_core::numeric::Float;
10use std::fmt::Debug;
11
12/// Default Tikhonov damping applied when a matrix is detected as singular during
13/// general inversion. This mirrors the K-FAC regularization convention of
14/// inverting `(A + λI)` rather than failing outright on a (numerically) singular
15/// factor.
16pub(crate) const KFAC_SINGULAR_DAMPING: f64 = 1e-8;
17
18/// Invert a general square matrix using Gauss-Jordan elimination with partial
19/// pivoting, with K-FAC-style Tikhonov fallback on (near-)singularity.
20///
21/// # Algorithm
22///
23/// The routine forms the augmented system `[A | I]` and reduces the left block to
24/// the identity via elementary row operations. At each pivot column the row with
25/// the largest absolute pivot at or below the diagonal is swapped into place
26/// (partial pivoting) for numerical stability; the right block then holds `A⁻¹`.
27///
28/// # Singular handling
29///
30/// If, after partial pivoting, the chosen pivot is effectively zero (|pivot| below
31/// a scale-aware tolerance) the matrix is treated as singular. Rather than
32/// returning a bogus identity, the routine retries ONCE on the Tikhonov-damped
33/// matrix `A + λI` (with `λ = `[`KFAC_SINGULAR_DAMPING`]). If the damped system is
34/// still singular an [`OptimError::ComputationError`] is returned. This guarantees
35/// the result is either a genuine (possibly damped) inverse or an explicit error —
36/// never a silent identity.
37///
38/// Works for any square size `n ≥ 1` over a generic `T: Float`; no external linear
39/// algebra dependency is used.
40pub(crate) fn general_matrix_inverse<T>(matrix: &Array2<T>) -> Result<Array2<T>>
41where
42    T: Float,
43{
44    let n = matrix.nrows();
45    if n != matrix.ncols() {
46        return Err(crate::error::OptimError::InvalidParameter(
47            "Matrix must be square for inversion".to_string(),
48        ));
49    }
50
51    if n == 0 {
52        return Ok(
53            Array2::from_shape_vec((0, 0), Vec::new()).unwrap_or_else(|_| Array2::zeros((0, 0)))
54        );
55    }
56
57    // First attempt: invert A directly.
58    match gauss_jordan_inverse(matrix) {
59        Ok(inv) => Ok(inv),
60        Err(_) => {
61            // Singular: retry once on the Tikhonov-damped matrix (A + λI).
62            let lambda = T::from(KFAC_SINGULAR_DAMPING).unwrap_or_else(|| T::zero());
63            let mut damped = matrix.clone();
64            for i in 0..n {
65                damped[[i, i]] = damped[[i, i]] + lambda;
66            }
67            gauss_jordan_inverse(&damped).map_err(|_| {
68                crate::error::OptimError::ComputationError(
69                    "Matrix is singular even after Tikhonov damping; inverse does not exist"
70                        .to_string(),
71                )
72            })
73        }
74    }
75}
76
77/// Core Gauss-Jordan elimination with partial pivoting.
78///
79/// Returns `Err(ComputationError)` if a (near-)zero pivot is encountered after
80/// pivoting, signalling that the input is numerically singular. The caller is
81/// responsible for any damping/regularization retry.
82fn gauss_jordan_inverse<T>(matrix: &Array2<T>) -> Result<Array2<T>>
83where
84    T: Float,
85{
86    let n = matrix.nrows();
87
88    // Working copy of A and the augmented identity that becomes A^{-1}.
89    let mut a = matrix.clone();
90    let mut inv: Array2<T> = Array2::eye(n);
91
92    // Scale-aware singularity tolerance: relative to the largest magnitude entry
93    // so that the test is invariant to overall matrix scaling.
94    let mut max_abs = T::zero();
95    for &v in a.iter() {
96        let av = v.abs();
97        if av > max_abs {
98            max_abs = av;
99        }
100    }
101    let base_eps = T::from(1e-12).unwrap_or_else(|| T::zero());
102    let tol = if max_abs > T::zero() {
103        base_eps * max_abs
104    } else {
105        // All-zero matrix is singular.
106        return Err(crate::error::OptimError::ComputationError(
107            "Matrix is singular (zero matrix)".to_string(),
108        ));
109    };
110
111    for col in 0..n {
112        // Partial pivoting: find the row >= col with the largest |pivot| in `col`.
113        let mut pivot_row = col;
114        let mut pivot_mag = a[[col, col]].abs();
115        for row in (col + 1)..n {
116            let mag = a[[row, col]].abs();
117            if mag > pivot_mag {
118                pivot_mag = mag;
119                pivot_row = row;
120            }
121        }
122
123        if pivot_mag <= tol {
124            return Err(crate::error::OptimError::ComputationError(
125                "Matrix is singular (zero pivot after partial pivoting)".to_string(),
126            ));
127        }
128
129        // Swap the pivot row into position in both A and the augmented matrix.
130        if pivot_row != col {
131            swap_rows(&mut a, col, pivot_row);
132            swap_rows(&mut inv, col, pivot_row);
133        }
134
135        // Normalize the pivot row so that a[col, col] == 1.
136        let pivot = a[[col, col]];
137        let inv_pivot = T::one() / pivot;
138        for j in 0..n {
139            a[[col, j]] = a[[col, j]] * inv_pivot;
140            inv[[col, j]] = inv[[col, j]] * inv_pivot;
141        }
142
143        // Eliminate the pivot column from every other row.
144        for row in 0..n {
145            if row == col {
146                continue;
147            }
148            let factor = a[[row, col]];
149            if factor == T::zero() {
150                continue;
151            }
152            for j in 0..n {
153                a[[row, j]] = a[[row, j]] - factor * a[[col, j]];
154                inv[[row, j]] = inv[[row, j]] - factor * inv[[col, j]];
155            }
156        }
157    }
158
159    Ok(inv)
160}
161
162/// Swap two rows of a matrix in place.
163fn swap_rows<T: Float>(matrix: &mut Array2<T>, r1: usize, r2: usize) {
164    if r1 == r2 {
165        return;
166    }
167    let ncols = matrix.ncols();
168    for j in 0..ncols {
169        let tmp = matrix[[r1, j]];
170        matrix[[r1, j]] = matrix[[r2, j]];
171        matrix[[r2, j]] = tmp;
172    }
173}
174
175/// K-FAC utilities for layer-specific operations
176pub struct KFACUtils;
177
178impl KFACUtils {
179    /// Compute the K-FAC **weight-gradient statistic** for a convolutional layer
180    /// from already-extracted patches — the same quantity
181    /// [`super::layer_state::KFACLayerState::weight_gradient`] computes for dense
182    /// layers. This is the *un-preconditioned* gradient; feed the result through
183    /// [`super::core::KFAC::apply_update_weight`] (`G⁻¹ · grad · A⁻¹`) to get the
184    /// actual K-FAC parameter update.
185    ///
186    /// `input_patches` is the im2col-style patch matrix `[samples, in_channels * kh *
187    /// kw]` (one row per `(batch element, output spatial location)` pair), optionally
188    /// with one extra trailing bias column, and `output_gradients` is the matching
189    /// per-location output gradient `[samples, out_channels]`; the two must
190    /// therefore share the same row count.
191    ///
192    /// `kernel_size`, `stride` and `padding` describe how the caller produced
193    /// `input_patches` and are accepted as provenance metadata / a light
194    /// consistency check on `input_patches`'s column count. This function has no
195    /// [`super::config::LayerInfo`] to consult, so — unlike the dense path — it
196    /// cannot know whether the caller included a bias column; `kernel_size` is
197    /// therefore checked loosely (with or without one trailing bias column) rather
198    /// than rejected outright. `stride`/`padding` do not otherwise enter the
199    /// computation: the covariance of already-extracted patches doesn't depend on
200    /// how they were extracted. This function does **not** perform patch
201    /// extraction itself — pass already-extracted patches.
202    ///
203    /// The result is `output_gradients^T @ input_patches / samples`, the same
204    /// `[out_features, in_features]` convention used by
205    /// [`super::layer_state::KFACLayerState::weight_gradient`] for dense layers.
206    ///
207    /// # Errors
208    ///
209    /// Returns [`crate::error::OptimError::DimensionMismatch`] when `input_patches`
210    /// and `output_gradients` disagree on the sample count, or
211    /// [`crate::error::OptimError::InvalidParameter`] when `input_patches`'s column
212    /// count is not `kernel_size.0 * kernel_size.1 * in_channels` for any positive
213    /// `in_channels`, with or without one trailing bias column.
214    pub fn conv_kfac_update<T: Float + scirs2_core::ndarray::ScalarOperand + 'static>(
215        input_patches: &Array2<T>,
216        output_gradients: &Array2<T>,
217        kernel_size: (usize, usize),
218        _stride: (usize, usize),
219        _padding: (usize, usize),
220    ) -> Result<Array2<T>> {
221        let batch_size = input_patches.nrows();
222        let input_dim = input_patches.ncols();
223        let output_dim = output_gradients.ncols();
224
225        if batch_size != output_gradients.nrows() {
226            return Err(crate::error::OptimError::DimensionMismatch(format!(
227                "conv_kfac_update: input_patches has {} samples but output_gradients has {}",
228                batch_size,
229                output_gradients.nrows()
230            )));
231        }
232
233        let patch_size = kernel_size.0.saturating_mul(kernel_size.1);
234        // `input_dim` must be `patch_size * in_channels` for some positive
235        // `in_channels` — i.e. a positive multiple of `patch_size` — optionally
236        // plus one trailing bias column (`homogeneous_input`'s convention
237        // elsewhere in this module; see `KFACLayerState::homogeneous_input`).
238        let without_bias_ok =
239            patch_size > 0 && input_dim > 0 && input_dim.is_multiple_of(patch_size);
240        let with_bias_ok =
241            patch_size > 0 && input_dim > 0 && (input_dim - 1).is_multiple_of(patch_size);
242        if !(without_bias_ok || with_bias_ok) {
243            return Err(crate::error::OptimError::InvalidParameter(format!(
244                "conv_kfac_update: input_patches has {input_dim} columns, which is not \
245                 kernel_size {}x{} ({patch_size}) times a positive channel count, with or \
246                 without a trailing bias column",
247                kernel_size.0, kernel_size.1
248            )));
249        }
250
251        if batch_size == 0 {
252            return Ok(Array2::zeros((output_dim, input_dim)));
253        }
254
255        // E[grad_output (x) patch], the Kronecker-factored weight-gradient
256        // statistic, in the same [out_features, in_features] convention as the
257        // dense-layer path (`KFACLayerState::weight_gradient`).
258        let scale = T::one() / T::from(batch_size).unwrap_or_else(T::one);
259        Ok(output_gradients.t().dot(input_patches) * scale)
260    }
261
262    /// Compute batch normalization statistics for K-FAC
263    pub fn batchnorm_statistics<T: Float + scirs2_core::numeric::FromPrimitive>(
264        input: &Array2<T>,
265        eps: T,
266    ) -> Result<(Array1<T>, Array1<T>)> {
267        let batch_size = input.nrows();
268        let num_features = input.ncols();
269
270        if batch_size == 0 {
271            return Ok((Array1::zeros(num_features), Array1::ones(num_features)));
272        }
273
274        let batch_size_t = T::from(batch_size).unwrap_or_else(|| T::zero());
275
276        // Compute mean (guaranteed Some: batch_size > 0 was checked above)
277        let mean = input
278            .mean_axis(scirs2_core::ndarray::Axis(0))
279            .ok_or_else(|| {
280                OptimError::ComputationError(
281                    "batchnorm_statistics: mean_axis returned None for a non-empty batch"
282                        .to_string(),
283                )
284            })?;
285
286        // Compute variance
287        let mut var = Array1::zeros(num_features);
288        for i in 0..num_features {
289            let mut sum_sq_diff = T::zero();
290            for j in 0..batch_size {
291                let diff = input[[j, i]] - mean[i];
292                sum_sq_diff = sum_sq_diff + diff * diff;
293            }
294            var[i] = sum_sq_diff / batch_size_t + eps;
295        }
296
297        Ok((mean, var))
298    }
299
300    /// Compute K-FAC update for grouped convolution layers
301    pub fn grouped_conv_kfac<T: Float + scirs2_core::ndarray::ScalarOperand>(
302        input: &Array2<T>,
303        gradients: &Array2<T>,
304        num_groups: usize,
305    ) -> Result<Array2<T>> {
306        let batch_size = input.nrows();
307        let input_channels = input.ncols();
308        let output_channels = gradients.ncols();
309
310        if num_groups == 0 {
311            return Err(crate::error::OptimError::InvalidParameter(
312                "Number of groups must be positive".to_string(),
313            ));
314        }
315
316        let input_per_group = input_channels / num_groups;
317        let output_per_group = output_channels / num_groups;
318
319        let mut result = Array2::zeros((input_channels, output_channels));
320
321        // Process each group separately
322        for group in 0..num_groups {
323            let input_start = group * input_per_group;
324            let input_end = input_start + input_per_group;
325            let output_start = group * output_per_group;
326            let output_end = output_start + output_per_group;
327
328            // Extract group data
329            let group_input = input.slice(scirs2_core::ndarray::s![.., input_start..input_end]);
330            let group_gradients =
331                gradients.slice(scirs2_core::ndarray::s![.., output_start..output_end]);
332
333            // Compute group covariance
334            let group_update = group_input.t().dot(&group_gradients);
335
336            // Place back in result
337            result
338                .slice_mut(scirs2_core::ndarray::s![
339                    input_start..input_end,
340                    output_start..output_end
341                ])
342                .assign(&group_update);
343        }
344
345        // Normalize by batch size
346        if batch_size > 0 {
347            let scale = T::one() / T::from(batch_size).unwrap_or_else(|| T::zero());
348            result = result * scale;
349        }
350
351        Ok(result)
352    }
353
354    /// Compute eigenvalue-based regularization
355    pub fn eigenvalue_regularization<T: Float + Debug + Send + Sync + 'static>(
356        matrix: &Array2<T>,
357        min_eigenvalue: T,
358    ) -> Array2<T> {
359        let n = matrix.nrows();
360        let mut regularized = matrix.clone();
361
362        // Simple diagonal regularization (in practice, would use proper eigendecomposition)
363        for i in 0..n {
364            if regularized[[i, i]] < min_eigenvalue {
365                regularized[[i, i]] = min_eigenvalue;
366            }
367        }
368
369        regularized
370    }
371
372    /// Compute Kronecker product approximation for two matrices
373    pub fn kronecker_product_approx<T: Float + Debug + Send + Sync + 'static>(
374        a: &Array2<T>,
375        b: &Array2<T>,
376    ) -> Array2<T> {
377        let (a_rows, a_cols) = a.dim();
378        let (b_rows, b_cols) = b.dim();
379
380        let mut result = Array2::zeros((a_rows * b_rows, a_cols * b_cols));
381
382        for i in 0..a_rows {
383            for j in 0..a_cols {
384                let a_val = a[[i, j]];
385                for k in 0..b_rows {
386                    for l in 0..b_cols {
387                        result[[i * b_rows + k, j * b_cols + l]] = a_val * b[[k, l]];
388                    }
389                }
390            }
391        }
392
393        result
394    }
395
396    /// Compute trace of a matrix
397    pub fn trace<T: Float + Debug + Send + Sync + 'static>(matrix: &Array2<T>) -> T {
398        let n = matrix.nrows().min(matrix.ncols());
399        let mut trace = T::zero();
400
401        for i in 0..n {
402            trace = trace + matrix[[i, i]];
403        }
404
405        trace
406    }
407
408    /// Compute Frobenius norm of a matrix
409    pub fn frobenius_norm<T: Float + std::iter::Sum>(matrix: &Array2<T>) -> T {
410        matrix.iter().map(|&x| x * x).sum::<T>().sqrt()
411    }
412
413    /// Check if two matrices are approximately equal
414    pub fn matrices_approx_equal<T: Float + Debug + Send + Sync + 'static>(
415        a: &Array2<T>,
416        b: &Array2<T>,
417        tolerance: T,
418    ) -> bool {
419        if a.dim() != b.dim() {
420            return false;
421        }
422
423        for (a_val, b_val) in a.iter().zip(b.iter()) {
424            if (*a_val - *b_val).abs() > tolerance {
425                return false;
426            }
427        }
428
429        true
430    }
431
432    /// Compute running average with exponential decay
433    pub fn exponential_moving_average<T: Float + Debug + Send + Sync + 'static>(
434        current_value: T,
435        new_value: T,
436        decay: T,
437    ) -> T {
438        decay * current_value + (T::one() - decay) * new_value
439    }
440
441    /// Clamp eigenvalues to prevent numerical instability
442    pub fn clamp_eigenvalues<T: Float + Debug + Send + Sync + 'static>(
443        eigenvalues: &mut Array1<T>,
444        min_val: T,
445        max_val: T,
446    ) {
447        for eigenval in eigenvalues.iter_mut() {
448            *eigenval = (*eigenval).max(min_val).min(max_val);
449        }
450    }
451
452    /// Compute condition number using singular values (approximation)
453    pub fn condition_number_svd_approx<T: Float + Debug + Send + Sync + 'static>(
454        matrix: &Array2<T>,
455    ) -> T {
456        // Simple approximation using diagonal elements
457        let diag = matrix.diag();
458        let max_diag = diag
459            .iter()
460            .fold(T::neg_infinity(), |acc, &x| acc.max(x.abs()));
461        let min_diag = diag.iter().fold(T::infinity(), |acc, &x| acc.min(x.abs()));
462
463        if min_diag > T::zero() {
464            max_diag / min_diag
465        } else {
466            T::infinity()
467        }
468    }
469
470    /// Extract diagonal elements and create diagonal matrix
471    pub fn diag_matrix<T: Float + Clone>(diagonal: &Array1<T>) -> Array2<T> {
472        let n = diagonal.len();
473        let mut matrix = Array2::zeros((n, n));
474
475        for i in 0..n {
476            matrix[[i, i]] = diagonal[i];
477        }
478
479        matrix
480    }
481
482    /// Symmetrize a matrix: (A + A^T) / 2
483    pub fn symmetrize<T: Float + Debug + Send + Sync + 'static>(matrix: &Array2<T>) -> Array2<T> {
484        let n = matrix.nrows();
485        let mut result = Array2::zeros((n, n));
486
487        for i in 0..n {
488            for j in 0..n {
489                result[[i, j]] =
490                    (matrix[[i, j]] + matrix[[j, i]]) / T::from(2.0).unwrap_or_else(|| T::zero());
491            }
492        }
493
494        result
495    }
496}
497
498/// Ordered float wrapper for comparison operations
499#[derive(Debug, Clone, Copy)]
500pub struct OrderedFloat<T: Float + Debug + Send + Sync + 'static>(pub T);
501
502impl<T: Float + Debug + Send + Sync + 'static> PartialEq for OrderedFloat<T> {
503    fn eq(&self, other: &Self) -> bool {
504        self.0 == other.0 || (self.0.is_nan() && other.0.is_nan())
505    }
506}
507
508impl<T: Float + Debug + Send + Sync + 'static> Eq for OrderedFloat<T> {}
509
510impl<T: Float + Debug + Send + Sync + 'static> Ord for OrderedFloat<T> {
511    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
512        self.0
513            .partial_cmp(&other.0)
514            .unwrap_or(std::cmp::Ordering::Equal)
515    }
516}
517
518impl<T: Float + Debug + Send + Sync + 'static> PartialOrd for OrderedFloat<T> {
519    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
520        Some(self.cmp(other))
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    /// Regression test: `conv_kfac_update` used to reshape its result to
529    /// `(kernel_size.0 * kernel_size.1, output_dim)` and fill it via
530    /// `i % input_dim` / `j % output_dim` wraparound indexing — a matrix of the
531    /// wrong shape holding a value that is not any real statistic of the inputs.
532    /// The correct result is the plain outer-product average
533    /// `output_gradients^T @ input_patches / batch`, shaped `(output_dim,
534    /// input_dim)`, matching `KFACLayerState::weight_gradient`'s convention.
535    #[test]
536    fn conv_kfac_update_matches_dense_outer_product_convention() {
537        // 2 samples, in_channels * kh * kw = 1 * 2 * 2 = 4, out_channels = 3.
538        let patches = Array2::from_shape_vec((2, 4), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0])
539            .expect("valid shape");
540        let grads = Array2::from_shape_vec((2, 3), vec![1.0, 0.0, -1.0, 0.5, 0.5, 0.5])
541            .expect("valid shape");
542
543        let update = KFACUtils::conv_kfac_update(&patches, &grads, (2, 2), (1, 1), (0, 0))
544            .expect("conv_kfac_update should succeed");
545
546        // Shape must be [out_channels, input_dim], not [kh*kw, out_channels].
547        assert_eq!(update.dim(), (3, 4));
548
549        let expected = grads.t().dot(&patches) / 2.0;
550        for (actual, expected) in update.iter().zip(expected.iter()) {
551            assert!(
552                (actual - expected).abs() < 1e-12,
553                "expected {expected}, got {actual}"
554            );
555        }
556    }
557
558    #[test]
559    fn conv_kfac_update_rejects_column_count_inconsistent_with_kernel_size() {
560        // 7 columns cannot come from any positive channel count with a 2x2 kernel,
561        // with or without a trailing bias column: 7 % 4 == 3 and (7-1) % 4 == 2.
562        let patches = Array2::<f64>::zeros((2, 7));
563        let grads = Array2::<f64>::zeros((2, 3));
564
565        let result = KFACUtils::conv_kfac_update(&patches, &grads, (2, 2), (1, 1), (0, 0));
566        assert!(result.is_err());
567    }
568
569    /// Regression test: the column-count check must not reject the bias-augmented
570    /// case. `KFACLayerState::homogeneous_input` appends exactly one trailing bias
571    /// column elsewhere in this module (see the F12 covariance tests), so
572    /// `conv_kfac_update` must accept `in_channels * kh * kw + 1` columns too, not
573    /// just an exact multiple of `kh * kw`.
574    #[test]
575    fn conv_kfac_update_accepts_a_trailing_bias_column() {
576        // in_channels * kh * kw = 1 * 2 * 2 = 4, plus one bias column = 5.
577        let patches = Array2::from_shape_vec(
578            (2, 5),
579            vec![1.0, 2.0, 3.0, 4.0, 1.0, 5.0, 6.0, 7.0, 8.0, 1.0],
580        )
581        .expect("valid shape");
582        let grads = Array2::from_shape_vec((2, 3), vec![1.0, 0.0, -1.0, 0.5, 0.5, 0.5])
583            .expect("valid shape");
584
585        let update = KFACUtils::conv_kfac_update(&patches, &grads, (2, 2), (1, 1), (0, 0))
586            .expect("bias-augmented patches should be accepted");
587        assert_eq!(update.dim(), (3, 5));
588
589        let expected = grads.t().dot(&patches) / 2.0;
590        for (actual, expected) in update.iter().zip(expected.iter()) {
591            assert!((actual - expected).abs() < 1e-12);
592        }
593    }
594
595    #[test]
596    fn conv_kfac_update_rejects_sample_count_mismatch() {
597        let patches = Array2::<f64>::zeros((2, 4));
598        let grads = Array2::<f64>::zeros((3, 3)); // wrong row count
599
600        let result = KFACUtils::conv_kfac_update(&patches, &grads, (2, 2), (1, 1), (0, 0));
601        assert!(result.is_err());
602    }
603
604    #[test]
605    fn conv_kfac_update_empty_batch_is_zero_of_correct_shape() {
606        let patches = Array2::<f64>::zeros((0, 4));
607        let grads = Array2::<f64>::zeros((0, 3));
608
609        let update = KFACUtils::conv_kfac_update(&patches, &grads, (2, 2), (1, 1), (0, 0))
610            .expect("empty batch should succeed");
611        assert_eq!(update.dim(), (3, 4));
612        assert!(update.iter().all(|&x| x == 0.0));
613    }
614
615    #[test]
616    fn test_trace_computation() {
617        let matrix =
618            Array2::from_shape_vec((3, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0])
619                .expect("Array2::from_shape_vec succeeds in test_trace_computation");
620        let trace = KFACUtils::trace(&matrix);
621        assert!((trace - 15.0).abs() < 1e-10); // 1 + 5 + 9 = 15
622    }
623
624    #[test]
625    fn test_frobenius_norm() {
626        let matrix = Array2::from_shape_vec((2, 2), vec![3.0, 4.0, 0.0, 0.0])
627            .expect("Array2::from_shape_vec succeeds in test_frobenius_norm");
628        let norm = KFACUtils::frobenius_norm(&matrix);
629        assert!((norm - 5.0).abs() < 1e-10); // sqrt(9 + 16) = 5
630    }
631
632    #[test]
633    fn test_exponential_moving_average() {
634        let current = 10.0;
635        let new_val = 20.0;
636        let decay = 0.9;
637
638        let result = KFACUtils::exponential_moving_average(current, new_val, decay);
639        let expected = 0.9 * 10.0 + 0.1 * 20.0; // 9.0 + 2.0 = 11.0
640        assert!((result - expected).abs() < 1e-10);
641    }
642
643    #[test]
644    fn test_matrices_approx_equal() {
645        let a = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0])
646            .expect("Array2::from_shape_vec succeeds in test_matrices_approx_equal");
647        let b = Array2::from_shape_vec((2, 2), vec![1.001, 2.001, 3.001, 4.001])
648            .expect("Array2::from_shape_vec succeeds in test_matrices_approx_equal");
649
650        assert!(KFACUtils::matrices_approx_equal(&a, &b, 0.01));
651        assert!(!KFACUtils::matrices_approx_equal(&a, &b, 0.0001));
652    }
653
654    #[test]
655    fn test_symmetrize() {
656        let matrix = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0])
657            .expect("Array2::from_shape_vec succeeds in test_symmetrize");
658        let symmetric = KFACUtils::symmetrize(&matrix);
659
660        assert!((symmetric[[0, 0]] - 1.0).abs() < 1e-10);
661        assert!((symmetric[[0, 1]] - 2.5).abs() < 1e-10); // (2 + 3) / 2
662        assert!((symmetric[[1, 0]] - 2.5).abs() < 1e-10); // (3 + 2) / 2
663        assert!((symmetric[[1, 1]] - 4.0).abs() < 1e-10);
664    }
665
666    #[test]
667    fn test_diag_matrix() {
668        let diagonal = Array1::from_vec(vec![1.0, 2.0, 3.0]);
669        let matrix = KFACUtils::diag_matrix(&diagonal);
670
671        assert_eq!(matrix.dim(), (3, 3));
672        assert!((matrix[[0, 0]] - 1.0).abs() < 1e-10);
673        assert!((matrix[[1, 1]] - 2.0).abs() < 1e-10);
674        assert!((matrix[[2, 2]] - 3.0).abs() < 1e-10);
675        assert!((matrix[[0, 1]]).abs() < 1e-10); // Off-diagonal should be zero
676    }
677
678    #[test]
679    fn test_ordered_float() {
680        let a = OrderedFloat(1.5);
681        let b = OrderedFloat(2.5);
682        let c = OrderedFloat(1.5);
683
684        assert!(a < b);
685        assert!(a == c);
686        assert!(b > a);
687    }
688
689    #[test]
690    fn test_batchnorm_statistics() {
691        let input = Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0])
692            .expect("Array2::from_shape_vec succeeds in test_batchnorm_statistics");
693
694        let (mean, var) = KFACUtils::batchnorm_statistics(&input, 1e-8)
695            .expect("KFACUtils::batchnorm_statistics succeeds in test_batchnorm_statistics");
696
697        // Expected mean: [4.0, 5.0] (column-wise average)
698        assert!((mean[0] - 4.0).abs() < 1e-6);
699        assert!((mean[1] - 5.0).abs() < 1e-6);
700
701        // Variance should be positive
702        assert!(var[0] > 0.0);
703        assert!(var[1] > 0.0);
704    }
705
706    // ---- General matrix inversion (Gauss-Jordan with partial pivoting) ----
707
708    use approx::assert_abs_diff_eq;
709
710    /// Assert that `A · inv(A) ≈ I` to the given epsilon.
711    fn assert_is_inverse(a: &Array2<f64>, inv: &Array2<f64>, eps: f64) {
712        let n = a.nrows();
713        let product = a.dot(inv);
714        let identity: Array2<f64> = Array2::eye(n);
715        for i in 0..n {
716            for j in 0..n {
717                assert_abs_diff_eq!(product[[i, j]], identity[[i, j]], epsilon = eps);
718            }
719        }
720    }
721
722    #[test]
723    fn test_general_inverse_known_2x2() {
724        // Hand-chosen matrix with a known inverse.
725        // A = [[4, 7], [2, 6]]  =>  inv(A) = [[0.6, -0.7], [-0.2, 0.4]]
726        let a = Array2::from_shape_vec((2, 2), vec![4.0, 7.0, 2.0, 6.0]).expect("shape");
727        let inv = general_matrix_inverse(&a).expect("invertible");
728
729        assert_abs_diff_eq!(inv[[0, 0]], 0.6, epsilon = 1e-12);
730        assert_abs_diff_eq!(inv[[0, 1]], -0.7, epsilon = 1e-12);
731        assert_abs_diff_eq!(inv[[1, 0]], -0.2, epsilon = 1e-12);
732        assert_abs_diff_eq!(inv[[1, 1]], 0.4, epsilon = 1e-12);
733    }
734
735    #[test]
736    fn test_general_inverse_4x4_spd() {
737        // 4x4 SPD matrix built as M = B^T B + I (guaranteed positive definite).
738        let b = Array2::from_shape_vec(
739            (4, 4),
740            vec![
741                1.0, 0.5, -0.3, 0.2, 0.0, 1.2, 0.7, -0.4, 0.3, -0.1, 0.9, 0.6, -0.2, 0.4, 0.1, 1.1,
742            ],
743        )
744        .expect("shape");
745        let mut spd = b.t().dot(&b);
746        for i in 0..4 {
747            spd[[i, i]] += 1.0;
748        }
749
750        let inv = general_matrix_inverse(&spd).expect("invertible");
751        assert_is_inverse(&spd, &inv, 1e-6);
752    }
753
754    #[test]
755    fn test_general_inverse_8x8_spd() {
756        // 8x8 SPD matrix M = B^T B + 2I with deterministic, well-conditioned data.
757        let n = 8usize;
758        let mut b = Array2::<f64>::zeros((n, n));
759        for i in 0..n {
760            for j in 0..n {
761                // A smooth, non-degenerate pattern.
762                let v = ((i as f64 + 1.0) * 0.3 - (j as f64) * 0.17).sin()
763                    + 0.05 * (i as f64 - j as f64);
764                b[[i, j]] = v;
765            }
766        }
767        let mut spd = b.t().dot(&b);
768        for i in 0..n {
769            spd[[i, i]] += 2.0;
770        }
771
772        let inv = general_matrix_inverse(&spd).expect("invertible");
773        assert_is_inverse(&spd, &inv, 1e-6);
774    }
775
776    #[test]
777    fn test_general_inverse_nonsymmetric() {
778        // A general (non-symmetric) invertible matrix; partial pivoting is needed
779        // because the (0,0) entry is zero.
780        let a = Array2::from_shape_vec(
781            (4, 4),
782            vec![
783                0.0, 2.0, 1.0, 3.0, 4.0, 1.0, 0.0, 2.0, 1.0, 5.0, 3.0, 0.0, 2.0, 1.0, 4.0, 1.0,
784            ],
785        )
786        .expect("shape");
787        let inv = general_matrix_inverse(&a).expect("invertible");
788        assert_is_inverse(&a, &inv, 1e-6);
789
790        // inv(A) · A ≈ I as well (left inverse).
791        let left = inv.dot(&a);
792        let identity: Array2<f64> = Array2::eye(4);
793        for i in 0..4 {
794            for j in 0..4 {
795                assert_abs_diff_eq!(left[[i, j]], identity[[i, j]], epsilon = 1e-6);
796            }
797        }
798    }
799
800    #[test]
801    fn test_general_inverse_near_singular_damps_to_finite() {
802        // A genuinely singular matrix (row 2 = 2 * row 0). The damping path inverts
803        // (A + λI) and must yield a finite, well-defined result (no NaN/Inf).
804        let a = Array2::from_shape_vec((3, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 2.0, 4.0, 6.0])
805            .expect("shape");
806        let inv = general_matrix_inverse(&a).expect("damping fallback should succeed");
807        for &v in inv.iter() {
808            assert!(v.is_finite(), "damped inverse contains non-finite entry");
809        }
810    }
811
812    #[test]
813    fn test_general_inverse_not_identity_regression() {
814        // Regression guard for the old bug where the inverse silently returned the
815        // identity. For a non-identity input the inverse must NOT equal the input's
816        // identity-shaped matrix.
817        let a = Array2::from_shape_vec(
818            (4, 4),
819            vec![
820                2.0, 1.0, 0.0, 0.0, 1.0, 2.0, 1.0, 0.0, 0.0, 1.0, 2.0, 1.0, 0.0, 0.0, 1.0, 2.0,
821            ],
822        )
823        .expect("shape");
824        let inv = general_matrix_inverse(&a).expect("invertible");
825        let identity: Array2<f64> = Array2::eye(4);
826        assert!(
827            !KFACUtils::matrices_approx_equal(&inv, &identity, 1e-9),
828            "inverse of a non-identity matrix must not be the identity"
829        );
830        // And it must be a real inverse.
831        assert_is_inverse(&a, &inv, 1e-6);
832    }
833
834    #[test]
835    fn test_general_inverse_non_square_errors() {
836        let a = Array2::<f64>::zeros((2, 3));
837        assert!(general_matrix_inverse(&a).is_err());
838    }
839}