la_stack/matrix.rs
1#![forbid(unsafe_code)]
2
3//! Fixed-size, stack-allocated square matrices.
4
5use core::hint::cold_path;
6
7use crate::ldlt::Ldlt;
8use crate::lu::Lu;
9use crate::{ArithmeticOperation, ERR_COEFF_2, ERR_COEFF_3, ERR_COEFF_4, LaError, Tolerance};
10
11/// A closed-form determinant and its certified absolute error bound.
12///
13/// Values of this type are produced by
14/// [`Matrix::det_direct_with_errbound`]. The paired result guarantees that the
15/// determinant was evaluated once and that its matching bound was computed for
16/// the same matrix in one call. The guarantee is unavailable when gradual
17/// underflow could invalidate the relative-error analysis or when the matrix
18/// dimension exceeds the closed-form D ≤ 4 scope.
19#[must_use]
20#[non_exhaustive]
21#[derive(Clone, Copy, Debug, PartialEq)]
22pub struct DeterminantWithErrorBound {
23 determinant: f64,
24 absolute_error_bound: f64,
25}
26
27impl DeterminantWithErrorBound {
28 /// Return the closed-form determinant approximation.
29 #[inline]
30 #[must_use]
31 pub const fn determinant(self) -> f64 {
32 self.determinant
33 }
34
35 /// Return the certified absolute error bound.
36 ///
37 /// The exact determinant lies in
38 /// `[determinant - bound, determinant + bound]`.
39 #[inline]
40 #[must_use]
41 pub const fn absolute_error_bound(self) -> f64 {
42 self.absolute_error_bound
43 }
44}
45
46/// Finite fixed-size square matrix `D×D`, stored inline.
47///
48/// `Matrix` is designed for small, robustness-sensitive systems where stack
49/// allocation and const-generic dimensions are useful. For large, dynamic, sparse,
50/// or parallel workloads, prefer a broader linear-algebra crate such as
51/// [`nalgebra`](https://crates.io/crates/nalgebra) or
52/// [`faer`](https://crates.io/crates/faer).
53///
54/// Public construction and mutation reject NaN and infinity through
55/// [`try_from_rows`](Self::try_from_rows) and [`set`](Self::set). The storage
56/// field is private, so a
57/// `Matrix` value carries the invariant that every stored entry is finite.
58/// Algorithms therefore do not re-scan stored entries at every use; user-visible
59/// non-finite errors come from construction/mutation boundaries or from values
60/// computed during arithmetic, such as overflowed elimination or determinant
61/// intermediates.
62///
63/// Direct field construction is intentionally unavailable to downstream callers:
64///
65/// ```compile_fail
66/// use la_stack::Matrix;
67///
68/// let _ = Matrix::<2> {
69/// rows: [[1.0, f64::NAN], [0.0, 1.0]],
70/// };
71/// ```
72#[must_use]
73#[derive(Clone, Copy, Debug, PartialEq)]
74pub struct Matrix<const D: usize> {
75 rows: [[f64; D]; D],
76}
77
78/// A finite [`Matrix`] proven exactly symmetric for LDLT factorization.
79///
80/// Mirrored entries have equal numeric values; IEEE-754 signed zeros may have
81/// different bit patterns because `+0.0 == -0.0`.
82#[must_use]
83#[derive(Clone, Copy, Debug, PartialEq)]
84pub(crate) struct SymmetricMatrix<const D: usize> {
85 matrix: Matrix<D>,
86}
87
88/// Rounded arithmetic result together with proof that gradual underflow could
89/// not have changed that operation's result.
90///
91/// The determinant filter may only use its relative-error coefficients while
92/// every rounded operation in both the determinant and absolute-Leibniz trees
93/// stays in the normal range. Exact structural zeros are safe; cancellation to
94/// zero is conservatively treated as inconclusive.
95#[derive(Clone, Copy, Debug, PartialEq)]
96struct FilterArithmetic<const TRACK_UNDERFLOW: bool> {
97 value: f64,
98 underflow_safe: bool,
99}
100
101impl<const TRACK_UNDERFLOW: bool> FilterArithmetic<TRACK_UNDERFLOW> {
102 /// Return whether a rounded result is normal or non-finite.
103 ///
104 /// A single exponent-field test keeps the overwhelmingly common normal
105 /// path cheap. Callers inspect operands only when the result is zero or
106 /// subnormal so they can distinguish structural zero from range loss.
107 #[expect(
108 clippy::inline_always,
109 reason = "determinant hot-path specialization must eliminate unused safety state"
110 )]
111 #[inline(always)]
112 const fn has_nonzero_exponent(value: f64) -> bool {
113 value.to_bits() & 0x7ff0_0000_0000_0000 != 0
114 }
115
116 /// Ordinary floating-point multiplication.
117 #[expect(
118 clippy::inline_always,
119 reason = "determinant hot-path specialization must eliminate unused safety state"
120 )]
121 #[inline(always)]
122 const fn multiply(lhs: f64, rhs: f64) -> Self {
123 let value = lhs * rhs;
124 Self {
125 value,
126 underflow_safe: !TRACK_UNDERFLOW
127 || Self::has_nonzero_exponent(value)
128 || lhs == 0.0
129 || rhs == 0.0,
130 }
131 }
132
133 /// Ordinary addition of the non-negative terms used by the error-bound tree.
134 #[expect(
135 clippy::inline_always,
136 reason = "determinant hot-path specialization must eliminate unused safety state"
137 )]
138 #[inline(always)]
139 const fn add_non_negative(lhs: f64, rhs: f64) -> Self {
140 let value = lhs + rhs;
141 Self {
142 value,
143 underflow_safe: !TRACK_UNDERFLOW
144 || Self::has_nonzero_exponent(value)
145 || (lhs == 0.0 && rhs == 0.0),
146 }
147 }
148
149 /// Fused multiply-add.
150 #[expect(
151 clippy::inline_always,
152 reason = "determinant hot-path specialization must eliminate unused safety state"
153 )]
154 #[inline(always)]
155 const fn mul_add(lhs: f64, rhs: f64, addend: f64) -> Self {
156 let value = lhs.mul_add(rhs, addend);
157 Self {
158 value,
159 underflow_safe: !TRACK_UNDERFLOW
160 || Self::has_nonzero_exponent(value)
161 || ((lhs == 0.0 || rhs == 0.0) && addend == 0.0),
162 }
163 }
164}
165
166/// A finite D=4 matrix proven safe for shared-minor determinant and permanent
167/// evaluation.
168///
169/// Construction proves both the fixed dimension and that every coefficient in
170/// the first two rows is non-zero. The latter makes every shared 2×2 minor part
171/// of an active Leibniz term, so the dense kernel cannot evaluate an overflowing
172/// minor solely for a mathematically absent term.
173#[repr(transparent)]
174#[derive(Clone, Copy)]
175struct Det4SharedMinorInput<'a, const D: usize> {
176 matrix: &'a Matrix<D>,
177}
178
179impl<'a, const D: usize> Det4SharedMinorInput<'a, D> {
180 /// Parse a matrix into the shared-minor D=4 domain.
181 ///
182 /// `None` selects the guarded determinant path; it does not represent an
183 /// invalid public matrix.
184 #[expect(
185 clippy::inline_always,
186 reason = "the D=4 determinant hot path must eliminate its proof wrapper"
187 )]
188 #[inline(always)]
189 const fn try_new(matrix: &'a Matrix<D>) -> Option<Self> {
190 if D != 4 {
191 return None;
192 }
193
194 let r = &matrix.rows;
195 let shared_minors_are_active = (r[0][0] != 0.0)
196 && (r[0][1] != 0.0)
197 && (r[0][2] != 0.0)
198 && (r[0][3] != 0.0)
199 && (r[1][0] != 0.0)
200 && (r[1][1] != 0.0)
201 && (r[1][2] != 0.0)
202 && (r[1][3] != 0.0);
203
204 if shared_minors_are_active {
205 Some(Self { matrix })
206 } else {
207 None
208 }
209 }
210}
211
212impl<const D: usize> SymmetricMatrix<D> {
213 /// Consume the wrapper and return the underlying matrix.
214 #[inline]
215 pub(crate) const fn into_matrix(self) -> Matrix<D> {
216 self.matrix
217 }
218
219 /// Construct a symmetric matrix proof without checking the invariant.
220 ///
221 /// This constructor is only for paths that have already validated exact
222 /// mirrored-entry equality with the same predicate as
223 /// [`try_new`](Self::try_new). Finiteness is carried by [`Matrix`].
224 #[inline]
225 const fn new_unchecked(matrix: Matrix<D>) -> Self {
226 Self { matrix }
227 }
228
229 /// Validate that every mirrored pair has exactly the same finite value.
230 ///
231 /// IEEE-754 signed zeros compare equal, so `+0.0` and `-0.0` satisfy this
232 /// mathematical-symmetry proof even though their bit patterns differ.
233 ///
234 /// # Errors
235 /// Returns [`LaError::Asymmetric`] with `allowed_abs_diff == 0.0` when the
236 /// first off-diagonal pair is not exactly equal.
237 #[inline]
238 #[expect(
239 clippy::float_cmp,
240 reason = "LDLT requires exact mirrored-entry equality to factor the supplied operator"
241 )]
242 fn try_new(matrix: Matrix<D>) -> Result<Self, LaError> {
243 for row in 0..D {
244 for col in (row + 1)..D {
245 let upper = matrix.rows[row][col];
246 let lower = matrix.rows[col][row];
247 if upper != lower {
248 cold_path();
249 return Err(LaError::asymmetric(row, col, D, upper, lower, 0.0));
250 }
251 }
252 }
253
254 Ok(Self::new_unchecked(matrix))
255 }
256}
257
258impl<const D: usize> Matrix<D> {
259 /// Try to create a finite matrix from row-major storage.
260 ///
261 /// This is the public raw-storage boundary for matrices. Successful
262 /// construction makes the returned [`Matrix`] a finite-storage proof.
263 ///
264 /// # Examples
265 /// ```
266 /// use la_stack::prelude::*;
267 ///
268 /// # fn main() -> Result<(), LaError> {
269 /// let m = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]])?;
270 /// assert_eq!(m.get(0, 1), Some(2.0));
271 /// # Ok(())
272 /// # }
273 /// ```
274 ///
275 /// # Errors
276 /// Returns [`LaError::NonFinite`] with matrix coordinates for the first
277 /// offending entry in row-major order when `rows` contains NaN or infinity.
278 #[inline]
279 pub const fn try_from_rows(rows: [[f64; D]; D]) -> Result<Self, LaError> {
280 if let Some((row, col)) = Self::first_non_finite_cell(&rows) {
281 Err(LaError::non_finite_input_matrix(row, col))
282 } else {
283 Ok(Self::from_rows_unchecked(rows))
284 }
285 }
286
287 /// Construct a matrix without checking that entries are finite.
288 ///
289 /// This module-private escape hatch is reserved for finite literals and
290 /// algorithm outputs whose finite invariant is visible at the call site.
291 /// Computed outputs must be validated before becoming observable API values.
292 #[inline]
293 const fn from_rows_unchecked(rows: [[f64; D]; D]) -> Self {
294 Self { rows }
295 }
296
297 /// Borrow the finite row-major backing array.
298 ///
299 /// The returned view is tied to this [`Matrix`], so callers can inspect the
300 /// canonical storage without copying it or bypassing the finite-value
301 /// invariant.
302 ///
303 /// # Examples
304 /// ```
305 /// use la_stack::prelude::*;
306 ///
307 /// # fn main() -> Result<(), LaError> {
308 /// let matrix = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]])?;
309 /// assert_eq!(matrix.as_rows(), &[[1.0, 2.0], [3.0, 4.0]]);
310 /// # Ok(())
311 /// # }
312 /// ```
313 ///
314 /// A live view keeps the matrix immutably borrowed, so validated mutation
315 /// cannot occur until the view is no longer used:
316 ///
317 /// ```compile_fail
318 /// use la_stack::Matrix;
319 ///
320 /// let mut matrix = Matrix::<2>::identity();
321 /// let rows = matrix.as_rows();
322 /// assert!(matrix.set(0, 0, 5.0).is_ok());
323 /// assert_eq!(rows[0][0], 1.0);
324 /// ```
325 #[inline]
326 #[must_use]
327 pub const fn as_rows(&self) -> &[[f64; D]; D] {
328 &self.rows
329 }
330
331 /// Consume this matrix and return its finite row-major backing array.
332 ///
333 /// # Examples
334 /// ```
335 /// use la_stack::prelude::*;
336 ///
337 /// # fn main() -> Result<(), LaError> {
338 /// let matrix = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]])?;
339 /// assert_eq!(matrix.into_rows(), [[1.0, 2.0], [3.0, 4.0]]);
340 /// # Ok(())
341 /// # }
342 /// ```
343 #[inline]
344 #[must_use]
345 pub const fn into_rows(self) -> [[f64; D]; D] {
346 self.rows
347 }
348
349 /// All-zeros finite matrix.
350 ///
351 /// # Examples
352 /// ```
353 /// use la_stack::prelude::*;
354 ///
355 /// let z = Matrix::<2>::zero();
356 /// assert_eq!(z.get(1, 1), Some(0.0));
357 /// ```
358 #[inline]
359 pub const fn zero() -> Self {
360 Self::from_rows_unchecked([[0.0; D]; D])
361 }
362
363 /// Finite identity matrix.
364 ///
365 /// # Examples
366 /// ```
367 /// use la_stack::prelude::*;
368 ///
369 /// let i = Matrix::<3>::identity();
370 /// assert_eq!(i.get(0, 0), Some(1.0));
371 /// assert_eq!(i.get(0, 1), Some(0.0));
372 /// assert_eq!(i.get(2, 2), Some(1.0));
373 /// ```
374 #[inline]
375 pub const fn identity() -> Self {
376 let mut m = Self::zero();
377
378 let mut i = 0;
379 while i < D {
380 m.rows[i][i] = 1.0;
381 i += 1;
382 }
383
384 m
385 }
386
387 /// Get a finite element with bounds checking.
388 ///
389 /// # Examples
390 /// ```
391 /// use la_stack::prelude::*;
392 ///
393 /// # fn main() -> Result<(), LaError> {
394 /// let m = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]])?;
395 /// assert_eq!(m.get(1, 0), Some(3.0));
396 /// assert_eq!(m.get(2, 0), None);
397 /// # Ok(())
398 /// # }
399 /// ```
400 #[inline]
401 #[must_use]
402 pub const fn get(&self, row: usize, col: usize) -> Option<f64> {
403 if row < D && col < D {
404 Some(self.rows[row][col])
405 } else {
406 None
407 }
408 }
409
410 /// Get a finite element, preserving index context on failure.
411 ///
412 /// Prefer [`get`](Self::get) for const or hot paths that only need
413 /// `Option`-style absence. Use this method at public runtime boundaries
414 /// where row, column, and dimension context should survive in a typed error.
415 ///
416 /// # Examples
417 /// ```
418 /// use core::assert_matches;
419 /// use la_stack::prelude::*;
420 ///
421 /// # fn main() -> Result<(), LaError> {
422 /// let m = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]])?;
423 /// assert_eq!(m.try_get(1, 0)?, 3.0);
424 /// assert_matches!(
425 /// m.try_get(2, 0),
426 /// Err(LaError::IndexOutOfBounds {
427 /// row: 2,
428 /// col: 0,
429 /// dim: 2,
430 /// ..
431 /// })
432 /// );
433 /// # Ok(())
434 /// # }
435 /// ```
436 ///
437 /// # Errors
438 /// Returns [`LaError::IndexOutOfBounds`] when either index is not `< D`.
439 #[inline]
440 pub const fn try_get(&self, row: usize, col: usize) -> Result<f64, LaError> {
441 if row < D && col < D {
442 Ok(self.rows[row][col])
443 } else {
444 Err(LaError::index_out_of_bounds(row, col, D))
445 }
446 }
447
448 /// Set a finite element with bounds checking.
449 ///
450 /// # Examples
451 /// ```
452 /// use core::assert_matches;
453 /// use la_stack::prelude::*;
454 ///
455 /// # fn main() -> Result<(), LaError> {
456 /// let mut m = Matrix::<2>::zero();
457 /// assert_eq!(m.set(0, 1, 2.5), Ok(()));
458 /// assert_eq!(m.get(0, 1), Some(2.5));
459 /// assert_matches!(
460 /// m.set(10, 0, 1.0),
461 /// Err(LaError::IndexOutOfBounds {
462 /// row: 10,
463 /// col: 0,
464 /// dim: 2,
465 /// ..
466 /// })
467 /// );
468 /// # Ok(())
469 /// # }
470 /// ```
471 ///
472 /// # Errors
473 /// Returns [`LaError::IndexOutOfBounds`] when either index is not `< D`.
474 /// Returns [`LaError::NonFinite`] when `value` is NaN or infinity.
475 #[inline]
476 pub const fn set(&mut self, row: usize, col: usize, value: f64) -> Result<(), LaError> {
477 if row >= D || col >= D {
478 return Err(LaError::index_out_of_bounds(row, col, D));
479 }
480 if !value.is_finite() {
481 return Err(LaError::non_finite_input_matrix(row, col));
482 }
483 self.rows[row][col] = value;
484 Ok(())
485 }
486
487 /// Infinity norm (maximum absolute row sum).
488 ///
489 /// # Non-finite handling
490 /// Public constructors and setters reject raw non-finite entries, but
491 /// `Matrix` values are finite by construction. `inf_norm` returns
492 /// [`LaError::NonFinite`] with the matrix cell whose addition first makes a
493 /// row sum non-finite.
494 ///
495 /// Row sums are accumulated in `f64` with ordinary addition. This method
496 /// checks for overflowed accumulators, but it does not provide a certified
497 /// absolute rounding bound for the returned norm.
498 ///
499 /// # Examples
500 /// ```
501 /// use core::assert_matches;
502 /// use la_stack::prelude::*;
503 ///
504 /// # fn main() -> Result<(), LaError> {
505 /// let m = Matrix::<2>::try_from_rows([[1.0, -2.0], [3.0, 4.0]])?;
506 /// assert!((m.inf_norm()? - 7.0).abs() <= 1e-12);
507 ///
508 /// // Raw NaN entries are rejected with coordinates.
509 /// assert_matches!(
510 /// Matrix::<2>::try_from_rows([[f64::NAN, 1.0], [2.0, 3.0]]),
511 /// Err(LaError::NonFinite {
512 /// location: NonFiniteLocation::MatrixCell { row: 0, col: 0, .. },
513 /// origin: NonFiniteOrigin::Input,
514 /// ..
515 /// })
516 /// );
517 /// # Ok(())
518 /// # }
519 /// ```
520 ///
521 /// # Errors
522 /// Returns [`LaError::NonFinite`] with matrix coordinates when a row sum
523 /// overflows to NaN or infinity.
524 #[inline]
525 pub const fn inf_norm(&self) -> Result<f64, LaError> {
526 let mut max_row_sum: f64 = 0.0;
527
528 let mut r = 0;
529 while r < D {
530 let row = &self.rows[r];
531 let mut row_sum: f64 = 0.0;
532 let mut c = 0;
533 while c < D {
534 row_sum += row[c].abs();
535 c += 1;
536 }
537 if !row_sum.is_finite() {
538 cold_path();
539 return Err(Self::inf_norm_overflow_error(row, r));
540 }
541 if row_sum > max_row_sum {
542 max_row_sum = row_sum;
543 }
544 r += 1;
545 }
546
547 Ok(max_row_sum)
548 }
549
550 /// Replay an overflowed infinity-norm row to locate the first non-finite sum.
551 ///
552 /// This runs only after the success-path traversal has found a non-finite
553 /// completed row sum. Because stored entries are finite and their absolute
554 /// values are non-negative, replaying the same additions must find the
555 /// first column whose addition overflowed; if every earlier prefix is
556 /// finite, the final column is that first failure.
557 #[cold]
558 const fn inf_norm_overflow_error(row: &[f64; D], row_index: usize) -> LaError {
559 let mut row_sum = 0.0;
560 let mut col = 0;
561 let last_col = D.saturating_sub(1);
562 while col < last_col {
563 row_sum += row[col].abs();
564 if !row_sum.is_finite() {
565 return LaError::non_finite_computation_matrix(
566 ArithmeticOperation::MatrixInfinityNorm,
567 row_index,
568 col,
569 );
570 }
571 col += 1;
572 }
573
574 LaError::non_finite_computation_matrix(
575 ArithmeticOperation::MatrixInfinityNorm,
576 row_index,
577 last_col,
578 )
579 }
580
581 /// Returns `true` if the matrix is approximately symmetric within a relative tolerance.
582 ///
583 /// Two entries `self[r][c]` and `self[c][r]` are considered equal (for the
584 /// purposes of symmetry) when
585 /// `|self[r][c] - self[c][r]| <= rel_tol * max(1.0, inf_norm(self))`.
586 /// This is a diagnostic predicate for applications that have an
587 /// approximation-specific symmetry threshold. It is not the precondition
588 /// used by [`ldlt`](Self::ldlt), which requires exact mirrored-entry
589 /// equality so the returned factors represent the original matrix.
590 ///
591 /// Use [`first_asymmetry`](Self::first_asymmetry) to locate the first
592 /// offending pair when this returns `Ok(false)`.
593 ///
594 /// The `rel_tol` argument is a [`Tolerance`], so raw caller input must be
595 /// finite and non-negative before it can reach this predicate. Use
596 /// [`Tolerance::try_new`] when accepting a raw `f64`; negative, NaN, and
597 /// infinite tolerances return
598 /// [`LaError::InvalidTolerance`].
599 ///
600 /// # Overflow handling
601 /// A finite matrix can return [`LaError::NonFinite`] with matrix coordinates
602 /// if computing the scaled symmetry tolerance overflows to NaN or infinity.
603 /// If both stored entries are finite but their difference overflows to ±∞,
604 /// the pair is reported as asymmetric.
605 ///
606 /// # Examples
607 /// ```
608 /// use la_stack::prelude::*;
609 ///
610 /// # fn main() -> Result<(), LaError> {
611 /// let a = Matrix::<2>::try_from_rows([[4.0, 2.0], [2.0, 3.0]])?;
612 /// let tol = Tolerance::try_new(1e-12)?;
613 /// assert!(a.is_symmetric(tol)?);
614 ///
615 /// let b = Matrix::<2>::try_from_rows([[4.0, 2.0], [3.0, 3.0]])?;
616 /// assert!(!b.is_symmetric(tol)?);
617 /// # Ok(())
618 /// # }
619 /// ```
620 ///
621 /// # Errors
622 /// Returns [`LaError::NonFinite`] with matrix coordinates when computing the
623 /// scaled symmetry tolerance overflows to NaN or infinity.
624 #[inline]
625 pub fn is_symmetric(&self, rel_tol: Tolerance) -> Result<bool, LaError> {
626 Ok(self.first_asymmetry(rel_tol)?.is_none())
627 }
628
629 /// Returns the indices `(r, c)` (with `r < c`) of the first off-diagonal
630 /// pair that violates approximate symmetry, or `None` if the matrix is
631 /// symmetric within `rel_tol`.
632 ///
633 /// Iteration order is row-major over the strict upper triangle, so the
634 /// returned indices are the lexicographically smallest such pair. The
635 /// predicate is the same as [`is_symmetric`](Self::is_symmetric):
636 /// `|self[r][c] - self[c][r]| <= rel_tol * max(1.0, inf_norm(self))`.
637 /// It is intentionally distinct from the exact equality required by
638 /// [`ldlt`](Self::ldlt).
639 ///
640 /// A finite matrix can return [`LaError::NonFinite`] with matrix coordinates
641 /// if computing the scaled symmetry tolerance overflows to NaN or infinity.
642 /// If both stored entries are finite but their difference overflows to ±∞,
643 /// the pair is reported as asymmetric.
644 ///
645 /// The `rel_tol` argument is a [`Tolerance`], so raw caller input must be
646 /// finite and non-negative before it can reach this predicate. Use
647 /// [`Tolerance::try_new`] when accepting a raw `f64`; negative, NaN, and
648 /// infinite tolerances return
649 /// [`LaError::InvalidTolerance`].
650 ///
651 /// # Examples
652 /// ```
653 /// use la_stack::prelude::*;
654 ///
655 /// # fn main() -> Result<(), LaError> {
656 /// let a = Matrix::<3>::try_from_rows([
657 /// [1.0, 2.0, 0.0],
658 /// [2.0, 4.0, 5.0],
659 /// [0.0, 6.0, 9.0], // 6.0 breaks symmetry with a[1][2] = 5.0
660 /// ])?;
661 /// let tol = Tolerance::try_new(1e-12)?;
662 /// assert_eq!(a.first_asymmetry(tol)?, Some((1, 2)));
663 /// assert_eq!(Matrix::<3>::identity().first_asymmetry(tol)?, None);
664 /// # Ok(())
665 /// # }
666 /// ```
667 ///
668 /// # Errors
669 /// Returns [`LaError::NonFinite`] with matrix coordinates when computing the
670 /// scaled symmetry tolerance overflows to NaN or infinity.
671 #[inline]
672 pub fn first_asymmetry(&self, rel_tol: Tolerance) -> Result<Option<(usize, usize)>, LaError> {
673 let eps = self.symmetry_epsilon(rel_tol)?;
674 for r in 0..D {
675 for c in (r + 1)..D {
676 let upper = self.rows[r][c];
677 let lower = self.rows[c][r];
678
679 let diff = (upper - lower).abs();
680 if !diff.is_finite() || diff > eps {
681 cold_path();
682 return Ok(Some((r, c)));
683 }
684 }
685 }
686 Ok(None)
687 }
688
689 /// Compute an LU decomposition with partial pivoting.
690 ///
691 /// `D = 0` follows the empty-matrix convention: factorization succeeds,
692 /// [`Lu::det`](crate::Lu::det) returns `1.0`, and solving a length-zero
693 /// right-hand side returns a length-zero [`Vector`](crate::Vector).
694 /// Partial pivoting is a practical finite-precision strategy, not a
695 /// certified accuracy guarantee; see `REFERENCES.md` \[1-3, 11-12\].
696 ///
697 /// # Examples
698 /// ```
699 /// use la_stack::prelude::*;
700 ///
701 /// # fn main() -> Result<(), LaError> {
702 /// let a = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]])?;
703 /// let lu = a.lu(DEFAULT_SINGULAR_TOL)?;
704 ///
705 /// let b = Vector::<2>::try_new([5.0, 11.0])?;
706 /// let x = lu.solve(b)?.into_array();
707 ///
708 /// assert!((x[0] - 1.0).abs() <= 1e-12);
709 /// assert!((x[1] - 2.0).abs() <= 1e-12);
710 /// # Ok(())
711 /// # }
712 /// ```
713 ///
714 /// Empty matrices use the standard empty-product convention:
715 ///
716 /// ```
717 /// use la_stack::prelude::*;
718 ///
719 /// # fn main() -> Result<(), LaError> {
720 /// let lu = Matrix::<0>::zero().lu(DEFAULT_SINGULAR_TOL)?;
721 ///
722 /// assert_eq!(lu.det()?, 1.0);
723 /// assert!(lu.solve(Vector::<0>::zero())?.into_array().is_empty());
724 /// # Ok(())
725 /// # }
726 /// ```
727 ///
728 /// The `tol` argument is a [`Tolerance`], so raw caller input must be
729 /// finite and non-negative before it can reach factorization. Use
730 /// [`Tolerance::try_new`] when accepting a raw `f64`; negative, NaN, and
731 /// infinite tolerances return
732 /// [`LaError::InvalidTolerance`].
733 ///
734 /// # Errors
735 /// Returns [`LaError::Singular`] if, for some column `k`, the largest-magnitude candidate pivot
736 /// in that column satisfies `|pivot| <= tol` (so no numerically usable pivot exists).
737 /// Returns [`LaError::NonFinite`] if an elimination intermediate overflows
738 /// to NaN/∞ before it can be stored in the returned [`Lu`].
739 #[inline]
740 pub fn lu(self, tol: Tolerance) -> Result<Lu<D>, LaError> {
741 Lu::factor_finite(self, tol)
742 }
743
744 /// Compute an LDLT factorization (`A = L D Lᵀ`) without pivoting.
745 ///
746 /// `D = 0` follows the empty-matrix convention: factorization succeeds,
747 /// [`Ldlt::det`](crate::Ldlt::det) returns `1.0`, and solving a length-zero
748 /// right-hand side returns a length-zero [`Vector`](crate::Vector).
749 ///
750 /// This is intended for exactly symmetric positive-definite matrices such
751 /// as nonsingular Gram matrices. Computed zero and tolerance-small positive
752 /// pivots are diagnosed as singular rather than returned in a usable
753 /// factorization. Because pivots are computed in binary64, success is not
754 /// an exact proof that the stored matrix is positive definite.
755 /// See `REFERENCES.md` \[4-6, 11-12\] for Cholesky/LDLT background and the
756 /// pivoted symmetric-indefinite alternative.
757 ///
758 /// # Symmetry validation
759 /// The input matrix `self` must be exactly symmetric: every mirrored pair
760 /// must satisfy `self[i][j] == self[j][i]`. IEEE-754 signed zeros compare
761 /// equal and are therefore accepted. Exact equality is a correctness
762 /// invariant, not merely a performance hint: LDLT reads only the lower
763 /// triangle, so accepting an approximate mismatch would factor a different
764 /// operator than the matrix supplied by the caller. Asymmetric inputs return
765 /// [`LaError::Asymmetric`] with an allowed absolute difference of `0.0`
766 /// before factorization starts.
767 ///
768 /// [`is_symmetric`](Self::is_symmetric) remains available as a
769 /// tolerance-based diagnostic, but `Ok(true)` from that method does not
770 /// establish this exact LDLT precondition. If you need a general-purpose
771 /// factorization for a non-symmetric matrix, use [`lu`](Self::lu) instead.
772 ///
773 /// The `tol` argument is a [`Tolerance`], so raw caller input must be
774 /// finite and non-negative before it can reach factorization. Use
775 /// [`Tolerance::try_new`] when accepting a raw `f64`; negative, NaN, and
776 /// infinite tolerances return
777 /// [`LaError::InvalidTolerance`].
778 ///
779 /// # Examples
780 /// ```
781 /// use la_stack::prelude::*;
782 ///
783 /// # fn main() -> Result<(), LaError> {
784 /// // Note the symmetric layout: a[0][1] == a[1][0] == 2.0.
785 /// let a = Matrix::<2>::try_from_rows([[4.0, 2.0], [2.0, 3.0]])?;
786 /// let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL)?;
787 ///
788 /// // det(A) = 8
789 /// assert!((ldlt.det()? - 8.0).abs() <= 1e-12);
790 ///
791 /// // Solve A x = b
792 /// let b = Vector::<2>::try_new([1.0, 2.0])?;
793 /// let x = ldlt.solve(b)?.into_array();
794 /// assert!((x[0] - (-0.125)).abs() <= 1e-12);
795 /// assert!((x[1] - 0.75).abs() <= 1e-12);
796 /// # Ok(())
797 /// # }
798 /// ```
799 ///
800 /// Empty matrices use the standard empty-product convention:
801 ///
802 /// ```
803 /// use la_stack::prelude::*;
804 ///
805 /// # fn main() -> Result<(), LaError> {
806 /// let ldlt = Matrix::<0>::zero().ldlt(DEFAULT_SINGULAR_TOL)?;
807 ///
808 /// assert_eq!(ldlt.det()?, 1.0);
809 /// assert!(ldlt.solve(Vector::<0>::zero())?.into_array().is_empty());
810 /// # Ok(())
811 /// # }
812 /// ```
813 ///
814 /// # Errors
815 /// Returns [`LaError::NotPositiveSemidefinite`] if a pivot is negative or a
816 /// zero pivot retains a non-zero coupling below it.
817 /// Returns [`LaError::Singular`] if a zero pivot has no remaining coupling,
818 /// or if a positive pivot satisfies `d <= tol`, treating PSD degeneracy as
819 /// singular.
820 /// Returns [`LaError::NonFinite`] if factorization computes a non-finite
821 /// intermediate.
822 /// Returns [`LaError::Asymmetric`] if the input matrix is not symmetric.
823 #[inline]
824 pub fn ldlt(self, tol: Tolerance) -> Result<Ldlt<D>, LaError> {
825 Ldlt::factor_symmetric(SymmetricMatrix::try_new(self)?, tol)
826 }
827
828 /// Return the first non-finite stored cell in row-major order.
829 const fn first_non_finite_cell(rows: &[[f64; D]; D]) -> Option<(usize, usize)> {
830 let mut r = 0;
831 while r < D {
832 let mut c = 0;
833 while c < D {
834 if !rows[r][c].is_finite() {
835 return Some((r, c));
836 }
837 c += 1;
838 }
839 r += 1;
840 }
841 None
842 }
843
844 /// Compute the approximate-symmetry tolerance scale for a finite matrix.
845 ///
846 /// This helper protects the public [`is_symmetric`](Self::is_symmetric) and
847 /// [`first_asymmetry`](Self::first_asymmetry) diagnostic contracts: the
848 /// documented norm-first formula is used whenever its intermediate is
849 /// representable, while an overflow-safe termwise fallback reports the
850 /// matrix cell that makes the scaled tolerance non-finite.
851 fn symmetry_epsilon(&self, rel_tol: Tolerance) -> Result<f64, LaError> {
852 let rel_tol = rel_tol.get();
853
854 if rel_tol == 0.0 {
855 return Ok(rel_tol);
856 }
857
858 if let Ok(norm) = self.inf_norm() {
859 let scale = if norm > 1.0 { norm } else { 1.0 };
860 let eps = rel_tol * scale;
861 if eps.is_finite() {
862 return Ok(eps);
863 }
864 }
865
866 // If the unscaled row sum or the final multiplication overflows, apply
867 // the tolerance to each non-negative contribution before summing. A row
868 // can overflow only at magnitudes where multiplication by the smallest
869 // positive tolerance is normal, so this fallback cannot introduce the
870 // gradual-underflow discrepancy avoided by the direct path above.
871 let mut eps = rel_tol;
872
873 for r in 0..D {
874 let mut row_eps = 0.0;
875 for c in 0..D {
876 row_eps = rel_tol.mul_add(self.rows[r][c].abs(), row_eps);
877 if !row_eps.is_finite() {
878 cold_path();
879 return Err(LaError::non_finite_computation_matrix(
880 ArithmeticOperation::SymmetryCheck,
881 r,
882 c,
883 ));
884 }
885 }
886 if row_eps > eps {
887 eps = row_eps;
888 }
889 }
890
891 Ok(eps)
892 }
893
894 /// Closed-form determinant for dimensions 0–4, bypassing LU factorization.
895 ///
896 /// Returns `Ok(Some(det))` for `D` ∈ {0, 1, 2, 3, 4}, `Ok(None)` for D ≥ 5.
897 /// `D = 0` returns `Ok(Some(1.0))` (empty product).
898 /// This is a `const fn` (Rust 1.94+) and uses fused multiply-add (`mul_add`)
899 /// for improved accuracy and performance.
900 ///
901 /// For a determinant that works for any dimension (falling back to LU for D ≥ 5),
902 /// use [`det`](Self::det).
903 ///
904 /// # Examples
905 /// ```
906 /// use la_stack::prelude::*;
907 ///
908 /// # fn main() -> Result<(), LaError> {
909 /// let m = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]])?;
910 /// assert_eq!(m.det_direct()?, Some(-2.0));
911 ///
912 /// // D = 0 is the empty product.
913 /// assert_eq!(Matrix::<0>::zero().det_direct()?, Some(1.0));
914 ///
915 /// // D ≥ 5 returns None.
916 /// assert!(Matrix::<5>::identity().det_direct()?.is_none());
917 /// # Ok(())
918 /// # }
919 /// ```
920 ///
921 /// # Errors
922 /// Returns [`LaError::NonFinite`] when the closed-form determinant overflows
923 /// to NaN or infinity.
924 #[inline]
925 pub const fn det_direct(&self) -> Result<Option<f64>, LaError> {
926 let Some(det) = self.det_direct_arithmetic::<false>() else {
927 cold_path();
928 return Ok(None);
929 };
930
931 Self::computed_scalar_result(ArithmeticOperation::Determinant, det.value)
932 }
933
934 /// Evaluate the closed-form determinant while certifying every rounded
935 /// operation against gradual underflow.
936 #[expect(
937 clippy::inline_always,
938 reason = "det_direct callers must eliminate unused filter-safety bookkeeping"
939 )]
940 #[inline(always)]
941 const fn det_direct_arithmetic<const TRACK_UNDERFLOW: bool>(
942 &self,
943 ) -> Option<FilterArithmetic<TRACK_UNDERFLOW>> {
944 match D {
945 0 => Some(FilterArithmetic {
946 value: 1.0,
947 underflow_safe: true,
948 }),
949 1 => Some(FilterArithmetic {
950 value: self.rows[0][0],
951 underflow_safe: true,
952 }),
953 2 => {
954 let a = self.rows[0][0];
955 let b = self.rows[0][1];
956 let c = self.rows[1][0];
957 let d = self.rows[1][1];
958 let subtrahend = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(b, c);
959 let mut det = FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(a, d, -subtrahend.value);
960 det.underflow_safe &= subtrahend.underflow_safe;
961 Some(det)
962 }
963 3 => Some(Self::det3_elements::<TRACK_UNDERFLOW>(
964 [self.rows[0][0], self.rows[0][1], self.rows[0][2]],
965 [self.rows[1][0], self.rows[1][1], self.rows[1][2]],
966 [self.rows[2][0], self.rows[2][1], self.rows[2][2]],
967 )),
968 4 => {
969 if !TRACK_UNDERFLOW && let Some(input) = Det4SharedMinorInput::try_new(self) {
970 return Some(FilterArithmetic {
971 value: Self::det4_dense_elements(input),
972 underflow_safe: true,
973 });
974 }
975
976 let r = &self.rows;
977 let mut det = if r[0][3] == 0.0 {
978 FilterArithmetic {
979 value: 0.0,
980 underflow_safe: true,
981 }
982 } else {
983 let c03 = Self::det3_elements::<TRACK_UNDERFLOW>(
984 [r[1][0], r[1][1], r[1][2]],
985 [r[2][0], r[2][1], r[2][2]],
986 [r[3][0], r[3][1], r[3][2]],
987 );
988 let mut term =
989 FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r[0][3], c03.value);
990 term.value = -term.value;
991 term.underflow_safe &= c03.underflow_safe;
992 term
993 };
994 if r[0][2] != 0.0 {
995 let c02 = Self::det3_elements::<TRACK_UNDERFLOW>(
996 [r[1][0], r[1][1], r[1][3]],
997 [r[2][0], r[2][1], r[2][3]],
998 [r[3][0], r[3][1], r[3][3]],
999 );
1000 let prior_safe = det.underflow_safe && c02.underflow_safe;
1001 det =
1002 FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(r[0][2], c02.value, det.value);
1003 det.underflow_safe &= prior_safe;
1004 }
1005 if r[0][1] != 0.0 {
1006 let c01 = Self::det3_elements::<TRACK_UNDERFLOW>(
1007 [r[1][0], r[1][2], r[1][3]],
1008 [r[2][0], r[2][2], r[2][3]],
1009 [r[3][0], r[3][2], r[3][3]],
1010 );
1011 let prior_safe = det.underflow_safe && c01.underflow_safe;
1012 det = FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(
1013 -r[0][1], c01.value, det.value,
1014 );
1015 det.underflow_safe &= prior_safe;
1016 }
1017 if r[0][0] != 0.0 {
1018 let c00 = Self::det3_elements::<TRACK_UNDERFLOW>(
1019 [r[1][1], r[1][2], r[1][3]],
1020 [r[2][1], r[2][2], r[2][3]],
1021 [r[3][1], r[3][2], r[3][3]],
1022 );
1023 let prior_safe = det.underflow_safe && c00.underflow_safe;
1024 det =
1025 FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(r[0][0], c00.value, det.value);
1026 det.underflow_safe &= prior_safe;
1027 }
1028
1029 Some(det)
1030 }
1031 _ => None,
1032 }
1033 }
1034
1035 /// Evaluate the proof-bearing 4×4 cofactor expansion with shared 2×2 minors.
1036 ///
1037 /// When no intermediate undergoes gradual underflow, the rounding error is
1038 /// bounded by `ERR_COEFF_4 · p(|A|)`, where `p(|A|)` is the absolute Leibniz
1039 /// sum. This helper returns only the determinant; use
1040 /// [`Self::det_errbound`] or [`Self::det_direct_with_errbound`] to obtain the
1041 /// certified bound.
1042 #[expect(
1043 clippy::inline_always,
1044 reason = "the D=4 determinant hot path must inline its shared-minor expansion"
1045 )]
1046 #[inline(always)]
1047 const fn det4_dense_elements(input: Det4SharedMinorInput<'_, D>) -> f64 {
1048 let r = &input.matrix.rows;
1049 let s23 = r[2][2].mul_add(r[3][3], -(r[2][3] * r[3][2]));
1050 let s13 = r[2][1].mul_add(r[3][3], -(r[2][3] * r[3][1]));
1051 let s12 = r[2][1].mul_add(r[3][2], -(r[2][2] * r[3][1]));
1052 let s03 = r[2][0].mul_add(r[3][3], -(r[2][3] * r[3][0]));
1053 let s02 = r[2][0].mul_add(r[3][2], -(r[2][2] * r[3][0]));
1054 let s01 = r[2][0].mul_add(r[3][1], -(r[2][1] * r[3][0]));
1055
1056 let c00 = r[1][1].mul_add(s23, (-r[1][2]).mul_add(s13, r[1][3] * s12));
1057 let c01 = r[1][0].mul_add(s23, (-r[1][2]).mul_add(s03, r[1][3] * s02));
1058 let c02 = r[1][0].mul_add(s13, (-r[1][1]).mul_add(s03, r[1][3] * s01));
1059 let c03 = r[1][0].mul_add(s12, (-r[1][1]).mul_add(s02, r[1][2] * s01));
1060
1061 r[0][0].mul_add(
1062 c00,
1063 (-r[0][1]).mul_add(c01, r[0][2].mul_add(c02, -(r[0][3] * c03))),
1064 )
1065 }
1066
1067 /// Evaluate the dense 4×4 absolute permanent with shared 2×2 minors.
1068 ///
1069 /// The proof carried by `input` makes every shared minor part of an active
1070 /// Leibniz term. The caller separately establishes a wide exponent margin,
1071 /// so this branch-free kernel cannot hide gradual underflow or evaluate an
1072 /// overflowing minor for a mathematically absent term.
1073 #[expect(
1074 clippy::inline_always,
1075 reason = "the D=4 determinant filter must inline its shared-minor permanent"
1076 )]
1077 #[inline(always)]
1078 const fn det4_dense_abs_permanent_elements(input: Det4SharedMinorInput<'_, D>) -> f64 {
1079 let r = &input.matrix.rows;
1080 let sp23 = (r[2][2] * r[3][3]).abs() + (r[2][3] * r[3][2]).abs();
1081 let sp13 = (r[2][1] * r[3][3]).abs() + (r[2][3] * r[3][1]).abs();
1082 let sp12 = (r[2][1] * r[3][2]).abs() + (r[2][2] * r[3][1]).abs();
1083 let sp03 = (r[2][0] * r[3][3]).abs() + (r[2][3] * r[3][0]).abs();
1084 let sp02 = (r[2][0] * r[3][2]).abs() + (r[2][2] * r[3][0]).abs();
1085 let sp01 = (r[2][0] * r[3][1]).abs() + (r[2][1] * r[3][0]).abs();
1086
1087 let pc0 = r[1][3]
1088 .abs()
1089 .mul_add(sp12, r[1][2].abs().mul_add(sp13, r[1][1].abs() * sp23));
1090 let pc1 = r[1][3]
1091 .abs()
1092 .mul_add(sp02, r[1][2].abs().mul_add(sp03, r[1][0].abs() * sp23));
1093 let pc2 = r[1][3]
1094 .abs()
1095 .mul_add(sp01, r[1][1].abs().mul_add(sp03, r[1][0].abs() * sp13));
1096 let pc3 = r[1][2]
1097 .abs()
1098 .mul_add(sp01, r[1][1].abs().mul_add(sp02, r[1][0].abs() * sp12));
1099
1100 r[0][3].abs().mul_add(
1101 pc3,
1102 r[0][2]
1103 .abs()
1104 .mul_add(pc2, r[0][1].abs().mul_add(pc1, r[0][0].abs() * pc0)),
1105 )
1106 }
1107
1108 /// Floating-point determinant, using closed-form formulas for D ≤ 4 and
1109 /// LU decomposition for D ≥ 5.
1110 ///
1111 /// For D ∈ {1, 2, 3, 4}, this bypasses LU factorization entirely for a significant
1112 /// speedup (see [`det_direct`](Self::det_direct)).
1113 ///
1114 /// Because this method mixes closed-form paths from
1115 /// [`det_direct`](Self::det_direct) with an LU fallback, the returned value has
1116 /// no certified absolute error bound. Use
1117 /// [`det_errbound`](Self::det_errbound) for D ≤ 4 bounds, or the exact
1118 /// determinant APIs when exact singularity classification or certified values
1119 /// matter. For D ≥ 5, the zero-tolerance LU fallback surfaces
1120 /// [`LaError::Singular`] when elimination cannot produce a non-zero pivot.
1121 /// Floating-point elimination cannot in general distinguish an exactly
1122 /// singular matrix from a non-singular matrix whose intermediate pivot
1123 /// rounded to zero, so this method never converts that numerical failure into
1124 /// an exact `0.0` result.
1125 ///
1126 /// # Examples
1127 /// ```
1128 /// use la_stack::prelude::*;
1129 ///
1130 /// # fn main() -> Result<(), LaError> {
1131 /// let det = Matrix::<3>::identity().det()?;
1132 /// assert!((det - 1.0).abs() <= 1e-12);
1133 /// # Ok(())
1134 /// # }
1135 /// ```
1136 ///
1137 /// The LU fallback accumulates its diagonal product with power-of-two
1138 /// scaling, so factor order cannot cause premature overflow or underflow in
1139 /// the final product. Elimination intermediates remain subject to binary64
1140 /// rounding and range limits.
1141 ///
1142 /// # Errors
1143 /// Returns [`LaError::Singular`] if the D ≥ 5 LU fallback cannot produce a
1144 /// non-zero pivot, including when a non-zero mathematical intermediate rounds
1145 /// to zero during elimination. Returns [`LaError::NonFinite`] if a D ≤ 4
1146 /// closed-form result is non-finite, if the LU fallback computes a
1147 /// non-finite factorization cell, or if its final scaled determinant cannot
1148 /// be represented as a finite `f64`.
1149 #[inline]
1150 pub fn det(self) -> Result<f64, LaError> {
1151 if let Some(d) = self.det_direct()? {
1152 return Ok(d);
1153 }
1154 self.lu(Tolerance::ZERO)?.det()
1155 }
1156
1157 /// Evaluate `det_direct()` and its absolute error bound together.
1158 ///
1159 /// Returns `Ok(Some(result))` for D ≤ 4 when the relative-error analysis
1160 /// is valid. The result contains the closed-form determinant and a bound
1161 /// such that `|result.determinant() - det_exact| ≤
1162 /// result.absolute_error_bound()`. Returns `Ok(None)` when gradual
1163 /// underflow could invalidate that analysis or for D ≥ 5, where no
1164 /// closed-form bound is available.
1165 ///
1166 /// This is the preferred API when both values are needed: it evaluates the
1167 /// determinant arithmetic tree once, then computes the matching bound for
1168 /// the same matrix within that call.
1169 ///
1170 /// # Examples
1171 /// ```
1172 /// use la_stack::prelude::*;
1173 ///
1174 /// # fn main() -> Result<(), LaError> {
1175 /// let matrix = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]])?;
1176 /// if let Some(estimate) = matrix.det_direct_with_errbound()? {
1177 /// assert_eq!(estimate.determinant(), -2.0);
1178 /// assert!(estimate.absolute_error_bound() >= 0.0);
1179 /// }
1180 /// # Ok(())
1181 /// # }
1182 /// ```
1183 ///
1184 /// # Errors
1185 /// Returns [`LaError::NonFinite`] when the determinant or bound computation
1186 /// overflows to NaN or infinity. Underflow-sensitive finite computations
1187 /// return `Ok(None)` because they remain valid inputs for an exact fallback.
1188 #[inline]
1189 pub const fn det_direct_with_errbound(
1190 &self,
1191 ) -> Result<Option<DeterminantWithErrorBound>, LaError> {
1192 if self.det_bound_inputs_have_wide_exponent_margin() {
1193 let Some(det) = self.det_direct_arithmetic::<false>() else {
1194 cold_path();
1195 return Ok(None);
1196 };
1197 return self.det_direct_with_errbound_from_arithmetic(det);
1198 }
1199
1200 let Some(det) = self.det_direct_arithmetic::<true>() else {
1201 cold_path();
1202 return Ok(None);
1203 };
1204 self.det_direct_with_errbound_from_arithmetic(det)
1205 }
1206
1207 /// Conservative absolute error bound for `det_direct()`.
1208 ///
1209 /// Returns `Ok(Some(bound))` such that `|det_direct() - det_exact| ≤ bound`
1210 /// when every rounded intermediate used by the closed-form determinant and
1211 /// bound is normal (or an exact structural zero). Returns `Ok(None)` when
1212 /// gradual underflow could invalidate the relative-error analysis, or for
1213 /// D ≥ 5 where no fast bound is available.
1214 ///
1215 /// For D ≤ 4, the bound is derived from the absolute Leibniz sum using
1216 /// Shewchuk-style error analysis (see `REFERENCES.md` \[8\] and the
1217 /// per-constant docs on [`ERR_COEFF_2`], [`ERR_COEFF_3`], and
1218 /// [`ERR_COEFF_4`]). For D = 0 or 1, returns
1219 /// `Some(0.0)` since the determinant computation is exact (no
1220 /// arithmetic).
1221 ///
1222 /// This method does NOT require the `exact` feature — the bounds use
1223 /// pure f64 arithmetic and are useful for custom adaptive-precision logic.
1224 ///
1225 /// # When to use
1226 ///
1227 /// Use [`det_direct_with_errbound`](Self::det_direct_with_errbound) when the
1228 /// determinant and bound are both needed. This accessor is convenient when
1229 /// only the bound is needed.
1230 ///
1231 /// # Examples
1232 /// ```
1233 /// use la_stack::prelude::*;
1234 ///
1235 /// # fn main() -> Result<(), LaError> {
1236 /// let m = Matrix::<3>::try_from_rows([
1237 /// [1.0, 2.0, 3.0],
1238 /// [4.0, 5.0, 6.0],
1239 /// [7.0, 8.0, 9.0],
1240 /// ])?;
1241 /// if let Some(bound) = m.det_errbound()? {
1242 /// assert!(bound >= 0.0);
1243 /// }
1244 /// # Ok(())
1245 /// # }
1246 /// ```
1247 ///
1248 /// # Adaptive precision pattern (requires `exact` feature)
1249 /// ```ignore
1250 /// use la_stack::prelude::*;
1251 ///
1252 /// fn adaptive_det_sign<const D: usize>(
1253 /// matrix: &Matrix<D>,
1254 /// ) -> DeterminantSign {
1255 /// if let Ok(Some(estimate)) = matrix.det_direct_with_errbound() {
1256 /// if estimate.determinant().abs() > estimate.absolute_error_bound() {
1257 /// return if estimate.determinant() > 0.0 {
1258 /// DeterminantSign::Positive
1259 /// } else {
1260 /// DeterminantSign::Negative
1261 /// };
1262 /// }
1263 /// }
1264 ///
1265 /// matrix.det_sign_exact()
1266 /// }
1267 ///
1268 /// fn main() -> Result<(), LaError> {
1269 /// assert_eq!(
1270 /// adaptive_det_sign(&Matrix::<3>::identity()),
1271 /// DeterminantSign::Positive
1272 /// );
1273 ///
1274 /// let big = f64::MAX / 2.0;
1275 /// let overflowing = Matrix::<3>::try_from_rows([
1276 /// [0.0, 0.0, 1.0],
1277 /// [big, 0.0, 1.0],
1278 /// [0.0, big, 1.0],
1279 /// ])?;
1280 /// assert_eq!(
1281 /// adaptive_det_sign(&overflowing),
1282 /// DeterminantSign::Positive
1283 /// );
1284 /// Ok(())
1285 /// }
1286 /// ```
1287 ///
1288 /// # Errors
1289 /// Returns [`LaError::NonFinite`] when the bound computation overflows to
1290 /// NaN or infinity. Underflow-sensitive finite computations return
1291 /// `Ok(None)` instead because they are valid inputs for an exact fallback.
1292 #[inline]
1293 pub const fn det_errbound(&self) -> Result<Option<f64>, LaError> {
1294 match self.det_direct_with_errbound() {
1295 Ok(Some(result)) => Ok(Some(result.absolute_error_bound)),
1296 Ok(None) => Ok(None),
1297 Err(error) => Err(error),
1298 }
1299 }
1300
1301 /// Return whether every non-zero entry is large enough that the complete
1302 /// D≤4 determinant and permanent trees cannot gradually underflow.
1303 ///
1304 /// The `2^-16` threshold leaves hundreds of binary exponent bits of margin
1305 /// even after the D=4 tree's products, FMAs, and binary64 rounding steps.
1306 /// Overflow remains possible and is classified after evaluation. Inputs
1307 /// below this conservative threshold use per-operation tracking instead.
1308 const fn det_bound_inputs_have_wide_exponent_margin(&self) -> bool {
1309 const MIN_MAGNITUDE_BITS: u64 = 1007_u64 << 52; // 2^-16
1310 const MAGNITUDE_MASK: u64 = !(1_u64 << 63);
1311
1312 if D > 4 {
1313 return false;
1314 }
1315
1316 let mut row = 0;
1317 while row < D {
1318 let mut col = 0;
1319 while col < D {
1320 let magnitude_bits = self.rows[row][col].to_bits() & MAGNITUDE_MASK;
1321 if magnitude_bits != 0 && magnitude_bits < MIN_MAGNITUDE_BITS {
1322 return false;
1323 }
1324 col += 1;
1325 }
1326 row += 1;
1327 }
1328 true
1329 }
1330
1331 /// Classify a completed determinant tree and construct its matching bound.
1332 const fn det_direct_with_errbound_from_arithmetic<const TRACK_UNDERFLOW: bool>(
1333 &self,
1334 det: FilterArithmetic<TRACK_UNDERFLOW>,
1335 ) -> Result<Option<DeterminantWithErrorBound>, LaError> {
1336 let bound = match self.det_errbound_from_arithmetic(det) {
1337 Ok(Some(bound)) => bound,
1338 Ok(None) => {
1339 if !det.value.is_finite() {
1340 cold_path();
1341 return Err(LaError::non_finite_computation_scalar(
1342 ArithmeticOperation::Determinant,
1343 ));
1344 }
1345 return Ok(None);
1346 }
1347 Err(error) => return Err(error),
1348 };
1349 if !det.value.is_finite() {
1350 cold_path();
1351 return Err(LaError::non_finite_computation_scalar(
1352 ArithmeticOperation::Determinant,
1353 ));
1354 }
1355 Ok(Some(DeterminantWithErrorBound {
1356 determinant: det.value,
1357 absolute_error_bound: bound,
1358 }))
1359 }
1360
1361 /// Compute a bound after the matching determinant tree has been evaluated.
1362 const fn det_errbound_from_arithmetic<const TRACK_UNDERFLOW: bool>(
1363 &self,
1364 det: FilterArithmetic<TRACK_UNDERFLOW>,
1365 ) -> Result<Option<f64>, LaError> {
1366 if !det.underflow_safe {
1367 cold_path();
1368 return Ok(None);
1369 }
1370
1371 match D {
1372 0 | 1 => Self::computed_scalar_result(ArithmeticOperation::DeterminantErrorBound, 0.0),
1373 2 => {
1374 let r = &self.rows;
1375 let product_0 = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r[0][0], r[1][1]);
1376 let product_1 = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r[0][1], r[1][0]);
1377 let mut permanent = FilterArithmetic::<TRACK_UNDERFLOW>::add_non_negative(
1378 product_0.value.abs(),
1379 product_1.value.abs(),
1380 );
1381 permanent.underflow_safe &= product_0.underflow_safe && product_1.underflow_safe;
1382 Self::certified_error_bound(ERR_COEFF_2, permanent)
1383 }
1384 3 => {
1385 let r = &self.rows;
1386 let permanent = Self::det3_abs_permanent_elements::<TRACK_UNDERFLOW>(
1387 [r[0][0], r[0][1], r[0][2]],
1388 [r[1][0], r[1][1], r[1][2]],
1389 [r[2][0], r[2][1], r[2][2]],
1390 );
1391 Self::certified_error_bound(ERR_COEFF_3, permanent)
1392 }
1393 4 => self.det4_errbound::<TRACK_UNDERFLOW>(),
1394 _ => {
1395 cold_path();
1396 Ok(None)
1397 }
1398 }
1399 }
1400
1401 /// Compute the D=4 determinant error bound after the dimension dispatch.
1402 const fn det4_errbound<const TRACK_UNDERFLOW: bool>(&self) -> Result<Option<f64>, LaError> {
1403 if !TRACK_UNDERFLOW && let Some(input) = Det4SharedMinorInput::try_new(self) {
1404 return Self::certified_error_bound(
1405 ERR_COEFF_4,
1406 FilterArithmetic::<TRACK_UNDERFLOW> {
1407 value: Self::det4_dense_abs_permanent_elements(input),
1408 underflow_safe: true,
1409 },
1410 );
1411 }
1412
1413 let r = &self.rows;
1414 let mut permanent = if r[0][3] == 0.0 {
1415 FilterArithmetic {
1416 value: 0.0,
1417 underflow_safe: true,
1418 }
1419 } else {
1420 let pc3 = Self::det3_abs_permanent_elements::<TRACK_UNDERFLOW>(
1421 [r[1][0], r[1][1], r[1][2]],
1422 [r[2][0], r[2][1], r[2][2]],
1423 [r[3][0], r[3][1], r[3][2]],
1424 );
1425 let mut term = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r[0][3].abs(), pc3.value);
1426 term.underflow_safe &= pc3.underflow_safe;
1427 term
1428 };
1429 if r[0][2] != 0.0 {
1430 let pc2 = Self::det3_abs_permanent_elements::<TRACK_UNDERFLOW>(
1431 [r[1][0], r[1][1], r[1][3]],
1432 [r[2][0], r[2][1], r[2][3]],
1433 [r[3][0], r[3][1], r[3][3]],
1434 );
1435 let prior_safe = permanent.underflow_safe && pc2.underflow_safe;
1436 permanent = FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(
1437 r[0][2].abs(),
1438 pc2.value,
1439 permanent.value,
1440 );
1441 permanent.underflow_safe &= prior_safe;
1442 }
1443 if r[0][1] != 0.0 {
1444 let pc1 = Self::det3_abs_permanent_elements::<TRACK_UNDERFLOW>(
1445 [r[1][0], r[1][2], r[1][3]],
1446 [r[2][0], r[2][2], r[2][3]],
1447 [r[3][0], r[3][2], r[3][3]],
1448 );
1449 let prior_safe = permanent.underflow_safe && pc1.underflow_safe;
1450 permanent = FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(
1451 r[0][1].abs(),
1452 pc1.value,
1453 permanent.value,
1454 );
1455 permanent.underflow_safe &= prior_safe;
1456 }
1457 if r[0][0] != 0.0 {
1458 let pc0 = Self::det3_abs_permanent_elements::<TRACK_UNDERFLOW>(
1459 [r[1][1], r[1][2], r[1][3]],
1460 [r[2][1], r[2][2], r[2][3]],
1461 [r[3][1], r[3][2], r[3][3]],
1462 );
1463 let prior_safe = permanent.underflow_safe && pc0.underflow_safe;
1464 permanent = FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(
1465 r[0][0].abs(),
1466 pc0.value,
1467 permanent.value,
1468 );
1469 permanent.underflow_safe &= prior_safe;
1470 }
1471 Self::certified_error_bound(ERR_COEFF_4, permanent)
1472 }
1473
1474 /// Evaluate a 3×3 determinant expansion with a guarded sparse fallback.
1475 ///
1476 /// When all three first-row coefficients are non-zero, one branch-free
1477 /// closed form is used. The sparse fallback protects the public
1478 /// [`det_direct`](Self::det_direct) contract: a mathematically absent term
1479 /// must not compute an overflowing minor and poison the determinant with
1480 /// `0.0 * inf == NaN`. Nonzero terms keep the same fused multiply-add
1481 /// ordering as the closed-form expansion.
1482 #[expect(
1483 clippy::inline_always,
1484 reason = "det_direct callers must eliminate unused filter-safety bookkeeping"
1485 )]
1486 #[inline(always)]
1487 const fn det3_elements<const TRACK_UNDERFLOW: bool>(
1488 r0: [f64; 3],
1489 r1: [f64; 3],
1490 r2: [f64; 3],
1491 ) -> FilterArithmetic<TRACK_UNDERFLOW> {
1492 let dense = (r0[0] != 0.0) && (r0[1] != 0.0) && (r0[2] != 0.0);
1493 if !TRACK_UNDERFLOW && dense {
1494 let m00 = r1[1].mul_add(r2[2], -(r1[2] * r2[1]));
1495 let m01 = r1[0].mul_add(r2[2], -(r1[2] * r2[0]));
1496 let m02 = r1[0].mul_add(r2[1], -(r1[1] * r2[0]));
1497 return FilterArithmetic {
1498 value: r0[0].mul_add(m00, (-r0[1]).mul_add(m01, r0[2] * m02)),
1499 underflow_safe: true,
1500 };
1501 }
1502
1503 let mut det = if r0[2] == 0.0 {
1504 FilterArithmetic {
1505 value: 0.0,
1506 underflow_safe: true,
1507 }
1508 } else {
1509 let subtrahend = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r1[1], r2[0]);
1510 let mut m02 =
1511 FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(r1[0], r2[1], -subtrahend.value);
1512 m02.underflow_safe &= subtrahend.underflow_safe;
1513 let mut term = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r0[2], m02.value);
1514 term.underflow_safe &= m02.underflow_safe;
1515 term
1516 };
1517 if r0[1] != 0.0 {
1518 let subtrahend = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r1[2], r2[0]);
1519 let mut m01 =
1520 FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(r1[0], r2[2], -subtrahend.value);
1521 m01.underflow_safe &= subtrahend.underflow_safe;
1522 let prior_safe = det.underflow_safe && m01.underflow_safe;
1523 det = FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(-r0[1], m01.value, det.value);
1524 det.underflow_safe &= prior_safe;
1525 }
1526 if r0[0] != 0.0 {
1527 let subtrahend = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r1[2], r2[1]);
1528 let mut m00 =
1529 FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(r1[1], r2[2], -subtrahend.value);
1530 m00.underflow_safe &= subtrahend.underflow_safe;
1531 let prior_safe = det.underflow_safe && m00.underflow_safe;
1532 det = FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(r0[0], m00.value, det.value);
1533 det.underflow_safe &= prior_safe;
1534 }
1535 det
1536 }
1537
1538 /// Evaluate a 3×3 absolute permanent while skipping zero coefficients.
1539 ///
1540 /// This mirrors [`det3_elements`](Self::det3_elements) for error-bound
1541 /// computation: absent determinant terms should not force evaluation of an
1542 /// overflowing absolute minor.
1543 #[expect(
1544 clippy::inline_always,
1545 reason = "error-bound call-site specialization avoids tracked-helper overhead"
1546 )]
1547 #[inline(always)]
1548 const fn det3_abs_permanent_elements<const TRACK_UNDERFLOW: bool>(
1549 r0: [f64; 3],
1550 r1: [f64; 3],
1551 r2: [f64; 3],
1552 ) -> FilterArithmetic<TRACK_UNDERFLOW> {
1553 let dense = (r0[0] != 0.0) && (r0[1] != 0.0) && (r0[2] != 0.0);
1554 if !TRACK_UNDERFLOW && dense {
1555 let pm00 = (r1[1] * r2[2]).abs() + (r1[2] * r2[1]).abs();
1556 let pm01 = (r1[0] * r2[2]).abs() + (r1[2] * r2[0]).abs();
1557 let pm02 = (r1[0] * r2[1]).abs() + (r1[1] * r2[0]).abs();
1558 return FilterArithmetic {
1559 value: r0[2]
1560 .abs()
1561 .mul_add(pm02, r0[1].abs().mul_add(pm01, r0[0].abs() * pm00)),
1562 underflow_safe: true,
1563 };
1564 }
1565
1566 let mut permanent = if r0[2] == 0.0 {
1567 FilterArithmetic {
1568 value: 0.0,
1569 underflow_safe: true,
1570 }
1571 } else {
1572 let product_0 = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r1[0], r2[1]);
1573 let product_1 = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r1[1], r2[0]);
1574 let mut pm02 = FilterArithmetic::<TRACK_UNDERFLOW>::add_non_negative(
1575 product_0.value.abs(),
1576 product_1.value.abs(),
1577 );
1578 pm02.underflow_safe &= product_0.underflow_safe && product_1.underflow_safe;
1579 let mut term = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r0[2].abs(), pm02.value);
1580 term.underflow_safe &= pm02.underflow_safe;
1581 term
1582 };
1583 if r0[1] != 0.0 {
1584 let product_0 = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r1[0], r2[2]);
1585 let product_1 = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r1[2], r2[0]);
1586 let mut pm01 = FilterArithmetic::<TRACK_UNDERFLOW>::add_non_negative(
1587 product_0.value.abs(),
1588 product_1.value.abs(),
1589 );
1590 pm01.underflow_safe &= product_0.underflow_safe && product_1.underflow_safe;
1591 let prior_safe = permanent.underflow_safe && pm01.underflow_safe;
1592 permanent = FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(
1593 r0[1].abs(),
1594 pm01.value,
1595 permanent.value,
1596 );
1597 permanent.underflow_safe &= prior_safe;
1598 }
1599 if r0[0] != 0.0 {
1600 let product_0 = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r1[1], r2[2]);
1601 let product_1 = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r1[2], r2[1]);
1602 let mut pm00 = FilterArithmetic::<TRACK_UNDERFLOW>::add_non_negative(
1603 product_0.value.abs(),
1604 product_1.value.abs(),
1605 );
1606 pm00.underflow_safe &= product_0.underflow_safe && product_1.underflow_safe;
1607 let prior_safe = permanent.underflow_safe && pm00.underflow_safe;
1608 permanent = FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(
1609 r0[0].abs(),
1610 pm00.value,
1611 permanent.value,
1612 );
1613 permanent.underflow_safe &= prior_safe;
1614 }
1615 permanent
1616 }
1617
1618 /// Finish a determinant error bound only when its full arithmetic tree is
1619 /// outside the gradual-underflow regime.
1620 const fn certified_error_bound<const TRACK_UNDERFLOW: bool>(
1621 coefficient: f64,
1622 permanent: FilterArithmetic<TRACK_UNDERFLOW>,
1623 ) -> Result<Option<f64>, LaError> {
1624 let mut bound = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(coefficient, permanent.value);
1625 bound.underflow_safe &= permanent.underflow_safe;
1626 if bound.underflow_safe {
1627 Self::computed_scalar_result(ArithmeticOperation::DeterminantErrorBound, bound.value)
1628 } else {
1629 cold_path();
1630 Ok(None)
1631 }
1632 }
1633
1634 /// Return a computed scalar result for a matrix with finite stored entries.
1635 const fn computed_scalar_result(
1636 operation: ArithmeticOperation,
1637 value: f64,
1638 ) -> Result<Option<f64>, LaError> {
1639 if value.is_finite() {
1640 Ok(Some(value))
1641 } else {
1642 Err(LaError::non_finite_computation_scalar(operation))
1643 }
1644 }
1645}
1646
1647impl<const D: usize> Default for Matrix<D> {
1648 #[inline]
1649 fn default() -> Self {
1650 Self::zero()
1651 }
1652}
1653
1654#[cfg(all(doc, feature = "exact"))]
1655mod det_errbound_doctests {
1656 /// ```rust
1657 /// use la_stack::prelude::*;
1658 ///
1659 /// fn adaptive_det_sign<const D: usize>(
1660 /// matrix: &Matrix<D>,
1661 /// ) -> DeterminantSign {
1662 /// if let Ok(Some(estimate)) = matrix.det_direct_with_errbound() {
1663 /// if estimate.determinant().abs() > estimate.absolute_error_bound() {
1664 /// return if estimate.determinant() > 0.0 {
1665 /// DeterminantSign::Positive
1666 /// } else {
1667 /// DeterminantSign::Negative
1668 /// };
1669 /// }
1670 /// }
1671 ///
1672 /// matrix.det_sign_exact()
1673 /// }
1674 ///
1675 /// # fn main() -> Result<(), LaError> {
1676 /// let identity = Matrix::<3>::identity();
1677 /// assert_eq!(
1678 /// adaptive_det_sign(&identity),
1679 /// DeterminantSign::Positive
1680 /// );
1681 ///
1682 /// let singular = Matrix::<3>::try_from_rows([
1683 /// [1.0, 2.0, 3.0],
1684 /// [4.0, 5.0, 6.0],
1685 /// [7.0, 8.0, 9.0],
1686 /// ])?;
1687 /// assert_eq!(adaptive_det_sign(&singular), DeterminantSign::Zero);
1688 ///
1689 /// let big = f64::MAX / 2.0;
1690 /// let overflowing = Matrix::<3>::try_from_rows([
1691 /// [0.0, 0.0, 1.0],
1692 /// [big, 0.0, 1.0],
1693 /// [0.0, big, 1.0],
1694 /// ])?;
1695 /// assert_eq!(
1696 /// adaptive_det_sign(&overflowing),
1697 /// DeterminantSign::Positive
1698 /// );
1699 /// # Ok(())
1700 /// # }
1701 /// ```
1702 fn adaptive_precision_pattern() {}
1703}
1704
1705#[cfg(test)]
1706mod tests {
1707 use core::hint::black_box;
1708
1709 use approx::assert_abs_diff_eq;
1710 use pastey::paste;
1711
1712 use super::*;
1713 use crate::{DEFAULT_SINGULAR_TOL, FactorizationKind, Vector};
1714
1715 macro_rules! gen_matrix_tests {
1716 ($d:literal) => {
1717 paste! {
1718 #[test]
1719 fn [<matrix_try_from_rows_get_set_bounds_checked_ $d d>]() {
1720 let mut rows = [[0.0f64; $d]; $d];
1721 rows[0][0] = 1.0;
1722 rows[$d - 1][$d - 1] = -2.0;
1723
1724 let mut m = Matrix::<$d>::try_from_rows(rows).unwrap();
1725
1726 assert_eq!(m.get(0, 0), Some(1.0));
1727 assert_eq!(m.get($d - 1, $d - 1), Some(-2.0));
1728 assert_eq!(m.try_get(0, 0), Ok(1.0));
1729 assert_eq!(m.try_get($d - 1, $d - 1), Ok(-2.0));
1730
1731 // Out-of-bounds is None.
1732 assert_eq!(m.get($d, 0), None);
1733 assert_eq!(
1734 m.try_get($d, 0),
1735 Err(LaError::IndexOutOfBounds {
1736 row: $d,
1737 col: 0,
1738 dim: $d,
1739 })
1740 );
1741
1742 // Out-of-bounds set fails.
1743 let before_failed_set = m;
1744 assert_eq!(
1745 m.set($d, 0, 3.0),
1746 Err(LaError::IndexOutOfBounds {
1747 row: $d,
1748 col: 0,
1749 dim: $d,
1750 })
1751 );
1752 assert_eq!(m, before_failed_set);
1753 assert_eq!(
1754 m.set(0, $d, 3.0),
1755 Err(LaError::IndexOutOfBounds {
1756 row: 0,
1757 col: $d,
1758 dim: $d,
1759 })
1760 );
1761 assert_eq!(m, before_failed_set);
1762 assert_eq!(m.get(0, 0), Some(1.0));
1763
1764 // In-bounds set works.
1765 assert_eq!(m.set(0, $d - 1, 3.0), Ok(()));
1766 assert_eq!(m.get(0, $d - 1), Some(3.0));
1767 assert_eq!(m.set($d - 1, 0, 4.0), Ok(()));
1768 assert_eq!(m.try_get($d - 1, 0), Ok(4.0));
1769 }
1770
1771 #[test]
1772 fn [<matrix_set_rejects_non_finite_and_preserves_storage_ $d d>]() {
1773 for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1774 let mut m = Matrix::<$d>::identity();
1775 let before = m;
1776 assert_eq!(
1777 m.set($d - 1, 0, value),
1778 Err(LaError::non_finite_input_matrix($d - 1, 0))
1779 );
1780 assert_eq!(m, before);
1781 }
1782 }
1783
1784 #[test]
1785 fn [<matrix_try_from_rows_rejects_non_finite_ $d d>]() {
1786 for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1787 let mut rows = [[0.0f64; $d]; $d];
1788 rows[$d - 1][$d - 1] = value;
1789 assert_eq!(
1790 Matrix::<$d>::try_from_rows(rows),
1791 Err(LaError::non_finite_input_matrix($d - 1, $d - 1))
1792 );
1793 }
1794
1795 let mut rows = [[0.0f64; $d]; $d];
1796 rows[0][$d - 1] = f64::INFINITY;
1797 rows[$d - 1][0] = f64::NAN;
1798 assert_eq!(
1799 Matrix::<$d>::try_from_rows(rows),
1800 Err(LaError::non_finite_input_matrix(0, $d - 1))
1801 );
1802 }
1803
1804 #[test]
1805 fn [<matrix_zero_and_default_are_zero_ $d d>]() {
1806 let z = Matrix::<$d>::zero();
1807 assert_abs_diff_eq!(z.inf_norm().unwrap(), 0.0, epsilon = 0.0);
1808
1809 let d = Matrix::<$d>::default();
1810 assert_abs_diff_eq!(d.inf_norm().unwrap(), 0.0, epsilon = 0.0);
1811 }
1812
1813 #[test]
1814 fn [<matrix_inf_norm_max_row_sum_ $d d>]() {
1815 let mut rows = [[0.0f64; $d]; $d];
1816
1817 // Row 0 has a smaller absolute row sum.
1818 for c in 0..$d {
1819 rows[0][c] = 0.5;
1820 }
1821
1822 // The last row has absolute row sum = D.
1823 for c in 0..$d {
1824 rows[$d - 1][c] = -1.0;
1825 }
1826
1827 let m = Matrix::<$d>::try_from_rows(rows).unwrap();
1828 assert_abs_diff_eq!(m.inf_norm().unwrap(), f64::from($d), epsilon = 0.0);
1829 }
1830
1831 #[test]
1832 fn [<matrix_inf_norm_reports_first_overflowing_column_ $d d>]() {
1833 let mut rows = [[0.0f64; $d]; $d];
1834 rows[$d - 1][0] = f64::MAX;
1835 rows[$d - 1][1] = f64::MAX;
1836
1837 let m = Matrix::<$d>::try_from_rows(rows).unwrap();
1838 assert_eq!(
1839 m.inf_norm(),
1840 Err(LaError::non_finite_computation_matrix(
1841 ArithmeticOperation::MatrixInfinityNorm,
1842 $d - 1,
1843 1,
1844 ))
1845 );
1846 }
1847
1848 #[test]
1849 fn [<matrix_inf_norm_reports_first_overflowing_row_ $d d>]() {
1850 let mut rows = [[0.0f64; $d]; $d];
1851 rows[0][0] = f64::MAX;
1852 rows[0][$d - 1] = f64::MAX;
1853 rows[$d - 1][0] = f64::MAX;
1854 rows[$d - 1][1] = f64::MAX;
1855
1856 let m = Matrix::<$d>::try_from_rows(rows).unwrap();
1857 assert_eq!(
1858 m.inf_norm(),
1859 Err(LaError::non_finite_computation_matrix(
1860 ArithmeticOperation::MatrixInfinityNorm,
1861 0,
1862 $d - 1,
1863 ))
1864 );
1865 }
1866
1867 #[test]
1868 fn [<matrix_identity_lu_det_solve_ $d d>]() {
1869 let m = Matrix::<$d>::identity();
1870
1871 // Identity has ones on diag and zeros off diag.
1872 for r in 0..$d {
1873 for c in 0..$d {
1874 let expected = if r == c { 1.0 } else { 0.0 };
1875 assert_abs_diff_eq!(m.get(r, c).unwrap(), expected, epsilon = 0.0);
1876 }
1877 }
1878
1879 // Determinant is 1.
1880 let det = m.det().unwrap();
1881 assert_abs_diff_eq!(det, 1.0, epsilon = 1e-12);
1882
1883 // LU solve on identity returns the RHS.
1884 let lu = m.lu(DEFAULT_SINGULAR_TOL).unwrap();
1885
1886 let b_arr = {
1887 let mut arr = [0.0f64; $d];
1888 let values = [1.0f64, 2.0, 3.0, 4.0, 5.0];
1889 for (dst, src) in arr.iter_mut().zip(values.iter()) {
1890 *dst = *src;
1891 }
1892 arr
1893 };
1894
1895 let b = Vector::<$d>::new(b_arr);
1896 let x = lu.solve(b).unwrap().into_array();
1897
1898 for (x_i, b_i) in x.iter().zip(b_arr.iter()) {
1899 assert_abs_diff_eq!(*x_i, *b_i, epsilon = 1e-12);
1900 }
1901 }
1902
1903 }
1904 };
1905 }
1906
1907 // Mirror delaunay-style multi-dimension tests.
1908 gen_matrix_tests!(2);
1909 gen_matrix_tests!(3);
1910 gen_matrix_tests!(4);
1911 gen_matrix_tests!(5);
1912
1913 #[test]
1914 fn matrix_inf_norm_preserves_left_to_right_row_sum_order() {
1915 let large = 9_007_199_254_740_992.0;
1916 let matrix =
1917 Matrix::<4>::try_from_rows([[large, 1.0, 1.0, 1.0], [0.0; 4], [0.0; 4], [0.0; 4]])
1918 .unwrap();
1919
1920 assert_eq!(matrix.inf_norm(), Ok(large));
1921 }
1922
1923 // === det_direct tests ===
1924
1925 #[test]
1926 fn det_direct_d0_is_one() {
1927 assert_eq!(Matrix::<0>::zero().det_direct(), Ok(Some(1.0)));
1928 }
1929
1930 #[test]
1931 fn det_direct_d1_returns_element() {
1932 let m = Matrix::<1>::try_from_rows([[42.0]]).unwrap();
1933 assert_eq!(m.det_direct(), Ok(Some(42.0)));
1934 }
1935
1936 #[test]
1937 fn det_direct_d2_known_value() {
1938 // [[1,2],[3,4]] → det = 1*4 - 2*3 = -2
1939 // black_box prevents compile-time constant folding of the const fn.
1940 let m = black_box(Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]]).unwrap());
1941 assert_abs_diff_eq!(m.det_direct().unwrap().unwrap(), -2.0, epsilon = 1e-15);
1942 }
1943
1944 #[test]
1945 fn det_direct_d3_known_value() {
1946 // Classic 3×3: det = 0
1947 let m = black_box(
1948 Matrix::<3>::try_from_rows([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]])
1949 .unwrap(),
1950 );
1951 assert_abs_diff_eq!(m.det_direct().unwrap().unwrap(), 0.0, epsilon = 1e-12);
1952 }
1953
1954 #[test]
1955 fn det_direct_d3_dense_known_value() {
1956 // det = 1*(5*8 - 7*6) - 2*(4*8 - 7*2) + 3*(4*6 - 5*2) = 4
1957 let m = black_box(
1958 Matrix::<3>::try_from_rows([[1.0, 2.0, 3.0], [4.0, 5.0, 7.0], [2.0, 6.0, 8.0]])
1959 .unwrap(),
1960 );
1961 let direct = m.det_direct().unwrap().unwrap();
1962 let paired = m.det_direct_with_errbound().unwrap().unwrap();
1963
1964 assert_abs_diff_eq!(direct, 4.0, epsilon = 1e-12);
1965 assert_eq!(paired.determinant().to_bits(), direct.to_bits());
1966 }
1967
1968 #[test]
1969 fn det_direct_d3_dense_reports_legitimate_overflow() {
1970 // The unscaled matrix has determinant 54, so scaling every entry by
1971 // 1.6e102 gives a determinant of approximately 2.21e308.
1972 let scale = 1.6e102;
1973 let m = black_box(
1974 Matrix::<3>::try_from_rows([
1975 [4.0 * scale, scale, scale],
1976 [scale, 4.0 * scale, scale],
1977 [scale, scale, 4.0 * scale],
1978 ])
1979 .unwrap(),
1980 );
1981 let expected = LaError::non_finite_computation_scalar(ArithmeticOperation::Determinant);
1982
1983 assert_eq!(m.det_direct(), Err(expected));
1984 assert_eq!(m.det(), Err(expected));
1985 }
1986
1987 #[test]
1988 fn det_errbound_d3_dense_reports_legitimate_overflow() {
1989 // The unscaled matrix has determinant 54, so scaling every entry by
1990 // 1.6e102 gives a determinant of approximately 2.21e308.
1991 let scale = 1.6e102;
1992 let m = black_box(
1993 Matrix::<3>::try_from_rows([
1994 [4.0 * scale, scale, scale],
1995 [scale, 4.0 * scale, scale],
1996 [scale, scale, 4.0 * scale],
1997 ])
1998 .unwrap(),
1999 );
2000 let expected =
2001 LaError::non_finite_computation_scalar(ArithmeticOperation::DeterminantErrorBound);
2002
2003 assert_eq!(m.det_errbound(), Err(expected));
2004 assert_eq!(m.det_direct_with_errbound(), Err(expected));
2005 }
2006
2007 #[test]
2008 fn det_direct_d3_nonsingular() {
2009 // [[2,1,0],[0,3,1],[1,0,2]] → det = 2*(6-0) - 1*(0-1) + 0 = 13
2010 let m = black_box(
2011 Matrix::<3>::try_from_rows([[2.0, 1.0, 0.0], [0.0, 3.0, 1.0], [1.0, 0.0, 2.0]])
2012 .unwrap(),
2013 );
2014 assert_abs_diff_eq!(m.det_direct().unwrap().unwrap(), 13.0, epsilon = 1e-12);
2015 }
2016
2017 #[test]
2018 fn det_direct_d3_skips_zero_coefficient_minor_that_would_overflow() {
2019 let m = black_box(
2020 Matrix::<3>::try_from_rows([
2021 [1.0, 0.0, 0.0],
2022 [1.0e300, 1.0, 1.0e300],
2023 [1.0e300, 0.0, 1.0e300],
2024 ])
2025 .unwrap(),
2026 );
2027 assert_eq!(m.det_direct(), Ok(Some(1.0e300)));
2028 }
2029
2030 #[test]
2031 fn det_direct_d4_known_value() {
2032 // Diagonal matrix: det = product of diagonal entries.
2033 let mut rows = [[0.0f64; 4]; 4];
2034 rows[0][0] = 2.0;
2035 rows[1][1] = 3.0;
2036 rows[2][2] = 5.0;
2037 rows[3][3] = 7.0;
2038 let m = black_box(Matrix::<4>::try_from_rows(rows).unwrap());
2039 assert_abs_diff_eq!(m.det_direct().unwrap().unwrap(), 210.0, epsilon = 1e-12);
2040 }
2041
2042 #[test]
2043 fn det_direct_d4_dense_known_value() {
2044 let m = black_box(
2045 Matrix::<4>::try_from_rows([
2046 [4.0, 1.0, 3.0, 2.0],
2047 [1.0, 5.0, 2.0, 1.0],
2048 [7.0, 2.0, 6.0, 3.0],
2049 [1.0, 8.0, 4.0, 9.0],
2050 ])
2051 .unwrap(),
2052 );
2053 let direct = m.det_direct().unwrap().unwrap();
2054 let paired = m.det_direct_with_errbound().unwrap().unwrap();
2055
2056 assert_abs_diff_eq!(direct, 112.0, epsilon = 1e-12);
2057 assert_eq!(paired.determinant().to_bits(), direct.to_bits());
2058 }
2059
2060 #[test]
2061 fn det_direct_d4_dense_reports_legitimate_overflow() {
2062 // The unscaled matrix has determinant 189, so scaling every entry by
2063 // 3.2e76 gives a determinant of approximately 1.98e308.
2064 let scale = 3.2e76;
2065 let m = black_box(
2066 Matrix::<4>::try_from_rows([
2067 [4.0 * scale, scale, scale, scale],
2068 [scale, 4.0 * scale, scale, scale],
2069 [scale, scale, 4.0 * scale, scale],
2070 [scale, scale, scale, 4.0 * scale],
2071 ])
2072 .unwrap(),
2073 );
2074 let expected = LaError::non_finite_computation_scalar(ArithmeticOperation::Determinant);
2075
2076 assert_eq!(m.det_direct(), Err(expected));
2077 assert_eq!(m.det(), Err(expected));
2078 }
2079
2080 #[test]
2081 fn det_errbound_d4_dense_reports_legitimate_overflow() {
2082 // The unscaled matrix has determinant 189, so scaling every entry by
2083 // 3.2e76 gives a determinant of approximately 1.98e308.
2084 let scale = 3.2e76;
2085 let m = black_box(
2086 Matrix::<4>::try_from_rows([
2087 [4.0 * scale, scale, scale, scale],
2088 [scale, 4.0 * scale, scale, scale],
2089 [scale, scale, 4.0 * scale, scale],
2090 [scale, scale, scale, 4.0 * scale],
2091 ])
2092 .unwrap(),
2093 );
2094 let expected =
2095 LaError::non_finite_computation_scalar(ArithmeticOperation::DeterminantErrorBound);
2096
2097 assert_eq!(m.det_errbound(), Err(expected));
2098 assert_eq!(m.det_direct_with_errbound(), Err(expected));
2099 }
2100
2101 #[test]
2102 fn det_direct_d4_skips_zero_coefficient_cofactors_that_would_overflow() {
2103 let m = black_box(
2104 Matrix::<4>::try_from_rows([
2105 [0.0, 0.0, 0.0, 0.0],
2106 [0.0, 0.0, 0.0, 0.0],
2107 [1.0e300, 0.0, 1.0e300, 1.0e300],
2108 [1.0e300, 0.0, 1.0e300, -1.0e300],
2109 ])
2110 .unwrap(),
2111 );
2112 assert_eq!(m.det_direct(), Ok(Some(0.0)));
2113 }
2114
2115 #[test]
2116 fn det_direct_d4_sparse_second_row_skips_inactive_overflowing_minors() {
2117 let m = black_box(
2118 Matrix::<4>::try_from_rows([
2119 [1.0e-300, 1.0, 1.0, 1.0],
2120 [0.0, 1.0, 0.0, 0.0],
2121 [0.0, 1.0e300, 1.0, 1.0e300],
2122 [0.0, 1.0e300, 0.0, 1.0e300],
2123 ])
2124 .unwrap(),
2125 );
2126
2127 assert_eq!(m.det_direct(), Ok(Some(1.0)));
2128 assert_eq!(m.det(), Ok(1.0));
2129 }
2130
2131 #[test]
2132 fn det_direct_d5_returns_none() {
2133 assert_eq!(Matrix::<5>::identity().det_direct(), Ok(None));
2134 }
2135
2136 #[test]
2137 fn det_direct_d8_returns_none() {
2138 assert_eq!(Matrix::<8>::zero().det_direct(), Ok(None));
2139 }
2140
2141 #[test]
2142 fn det_direct_rejects_computed_overflow() {
2143 let m = Matrix::<2>::try_from_rows([[1e300, 0.0], [0.0, 1e300]]).unwrap();
2144 assert_eq!(
2145 m.det_direct(),
2146 Err(LaError::non_finite_computation_scalar(
2147 ArithmeticOperation::Determinant
2148 ))
2149 );
2150 }
2151
2152 #[test]
2153 fn det_d5_rejects_lu_product_overflow() {
2154 let m = Matrix::<5>::try_from_rows([
2155 [1.0e100, 0.0, 0.0, 0.0, 0.0],
2156 [0.0, 1.0e100, 0.0, 0.0, 0.0],
2157 [0.0, 0.0, 1.0e100, 0.0, 0.0],
2158 [0.0, 0.0, 0.0, 1.0e100, 0.0],
2159 [0.0, 0.0, 0.0, 0.0, 1.0e100],
2160 ])
2161 .unwrap();
2162 assert_eq!(
2163 m.det(),
2164 Err(LaError::non_finite_computation_step(
2165 ArithmeticOperation::Determinant,
2166 4
2167 ))
2168 );
2169 }
2170
2171 #[test]
2172 fn det_d5_rejects_lu_trailing_update_overflow() {
2173 let m = Matrix::<5>::try_from_rows([
2174 [1.0, f64::MAX, 0.0, 0.0, 0.0],
2175 [-1.0, f64::MAX, 0.0, 0.0, 0.0],
2176 [0.0, 0.0, 1.0, 0.0, 0.0],
2177 [0.0, 0.0, 0.0, 1.0, 0.0],
2178 [0.0, 0.0, 0.0, 0.0, 1.0],
2179 ])
2180 .unwrap();
2181
2182 assert_eq!(
2183 m.det(),
2184 Err(LaError::non_finite_computation_matrix(
2185 ArithmeticOperation::LuFactorization,
2186 1,
2187 1
2188 ))
2189 );
2190 }
2191
2192 macro_rules! gen_det_direct_agrees_with_lu {
2193 ($d:literal) => {
2194 paste! {
2195 #[test]
2196 #[expect(
2197 clippy::cast_precision_loss,
2198 reason = "r, c, and D are tiny test integers exactly representable as f64"
2199 )]
2200 fn [<det_direct_agrees_with_lu_ $d d>]() {
2201 // Well-conditioned matrix: diagonally dominant.
2202 let mut rows = [[0.0f64; $d]; $d];
2203 for r in 0..$d {
2204 for c in 0..$d {
2205 rows[r][c] = if r == c {
2206 (r as f64) + f64::from($d) + 1.0
2207 } else {
2208 0.1 / ((r + c + 1) as f64)
2209 };
2210 }
2211 }
2212 let m = Matrix::<$d>::try_from_rows(rows).unwrap();
2213 let direct = m.det_direct().unwrap().unwrap();
2214 let lu_det = m.lu(DEFAULT_SINGULAR_TOL).unwrap().det().unwrap();
2215 let eps = lu_det.abs().mul_add(1e-12, 1e-12);
2216 assert_abs_diff_eq!(direct, lu_det, epsilon = eps);
2217 }
2218 }
2219 };
2220 }
2221
2222 gen_det_direct_agrees_with_lu!(1);
2223 gen_det_direct_agrees_with_lu!(2);
2224 gen_det_direct_agrees_with_lu!(3);
2225 gen_det_direct_agrees_with_lu!(4);
2226
2227 #[test]
2228 fn det_direct_identity_all_dims() {
2229 assert_abs_diff_eq!(
2230 Matrix::<1>::identity().det_direct().unwrap().unwrap(),
2231 1.0,
2232 epsilon = 0.0
2233 );
2234 assert_abs_diff_eq!(
2235 Matrix::<2>::identity().det_direct().unwrap().unwrap(),
2236 1.0,
2237 epsilon = 0.0
2238 );
2239 assert_abs_diff_eq!(
2240 Matrix::<3>::identity().det_direct().unwrap().unwrap(),
2241 1.0,
2242 epsilon = 0.0
2243 );
2244 assert_abs_diff_eq!(
2245 Matrix::<4>::identity().det_direct().unwrap().unwrap(),
2246 1.0,
2247 epsilon = 0.0
2248 );
2249 }
2250
2251 #[test]
2252 fn det_direct_zero_matrix() {
2253 assert_abs_diff_eq!(
2254 Matrix::<2>::zero().det_direct().unwrap().unwrap(),
2255 0.0,
2256 epsilon = 0.0
2257 );
2258 assert_abs_diff_eq!(
2259 Matrix::<3>::zero().det_direct().unwrap().unwrap(),
2260 0.0,
2261 epsilon = 0.0
2262 );
2263 assert_abs_diff_eq!(
2264 Matrix::<4>::zero().det_direct().unwrap().unwrap(),
2265 0.0,
2266 epsilon = 0.0
2267 );
2268 }
2269
2270 macro_rules! gen_det_singular_zero_matrix_tests {
2271 ($d:literal) => {
2272 paste! {
2273 #[test]
2274 fn [<det_singular_zero_matrix_returns_zero_ $d d>]() {
2275 assert_abs_diff_eq!(
2276 Matrix::<$d>::zero().det().unwrap(),
2277 0.0,
2278 epsilon = 0.0
2279 );
2280 }
2281 }
2282 };
2283 }
2284
2285 gen_det_singular_zero_matrix_tests!(2);
2286 gen_det_singular_zero_matrix_tests!(3);
2287 gen_det_singular_zero_matrix_tests!(4);
2288
2289 #[test]
2290 fn det_singular_zero_matrix_d5_preserves_lu_error() {
2291 assert_eq!(
2292 Matrix::<5>::zero().det(),
2293 Err(LaError::singular_numerical(
2294 0,
2295 FactorizationKind::Lu,
2296 0.0,
2297 0.0
2298 ))
2299 );
2300 }
2301
2302 #[test]
2303 fn det_d5_does_not_turn_elimination_underflow_into_exact_zero() {
2304 let min_subnormal = f64::from_bits(1);
2305 let two_pow_800 = f64::from_bits(1823_u64 << 52);
2306 let m = Matrix::<5>::try_from_rows([
2307 [2.0, min_subnormal, 0.0, 0.0, 0.0],
2308 [1.0, 0.0, 0.0, 0.0, 0.0],
2309 [0.0, 0.0, two_pow_800, 0.0, 0.0],
2310 [0.0, 0.0, 0.0, 1.0, 0.0],
2311 [0.0, 0.0, 0.0, 0.0, 1.0],
2312 ])
2313 .unwrap();
2314
2315 assert_eq!(
2316 m.det(),
2317 Err(LaError::singular_numerical(
2318 1,
2319 FactorizationKind::Lu,
2320 0.0,
2321 0.0
2322 ))
2323 );
2324 }
2325
2326 #[test]
2327 fn det_d5_ignores_pivot_tolerance_for_tiny_nonsingular_matrix() {
2328 // A small nonzero determinant is still a determinant. `det` must not
2329 // flatten the value to zero merely because the default LU tolerance
2330 // would reject a pivot this small.
2331 let m = Matrix::<5>::try_from_rows([
2332 [1e-13, 0.0, 0.0, 0.0, 0.0],
2333 [0.0, 1.0, 0.0, 0.0, 0.0],
2334 [0.0, 0.0, 1.0, 0.0, 0.0],
2335 [0.0, 0.0, 0.0, 1.0, 0.0],
2336 [0.0, 0.0, 0.0, 0.0, 1.0],
2337 ])
2338 .unwrap();
2339
2340 assert_abs_diff_eq!(m.det().unwrap(), 1e-13, epsilon = 0.0);
2341 assert_eq!(
2342 m.lu(DEFAULT_SINGULAR_TOL),
2343 Err(LaError::singular_numerical(
2344 0,
2345 FactorizationKind::Lu,
2346 1e-13,
2347 DEFAULT_SINGULAR_TOL.get()
2348 ))
2349 );
2350 }
2351
2352 #[test]
2353 fn det_returns_non_finite_error_for_overflow_with_finite_entries() {
2354 // det_direct produces an overflowing f64 (1e300 * 1e300 = ∞) even
2355 // though every matrix entry is finite. The entry scan in `det`
2356 // falls through and reports a computed determinant overflow rather
2357 // than a NaN/∞ input.
2358 let m = Matrix::<2>::try_from_rows([[1e300, 0.0], [0.0, 1e300]]).unwrap();
2359 assert_eq!(
2360 m.det(),
2361 Err(LaError::non_finite_computation_scalar(
2362 ArithmeticOperation::Determinant
2363 ))
2364 );
2365 }
2366
2367 // === det_direct const-evaluability tests (D = 2..=5) ===
2368 //
2369 // Every dimension hits a distinct arm of the `match D { … }` body inside
2370 // `det_direct`, so exercising each at compile time is the tightest
2371 // const-fn proof available.
2372
2373 macro_rules! gen_det_direct_const_eval_tests {
2374 ($d:literal) => {
2375 paste! {
2376 /// `Matrix::<D>::det_direct()` on the identity must const-evaluate
2377 /// to `Ok(Some(1.0))` for every closed-form dimension `D ∈ {1, 2, 3, 4}`.
2378 #[test]
2379 fn [<det_direct_const_eval_ $d d>]() {
2380 const DET: Result<Option<f64>, LaError> = Matrix::<$d>::identity().det_direct();
2381 assert_eq!(DET, Ok(Some(1.0)));
2382 }
2383 }
2384 };
2385 }
2386
2387 gen_det_direct_const_eval_tests!(2);
2388 gen_det_direct_const_eval_tests!(3);
2389 gen_det_direct_const_eval_tests!(4);
2390
2391 #[test]
2392 fn det_direct_const_eval_d5_is_none() {
2393 // D ≥ 5 has no closed-form arm; `det_direct` returns `Ok(None)`. Verify
2394 // that the wildcard arm is reachable in a `const { … }` context.
2395 const DET: Result<Option<f64>, LaError> = Matrix::<5>::identity().det_direct();
2396 assert_eq!(DET, Ok(None));
2397 }
2398
2399 // === det_errbound tests (no `exact` feature required) ===
2400
2401 #[test]
2402 fn det_errbound_matches_documented_coefficient_scale() {
2403 let m2 = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]]).unwrap();
2404 let expected_2 = ERR_COEFF_2 * ((1.0_f64 * 4.0).abs() + (2.0_f64 * 3.0).abs());
2405 assert_abs_diff_eq!(
2406 m2.det_errbound().unwrap().unwrap(),
2407 expected_2,
2408 epsilon = 0.0
2409 );
2410
2411 assert_abs_diff_eq!(
2412 Matrix::<3>::identity().det_errbound().unwrap().unwrap(),
2413 ERR_COEFF_3,
2414 epsilon = 0.0
2415 );
2416 assert_abs_diff_eq!(
2417 Matrix::<4>::identity().det_errbound().unwrap().unwrap(),
2418 ERR_COEFF_4,
2419 epsilon = 0.0
2420 );
2421 }
2422
2423 #[test]
2424 fn det_errbound_d3_skips_zero_coefficient_minor_that_would_overflow() {
2425 let m = Matrix::<3>::try_from_rows([
2426 [1.0, 0.0, 0.0],
2427 [1.0e300, 1.0, 1.0e300],
2428 [1.0e300, 0.0, 1.0e300],
2429 ])
2430 .unwrap();
2431
2432 assert_eq!(m.det_errbound(), Ok(Some(ERR_COEFF_3 * 1.0e300)));
2433 }
2434
2435 #[test]
2436 fn det_errbound_d4_skips_zero_coefficient_cofactors_that_would_overflow() {
2437 let m = Matrix::<4>::try_from_rows([
2438 [0.0, 0.0, 0.0, 0.0],
2439 [0.0, 0.0, 0.0, 0.0],
2440 [1.0e300, 0.0, 1.0e300, 1.0e300],
2441 [1.0e300, 0.0, 1.0e300, -1.0e300],
2442 ])
2443 .unwrap();
2444
2445 assert_eq!(m.det_errbound(), Ok(Some(0.0)));
2446 }
2447
2448 #[test]
2449 fn det_errbound_d5_returns_none() {
2450 // D=5 has no fast filter
2451 assert_eq!(Matrix::<5>::identity().det_errbound(), Ok(None));
2452 }
2453
2454 #[test]
2455 fn combined_det_bound_wide_exponent_fast_path_matches_tracked_arithmetic() {
2456 let threshold = f64::from_bits(1007_u64 << 52); // 2^-16
2457 let at_threshold = Matrix::<2>::try_from_rows([[threshold, 0.0], [0.0, 2.0]]).unwrap();
2458 assert!(at_threshold.det_bound_inputs_have_wide_exponent_margin());
2459
2460 let tracked = at_threshold
2461 .det_direct_with_errbound_from_arithmetic(
2462 at_threshold
2463 .det_direct_arithmetic::<true>()
2464 .expect("D=2 has direct arithmetic"),
2465 )
2466 .unwrap();
2467 assert_eq!(at_threshold.det_direct_with_errbound().unwrap(), tracked);
2468
2469 let just_below = f64::from_bits(threshold.to_bits() - 1);
2470 let below_threshold = Matrix::<2>::try_from_rows([[just_below, 0.0], [0.0, 2.0]]).unwrap();
2471 assert!(!below_threshold.det_bound_inputs_have_wide_exponent_margin());
2472 assert!(!Matrix::<5>::identity().det_bound_inputs_have_wide_exponent_margin());
2473 }
2474
2475 #[test]
2476 fn det_direct_with_errbound_covers_zero_and_one_dimensions() {
2477 let empty = Matrix::<0>::zero()
2478 .det_direct_with_errbound()
2479 .unwrap()
2480 .unwrap();
2481 assert_abs_diff_eq!(empty.determinant(), 1.0, epsilon = 0.0);
2482 assert_abs_diff_eq!(empty.absolute_error_bound(), 0.0, epsilon = 0.0);
2483
2484 let scalar = Matrix::<1>::try_from_rows([[-7.0]])
2485 .unwrap()
2486 .det_direct_with_errbound()
2487 .unwrap()
2488 .unwrap();
2489 assert_abs_diff_eq!(scalar.determinant(), -7.0, epsilon = 0.0);
2490 assert_abs_diff_eq!(scalar.absolute_error_bound(), 0.0, epsilon = 0.0);
2491 }
2492
2493 #[test]
2494 fn det_direct_with_errbound_pairs_the_closed_form_values() {
2495 let matrix = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]]).unwrap();
2496 let estimate = matrix.det_direct_with_errbound().unwrap().unwrap();
2497
2498 assert_abs_diff_eq!(
2499 estimate.determinant(),
2500 matrix.det_direct().unwrap().unwrap(),
2501 epsilon = 0.0
2502 );
2503 assert_abs_diff_eq!(
2504 estimate.absolute_error_bound(),
2505 ERR_COEFF_2 * (4.0_f64 + 6.0_f64),
2506 epsilon = 0.0
2507 );
2508 }
2509
2510 #[test]
2511 fn det_direct_with_errbound_d5_returns_none() {
2512 assert_eq!(Matrix::<5>::identity().det_direct_with_errbound(), Ok(None));
2513 }
2514
2515 #[test]
2516 fn det_errbound_rejects_computed_overflow() {
2517 let m = Matrix::<2>::try_from_rows([[1e300, 0.0], [0.0, 1e300]]).unwrap();
2518 assert_eq!(
2519 m.det_errbound(),
2520 Err(LaError::non_finite_computation_scalar(
2521 ArithmeticOperation::DeterminantErrorBound
2522 ))
2523 );
2524 }
2525
2526 // === det_errbound const-evaluability tests (D = 2..=5) ===
2527
2528 macro_rules! gen_det_errbound_const_eval_tests {
2529 ($d:literal) => {
2530 paste! {
2531 /// `Matrix::<D>::det_errbound()` on the identity must const-evaluate
2532 /// to `Ok(Some(bound))` with `bound > 0` for every closed-form dimension
2533 /// `D ∈ {2, 3, 4}`. Each dimension hits a distinct arm of
2534 /// `det_errbound` with a dimension-specific permanent computation.
2535 #[test]
2536 fn [<det_errbound_const_eval_ $d d>]() {
2537 const BOUND: Result<Option<f64>, LaError> = Matrix::<$d>::identity().det_errbound();
2538 assert!(BOUND.unwrap().unwrap() > 0.0);
2539 }
2540 }
2541 };
2542 }
2543
2544 gen_det_errbound_const_eval_tests!(2);
2545 gen_det_errbound_const_eval_tests!(3);
2546 gen_det_errbound_const_eval_tests!(4);
2547
2548 #[test]
2549 fn det_errbound_const_eval_d5_is_none() {
2550 // D ≥ 5 has no fast-filter bound; `det_errbound` returns `Ok(None)`.
2551 const BOUND: Result<Option<f64>, LaError> = Matrix::<5>::identity().det_errbound();
2552 assert_eq!(BOUND, Ok(None));
2553 }
2554
2555 // === inf_norm const-evaluability tests (D = 2..=5) ===
2556
2557 macro_rules! gen_inf_norm_const_eval_tests {
2558 ($d:literal) => {
2559 paste! {
2560 /// `Matrix::<D>::inf_norm()` on the identity must const-evaluate
2561 /// to `1.0` for every `D ≥ 1` — each row has a single `1.0`
2562 /// entry, so the max absolute row sum is exactly `1.0`.
2563 #[test]
2564 fn [<inf_norm_const_eval_ $d d>]() {
2565 const NORM: Result<f64, LaError> = Matrix::<$d>::identity().inf_norm();
2566 assert!((NORM.unwrap() - 1.0).abs() <= 1e-12);
2567 }
2568 }
2569 };
2570 }
2571
2572 gen_inf_norm_const_eval_tests!(2);
2573 gen_inf_norm_const_eval_tests!(3);
2574 gen_inf_norm_const_eval_tests!(4);
2575 gen_inf_norm_const_eval_tests!(5);
2576
2577 // === is_symmetric / first_asymmetry (public LDLT preconditions helpers) ===
2578
2579 macro_rules! gen_is_symmetric_tests {
2580 ($d:literal) => {
2581 paste! {
2582 #[test]
2583 fn [<is_symmetric_true_for_identity_ $d d>]() {
2584 let m = Matrix::<$d>::identity();
2585 assert!(m.is_symmetric(Tolerance::try_new(1e-12).unwrap()).unwrap());
2586 assert_eq!(m.first_asymmetry(Tolerance::try_new(1e-12).unwrap()).unwrap(), None);
2587 }
2588
2589 #[test]
2590 fn [<is_symmetric_true_for_zero_ $d d>]() {
2591 let m = Matrix::<$d>::zero();
2592 assert!(m.is_symmetric(Tolerance::try_new(1e-12).unwrap()).unwrap());
2593 assert_eq!(m.first_asymmetry(Tolerance::try_new(1e-12).unwrap()).unwrap(), None);
2594 }
2595
2596 #[test]
2597 fn [<is_symmetric_true_for_constructed_symmetric_ $d d>]() {
2598 // Construct A = M + Mᵀ so A is provably symmetric.
2599 let mut m = [[0.0f64; $d]; $d];
2600 for r in 0..$d {
2601 for c in 0..$d {
2602 #[expect(
2603 clippy::cast_precision_loss,
2604 reason = "matrix test indices are at most five and exactly representable as f64"
2605 )]
2606 {
2607 m[r][c] = (r * $d + c) as f64;
2608 }
2609 }
2610 }
2611 let mut sym = [[0.0f64; $d]; $d];
2612 for r in 0..$d {
2613 for c in 0..$d {
2614 sym[r][c] = m[r][c] + m[c][r];
2615 }
2616 }
2617 let a = Matrix::<$d>::try_from_rows(sym).unwrap();
2618 assert!(a.is_symmetric(Tolerance::try_new(1e-12).unwrap()).unwrap());
2619 assert_eq!(a.first_asymmetry(Tolerance::try_new(1e-12).unwrap()).unwrap(), None);
2620 }
2621
2622 #[test]
2623 fn [<is_symmetric_false_for_asymmetric_offdiagonal_ $d d>]() {
2624 // Perturb a single off-diagonal entry so symmetry fails.
2625 let mut rows = [[0.0f64; $d]; $d];
2626 for i in 0..$d {
2627 rows[i][i] = 1.0;
2628 }
2629 rows[0][$d - 1] = 1.0;
2630 rows[$d - 1][0] = -1.0; // breaks symmetry
2631 let a = Matrix::<$d>::try_from_rows(rows).unwrap();
2632 assert!(!a.is_symmetric(Tolerance::try_new(1e-12).unwrap()).unwrap());
2633 assert_eq!(
2634 a.first_asymmetry(Tolerance::try_new(1e-12).unwrap()).unwrap(),
2635 Some((0, $d - 1))
2636 );
2637 }
2638
2639 }
2640 };
2641 }
2642
2643 gen_is_symmetric_tests!(2);
2644 gen_is_symmetric_tests!(3);
2645 gen_is_symmetric_tests!(4);
2646 gen_is_symmetric_tests!(5);
2647
2648 macro_rules! gen_ldlt_symmetry_proof_tests {
2649 ($d:literal) => {
2650 paste! {
2651 #[test]
2652 fn [<matrix_ldlt_accepts_exact_symmetric_spd_ $d d>]() {
2653 // This exactly mirrored, strictly diagonally dominant
2654 // tridiagonal matrix is positive definite.
2655 let mut rows = [[0.0_f64; $d]; $d];
2656 for (index, row) in rows.iter_mut().enumerate() {
2657 row[index] = 2.0;
2658 }
2659 for index in 1..$d {
2660 rows[index - 1][index] = 0.5;
2661 rows[index][index - 1] = 0.5;
2662 }
2663
2664 let matrix = Matrix::<$d>::try_from_rows(rows).unwrap();
2665 let symmetric = SymmetricMatrix::try_new(matrix).unwrap();
2666 assert_eq!(symmetric.into_matrix(), matrix);
2667
2668 let ldlt = matrix.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
2669 assert!(ldlt.det().unwrap() > 0.0);
2670 }
2671
2672 #[test]
2673 fn [<symmetric_matrix_try_new_rejects_finite_asymmetric_ $d d>]() {
2674 let mut rows = [[0.0f64; $d]; $d];
2675 for (i, row) in rows.iter_mut().enumerate() {
2676 row[i] = 1.0;
2677 }
2678 rows[0][$d - 1] = 1.0;
2679 rows[$d - 1][0] = -1.0;
2680
2681 assert_eq!(
2682 Matrix::<$d>::try_from_rows(rows).and_then(SymmetricMatrix::try_new),
2683 Err(LaError::asymmetric(0, $d - 1, $d, 1.0, -1.0, 0.0))
2684 );
2685 }
2686 }
2687 };
2688 }
2689
2690 gen_ldlt_symmetry_proof_tests!(2);
2691 gen_ldlt_symmetry_proof_tests!(3);
2692 gen_ldlt_symmetry_proof_tests!(4);
2693 gen_ldlt_symmetry_proof_tests!(5);
2694
2695 #[test]
2696 fn symmetric_matrix_into_matrix_roundtrips_storage_internally() {
2697 let a = Matrix::<2>::try_from_rows([[2.0, 1.0], [1.0, 3.0]]).unwrap();
2698 let symmetric = SymmetricMatrix::try_new(a).unwrap();
2699
2700 assert_eq!(symmetric.into_matrix(), a);
2701 }
2702
2703 #[test]
2704 fn matrix_ldlt_accepts_opposite_signed_zero_mirrors() {
2705 let matrix = Matrix::<2>::try_from_rows([[2.0, 0.0], [-0.0, 2.0]]).unwrap();
2706 let ldlt = matrix.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
2707
2708 assert_eq!(ldlt.det(), Ok(4.0));
2709 }
2710
2711 #[test]
2712 fn is_symmetric_tolerance_scales_with_inf_norm() {
2713 // Off-diagonal entries differ by 1e-6. With inf_norm ≈ 2e6, the
2714 // relative tolerance 1e-12 yields eps ≈ 2e-6, which accepts the gap;
2715 // a stricter tol of 1e-15 rejects it.
2716 let a = Matrix::<2>::try_from_rows([[1.0e6, 1.0e6 + 1.0e-6], [1.0e6, 1.0e6]]).unwrap();
2717 assert!(a.is_symmetric(Tolerance::try_new(1e-12).unwrap()).unwrap());
2718 assert!(!a.is_symmetric(Tolerance::try_new(1e-15).unwrap()).unwrap());
2719 }
2720
2721 #[test]
2722 fn symmetry_epsilon_multiplies_after_row_sum_near_subnormal_boundary() {
2723 let min_subnormal = f64::from_bits(1);
2724 let mut rows = [[0.0; 5]; 5];
2725 let mut col = 0;
2726 while col < 4 {
2727 rows[0][col] = 0.4;
2728 rows[col][0] = 0.4;
2729 col += 1;
2730 }
2731 rows[0][4] = 2.0 * min_subnormal;
2732 rows[4][0] = 0.0;
2733
2734 let matrix = Matrix::<5>::try_from_rows(rows).unwrap();
2735 let tolerance = Tolerance::try_new(min_subnormal).unwrap();
2736 let expected_epsilon = tolerance.get() * matrix.inf_norm().unwrap().max(1.0);
2737
2738 assert_eq!(expected_epsilon.to_bits(), 2);
2739 assert_eq!(matrix.first_asymmetry(tolerance), Ok(None));
2740 assert_eq!(matrix.is_symmetric(tolerance), Ok(true));
2741 }
2742
2743 #[test]
2744 fn symmetry_epsilon_scales_terms_when_row_sum_overflows() {
2745 let matrix =
2746 Matrix::<2>::try_from_rows([[f64::MAX, f64::MAX], [f64::MAX / 2.0, f64::MAX]]).unwrap();
2747
2748 assert_eq!(
2749 matrix.inf_norm(),
2750 Err(LaError::non_finite_computation_matrix(
2751 ArithmeticOperation::MatrixInfinityNorm,
2752 0,
2753 1
2754 ))
2755 );
2756 assert_eq!(
2757 matrix.first_asymmetry(Tolerance::try_new(0.25).unwrap()),
2758 Ok(None)
2759 );
2760 assert_eq!(
2761 matrix.first_asymmetry(Tolerance::try_new(0.125).unwrap()),
2762 Ok(Some((0, 1)))
2763 );
2764 }
2765
2766 #[test]
2767 fn first_asymmetry_returns_lexicographically_first_pair() {
2768 // Two asymmetric pairs: (0, 2) and (1, 2). We must get (0, 2) first.
2769 let a = Matrix::<3>::try_from_rows([[1.0, 0.0, 2.0], [0.0, 1.0, 3.0], [-2.0, -3.0, 1.0]])
2770 .unwrap();
2771 assert_eq!(
2772 a.first_asymmetry(Tolerance::try_new(1e-12).unwrap())
2773 .unwrap(),
2774 Some((0, 2))
2775 );
2776 }
2777
2778 #[test]
2779 fn first_asymmetry_strict_tol_survives_row_sum_overflow() {
2780 let a = Matrix::<3>::try_from_rows([
2781 [1.0, 1.0, 0.0],
2782 [2.0, f64::MAX, f64::MAX],
2783 [0.0, 0.0, 1.0],
2784 ])
2785 .unwrap();
2786
2787 assert_eq!(
2788 a.inf_norm(),
2789 Err(LaError::non_finite_computation_matrix(
2790 ArithmeticOperation::MatrixInfinityNorm,
2791 1,
2792 2
2793 ))
2794 );
2795 assert_eq!(
2796 a.first_asymmetry(Tolerance::try_new(0.0).unwrap()).unwrap(),
2797 Some((0, 1))
2798 );
2799 assert!(!a.is_symmetric(Tolerance::try_new(0.0).unwrap()).unwrap());
2800 }
2801
2802 #[test]
2803 fn first_asymmetry_rejects_scaled_epsilon_overflow() {
2804 let a = Matrix::<2>::try_from_rows([[0.0, 0.0], [2.0, 1.0]]).unwrap();
2805 let tol = Tolerance::try_new(f64::MAX).unwrap();
2806
2807 assert_eq!(
2808 a.first_asymmetry(tol),
2809 Err(LaError::non_finite_computation_matrix(
2810 ArithmeticOperation::SymmetryCheck,
2811 1,
2812 0
2813 ))
2814 );
2815 assert_eq!(
2816 a.is_symmetric(tol),
2817 Err(LaError::non_finite_computation_matrix(
2818 ArithmeticOperation::SymmetryCheck,
2819 1,
2820 0
2821 ))
2822 );
2823 }
2824
2825 #[test]
2826 fn first_asymmetry_flags_overflowed_finite_difference() {
2827 let a = Matrix::<2>::try_from_rows([[1.0, f64::MAX], [-f64::MAX, 1.0]]).unwrap();
2828 assert_eq!(
2829 a.first_asymmetry(Tolerance::try_new(1e-12).unwrap())
2830 .unwrap(),
2831 Some((0, 1))
2832 );
2833 assert!(!a.is_symmetric(Tolerance::try_new(1e-12).unwrap()).unwrap());
2834 }
2835}