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) => return Ok(None),
1339 Err(error) => return Err(error),
1340 };
1341 if !det.value.is_finite() {
1342 cold_path();
1343 return Err(LaError::non_finite_computation_scalar(
1344 ArithmeticOperation::Determinant,
1345 ));
1346 }
1347 Ok(Some(DeterminantWithErrorBound {
1348 determinant: det.value,
1349 absolute_error_bound: bound,
1350 }))
1351 }
1352
1353 /// Compute a bound after the matching determinant tree has been evaluated.
1354 const fn det_errbound_from_arithmetic<const TRACK_UNDERFLOW: bool>(
1355 &self,
1356 det: FilterArithmetic<TRACK_UNDERFLOW>,
1357 ) -> Result<Option<f64>, LaError> {
1358 if !det.underflow_safe {
1359 cold_path();
1360 return Ok(None);
1361 }
1362
1363 match D {
1364 0 | 1 => Self::computed_scalar_result(ArithmeticOperation::DeterminantErrorBound, 0.0),
1365 2 => {
1366 let r = &self.rows;
1367 let product_0 = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r[0][0], r[1][1]);
1368 let product_1 = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r[0][1], r[1][0]);
1369 let mut permanent = FilterArithmetic::<TRACK_UNDERFLOW>::add_non_negative(
1370 product_0.value.abs(),
1371 product_1.value.abs(),
1372 );
1373 permanent.underflow_safe &= product_0.underflow_safe && product_1.underflow_safe;
1374 Self::certified_error_bound(ERR_COEFF_2, permanent)
1375 }
1376 3 => {
1377 let r = &self.rows;
1378 let permanent = Self::det3_abs_permanent_elements::<TRACK_UNDERFLOW>(
1379 [r[0][0], r[0][1], r[0][2]],
1380 [r[1][0], r[1][1], r[1][2]],
1381 [r[2][0], r[2][1], r[2][2]],
1382 );
1383 Self::certified_error_bound(ERR_COEFF_3, permanent)
1384 }
1385 4 => self.det4_errbound::<TRACK_UNDERFLOW>(),
1386 _ => {
1387 cold_path();
1388 Ok(None)
1389 }
1390 }
1391 }
1392
1393 /// Compute the D=4 determinant error bound after the dimension dispatch.
1394 const fn det4_errbound<const TRACK_UNDERFLOW: bool>(&self) -> Result<Option<f64>, LaError> {
1395 if !TRACK_UNDERFLOW && let Some(input) = Det4SharedMinorInput::try_new(self) {
1396 return Self::certified_error_bound(
1397 ERR_COEFF_4,
1398 FilterArithmetic::<TRACK_UNDERFLOW> {
1399 value: Self::det4_dense_abs_permanent_elements(input),
1400 underflow_safe: true,
1401 },
1402 );
1403 }
1404
1405 let r = &self.rows;
1406 let mut permanent = if r[0][3] == 0.0 {
1407 FilterArithmetic {
1408 value: 0.0,
1409 underflow_safe: true,
1410 }
1411 } else {
1412 let pc3 = Self::det3_abs_permanent_elements::<TRACK_UNDERFLOW>(
1413 [r[1][0], r[1][1], r[1][2]],
1414 [r[2][0], r[2][1], r[2][2]],
1415 [r[3][0], r[3][1], r[3][2]],
1416 );
1417 let mut term = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r[0][3].abs(), pc3.value);
1418 term.underflow_safe &= pc3.underflow_safe;
1419 term
1420 };
1421 if r[0][2] != 0.0 {
1422 let pc2 = Self::det3_abs_permanent_elements::<TRACK_UNDERFLOW>(
1423 [r[1][0], r[1][1], r[1][3]],
1424 [r[2][0], r[2][1], r[2][3]],
1425 [r[3][0], r[3][1], r[3][3]],
1426 );
1427 let prior_safe = permanent.underflow_safe && pc2.underflow_safe;
1428 permanent = FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(
1429 r[0][2].abs(),
1430 pc2.value,
1431 permanent.value,
1432 );
1433 permanent.underflow_safe &= prior_safe;
1434 }
1435 if r[0][1] != 0.0 {
1436 let pc1 = Self::det3_abs_permanent_elements::<TRACK_UNDERFLOW>(
1437 [r[1][0], r[1][2], r[1][3]],
1438 [r[2][0], r[2][2], r[2][3]],
1439 [r[3][0], r[3][2], r[3][3]],
1440 );
1441 let prior_safe = permanent.underflow_safe && pc1.underflow_safe;
1442 permanent = FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(
1443 r[0][1].abs(),
1444 pc1.value,
1445 permanent.value,
1446 );
1447 permanent.underflow_safe &= prior_safe;
1448 }
1449 if r[0][0] != 0.0 {
1450 let pc0 = Self::det3_abs_permanent_elements::<TRACK_UNDERFLOW>(
1451 [r[1][1], r[1][2], r[1][3]],
1452 [r[2][1], r[2][2], r[2][3]],
1453 [r[3][1], r[3][2], r[3][3]],
1454 );
1455 let prior_safe = permanent.underflow_safe && pc0.underflow_safe;
1456 permanent = FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(
1457 r[0][0].abs(),
1458 pc0.value,
1459 permanent.value,
1460 );
1461 permanent.underflow_safe &= prior_safe;
1462 }
1463 Self::certified_error_bound(ERR_COEFF_4, permanent)
1464 }
1465
1466 /// Evaluate a 3×3 determinant expansion with a guarded sparse fallback.
1467 ///
1468 /// When all three first-row coefficients are non-zero, one branch-free
1469 /// closed form is used. The sparse fallback protects the public
1470 /// [`det_direct`](Self::det_direct) contract: a mathematically absent term
1471 /// must not compute an overflowing minor and poison the determinant with
1472 /// `0.0 * inf == NaN`. Nonzero terms keep the same fused multiply-add
1473 /// ordering as the closed-form expansion.
1474 #[expect(
1475 clippy::inline_always,
1476 reason = "det_direct callers must eliminate unused filter-safety bookkeeping"
1477 )]
1478 #[inline(always)]
1479 const fn det3_elements<const TRACK_UNDERFLOW: bool>(
1480 r0: [f64; 3],
1481 r1: [f64; 3],
1482 r2: [f64; 3],
1483 ) -> FilterArithmetic<TRACK_UNDERFLOW> {
1484 let dense = (r0[0] != 0.0) && (r0[1] != 0.0) && (r0[2] != 0.0);
1485 if !TRACK_UNDERFLOW && dense {
1486 let m00 = r1[1].mul_add(r2[2], -(r1[2] * r2[1]));
1487 let m01 = r1[0].mul_add(r2[2], -(r1[2] * r2[0]));
1488 let m02 = r1[0].mul_add(r2[1], -(r1[1] * r2[0]));
1489 return FilterArithmetic {
1490 value: r0[0].mul_add(m00, (-r0[1]).mul_add(m01, r0[2] * m02)),
1491 underflow_safe: true,
1492 };
1493 }
1494
1495 let mut det = if r0[2] == 0.0 {
1496 FilterArithmetic {
1497 value: 0.0,
1498 underflow_safe: true,
1499 }
1500 } else {
1501 let subtrahend = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r1[1], r2[0]);
1502 let mut m02 =
1503 FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(r1[0], r2[1], -subtrahend.value);
1504 m02.underflow_safe &= subtrahend.underflow_safe;
1505 let mut term = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r0[2], m02.value);
1506 term.underflow_safe &= m02.underflow_safe;
1507 term
1508 };
1509 if r0[1] != 0.0 {
1510 let subtrahend = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r1[2], r2[0]);
1511 let mut m01 =
1512 FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(r1[0], r2[2], -subtrahend.value);
1513 m01.underflow_safe &= subtrahend.underflow_safe;
1514 let prior_safe = det.underflow_safe && m01.underflow_safe;
1515 det = FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(-r0[1], m01.value, det.value);
1516 det.underflow_safe &= prior_safe;
1517 }
1518 if r0[0] != 0.0 {
1519 let subtrahend = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r1[2], r2[1]);
1520 let mut m00 =
1521 FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(r1[1], r2[2], -subtrahend.value);
1522 m00.underflow_safe &= subtrahend.underflow_safe;
1523 let prior_safe = det.underflow_safe && m00.underflow_safe;
1524 det = FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(r0[0], m00.value, det.value);
1525 det.underflow_safe &= prior_safe;
1526 }
1527 det
1528 }
1529
1530 /// Evaluate a 3×3 absolute permanent while skipping zero coefficients.
1531 ///
1532 /// This mirrors [`det3_elements`](Self::det3_elements) for error-bound
1533 /// computation: absent determinant terms should not force evaluation of an
1534 /// overflowing absolute minor.
1535 #[expect(
1536 clippy::inline_always,
1537 reason = "error-bound call-site specialization avoids tracked-helper overhead"
1538 )]
1539 #[inline(always)]
1540 const fn det3_abs_permanent_elements<const TRACK_UNDERFLOW: bool>(
1541 r0: [f64; 3],
1542 r1: [f64; 3],
1543 r2: [f64; 3],
1544 ) -> FilterArithmetic<TRACK_UNDERFLOW> {
1545 let dense = (r0[0] != 0.0) && (r0[1] != 0.0) && (r0[2] != 0.0);
1546 if !TRACK_UNDERFLOW && dense {
1547 let pm00 = (r1[1] * r2[2]).abs() + (r1[2] * r2[1]).abs();
1548 let pm01 = (r1[0] * r2[2]).abs() + (r1[2] * r2[0]).abs();
1549 let pm02 = (r1[0] * r2[1]).abs() + (r1[1] * r2[0]).abs();
1550 return FilterArithmetic {
1551 value: r0[2]
1552 .abs()
1553 .mul_add(pm02, r0[1].abs().mul_add(pm01, r0[0].abs() * pm00)),
1554 underflow_safe: true,
1555 };
1556 }
1557
1558 let mut permanent = if r0[2] == 0.0 {
1559 FilterArithmetic {
1560 value: 0.0,
1561 underflow_safe: true,
1562 }
1563 } else {
1564 let product_0 = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r1[0], r2[1]);
1565 let product_1 = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r1[1], r2[0]);
1566 let mut pm02 = FilterArithmetic::<TRACK_UNDERFLOW>::add_non_negative(
1567 product_0.value.abs(),
1568 product_1.value.abs(),
1569 );
1570 pm02.underflow_safe &= product_0.underflow_safe && product_1.underflow_safe;
1571 let mut term = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r0[2].abs(), pm02.value);
1572 term.underflow_safe &= pm02.underflow_safe;
1573 term
1574 };
1575 if r0[1] != 0.0 {
1576 let product_0 = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r1[0], r2[2]);
1577 let product_1 = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r1[2], r2[0]);
1578 let mut pm01 = FilterArithmetic::<TRACK_UNDERFLOW>::add_non_negative(
1579 product_0.value.abs(),
1580 product_1.value.abs(),
1581 );
1582 pm01.underflow_safe &= product_0.underflow_safe && product_1.underflow_safe;
1583 let prior_safe = permanent.underflow_safe && pm01.underflow_safe;
1584 permanent = FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(
1585 r0[1].abs(),
1586 pm01.value,
1587 permanent.value,
1588 );
1589 permanent.underflow_safe &= prior_safe;
1590 }
1591 if r0[0] != 0.0 {
1592 let product_0 = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r1[1], r2[2]);
1593 let product_1 = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(r1[2], r2[1]);
1594 let mut pm00 = FilterArithmetic::<TRACK_UNDERFLOW>::add_non_negative(
1595 product_0.value.abs(),
1596 product_1.value.abs(),
1597 );
1598 pm00.underflow_safe &= product_0.underflow_safe && product_1.underflow_safe;
1599 let prior_safe = permanent.underflow_safe && pm00.underflow_safe;
1600 permanent = FilterArithmetic::<TRACK_UNDERFLOW>::mul_add(
1601 r0[0].abs(),
1602 pm00.value,
1603 permanent.value,
1604 );
1605 permanent.underflow_safe &= prior_safe;
1606 }
1607 permanent
1608 }
1609
1610 /// Finish a determinant error bound only when its full arithmetic tree is
1611 /// outside the gradual-underflow regime.
1612 const fn certified_error_bound<const TRACK_UNDERFLOW: bool>(
1613 coefficient: f64,
1614 permanent: FilterArithmetic<TRACK_UNDERFLOW>,
1615 ) -> Result<Option<f64>, LaError> {
1616 let mut bound = FilterArithmetic::<TRACK_UNDERFLOW>::multiply(coefficient, permanent.value);
1617 bound.underflow_safe &= permanent.underflow_safe;
1618 if bound.underflow_safe {
1619 Self::computed_scalar_result(ArithmeticOperation::DeterminantErrorBound, bound.value)
1620 } else {
1621 cold_path();
1622 Ok(None)
1623 }
1624 }
1625
1626 /// Return a computed scalar result for a matrix with finite stored entries.
1627 const fn computed_scalar_result(
1628 operation: ArithmeticOperation,
1629 value: f64,
1630 ) -> Result<Option<f64>, LaError> {
1631 if value.is_finite() {
1632 Ok(Some(value))
1633 } else {
1634 Err(LaError::non_finite_computation_scalar(operation))
1635 }
1636 }
1637}
1638
1639impl<const D: usize> Default for Matrix<D> {
1640 #[inline]
1641 fn default() -> Self {
1642 Self::zero()
1643 }
1644}
1645
1646#[cfg(all(doc, feature = "exact"))]
1647mod det_errbound_doctests {
1648 /// ```rust
1649 /// use la_stack::prelude::*;
1650 ///
1651 /// fn adaptive_det_sign<const D: usize>(
1652 /// matrix: &Matrix<D>,
1653 /// ) -> DeterminantSign {
1654 /// if let Ok(Some(estimate)) = matrix.det_direct_with_errbound() {
1655 /// if estimate.determinant().abs() > estimate.absolute_error_bound() {
1656 /// return if estimate.determinant() > 0.0 {
1657 /// DeterminantSign::Positive
1658 /// } else {
1659 /// DeterminantSign::Negative
1660 /// };
1661 /// }
1662 /// }
1663 ///
1664 /// matrix.det_sign_exact()
1665 /// }
1666 ///
1667 /// # fn main() -> Result<(), LaError> {
1668 /// let identity = Matrix::<3>::identity();
1669 /// assert_eq!(
1670 /// adaptive_det_sign(&identity),
1671 /// DeterminantSign::Positive
1672 /// );
1673 ///
1674 /// let singular = Matrix::<3>::try_from_rows([
1675 /// [1.0, 2.0, 3.0],
1676 /// [4.0, 5.0, 6.0],
1677 /// [7.0, 8.0, 9.0],
1678 /// ])?;
1679 /// assert_eq!(adaptive_det_sign(&singular), DeterminantSign::Zero);
1680 ///
1681 /// let big = f64::MAX / 2.0;
1682 /// let overflowing = Matrix::<3>::try_from_rows([
1683 /// [0.0, 0.0, 1.0],
1684 /// [big, 0.0, 1.0],
1685 /// [0.0, big, 1.0],
1686 /// ])?;
1687 /// assert_eq!(
1688 /// adaptive_det_sign(&overflowing),
1689 /// DeterminantSign::Positive
1690 /// );
1691 /// # Ok(())
1692 /// # }
1693 /// ```
1694 fn adaptive_precision_pattern() {}
1695}
1696
1697#[cfg(test)]
1698mod tests {
1699 use core::hint::black_box;
1700
1701 use approx::assert_abs_diff_eq;
1702 use pastey::paste;
1703
1704 use super::*;
1705 use crate::{DEFAULT_SINGULAR_TOL, FactorizationKind, Vector};
1706
1707 macro_rules! gen_matrix_tests {
1708 ($d:literal) => {
1709 paste! {
1710 #[test]
1711 fn [<matrix_try_from_rows_get_set_bounds_checked_ $d d>]() {
1712 let mut rows = [[0.0f64; $d]; $d];
1713 rows[0][0] = 1.0;
1714 rows[$d - 1][$d - 1] = -2.0;
1715
1716 let mut m = Matrix::<$d>::try_from_rows(rows).unwrap();
1717
1718 assert_eq!(m.get(0, 0), Some(1.0));
1719 assert_eq!(m.get($d - 1, $d - 1), Some(-2.0));
1720 assert_eq!(m.try_get(0, 0), Ok(1.0));
1721 assert_eq!(m.try_get($d - 1, $d - 1), Ok(-2.0));
1722
1723 // Out-of-bounds is None.
1724 assert_eq!(m.get($d, 0), None);
1725 assert_eq!(
1726 m.try_get($d, 0),
1727 Err(LaError::IndexOutOfBounds {
1728 row: $d,
1729 col: 0,
1730 dim: $d,
1731 })
1732 );
1733
1734 // Out-of-bounds set fails.
1735 let before_failed_set = m;
1736 assert_eq!(
1737 m.set($d, 0, 3.0),
1738 Err(LaError::IndexOutOfBounds {
1739 row: $d,
1740 col: 0,
1741 dim: $d,
1742 })
1743 );
1744 assert_eq!(m, before_failed_set);
1745 assert_eq!(
1746 m.set(0, $d, 3.0),
1747 Err(LaError::IndexOutOfBounds {
1748 row: 0,
1749 col: $d,
1750 dim: $d,
1751 })
1752 );
1753 assert_eq!(m, before_failed_set);
1754 assert_eq!(m.get(0, 0), Some(1.0));
1755
1756 // In-bounds set works.
1757 assert_eq!(m.set(0, $d - 1, 3.0), Ok(()));
1758 assert_eq!(m.get(0, $d - 1), Some(3.0));
1759 assert_eq!(m.set($d - 1, 0, 4.0), Ok(()));
1760 assert_eq!(m.try_get($d - 1, 0), Ok(4.0));
1761 }
1762
1763 #[test]
1764 fn [<matrix_set_rejects_non_finite_and_preserves_storage_ $d d>]() {
1765 for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1766 let mut m = Matrix::<$d>::identity();
1767 let before = m;
1768 assert_eq!(
1769 m.set($d - 1, 0, value),
1770 Err(LaError::non_finite_input_matrix($d - 1, 0))
1771 );
1772 assert_eq!(m, before);
1773 }
1774 }
1775
1776 #[test]
1777 fn [<matrix_try_from_rows_rejects_non_finite_ $d d>]() {
1778 for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1779 let mut rows = [[0.0f64; $d]; $d];
1780 rows[$d - 1][$d - 1] = value;
1781 assert_eq!(
1782 Matrix::<$d>::try_from_rows(rows),
1783 Err(LaError::non_finite_input_matrix($d - 1, $d - 1))
1784 );
1785 }
1786
1787 let mut rows = [[0.0f64; $d]; $d];
1788 rows[0][$d - 1] = f64::INFINITY;
1789 rows[$d - 1][0] = f64::NAN;
1790 assert_eq!(
1791 Matrix::<$d>::try_from_rows(rows),
1792 Err(LaError::non_finite_input_matrix(0, $d - 1))
1793 );
1794 }
1795
1796 #[test]
1797 fn [<matrix_zero_and_default_are_zero_ $d d>]() {
1798 let z = Matrix::<$d>::zero();
1799 assert_abs_diff_eq!(z.inf_norm().unwrap(), 0.0, epsilon = 0.0);
1800
1801 let d = Matrix::<$d>::default();
1802 assert_abs_diff_eq!(d.inf_norm().unwrap(), 0.0, epsilon = 0.0);
1803 }
1804
1805 #[test]
1806 fn [<matrix_inf_norm_max_row_sum_ $d d>]() {
1807 let mut rows = [[0.0f64; $d]; $d];
1808
1809 // Row 0 has a smaller absolute row sum.
1810 for c in 0..$d {
1811 rows[0][c] = 0.5;
1812 }
1813
1814 // The last row has absolute row sum = D.
1815 for c in 0..$d {
1816 rows[$d - 1][c] = -1.0;
1817 }
1818
1819 let m = Matrix::<$d>::try_from_rows(rows).unwrap();
1820 assert_abs_diff_eq!(m.inf_norm().unwrap(), f64::from($d), epsilon = 0.0);
1821 }
1822
1823 #[test]
1824 fn [<matrix_inf_norm_reports_first_overflowing_column_ $d d>]() {
1825 let mut rows = [[0.0f64; $d]; $d];
1826 rows[$d - 1][0] = f64::MAX;
1827 rows[$d - 1][1] = f64::MAX;
1828
1829 let m = Matrix::<$d>::try_from_rows(rows).unwrap();
1830 assert_eq!(
1831 m.inf_norm(),
1832 Err(LaError::non_finite_computation_matrix(
1833 ArithmeticOperation::MatrixInfinityNorm,
1834 $d - 1,
1835 1,
1836 ))
1837 );
1838 }
1839
1840 #[test]
1841 fn [<matrix_inf_norm_reports_first_overflowing_row_ $d d>]() {
1842 let mut rows = [[0.0f64; $d]; $d];
1843 rows[0][0] = f64::MAX;
1844 rows[0][$d - 1] = f64::MAX;
1845 rows[$d - 1][0] = f64::MAX;
1846 rows[$d - 1][1] = f64::MAX;
1847
1848 let m = Matrix::<$d>::try_from_rows(rows).unwrap();
1849 assert_eq!(
1850 m.inf_norm(),
1851 Err(LaError::non_finite_computation_matrix(
1852 ArithmeticOperation::MatrixInfinityNorm,
1853 0,
1854 $d - 1,
1855 ))
1856 );
1857 }
1858
1859 #[test]
1860 fn [<matrix_identity_lu_det_solve_ $d d>]() {
1861 let m = Matrix::<$d>::identity();
1862
1863 // Identity has ones on diag and zeros off diag.
1864 for r in 0..$d {
1865 for c in 0..$d {
1866 let expected = if r == c { 1.0 } else { 0.0 };
1867 assert_abs_diff_eq!(m.get(r, c).unwrap(), expected, epsilon = 0.0);
1868 }
1869 }
1870
1871 // Determinant is 1.
1872 let det = m.det().unwrap();
1873 assert_abs_diff_eq!(det, 1.0, epsilon = 1e-12);
1874
1875 // LU solve on identity returns the RHS.
1876 let lu = m.lu(DEFAULT_SINGULAR_TOL).unwrap();
1877
1878 let b_arr = {
1879 let mut arr = [0.0f64; $d];
1880 let values = [1.0f64, 2.0, 3.0, 4.0, 5.0];
1881 for (dst, src) in arr.iter_mut().zip(values.iter()) {
1882 *dst = *src;
1883 }
1884 arr
1885 };
1886
1887 let b = Vector::<$d>::new(b_arr);
1888 let x = lu.solve(b).unwrap().into_array();
1889
1890 for (x_i, b_i) in x.iter().zip(b_arr.iter()) {
1891 assert_abs_diff_eq!(*x_i, *b_i, epsilon = 1e-12);
1892 }
1893 }
1894
1895 }
1896 };
1897 }
1898
1899 // Mirror delaunay-style multi-dimension tests.
1900 gen_matrix_tests!(2);
1901 gen_matrix_tests!(3);
1902 gen_matrix_tests!(4);
1903 gen_matrix_tests!(5);
1904
1905 #[test]
1906 fn matrix_inf_norm_preserves_left_to_right_row_sum_order() {
1907 let large = 9_007_199_254_740_992.0;
1908 let matrix =
1909 Matrix::<4>::try_from_rows([[large, 1.0, 1.0, 1.0], [0.0; 4], [0.0; 4], [0.0; 4]])
1910 .unwrap();
1911
1912 assert_eq!(matrix.inf_norm(), Ok(large));
1913 }
1914
1915 // === det_direct tests ===
1916
1917 #[test]
1918 fn det_direct_d0_is_one() {
1919 assert_eq!(Matrix::<0>::zero().det_direct(), Ok(Some(1.0)));
1920 }
1921
1922 #[test]
1923 fn det_direct_d1_returns_element() {
1924 let m = Matrix::<1>::try_from_rows([[42.0]]).unwrap();
1925 assert_eq!(m.det_direct(), Ok(Some(42.0)));
1926 }
1927
1928 #[test]
1929 fn det_direct_d2_known_value() {
1930 // [[1,2],[3,4]] → det = 1*4 - 2*3 = -2
1931 // black_box prevents compile-time constant folding of the const fn.
1932 let m = black_box(Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]]).unwrap());
1933 assert_abs_diff_eq!(m.det_direct().unwrap().unwrap(), -2.0, epsilon = 1e-15);
1934 }
1935
1936 #[test]
1937 fn det_direct_d3_known_value() {
1938 // Classic 3×3: det = 0
1939 let m = black_box(
1940 Matrix::<3>::try_from_rows([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]])
1941 .unwrap(),
1942 );
1943 assert_abs_diff_eq!(m.det_direct().unwrap().unwrap(), 0.0, epsilon = 1e-12);
1944 }
1945
1946 #[test]
1947 fn det_direct_d3_dense_known_value() {
1948 // det = 1*(5*8 - 7*6) - 2*(4*8 - 7*2) + 3*(4*6 - 5*2) = 4
1949 let m = black_box(
1950 Matrix::<3>::try_from_rows([[1.0, 2.0, 3.0], [4.0, 5.0, 7.0], [2.0, 6.0, 8.0]])
1951 .unwrap(),
1952 );
1953 let direct = m.det_direct().unwrap().unwrap();
1954 let paired = m.det_direct_with_errbound().unwrap().unwrap();
1955
1956 assert_abs_diff_eq!(direct, 4.0, epsilon = 1e-12);
1957 assert_eq!(paired.determinant().to_bits(), direct.to_bits());
1958 }
1959
1960 #[test]
1961 fn det_direct_d3_dense_reports_legitimate_overflow() {
1962 // The unscaled matrix has determinant 54, so scaling every entry by
1963 // 1.6e102 gives a determinant of approximately 2.21e308.
1964 let scale = 1.6e102;
1965 let m = black_box(
1966 Matrix::<3>::try_from_rows([
1967 [4.0 * scale, scale, scale],
1968 [scale, 4.0 * scale, scale],
1969 [scale, scale, 4.0 * scale],
1970 ])
1971 .unwrap(),
1972 );
1973 let expected = LaError::non_finite_computation_scalar(ArithmeticOperation::Determinant);
1974
1975 assert_eq!(m.det_direct(), Err(expected));
1976 assert_eq!(m.det(), Err(expected));
1977 }
1978
1979 #[test]
1980 fn det_errbound_d3_dense_reports_legitimate_overflow() {
1981 // The unscaled matrix has determinant 54, so scaling every entry by
1982 // 1.6e102 gives a determinant of approximately 2.21e308.
1983 let scale = 1.6e102;
1984 let m = black_box(
1985 Matrix::<3>::try_from_rows([
1986 [4.0 * scale, scale, scale],
1987 [scale, 4.0 * scale, scale],
1988 [scale, scale, 4.0 * scale],
1989 ])
1990 .unwrap(),
1991 );
1992 let expected =
1993 LaError::non_finite_computation_scalar(ArithmeticOperation::DeterminantErrorBound);
1994
1995 assert_eq!(m.det_errbound(), Err(expected));
1996 assert_eq!(m.det_direct_with_errbound(), Err(expected));
1997 }
1998
1999 #[test]
2000 fn det_direct_d3_nonsingular() {
2001 // [[2,1,0],[0,3,1],[1,0,2]] → det = 2*(6-0) - 1*(0-1) + 0 = 13
2002 let m = black_box(
2003 Matrix::<3>::try_from_rows([[2.0, 1.0, 0.0], [0.0, 3.0, 1.0], [1.0, 0.0, 2.0]])
2004 .unwrap(),
2005 );
2006 assert_abs_diff_eq!(m.det_direct().unwrap().unwrap(), 13.0, epsilon = 1e-12);
2007 }
2008
2009 #[test]
2010 fn det_direct_d3_skips_zero_coefficient_minor_that_would_overflow() {
2011 let m = black_box(
2012 Matrix::<3>::try_from_rows([
2013 [1.0, 0.0, 0.0],
2014 [1.0e300, 1.0, 1.0e300],
2015 [1.0e300, 0.0, 1.0e300],
2016 ])
2017 .unwrap(),
2018 );
2019 assert_eq!(m.det_direct(), Ok(Some(1.0e300)));
2020 }
2021
2022 #[test]
2023 fn det_direct_d4_known_value() {
2024 // Diagonal matrix: det = product of diagonal entries.
2025 let mut rows = [[0.0f64; 4]; 4];
2026 rows[0][0] = 2.0;
2027 rows[1][1] = 3.0;
2028 rows[2][2] = 5.0;
2029 rows[3][3] = 7.0;
2030 let m = black_box(Matrix::<4>::try_from_rows(rows).unwrap());
2031 assert_abs_diff_eq!(m.det_direct().unwrap().unwrap(), 210.0, epsilon = 1e-12);
2032 }
2033
2034 #[test]
2035 fn det_direct_d4_dense_known_value() {
2036 let m = black_box(
2037 Matrix::<4>::try_from_rows([
2038 [4.0, 1.0, 3.0, 2.0],
2039 [1.0, 5.0, 2.0, 1.0],
2040 [7.0, 2.0, 6.0, 3.0],
2041 [1.0, 8.0, 4.0, 9.0],
2042 ])
2043 .unwrap(),
2044 );
2045 let direct = m.det_direct().unwrap().unwrap();
2046 let paired = m.det_direct_with_errbound().unwrap().unwrap();
2047
2048 assert_abs_diff_eq!(direct, 112.0, epsilon = 1e-12);
2049 assert_eq!(paired.determinant().to_bits(), direct.to_bits());
2050 }
2051
2052 #[test]
2053 fn det_direct_d4_dense_reports_legitimate_overflow() {
2054 // The unscaled matrix has determinant 189, so scaling every entry by
2055 // 3.2e76 gives a determinant of approximately 1.98e308.
2056 let scale = 3.2e76;
2057 let m = black_box(
2058 Matrix::<4>::try_from_rows([
2059 [4.0 * scale, scale, scale, scale],
2060 [scale, 4.0 * scale, scale, scale],
2061 [scale, scale, 4.0 * scale, scale],
2062 [scale, scale, scale, 4.0 * scale],
2063 ])
2064 .unwrap(),
2065 );
2066 let expected = LaError::non_finite_computation_scalar(ArithmeticOperation::Determinant);
2067
2068 assert_eq!(m.det_direct(), Err(expected));
2069 assert_eq!(m.det(), Err(expected));
2070 }
2071
2072 #[test]
2073 fn det_errbound_d4_dense_reports_legitimate_overflow() {
2074 // The unscaled matrix has determinant 189, so scaling every entry by
2075 // 3.2e76 gives a determinant of approximately 1.98e308.
2076 let scale = 3.2e76;
2077 let m = black_box(
2078 Matrix::<4>::try_from_rows([
2079 [4.0 * scale, scale, scale, scale],
2080 [scale, 4.0 * scale, scale, scale],
2081 [scale, scale, 4.0 * scale, scale],
2082 [scale, scale, scale, 4.0 * scale],
2083 ])
2084 .unwrap(),
2085 );
2086 let expected =
2087 LaError::non_finite_computation_scalar(ArithmeticOperation::DeterminantErrorBound);
2088
2089 assert_eq!(m.det_errbound(), Err(expected));
2090 assert_eq!(m.det_direct_with_errbound(), Err(expected));
2091 }
2092
2093 #[test]
2094 fn det_direct_d4_skips_zero_coefficient_cofactors_that_would_overflow() {
2095 let m = black_box(
2096 Matrix::<4>::try_from_rows([
2097 [0.0, 0.0, 0.0, 0.0],
2098 [0.0, 0.0, 0.0, 0.0],
2099 [1.0e300, 0.0, 1.0e300, 1.0e300],
2100 [1.0e300, 0.0, 1.0e300, -1.0e300],
2101 ])
2102 .unwrap(),
2103 );
2104 assert_eq!(m.det_direct(), Ok(Some(0.0)));
2105 }
2106
2107 #[test]
2108 fn det_direct_d4_sparse_second_row_skips_inactive_overflowing_minors() {
2109 let m = black_box(
2110 Matrix::<4>::try_from_rows([
2111 [1.0e-300, 1.0, 1.0, 1.0],
2112 [0.0, 1.0, 0.0, 0.0],
2113 [0.0, 1.0e300, 1.0, 1.0e300],
2114 [0.0, 1.0e300, 0.0, 1.0e300],
2115 ])
2116 .unwrap(),
2117 );
2118
2119 assert_eq!(m.det_direct(), Ok(Some(1.0)));
2120 assert_eq!(m.det(), Ok(1.0));
2121 }
2122
2123 #[test]
2124 fn det_direct_d5_returns_none() {
2125 assert_eq!(Matrix::<5>::identity().det_direct(), Ok(None));
2126 }
2127
2128 #[test]
2129 fn det_direct_d8_returns_none() {
2130 assert_eq!(Matrix::<8>::zero().det_direct(), Ok(None));
2131 }
2132
2133 #[test]
2134 fn det_direct_rejects_computed_overflow() {
2135 let m = Matrix::<2>::try_from_rows([[1e300, 0.0], [0.0, 1e300]]).unwrap();
2136 assert_eq!(
2137 m.det_direct(),
2138 Err(LaError::non_finite_computation_scalar(
2139 ArithmeticOperation::Determinant
2140 ))
2141 );
2142 }
2143
2144 #[test]
2145 fn det_d5_rejects_lu_product_overflow() {
2146 let m = Matrix::<5>::try_from_rows([
2147 [1.0e100, 0.0, 0.0, 0.0, 0.0],
2148 [0.0, 1.0e100, 0.0, 0.0, 0.0],
2149 [0.0, 0.0, 1.0e100, 0.0, 0.0],
2150 [0.0, 0.0, 0.0, 1.0e100, 0.0],
2151 [0.0, 0.0, 0.0, 0.0, 1.0e100],
2152 ])
2153 .unwrap();
2154 assert_eq!(
2155 m.det(),
2156 Err(LaError::non_finite_computation_step(
2157 ArithmeticOperation::Determinant,
2158 4
2159 ))
2160 );
2161 }
2162
2163 #[test]
2164 fn det_d5_rejects_lu_trailing_update_overflow() {
2165 let m = Matrix::<5>::try_from_rows([
2166 [1.0, f64::MAX, 0.0, 0.0, 0.0],
2167 [-1.0, f64::MAX, 0.0, 0.0, 0.0],
2168 [0.0, 0.0, 1.0, 0.0, 0.0],
2169 [0.0, 0.0, 0.0, 1.0, 0.0],
2170 [0.0, 0.0, 0.0, 0.0, 1.0],
2171 ])
2172 .unwrap();
2173
2174 assert_eq!(
2175 m.det(),
2176 Err(LaError::non_finite_computation_matrix(
2177 ArithmeticOperation::LuFactorization,
2178 1,
2179 1
2180 ))
2181 );
2182 }
2183
2184 macro_rules! gen_det_direct_agrees_with_lu {
2185 ($d:literal) => {
2186 paste! {
2187 #[test]
2188 #[expect(
2189 clippy::cast_precision_loss,
2190 reason = "r, c, and D are tiny test integers exactly representable as f64"
2191 )]
2192 fn [<det_direct_agrees_with_lu_ $d d>]() {
2193 // Well-conditioned matrix: diagonally dominant.
2194 let mut rows = [[0.0f64; $d]; $d];
2195 for r in 0..$d {
2196 for c in 0..$d {
2197 rows[r][c] = if r == c {
2198 (r as f64) + f64::from($d) + 1.0
2199 } else {
2200 0.1 / ((r + c + 1) as f64)
2201 };
2202 }
2203 }
2204 let m = Matrix::<$d>::try_from_rows(rows).unwrap();
2205 let direct = m.det_direct().unwrap().unwrap();
2206 let lu_det = m.lu(DEFAULT_SINGULAR_TOL).unwrap().det().unwrap();
2207 let eps = lu_det.abs().mul_add(1e-12, 1e-12);
2208 assert_abs_diff_eq!(direct, lu_det, epsilon = eps);
2209 }
2210 }
2211 };
2212 }
2213
2214 gen_det_direct_agrees_with_lu!(1);
2215 gen_det_direct_agrees_with_lu!(2);
2216 gen_det_direct_agrees_with_lu!(3);
2217 gen_det_direct_agrees_with_lu!(4);
2218
2219 #[test]
2220 fn det_direct_identity_all_dims() {
2221 assert_abs_diff_eq!(
2222 Matrix::<1>::identity().det_direct().unwrap().unwrap(),
2223 1.0,
2224 epsilon = 0.0
2225 );
2226 assert_abs_diff_eq!(
2227 Matrix::<2>::identity().det_direct().unwrap().unwrap(),
2228 1.0,
2229 epsilon = 0.0
2230 );
2231 assert_abs_diff_eq!(
2232 Matrix::<3>::identity().det_direct().unwrap().unwrap(),
2233 1.0,
2234 epsilon = 0.0
2235 );
2236 assert_abs_diff_eq!(
2237 Matrix::<4>::identity().det_direct().unwrap().unwrap(),
2238 1.0,
2239 epsilon = 0.0
2240 );
2241 }
2242
2243 #[test]
2244 fn det_direct_zero_matrix() {
2245 assert_abs_diff_eq!(
2246 Matrix::<2>::zero().det_direct().unwrap().unwrap(),
2247 0.0,
2248 epsilon = 0.0
2249 );
2250 assert_abs_diff_eq!(
2251 Matrix::<3>::zero().det_direct().unwrap().unwrap(),
2252 0.0,
2253 epsilon = 0.0
2254 );
2255 assert_abs_diff_eq!(
2256 Matrix::<4>::zero().det_direct().unwrap().unwrap(),
2257 0.0,
2258 epsilon = 0.0
2259 );
2260 }
2261
2262 macro_rules! gen_det_singular_zero_matrix_tests {
2263 ($d:literal) => {
2264 paste! {
2265 #[test]
2266 fn [<det_singular_zero_matrix_returns_zero_ $d d>]() {
2267 assert_abs_diff_eq!(
2268 Matrix::<$d>::zero().det().unwrap(),
2269 0.0,
2270 epsilon = 0.0
2271 );
2272 }
2273 }
2274 };
2275 }
2276
2277 gen_det_singular_zero_matrix_tests!(2);
2278 gen_det_singular_zero_matrix_tests!(3);
2279 gen_det_singular_zero_matrix_tests!(4);
2280
2281 #[test]
2282 fn det_singular_zero_matrix_d5_preserves_lu_error() {
2283 assert_eq!(
2284 Matrix::<5>::zero().det(),
2285 Err(LaError::singular_numerical(
2286 0,
2287 FactorizationKind::Lu,
2288 0.0,
2289 0.0
2290 ))
2291 );
2292 }
2293
2294 #[test]
2295 fn det_d5_does_not_turn_elimination_underflow_into_exact_zero() {
2296 let min_subnormal = f64::from_bits(1);
2297 let two_pow_800 = f64::from_bits(1823_u64 << 52);
2298 let m = Matrix::<5>::try_from_rows([
2299 [2.0, min_subnormal, 0.0, 0.0, 0.0],
2300 [1.0, 0.0, 0.0, 0.0, 0.0],
2301 [0.0, 0.0, two_pow_800, 0.0, 0.0],
2302 [0.0, 0.0, 0.0, 1.0, 0.0],
2303 [0.0, 0.0, 0.0, 0.0, 1.0],
2304 ])
2305 .unwrap();
2306
2307 assert_eq!(
2308 m.det(),
2309 Err(LaError::singular_numerical(
2310 1,
2311 FactorizationKind::Lu,
2312 0.0,
2313 0.0
2314 ))
2315 );
2316 }
2317
2318 #[test]
2319 fn det_d5_ignores_pivot_tolerance_for_tiny_nonsingular_matrix() {
2320 // A small nonzero determinant is still a determinant. `det` must not
2321 // flatten the value to zero merely because the default LU tolerance
2322 // would reject a pivot this small.
2323 let m = Matrix::<5>::try_from_rows([
2324 [1e-13, 0.0, 0.0, 0.0, 0.0],
2325 [0.0, 1.0, 0.0, 0.0, 0.0],
2326 [0.0, 0.0, 1.0, 0.0, 0.0],
2327 [0.0, 0.0, 0.0, 1.0, 0.0],
2328 [0.0, 0.0, 0.0, 0.0, 1.0],
2329 ])
2330 .unwrap();
2331
2332 assert_abs_diff_eq!(m.det().unwrap(), 1e-13, epsilon = 0.0);
2333 assert_eq!(
2334 m.lu(DEFAULT_SINGULAR_TOL),
2335 Err(LaError::singular_numerical(
2336 0,
2337 FactorizationKind::Lu,
2338 1e-13,
2339 DEFAULT_SINGULAR_TOL.get()
2340 ))
2341 );
2342 }
2343
2344 #[test]
2345 fn det_returns_non_finite_error_for_overflow_with_finite_entries() {
2346 // det_direct produces an overflowing f64 (1e300 * 1e300 = ∞) even
2347 // though every matrix entry is finite. The entry scan in `det`
2348 // falls through and reports a computed determinant overflow rather
2349 // than a NaN/∞ input.
2350 let m = Matrix::<2>::try_from_rows([[1e300, 0.0], [0.0, 1e300]]).unwrap();
2351 assert_eq!(
2352 m.det(),
2353 Err(LaError::non_finite_computation_scalar(
2354 ArithmeticOperation::Determinant
2355 ))
2356 );
2357 }
2358
2359 // === det_direct const-evaluability tests (D = 2..=5) ===
2360 //
2361 // Every dimension hits a distinct arm of the `match D { … }` body inside
2362 // `det_direct`, so exercising each at compile time is the tightest
2363 // const-fn proof available.
2364
2365 macro_rules! gen_det_direct_const_eval_tests {
2366 ($d:literal) => {
2367 paste! {
2368 /// `Matrix::<D>::det_direct()` on the identity must const-evaluate
2369 /// to `Ok(Some(1.0))` for every closed-form dimension `D ∈ {1, 2, 3, 4}`.
2370 #[test]
2371 fn [<det_direct_const_eval_ $d d>]() {
2372 const DET: Result<Option<f64>, LaError> = Matrix::<$d>::identity().det_direct();
2373 assert_eq!(DET, Ok(Some(1.0)));
2374 }
2375 }
2376 };
2377 }
2378
2379 gen_det_direct_const_eval_tests!(2);
2380 gen_det_direct_const_eval_tests!(3);
2381 gen_det_direct_const_eval_tests!(4);
2382
2383 #[test]
2384 fn det_direct_const_eval_d5_is_none() {
2385 // D ≥ 5 has no closed-form arm; `det_direct` returns `Ok(None)`. Verify
2386 // that the wildcard arm is reachable in a `const { … }` context.
2387 const DET: Result<Option<f64>, LaError> = Matrix::<5>::identity().det_direct();
2388 assert_eq!(DET, Ok(None));
2389 }
2390
2391 // === det_errbound tests (no `exact` feature required) ===
2392
2393 #[test]
2394 fn det_errbound_matches_documented_coefficient_scale() {
2395 let m2 = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]]).unwrap();
2396 let expected_2 = ERR_COEFF_2 * ((1.0_f64 * 4.0).abs() + (2.0_f64 * 3.0).abs());
2397 assert_abs_diff_eq!(
2398 m2.det_errbound().unwrap().unwrap(),
2399 expected_2,
2400 epsilon = 0.0
2401 );
2402
2403 assert_abs_diff_eq!(
2404 Matrix::<3>::identity().det_errbound().unwrap().unwrap(),
2405 ERR_COEFF_3,
2406 epsilon = 0.0
2407 );
2408 assert_abs_diff_eq!(
2409 Matrix::<4>::identity().det_errbound().unwrap().unwrap(),
2410 ERR_COEFF_4,
2411 epsilon = 0.0
2412 );
2413 }
2414
2415 #[test]
2416 fn det_errbound_d3_skips_zero_coefficient_minor_that_would_overflow() {
2417 let m = Matrix::<3>::try_from_rows([
2418 [1.0, 0.0, 0.0],
2419 [1.0e300, 1.0, 1.0e300],
2420 [1.0e300, 0.0, 1.0e300],
2421 ])
2422 .unwrap();
2423
2424 assert_eq!(m.det_errbound(), Ok(Some(ERR_COEFF_3 * 1.0e300)));
2425 }
2426
2427 #[test]
2428 fn det_errbound_d4_skips_zero_coefficient_cofactors_that_would_overflow() {
2429 let m = Matrix::<4>::try_from_rows([
2430 [0.0, 0.0, 0.0, 0.0],
2431 [0.0, 0.0, 0.0, 0.0],
2432 [1.0e300, 0.0, 1.0e300, 1.0e300],
2433 [1.0e300, 0.0, 1.0e300, -1.0e300],
2434 ])
2435 .unwrap();
2436
2437 assert_eq!(m.det_errbound(), Ok(Some(0.0)));
2438 }
2439
2440 #[test]
2441 fn det_errbound_d5_returns_none() {
2442 // D=5 has no fast filter
2443 assert_eq!(Matrix::<5>::identity().det_errbound(), Ok(None));
2444 }
2445
2446 #[test]
2447 fn combined_det_bound_wide_exponent_fast_path_matches_tracked_arithmetic() {
2448 let threshold = f64::from_bits(1007_u64 << 52); // 2^-16
2449 let at_threshold = Matrix::<2>::try_from_rows([[threshold, 0.0], [0.0, 2.0]]).unwrap();
2450 assert!(at_threshold.det_bound_inputs_have_wide_exponent_margin());
2451
2452 let tracked = at_threshold
2453 .det_direct_with_errbound_from_arithmetic(
2454 at_threshold
2455 .det_direct_arithmetic::<true>()
2456 .expect("D=2 has direct arithmetic"),
2457 )
2458 .unwrap();
2459 assert_eq!(at_threshold.det_direct_with_errbound().unwrap(), tracked);
2460
2461 let just_below = f64::from_bits(threshold.to_bits() - 1);
2462 let below_threshold = Matrix::<2>::try_from_rows([[just_below, 0.0], [0.0, 2.0]]).unwrap();
2463 assert!(!below_threshold.det_bound_inputs_have_wide_exponent_margin());
2464 assert!(!Matrix::<5>::identity().det_bound_inputs_have_wide_exponent_margin());
2465 }
2466
2467 #[test]
2468 fn det_direct_with_errbound_covers_zero_and_one_dimensions() {
2469 let empty = Matrix::<0>::zero()
2470 .det_direct_with_errbound()
2471 .unwrap()
2472 .unwrap();
2473 assert_abs_diff_eq!(empty.determinant(), 1.0, epsilon = 0.0);
2474 assert_abs_diff_eq!(empty.absolute_error_bound(), 0.0, epsilon = 0.0);
2475
2476 let scalar = Matrix::<1>::try_from_rows([[-7.0]])
2477 .unwrap()
2478 .det_direct_with_errbound()
2479 .unwrap()
2480 .unwrap();
2481 assert_abs_diff_eq!(scalar.determinant(), -7.0, epsilon = 0.0);
2482 assert_abs_diff_eq!(scalar.absolute_error_bound(), 0.0, epsilon = 0.0);
2483 }
2484
2485 #[test]
2486 fn det_direct_with_errbound_pairs_the_closed_form_values() {
2487 let matrix = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]]).unwrap();
2488 let estimate = matrix.det_direct_with_errbound().unwrap().unwrap();
2489
2490 assert_abs_diff_eq!(
2491 estimate.determinant(),
2492 matrix.det_direct().unwrap().unwrap(),
2493 epsilon = 0.0
2494 );
2495 assert_abs_diff_eq!(
2496 estimate.absolute_error_bound(),
2497 ERR_COEFF_2 * (4.0_f64 + 6.0_f64),
2498 epsilon = 0.0
2499 );
2500 }
2501
2502 #[test]
2503 fn det_direct_with_errbound_d5_returns_none() {
2504 assert_eq!(Matrix::<5>::identity().det_direct_with_errbound(), Ok(None));
2505 }
2506
2507 #[test]
2508 fn det_errbound_rejects_computed_overflow() {
2509 let m = Matrix::<2>::try_from_rows([[1e300, 0.0], [0.0, 1e300]]).unwrap();
2510 assert_eq!(
2511 m.det_errbound(),
2512 Err(LaError::non_finite_computation_scalar(
2513 ArithmeticOperation::DeterminantErrorBound
2514 ))
2515 );
2516 }
2517
2518 // === det_errbound const-evaluability tests (D = 2..=5) ===
2519
2520 macro_rules! gen_det_errbound_const_eval_tests {
2521 ($d:literal) => {
2522 paste! {
2523 /// `Matrix::<D>::det_errbound()` on the identity must const-evaluate
2524 /// to `Ok(Some(bound))` with `bound > 0` for every closed-form dimension
2525 /// `D ∈ {2, 3, 4}`. Each dimension hits a distinct arm of
2526 /// `det_errbound` with a dimension-specific permanent computation.
2527 #[test]
2528 fn [<det_errbound_const_eval_ $d d>]() {
2529 const BOUND: Result<Option<f64>, LaError> = Matrix::<$d>::identity().det_errbound();
2530 assert!(BOUND.unwrap().unwrap() > 0.0);
2531 }
2532 }
2533 };
2534 }
2535
2536 gen_det_errbound_const_eval_tests!(2);
2537 gen_det_errbound_const_eval_tests!(3);
2538 gen_det_errbound_const_eval_tests!(4);
2539
2540 #[test]
2541 fn det_errbound_const_eval_d5_is_none() {
2542 // D ≥ 5 has no fast-filter bound; `det_errbound` returns `Ok(None)`.
2543 const BOUND: Result<Option<f64>, LaError> = Matrix::<5>::identity().det_errbound();
2544 assert_eq!(BOUND, Ok(None));
2545 }
2546
2547 // === inf_norm const-evaluability tests (D = 2..=5) ===
2548
2549 macro_rules! gen_inf_norm_const_eval_tests {
2550 ($d:literal) => {
2551 paste! {
2552 /// `Matrix::<D>::inf_norm()` on the identity must const-evaluate
2553 /// to `1.0` for every `D ≥ 1` — each row has a single `1.0`
2554 /// entry, so the max absolute row sum is exactly `1.0`.
2555 #[test]
2556 fn [<inf_norm_const_eval_ $d d>]() {
2557 const NORM: Result<f64, LaError> = Matrix::<$d>::identity().inf_norm();
2558 assert!((NORM.unwrap() - 1.0).abs() <= 1e-12);
2559 }
2560 }
2561 };
2562 }
2563
2564 gen_inf_norm_const_eval_tests!(2);
2565 gen_inf_norm_const_eval_tests!(3);
2566 gen_inf_norm_const_eval_tests!(4);
2567 gen_inf_norm_const_eval_tests!(5);
2568
2569 // === is_symmetric / first_asymmetry (public LDLT preconditions helpers) ===
2570
2571 macro_rules! gen_is_symmetric_tests {
2572 ($d:literal) => {
2573 paste! {
2574 #[test]
2575 fn [<is_symmetric_true_for_identity_ $d d>]() {
2576 let m = Matrix::<$d>::identity();
2577 assert!(m.is_symmetric(Tolerance::try_new(1e-12).unwrap()).unwrap());
2578 assert_eq!(m.first_asymmetry(Tolerance::try_new(1e-12).unwrap()).unwrap(), None);
2579 }
2580
2581 #[test]
2582 fn [<is_symmetric_true_for_zero_ $d d>]() {
2583 let m = Matrix::<$d>::zero();
2584 assert!(m.is_symmetric(Tolerance::try_new(1e-12).unwrap()).unwrap());
2585 assert_eq!(m.first_asymmetry(Tolerance::try_new(1e-12).unwrap()).unwrap(), None);
2586 }
2587
2588 #[test]
2589 fn [<is_symmetric_true_for_constructed_symmetric_ $d d>]() {
2590 // Construct A = M + Mᵀ so A is provably symmetric.
2591 let mut m = [[0.0f64; $d]; $d];
2592 for r in 0..$d {
2593 for c in 0..$d {
2594 #[expect(
2595 clippy::cast_precision_loss,
2596 reason = "matrix test indices are at most five and exactly representable as f64"
2597 )]
2598 {
2599 m[r][c] = (r * $d + c) as f64;
2600 }
2601 }
2602 }
2603 let mut sym = [[0.0f64; $d]; $d];
2604 for r in 0..$d {
2605 for c in 0..$d {
2606 sym[r][c] = m[r][c] + m[c][r];
2607 }
2608 }
2609 let a = Matrix::<$d>::try_from_rows(sym).unwrap();
2610 assert!(a.is_symmetric(Tolerance::try_new(1e-12).unwrap()).unwrap());
2611 assert_eq!(a.first_asymmetry(Tolerance::try_new(1e-12).unwrap()).unwrap(), None);
2612 }
2613
2614 #[test]
2615 fn [<is_symmetric_false_for_asymmetric_offdiagonal_ $d d>]() {
2616 // Perturb a single off-diagonal entry so symmetry fails.
2617 let mut rows = [[0.0f64; $d]; $d];
2618 for i in 0..$d {
2619 rows[i][i] = 1.0;
2620 }
2621 rows[0][$d - 1] = 1.0;
2622 rows[$d - 1][0] = -1.0; // breaks symmetry
2623 let a = Matrix::<$d>::try_from_rows(rows).unwrap();
2624 assert!(!a.is_symmetric(Tolerance::try_new(1e-12).unwrap()).unwrap());
2625 assert_eq!(
2626 a.first_asymmetry(Tolerance::try_new(1e-12).unwrap()).unwrap(),
2627 Some((0, $d - 1))
2628 );
2629 }
2630
2631 }
2632 };
2633 }
2634
2635 gen_is_symmetric_tests!(2);
2636 gen_is_symmetric_tests!(3);
2637 gen_is_symmetric_tests!(4);
2638 gen_is_symmetric_tests!(5);
2639
2640 macro_rules! gen_ldlt_symmetry_proof_tests {
2641 ($d:literal) => {
2642 paste! {
2643 #[test]
2644 fn [<matrix_ldlt_accepts_exact_symmetric_spd_ $d d>]() {
2645 // This exactly mirrored, strictly diagonally dominant
2646 // tridiagonal matrix is positive definite.
2647 let mut rows = [[0.0_f64; $d]; $d];
2648 for (index, row) in rows.iter_mut().enumerate() {
2649 row[index] = 2.0;
2650 }
2651 for index in 1..$d {
2652 rows[index - 1][index] = 0.5;
2653 rows[index][index - 1] = 0.5;
2654 }
2655
2656 let matrix = Matrix::<$d>::try_from_rows(rows).unwrap();
2657 let symmetric = SymmetricMatrix::try_new(matrix).unwrap();
2658 assert_eq!(symmetric.into_matrix(), matrix);
2659
2660 let ldlt = matrix.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
2661 assert!(ldlt.det().unwrap() > 0.0);
2662 }
2663
2664 #[test]
2665 fn [<symmetric_matrix_try_new_rejects_finite_asymmetric_ $d d>]() {
2666 let mut rows = [[0.0f64; $d]; $d];
2667 for (i, row) in rows.iter_mut().enumerate() {
2668 row[i] = 1.0;
2669 }
2670 rows[0][$d - 1] = 1.0;
2671 rows[$d - 1][0] = -1.0;
2672
2673 assert_eq!(
2674 Matrix::<$d>::try_from_rows(rows).and_then(SymmetricMatrix::try_new),
2675 Err(LaError::asymmetric(0, $d - 1, $d, 1.0, -1.0, 0.0))
2676 );
2677 }
2678 }
2679 };
2680 }
2681
2682 gen_ldlt_symmetry_proof_tests!(2);
2683 gen_ldlt_symmetry_proof_tests!(3);
2684 gen_ldlt_symmetry_proof_tests!(4);
2685 gen_ldlt_symmetry_proof_tests!(5);
2686
2687 #[test]
2688 fn symmetric_matrix_into_matrix_roundtrips_storage_internally() {
2689 let a = Matrix::<2>::try_from_rows([[2.0, 1.0], [1.0, 3.0]]).unwrap();
2690 let symmetric = SymmetricMatrix::try_new(a).unwrap();
2691
2692 assert_eq!(symmetric.into_matrix(), a);
2693 }
2694
2695 #[test]
2696 fn matrix_ldlt_accepts_opposite_signed_zero_mirrors() {
2697 let matrix = Matrix::<2>::try_from_rows([[2.0, 0.0], [-0.0, 2.0]]).unwrap();
2698 let ldlt = matrix.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
2699
2700 assert_eq!(ldlt.det(), Ok(4.0));
2701 }
2702
2703 #[test]
2704 fn is_symmetric_tolerance_scales_with_inf_norm() {
2705 // Off-diagonal entries differ by 1e-6. With inf_norm ≈ 2e6, the
2706 // relative tolerance 1e-12 yields eps ≈ 2e-6, which accepts the gap;
2707 // a stricter tol of 1e-15 rejects it.
2708 let a = Matrix::<2>::try_from_rows([[1.0e6, 1.0e6 + 1.0e-6], [1.0e6, 1.0e6]]).unwrap();
2709 assert!(a.is_symmetric(Tolerance::try_new(1e-12).unwrap()).unwrap());
2710 assert!(!a.is_symmetric(Tolerance::try_new(1e-15).unwrap()).unwrap());
2711 }
2712
2713 #[test]
2714 fn symmetry_epsilon_multiplies_after_row_sum_near_subnormal_boundary() {
2715 let min_subnormal = f64::from_bits(1);
2716 let mut rows = [[0.0; 5]; 5];
2717 let mut col = 0;
2718 while col < 4 {
2719 rows[0][col] = 0.4;
2720 rows[col][0] = 0.4;
2721 col += 1;
2722 }
2723 rows[0][4] = 2.0 * min_subnormal;
2724 rows[4][0] = 0.0;
2725
2726 let matrix = Matrix::<5>::try_from_rows(rows).unwrap();
2727 let tolerance = Tolerance::try_new(min_subnormal).unwrap();
2728 let expected_epsilon = tolerance.get() * matrix.inf_norm().unwrap().max(1.0);
2729
2730 assert_eq!(expected_epsilon.to_bits(), 2);
2731 assert_eq!(matrix.first_asymmetry(tolerance), Ok(None));
2732 assert_eq!(matrix.is_symmetric(tolerance), Ok(true));
2733 }
2734
2735 #[test]
2736 fn symmetry_epsilon_scales_terms_when_row_sum_overflows() {
2737 let matrix =
2738 Matrix::<2>::try_from_rows([[f64::MAX, f64::MAX], [f64::MAX / 2.0, f64::MAX]]).unwrap();
2739
2740 assert_eq!(
2741 matrix.inf_norm(),
2742 Err(LaError::non_finite_computation_matrix(
2743 ArithmeticOperation::MatrixInfinityNorm,
2744 0,
2745 1
2746 ))
2747 );
2748 assert_eq!(
2749 matrix.first_asymmetry(Tolerance::try_new(0.25).unwrap()),
2750 Ok(None)
2751 );
2752 assert_eq!(
2753 matrix.first_asymmetry(Tolerance::try_new(0.125).unwrap()),
2754 Ok(Some((0, 1)))
2755 );
2756 }
2757
2758 #[test]
2759 fn first_asymmetry_returns_lexicographically_first_pair() {
2760 // Two asymmetric pairs: (0, 2) and (1, 2). We must get (0, 2) first.
2761 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]])
2762 .unwrap();
2763 assert_eq!(
2764 a.first_asymmetry(Tolerance::try_new(1e-12).unwrap())
2765 .unwrap(),
2766 Some((0, 2))
2767 );
2768 }
2769
2770 #[test]
2771 fn first_asymmetry_strict_tol_survives_row_sum_overflow() {
2772 let a = Matrix::<3>::try_from_rows([
2773 [1.0, 1.0, 0.0],
2774 [2.0, f64::MAX, f64::MAX],
2775 [0.0, 0.0, 1.0],
2776 ])
2777 .unwrap();
2778
2779 assert_eq!(
2780 a.inf_norm(),
2781 Err(LaError::non_finite_computation_matrix(
2782 ArithmeticOperation::MatrixInfinityNorm,
2783 1,
2784 2
2785 ))
2786 );
2787 assert_eq!(
2788 a.first_asymmetry(Tolerance::try_new(0.0).unwrap()).unwrap(),
2789 Some((0, 1))
2790 );
2791 assert!(!a.is_symmetric(Tolerance::try_new(0.0).unwrap()).unwrap());
2792 }
2793
2794 #[test]
2795 fn first_asymmetry_rejects_scaled_epsilon_overflow() {
2796 let a = Matrix::<2>::try_from_rows([[0.0, 0.0], [2.0, 1.0]]).unwrap();
2797 let tol = Tolerance::try_new(f64::MAX).unwrap();
2798
2799 assert_eq!(
2800 a.first_asymmetry(tol),
2801 Err(LaError::non_finite_computation_matrix(
2802 ArithmeticOperation::SymmetryCheck,
2803 1,
2804 0
2805 ))
2806 );
2807 assert_eq!(
2808 a.is_symmetric(tol),
2809 Err(LaError::non_finite_computation_matrix(
2810 ArithmeticOperation::SymmetryCheck,
2811 1,
2812 0
2813 ))
2814 );
2815 }
2816
2817 #[test]
2818 fn first_asymmetry_flags_overflowed_finite_difference() {
2819 let a = Matrix::<2>::try_from_rows([[1.0, f64::MAX], [-f64::MAX, 1.0]]).unwrap();
2820 assert_eq!(
2821 a.first_asymmetry(Tolerance::try_new(1e-12).unwrap())
2822 .unwrap(),
2823 Some((0, 1))
2824 );
2825 assert!(!a.is_symmetric(Tolerance::try_new(1e-12).unwrap()).unwrap());
2826 }
2827}