Skip to main content

kinavis_kernel/
matrix.rs

1//! Small dense matrices with checked access, for estimators.
2//!
3//! Dimensions are type parameters: shape mismatches do not compile, nothing
4//! allocates. Elements are accessed through methods, not indexing: the
5//! workspace denies `indexing_slicing`, since a bounds panic aborts the
6//! process. Every loop is bounded by a compile-time dimension.
7//!
8//! Not a general linear-algebra library: only what a Kalman filter needs —
9//! products, transposes, sums, and a Cholesky factorisation that reports
10//! non-positive-definite input — panic-free and deterministic.
11
12use core::array;
13use core::fmt;
14use core::ops::{Add, Mul, Sub};
15
16use crate::math;
17
18/// Dense `R × C` `f64` matrix.
19#[derive(Clone, Copy, PartialEq)]
20pub struct Matrix<const R: usize, const C: usize> {
21    rows: [[f64; C]; R],
22}
23
24/// Column vector of `N` elements.
25pub type Vector<const N: usize> = Matrix<N, 1>;
26
27impl<const R: usize, const C: usize> Matrix<R, C> {
28    /// Zero matrix.
29    pub const ZERO: Self = Self {
30        rows: [[0.0; C]; R],
31    };
32
33    /// From rows.
34    #[must_use]
35    pub const fn from_rows(rows: [[f64; C]; R]) -> Self {
36        Self { rows }
37    }
38
39    /// Element `(row, column)` from a closure.
40    #[must_use]
41    pub fn from_fn(mut element: impl FnMut(usize, usize) -> f64) -> Self {
42        Self {
43            rows: array::from_fn(|row| array::from_fn(|column| element(row, column))),
44        }
45    }
46
47    /// Rows.
48    #[must_use]
49    pub const fn rows(&self) -> &[[f64; C]; R] {
50        &self.rows
51    }
52
53    /// Element at `(row, column)`; `None` outside.
54    #[must_use]
55    pub fn get(&self, row: usize, column: usize) -> Option<f64> {
56        self.rows.get(row)?.get(column).copied()
57    }
58
59    /// Sets element `(row, column)`; returns `false` and does nothing outside
60    /// the matrix.
61    pub fn set(&mut self, row: usize, column: usize, value: f64) -> bool {
62        match self.rows.get_mut(row).and_then(|r| r.get_mut(column)) {
63            Some(slot) => {
64                *slot = value;
65                true
66            }
67            None => false,
68        }
69    }
70
71    /// Element at an index known to be in range (from `array::from_fn` over the
72    /// same dimensions).
73    fn at(&self, row: usize, column: usize) -> f64 {
74        self.get(row, column).unwrap_or(0.0)
75    }
76
77    /// Transpose.
78    #[must_use]
79    pub fn transpose(&self) -> Matrix<C, R> {
80        Matrix::from_fn(|row, column| self.at(column, row))
81    }
82
83    /// Scalar multiple.
84    #[must_use]
85    pub fn scaled(&self, factor: f64) -> Self {
86        Self::from_fn(|row, column| self.at(row, column) * factor)
87    }
88
89    /// Whether all elements are finite.
90    #[must_use]
91    pub fn is_finite(&self) -> bool {
92        self.rows.iter().flatten().all(|value| value.is_finite())
93    }
94
95    /// Maximum absolute element.
96    #[must_use]
97    pub fn max_abs(&self) -> f64 {
98        self.rows
99            .iter()
100            .flatten()
101            .fold(0.0, |largest, &value| largest.max(math::abs(value)))
102    }
103}
104
105impl<const N: usize> Matrix<N, N> {
106    /// Identity.
107    #[must_use]
108    pub fn identity() -> Self {
109        Self::from_fn(|row, column| if row == column { 1.0 } else { 0.0 })
110    }
111
112    /// Diagonal matrix.
113    #[must_use]
114    pub fn diagonal(values: [f64; N]) -> Self {
115        Self::from_fn(|row, column| {
116            if row == column {
117                values.get(row).copied().unwrap_or(0.0)
118            } else {
119                0.0
120            }
121        })
122    }
123
124    /// Trace.
125    #[must_use]
126    pub fn trace(&self) -> f64 {
127        (0..N).map(|index| self.at(index, index)).sum()
128    }
129
130    /// `(A + Aᵀ) / 2`: exactly symmetric; unchanged for a symmetric input.
131    ///
132    /// Removes the last-place asymmetries a covariance accumulates through
133    /// products.
134    #[must_use]
135    pub fn symmetrised(&self) -> Self {
136        Self::from_fn(|row, column| f64::midpoint(self.at(row, column), self.at(column, row)))
137    }
138
139    /// Cholesky factor `L` with `self = L Lᵀ`; `None` unless symmetric positive
140    /// definite.
141    ///
142    /// A pivot ≤ 0 within a matrix-scaled tolerance (an unobserved state can
143    /// have exactly zero variance) means not positive definite; the result is
144    /// `None`, not a factor of `NaN`s. Non-iterative: N³/6 operations.
145    #[must_use]
146    pub fn cholesky(&self) -> Option<Cholesky<N>> {
147        let tolerance = self.max_abs() * f64::EPSILON * math::count_to_f64(N);
148        let mut lower = Self::ZERO;
149        for j in 0..N {
150            let mut diagonal = self.at(j, j);
151            for k in 0..j {
152                diagonal -= lower.at(j, k) * lower.at(j, k);
153            }
154            if !diagonal.is_finite() || diagonal < -tolerance {
155                return None;
156            }
157            // Zero pivot: the state has zero variance; the column is zero and
158            // the matrix is semi-definite.
159            let pivot = if diagonal <= tolerance {
160                0.0
161            } else {
162                math::sqrt(diagonal)
163            };
164            lower.set(j, j, pivot);
165            for i in (j + 1)..N {
166                let mut sum = self.at(i, j);
167                for k in 0..j {
168                    sum -= lower.at(i, k) * lower.at(j, k);
169                }
170                let value = if pivot > 0.0 { sum / pivot } else { 0.0 };
171                if !value.is_finite() {
172                    return None;
173                }
174                lower.set(i, j, value);
175            }
176        }
177        Some(Cholesky { lower })
178    }
179
180    /// Whether symmetric (relative tolerance) and positive semi-definite, as
181    /// judged by [`Matrix::cholesky`].
182    #[must_use]
183    pub fn is_covariance(&self) -> bool {
184        let scale = self.max_abs().max(f64::MIN_POSITIVE);
185        let symmetric = (0..N).all(|row| {
186            (0..N).all(|column| {
187                math::abs(self.at(row, column) - self.at(column, row)) <= scale * 1e-9
188            })
189        });
190        symmetric && self.is_finite() && self.cholesky().is_some()
191    }
192}
193
194/// Cholesky factor of a positive definite matrix, for solving.
195#[derive(Debug, Clone, Copy, PartialEq)]
196pub struct Cholesky<const N: usize> {
197    lower: Matrix<N, N>,
198}
199
200impl<const N: usize> Cholesky<N> {
201    /// Lower-triangular factor `L`.
202    #[must_use]
203    pub const fn lower(&self) -> &Matrix<N, N> {
204        &self.lower
205    }
206
207    /// Solves `A X = B` with `A = L Lᵀ`.
208    ///
209    /// `None` if a zero pivot lies along a direction `B` requires: singular in
210    /// that direction.
211    #[must_use]
212    pub fn solve<const M: usize>(&self, rhs: &Matrix<N, M>) -> Option<Matrix<N, M>> {
213        // Forward substitution: L Y = B.
214        let mut y = Matrix::<N, M>::ZERO;
215        for column in 0..M {
216            for i in 0..N {
217                let mut sum = rhs.at(i, column);
218                for k in 0..i {
219                    sum -= self.lower.at(i, k) * y.at(k, column);
220                }
221                let pivot = self.lower.at(i, i);
222                if pivot == 0.0 {
223                    if math::abs(sum) > 0.0 {
224                        return None;
225                    }
226                    y.set(i, column, 0.0);
227                } else {
228                    y.set(i, column, sum / pivot);
229                }
230            }
231        }
232        // Back substitution: Lᵀ X = Y.
233        let mut x = Matrix::<N, M>::ZERO;
234        for column in 0..M {
235            for i in (0..N).rev() {
236                let mut sum = y.at(i, column);
237                for k in (i + 1)..N {
238                    sum -= self.lower.at(k, i) * x.at(k, column);
239                }
240                let pivot = self.lower.at(i, i);
241                if pivot == 0.0 {
242                    if math::abs(sum) > 0.0 {
243                        return None;
244                    }
245                    x.set(i, column, 0.0);
246                } else {
247                    x.set(i, column, sum / pivot);
248                }
249            }
250        }
251        x.is_finite().then_some(x)
252    }
253
254    /// Determinant: squared product of the pivots.
255    #[must_use]
256    pub fn determinant(&self) -> f64 {
257        let product: f64 = (0..N).map(|index| self.lower.at(index, index)).product();
258        product * product
259    }
260}
261
262impl<const N: usize> Vector<N> {
263    /// From elements.
264    #[must_use]
265    pub fn from_column(values: [f64; N]) -> Self {
266        Self::from_fn(|row, _| values.get(row).copied().unwrap_or(0.0))
267    }
268
269    /// Elements.
270    #[must_use]
271    pub fn to_column(&self) -> [f64; N] {
272        array::from_fn(|row| self.at(row, 0))
273    }
274
275    /// Element at `row`; `None` outside.
276    #[must_use]
277    pub fn element(&self, row: usize) -> Option<f64> {
278        self.get(row, 0)
279    }
280
281    /// Dot product.
282    #[must_use]
283    pub fn dot(&self, other: &Self) -> f64 {
284        (0..N).map(|row| self.at(row, 0) * other.at(row, 0)).sum()
285    }
286
287    /// Euclidean norm.
288    #[must_use]
289    pub fn norm(&self) -> f64 {
290        math::sqrt(self.dot(self))
291    }
292}
293
294impl<const R: usize, const C: usize> Add for Matrix<R, C> {
295    type Output = Self;
296
297    fn add(self, other: Self) -> Self {
298        Self::from_fn(|row, column| self.at(row, column) + other.at(row, column))
299    }
300}
301
302impl<const R: usize, const C: usize> Sub for Matrix<R, C> {
303    type Output = Self;
304
305    fn sub(self, other: Self) -> Self {
306        Self::from_fn(|row, column| self.at(row, column) - other.at(row, column))
307    }
308}
309
310impl<const R: usize, const K: usize, const C: usize> Mul<Matrix<K, C>> for Matrix<R, K> {
311    type Output = Matrix<R, C>;
312
313    fn mul(self, other: Matrix<K, C>) -> Matrix<R, C> {
314        Matrix::from_fn(|row, column| (0..K).map(|k| self.at(row, k) * other.at(k, column)).sum())
315    }
316}
317
318impl<const R: usize, const C: usize> fmt::Debug for Matrix<R, C> {
319    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
320        f.debug_list().entries(self.rows.iter()).finish()
321    }
322}
323
324#[cfg(test)]
325#[allow(clippy::unwrap_used, clippy::float_cmp)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn products_and_transposes_come_out_right() {
331        let a = Matrix::from_rows([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]);
332        let b = Matrix::from_rows([[7.0, 8.0], [9.0, 10.0], [11.0, 12.0]]);
333        let product = a * b;
334        assert_eq!(product.rows(), &[[58.0, 64.0], [139.0, 154.0]]);
335        assert_eq!(a.transpose().rows(), &[[1.0, 4.0], [2.0, 5.0], [3.0, 6.0]]);
336        assert_eq!((a + a).rows(), &a.scaled(2.0).rows().clone());
337        assert_eq!((a - a), Matrix::ZERO);
338        assert_eq!(a.get(1, 2), Some(6.0));
339        assert_eq!(a.get(2, 0), None);
340        let mut c = a;
341        assert!(!c.set(5, 5, 1.0));
342        assert!(c.set(0, 0, 9.0));
343        assert_eq!(c.get(0, 0), Some(9.0));
344        assert_eq!(Matrix::<3, 3>::identity().trace(), 3.0);
345    }
346
347    #[test]
348    fn cholesky_factors_and_solves_a_positive_definite_system() {
349        let a = Matrix::from_rows([
350            [4.0, 12.0, -16.0],
351            [12.0, 37.0, -43.0],
352            [-16.0, -43.0, 98.0],
353        ]);
354        let factor = a.cholesky().unwrap();
355        assert_eq!(
356            factor.lower().rows(),
357            &[[2.0, 0.0, 0.0], [6.0, 1.0, 0.0], [-8.0, 5.0, 3.0]]
358        );
359        assert!((factor.determinant() - 36.0).abs() < 1e-9);
360        let b = Vector::from_column([1.0, 2.0, 3.0]);
361        let x = factor.solve(&b).unwrap();
362        let residual = a * x - b;
363        assert!(residual.max_abs() < 1e-12);
364        assert!(a.is_covariance());
365    }
366
367    #[test]
368    fn an_indefinite_matrix_has_no_factor() {
369        let indefinite = Matrix::from_rows([[1.0, 2.0], [2.0, 1.0]]);
370        assert!(indefinite.cholesky().is_none());
371        assert!(!indefinite.is_covariance());
372        let asymmetric = Matrix::from_rows([[1.0, 0.5], [0.0, 1.0]]);
373        assert!(!asymmetric.is_covariance());
374        let nan = Matrix::from_rows([[f64::NAN, 0.0], [0.0, 1.0]]);
375        assert!(nan.cholesky().is_none());
376    }
377
378    #[test]
379    fn a_semidefinite_matrix_factors_with_a_zero_pivot_and_solves_where_it_can() {
380        // Variance in the first state only.
381        let semidefinite = Matrix::from_rows([[4.0, 0.0], [0.0, 0.0]]);
382        let factor = semidefinite.cholesky().unwrap();
383        assert_eq!(factor.lower().rows(), &[[2.0, 0.0], [0.0, 0.0]]);
384        assert!(semidefinite.is_covariance());
385        // Solvable in the first direction, not the second.
386        assert!(factor.solve(&Vector::from_column([2.0, 0.0])).is_some());
387        assert!(factor.solve(&Vector::from_column([0.0, 1.0])).is_none());
388    }
389
390    #[test]
391    fn symmetrising_removes_round_off_asymmetry() {
392        let slightly_off = Matrix::from_rows([[1.0, 0.5 + 1e-16], [0.5 - 1e-16, 1.0]]);
393        let fixed = slightly_off.symmetrised();
394        assert_eq!(fixed.get(0, 1), fixed.get(1, 0));
395    }
396
397    #[test]
398    fn vectors_dot_and_measure() {
399        let v = Vector::from_column([3.0, 4.0]);
400        assert_eq!(v.norm(), 5.0);
401        assert_eq!(v.dot(&v), 25.0);
402        assert_eq!(v.to_column(), [3.0, 4.0]);
403        assert_eq!(v.element(1), Some(4.0));
404        assert_eq!(v.element(2), None);
405        assert_eq!(Matrix::<2, 2>::diagonal([1.0, 2.0]).trace(), 3.0);
406    }
407}