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