la_stack/error.rs
1#![forbid(unsafe_code)]
2
3//! Error types and helpers for linear algebra operations.
4
5use core::fmt;
6
7use crate::Tolerance;
8
9/// Reason an exact result cannot satisfy an exact-to-`f64` conversion contract.
10///
11/// `RequiresRounding` is recoverable when the caller is willing to opt into a
12/// rounded exact-to-`f64` API. `NotFinite` means even the rounded result would
13/// not be a finite `f64`.
14///
15/// # Examples
16/// ```
17/// use la_stack::prelude::*;
18///
19/// let err = LaError::unrepresentable(None, UnrepresentableReason::RequiresRounding);
20/// assert!(err.requires_rounding());
21///
22/// let err = LaError::unrepresentable(None, UnrepresentableReason::NotFinite);
23/// assert_eq!(
24/// err.unrepresentable_reason(),
25/// Some(UnrepresentableReason::NotFinite)
26/// );
27/// ```
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum UnrepresentableReason {
31 /// A finite `f64` exists only after rounding, but the requested conversion
32 /// requires an exact binary64 representation.
33 RequiresRounding,
34 /// The exact value would convert to NaN or infinity rather than a finite
35 /// `f64`.
36 NotFinite,
37}
38
39/// Linear algebra errors.
40///
41/// This enum is `#[non_exhaustive]` — downstream `match` arms must include a
42/// wildcard (`_`) pattern to compile, allowing new variants to be added in
43/// future minor releases without breaking existing code.
44///
45/// # Examples
46/// ```
47/// use la_stack::prelude::*;
48///
49/// let err = LaError::unrepresentable(None, UnrepresentableReason::RequiresRounding);
50/// assert!(err.requires_rounding());
51///
52/// match LaError::unsupported_dimension(8, MAX_STACK_MATRIX_DISPATCH_DIM) {
53/// LaError::UnsupportedDimension { requested, max } => {
54/// assert_eq!((requested, max), (8, MAX_STACK_MATRIX_DISPATCH_DIM));
55/// }
56/// _ => unreachable!("constructor returns the requested variant"),
57/// }
58/// ```
59#[derive(Clone, Copy, Debug, PartialEq)]
60#[non_exhaustive]
61pub enum LaError {
62 /// The matrix is (numerically) singular.
63 Singular {
64 /// The factorization column/step where a suitable pivot/diagonal could not be found.
65 pivot_col: usize,
66 },
67 /// A non-finite value (NaN/∞) was encountered.
68 ///
69 /// The `(row, col)` coordinate follows a consistent convention across the crate:
70 ///
71 /// - `row: Some(r), col: c` — the non-finite value is tied to a matrix/factor
72 /// cell at `(r, c)`, either because a stored input/factor cell is already
73 /// non-finite or because factorization computed a non-finite value for
74 /// that cell before storing it.
75 /// - `row: None, col: c` — the non-finite value is tied to a vector entry,
76 /// determinant product, solve accumulator, or other scalar/intermediate
77 /// that has no matrix row coordinate.
78 NonFinite {
79 /// Row of the non-finite entry for a stored matrix cell, or `None` for
80 /// a vector-input entry or a computed intermediate. See the variant
81 /// docs for the full convention.
82 row: Option<usize>,
83 /// Column index (stored cell), vector index, or factorization/solve
84 /// step where the non-finite value was detected.
85 col: usize,
86 },
87 /// An exact result cannot satisfy the requested finite `f64` conversion.
88 ///
89 /// Returned by [`Matrix::det_exact_f64`](crate::Matrix::det_exact_f64) and
90 /// [`Matrix::solve_exact_f64`](crate::Matrix::solve_exact_f64) (requires the
91 /// `exact` feature) when the exact rational value is too large, too small,
92 /// or would require rounding in binary64. Also returned by the rounded
93 /// exact-to-`f64` APIs when the rounded result would be NaN or infinite.
94 Unrepresentable {
95 /// For vector results (e.g. `solve_exact_f64`), the index of the
96 /// component that failed conversion. `None` for scalar results.
97 index: Option<usize>,
98 /// Why the requested conversion cannot return a finite `f64`.
99 reason: UnrepresentableReason,
100 },
101 /// Exact determinant scaling overflowed the internal exponent representation.
102 DeterminantScaleOverflow {
103 /// Matrix dimension `D`.
104 dim: usize,
105 /// Minimum decomposed f64 exponent among non-zero matrix entries.
106 min_exponent: i32,
107 },
108 /// A requested runtime matrix dimension has no stack-dispatch arm.
109 UnsupportedDimension {
110 /// Runtime dimension requested by the caller.
111 requested: usize,
112 /// Largest runtime dimension supported by the dispatch helper.
113 max: usize,
114 },
115 /// A matrix index is outside the `D×D` storage domain.
116 IndexOutOfBounds {
117 /// Requested row index.
118 row: usize,
119 /// Requested column index.
120 col: usize,
121 /// Matrix dimension `D`; valid row and column indices are `< dim`.
122 dim: usize,
123 },
124 /// A tolerance value is not finite and non-negative.
125 InvalidTolerance {
126 /// Raw tolerance supplied by the caller.
127 value: f64,
128 },
129 /// A matrix required to be symmetric has an asymmetric off-diagonal pair.
130 Asymmetric {
131 /// Row index of the first asymmetric pair.
132 row: usize,
133 /// Column index of the first asymmetric pair.
134 col: usize,
135 /// Matrix dimension `D`.
136 dim: usize,
137 },
138 /// A symmetric matrix failed the positive-semidefinite LDLT domain check.
139 NotPositiveSemidefinite {
140 /// Factorization column/step where a negative LDLT diagonal was found.
141 pivot_col: usize,
142 /// Negative diagonal value observed at that step.
143 value: f64,
144 },
145}
146
147impl LaError {
148 /// Construct a [`LaError::NonFinite`] pinpointing a stored matrix cell at `(row, col)`.
149 ///
150 /// Use this for non-finite values read from a stored [`Matrix`](crate::Matrix)
151 /// entry or factorization cell, and for non-finite factorization updates
152 /// that would be stored at `(row, col)` if accepted. The resulting error has
153 /// `row: Some(row), col`, matching the matrix/factor-cell convention
154 /// documented on [`NonFinite`](Self::NonFinite). For vector-input entries
155 /// or scalar intermediates without a matrix row coordinate, use
156 /// [`non_finite_at`](Self::non_finite_at).
157 ///
158 /// # Examples
159 /// ```
160 /// use la_stack::prelude::*;
161 ///
162 /// assert_eq!(
163 /// LaError::non_finite_cell(1, 2),
164 /// LaError::NonFinite {
165 /// row: Some(1),
166 /// col: 2,
167 /// }
168 /// );
169 /// ```
170 #[inline]
171 #[must_use]
172 pub const fn non_finite_cell(row: usize, col: usize) -> Self {
173 Self::NonFinite {
174 row: Some(row),
175 col,
176 }
177 }
178
179 /// Construct a [`LaError::NonFinite`] pinpointing a vector-input entry or
180 /// computed scalar/intermediate at index `col`.
181 ///
182 /// Use this for non-finite values in a [`Vector`](crate::Vector) input,
183 /// determinant scalar, tolerance-scale accumulator, or solve accumulator
184 /// that overflowed during forward/back substitution. The resulting error
185 /// has `row: None, col`, matching the vector/scalar-intermediate convention
186 /// documented on [`NonFinite`](Self::NonFinite). For stored matrix cells or
187 /// computed factorization updates tied to a matrix cell, use
188 /// [`non_finite_cell`](Self::non_finite_cell).
189 ///
190 /// # Examples
191 /// ```
192 /// use la_stack::prelude::*;
193 ///
194 /// assert_eq!(
195 /// LaError::non_finite_at(2),
196 /// LaError::NonFinite { row: None, col: 2 }
197 /// );
198 /// ```
199 #[inline]
200 #[must_use]
201 pub const fn non_finite_at(col: usize) -> Self {
202 Self::NonFinite { row: None, col }
203 }
204
205 /// Construct a [`LaError::Unrepresentable`] for exact-to-`f64` conversion.
206 ///
207 /// # Examples
208 /// ```
209 /// use la_stack::prelude::*;
210 ///
211 /// assert_eq!(
212 /// LaError::unrepresentable(Some(2), UnrepresentableReason::RequiresRounding),
213 /// LaError::Unrepresentable {
214 /// index: Some(2),
215 /// reason: UnrepresentableReason::RequiresRounding,
216 /// }
217 /// );
218 /// ```
219 #[inline]
220 #[must_use]
221 pub const fn unrepresentable(index: Option<usize>, reason: UnrepresentableReason) -> Self {
222 Self::Unrepresentable { index, reason }
223 }
224
225 /// Return the reason for an exact-to-`f64` conversion failure.
226 ///
227 /// This is a concise alternative to matching the full
228 /// [`LaError::Unrepresentable`] variant when callers only need the
229 /// conversion reason.
230 ///
231 /// # Examples
232 /// ```
233 /// use la_stack::prelude::*;
234 ///
235 /// let err = LaError::unrepresentable(None, UnrepresentableReason::RequiresRounding);
236 /// assert_eq!(
237 /// err.unrepresentable_reason(),
238 /// Some(UnrepresentableReason::RequiresRounding)
239 /// );
240 /// assert_eq!(LaError::Singular { pivot_col: 0 }.unrepresentable_reason(), None);
241 /// ```
242 #[inline]
243 #[must_use]
244 pub const fn unrepresentable_reason(&self) -> Option<UnrepresentableReason> {
245 match self {
246 Self::Unrepresentable { reason, .. } => Some(*reason),
247 _ => None,
248 }
249 }
250
251 /// Return `true` when strict exact-to-`f64` conversion only failed because
252 /// rounding would be required.
253 ///
254 /// This is useful at the call site that wants to retry with an explicit
255 /// rounded exact-to-`f64` API while still propagating non-finite conversion
256 /// failures.
257 ///
258 /// # Examples
259 /// ```
260 /// use la_stack::prelude::*;
261 ///
262 /// let err = LaError::unrepresentable(None, UnrepresentableReason::RequiresRounding);
263 /// assert!(err.requires_rounding());
264 ///
265 /// let err = LaError::unrepresentable(None, UnrepresentableReason::NotFinite);
266 /// assert!(!err.requires_rounding());
267 /// ```
268 #[inline]
269 #[must_use]
270 pub const fn requires_rounding(&self) -> bool {
271 matches!(
272 self,
273 Self::Unrepresentable {
274 reason: UnrepresentableReason::RequiresRounding,
275 ..
276 }
277 )
278 }
279
280 /// Construct a [`LaError::DeterminantScaleOverflow`] for exact determinant scaling.
281 ///
282 /// # Examples
283 /// ```
284 /// use la_stack::prelude::*;
285 ///
286 /// assert_eq!(
287 /// LaError::determinant_scale_overflow(3, -1074),
288 /// LaError::DeterminantScaleOverflow {
289 /// dim: 3,
290 /// min_exponent: -1074,
291 /// }
292 /// );
293 /// ```
294 #[inline]
295 #[must_use]
296 pub const fn determinant_scale_overflow(dim: usize, min_exponent: i32) -> Self {
297 Self::DeterminantScaleOverflow { dim, min_exponent }
298 }
299
300 /// Construct a [`LaError::UnsupportedDimension`] for runtime stack dispatch.
301 ///
302 /// # Examples
303 /// ```
304 /// use la_stack::prelude::*;
305 ///
306 /// assert_eq!(
307 /// LaError::unsupported_dimension(8, MAX_STACK_MATRIX_DISPATCH_DIM),
308 /// LaError::UnsupportedDimension {
309 /// requested: 8,
310 /// max: MAX_STACK_MATRIX_DISPATCH_DIM,
311 /// }
312 /// );
313 /// ```
314 #[inline]
315 #[must_use]
316 pub const fn unsupported_dimension(requested: usize, max: usize) -> Self {
317 Self::UnsupportedDimension { requested, max }
318 }
319
320 /// Construct a [`LaError::IndexOutOfBounds`] for a `D×D` matrix index.
321 ///
322 /// # Examples
323 /// ```
324 /// use la_stack::prelude::*;
325 ///
326 /// assert_eq!(
327 /// LaError::index_out_of_bounds(2, 0, 2),
328 /// LaError::IndexOutOfBounds {
329 /// row: 2,
330 /// col: 0,
331 /// dim: 2,
332 /// }
333 /// );
334 /// ```
335 #[inline]
336 #[must_use]
337 pub const fn index_out_of_bounds(row: usize, col: usize, dim: usize) -> Self {
338 Self::IndexOutOfBounds { row, col, dim }
339 }
340
341 /// Construct a [`LaError::InvalidTolerance`] for a raw tolerance value.
342 ///
343 /// # Examples
344 /// ```
345 /// use la_stack::prelude::*;
346 ///
347 /// assert_eq!(
348 /// LaError::invalid_tolerance(-1.0),
349 /// LaError::InvalidTolerance { value: -1.0 }
350 /// );
351 /// ```
352 #[inline]
353 #[must_use]
354 pub const fn invalid_tolerance(value: f64) -> Self {
355 Self::InvalidTolerance { value }
356 }
357
358 /// Construct a [`LaError::Asymmetric`] for a `D×D` matrix.
359 ///
360 /// # Examples
361 /// ```
362 /// use la_stack::prelude::*;
363 ///
364 /// assert_eq!(
365 /// LaError::asymmetric(0, 1, 3),
366 /// LaError::Asymmetric {
367 /// row: 0,
368 /// col: 1,
369 /// dim: 3,
370 /// }
371 /// );
372 /// ```
373 #[inline]
374 #[must_use]
375 pub const fn asymmetric(row: usize, col: usize, dim: usize) -> Self {
376 Self::Asymmetric { row, col, dim }
377 }
378
379 /// Construct a [`LaError::NotPositiveSemidefinite`] for LDLT factorization.
380 ///
381 /// # Examples
382 /// ```
383 /// use la_stack::prelude::*;
384 ///
385 /// assert_eq!(
386 /// LaError::not_positive_semidefinite(1, -3.0),
387 /// LaError::NotPositiveSemidefinite {
388 /// pivot_col: 1,
389 /// value: -3.0,
390 /// }
391 /// );
392 /// ```
393 #[inline]
394 #[must_use]
395 pub const fn not_positive_semidefinite(pivot_col: usize, value: f64) -> Self {
396 Self::NotPositiveSemidefinite { pivot_col, value }
397 }
398
399 /// Parse a raw tolerance into a finite, non-negative [`Tolerance`].
400 ///
401 /// # Examples
402 /// ```
403 /// use la_stack::prelude::*;
404 ///
405 /// assert_eq!(LaError::validate_tolerance(1e-12)?.get(), 1e-12);
406 ///
407 /// let raw = 0.0;
408 /// let tol = LaError::validate_tolerance(raw)?;
409 /// let _lu = Matrix::<2>::identity().lu(tol)?;
410 ///
411 /// assert_eq!(
412 /// LaError::validate_tolerance(-1.0),
413 /// Err(LaError::InvalidTolerance { value: -1.0 })
414 /// );
415 /// # Ok::<(), LaError>(())
416 /// ```
417 ///
418 /// # Errors
419 /// Returns [`LaError::InvalidTolerance`] when `value` is NaN, infinite, or
420 /// negative.
421 #[inline]
422 pub const fn validate_tolerance(value: f64) -> Result<Tolerance, Self> {
423 Tolerance::new(value)
424 }
425}
426
427impl fmt::Display for LaError {
428 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
429 match *self {
430 Self::Singular { pivot_col } => {
431 write!(f, "singular matrix at pivot column {pivot_col}")
432 }
433 Self::NonFinite { row: Some(r), col } => {
434 write!(f, "non-finite value at ({r}, {col})")
435 }
436 Self::NonFinite { row: None, col } => {
437 write!(f, "non-finite value at index {col}")
438 }
439 Self::Unrepresentable {
440 index: Some(i),
441 reason: UnrepresentableReason::RequiresRounding,
442 } => write!(
443 f,
444 "exact result requires rounding to fit finite f64 at index {i}"
445 ),
446 Self::Unrepresentable {
447 index: None,
448 reason: UnrepresentableReason::RequiresRounding,
449 } => write!(f, "exact result requires rounding to fit finite f64"),
450 Self::Unrepresentable {
451 index: Some(i),
452 reason: UnrepresentableReason::NotFinite,
453 } => write!(f, "exact result does not round to finite f64 at index {i}"),
454 Self::Unrepresentable {
455 index: None,
456 reason: UnrepresentableReason::NotFinite,
457 } => write!(f, "exact result does not round to finite f64"),
458 Self::DeterminantScaleOverflow { dim, min_exponent } => {
459 write!(
460 f,
461 "exact determinant scale exponent overflows for dimension {dim} with minimum entry exponent {min_exponent}"
462 )
463 }
464 Self::UnsupportedDimension { requested, max } => {
465 write!(
466 f,
467 "unsupported matrix dimension {requested}; maximum stack-dispatch dimension is {max}"
468 )
469 }
470 Self::IndexOutOfBounds { row, col, dim } => {
471 write!(
472 f,
473 "matrix index ({row}, {col}) is out of bounds for dimension {dim}"
474 )
475 }
476 Self::InvalidTolerance { value } => {
477 write!(f, "invalid tolerance {value}; expected finite value >= 0")
478 }
479 Self::Asymmetric { row, col, dim } => {
480 write!(
481 f,
482 "matrix is not symmetric for dimension {dim}: asymmetric pair ({row}, {col})"
483 )
484 }
485 Self::NotPositiveSemidefinite { pivot_col, value } => {
486 write!(
487 f,
488 "matrix is not positive semidefinite at LDLT pivot column {pivot_col}: diagonal value {value} < 0"
489 )
490 }
491 }
492 }
493}
494
495impl std::error::Error for LaError {}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 use core::assert_matches;
502
503 #[test]
504 fn laerror_display_formats_singular() {
505 let err = LaError::Singular { pivot_col: 3 };
506 assert_eq!(err.to_string(), "singular matrix at pivot column 3");
507 }
508
509 #[test]
510 fn laerror_display_formats_nonfinite_with_row() {
511 let err = LaError::NonFinite {
512 row: Some(1),
513 col: 2,
514 };
515 assert_eq!(err.to_string(), "non-finite value at (1, 2)");
516 }
517
518 #[test]
519 fn laerror_display_formats_nonfinite_without_row() {
520 let err = LaError::NonFinite { row: None, col: 3 };
521 assert_eq!(err.to_string(), "non-finite value at index 3");
522 }
523
524 #[test]
525 fn laerror_display_formats_unrepresentable_requires_rounding() {
526 let err = LaError::Unrepresentable {
527 index: None,
528 reason: UnrepresentableReason::RequiresRounding,
529 };
530 assert_eq!(
531 err.to_string(),
532 "exact result requires rounding to fit finite f64"
533 );
534 }
535
536 #[test]
537 fn laerror_display_formats_unrepresentable_requires_rounding_with_index() {
538 let err = LaError::Unrepresentable {
539 index: Some(2),
540 reason: UnrepresentableReason::RequiresRounding,
541 };
542 assert_eq!(
543 err.to_string(),
544 "exact result requires rounding to fit finite f64 at index 2"
545 );
546 }
547
548 #[test]
549 fn laerror_display_formats_unrepresentable_not_finite() {
550 let err = LaError::Unrepresentable {
551 index: None,
552 reason: UnrepresentableReason::NotFinite,
553 };
554 assert_eq!(err.to_string(), "exact result does not round to finite f64");
555 }
556
557 #[test]
558 fn laerror_display_formats_unrepresentable_not_finite_with_index() {
559 let err = LaError::Unrepresentable {
560 index: Some(2),
561 reason: UnrepresentableReason::NotFinite,
562 };
563 assert_eq!(
564 err.to_string(),
565 "exact result does not round to finite f64 at index 2"
566 );
567 }
568
569 #[test]
570 fn laerror_unrepresentable_reason_reports_typed_reason() {
571 let rounding = LaError::Unrepresentable {
572 index: Some(2),
573 reason: UnrepresentableReason::RequiresRounding,
574 };
575 let not_finite = LaError::Unrepresentable {
576 index: None,
577 reason: UnrepresentableReason::NotFinite,
578 };
579
580 assert_eq!(
581 rounding.unrepresentable_reason(),
582 Some(UnrepresentableReason::RequiresRounding)
583 );
584 assert_eq!(
585 not_finite.unrepresentable_reason(),
586 Some(UnrepresentableReason::NotFinite)
587 );
588 assert_eq!(
589 LaError::Singular { pivot_col: 0 }.unrepresentable_reason(),
590 None
591 );
592 }
593
594 #[test]
595 fn laerror_requires_rounding_only_matches_rounding_reason() {
596 assert!(
597 LaError::Unrepresentable {
598 index: Some(2),
599 reason: UnrepresentableReason::RequiresRounding,
600 }
601 .requires_rounding()
602 );
603 assert!(
604 !LaError::Unrepresentable {
605 index: None,
606 reason: UnrepresentableReason::NotFinite,
607 }
608 .requires_rounding()
609 );
610 assert!(!LaError::Singular { pivot_col: 0 }.requires_rounding());
611 }
612
613 #[test]
614 fn laerror_display_formats_determinant_scale_overflow() {
615 let err = LaError::DeterminantScaleOverflow {
616 dim: 3,
617 min_exponent: -1074,
618 };
619 assert_eq!(
620 err.to_string(),
621 "exact determinant scale exponent overflows for dimension 3 with minimum entry exponent -1074"
622 );
623 }
624
625 #[test]
626 fn laerror_display_formats_unsupported_dimension() {
627 let err = LaError::UnsupportedDimension {
628 requested: 8,
629 max: crate::MAX_STACK_MATRIX_DISPATCH_DIM,
630 };
631 assert_eq!(
632 err.to_string(),
633 "unsupported matrix dimension 8; maximum stack-dispatch dimension is 7"
634 );
635 }
636
637 #[test]
638 fn laerror_display_formats_index_out_of_bounds() {
639 let err = LaError::IndexOutOfBounds {
640 row: 3,
641 col: 0,
642 dim: 3,
643 };
644 assert_eq!(
645 err.to_string(),
646 "matrix index (3, 0) is out of bounds for dimension 3"
647 );
648 }
649
650 #[test]
651 fn laerror_display_formats_invalid_tolerance() {
652 let err = LaError::InvalidTolerance { value: -1.0 };
653 assert_eq!(
654 err.to_string(),
655 "invalid tolerance -1; expected finite value >= 0"
656 );
657 }
658
659 #[test]
660 fn validate_tolerance_matches_tolerance_new() {
661 for value in [0.0, 1e-12, f64::MAX] {
662 assert_eq!(LaError::validate_tolerance(value), Tolerance::new(value));
663 }
664
665 assert_eq!(
666 LaError::validate_tolerance(-1.0),
667 Err(LaError::InvalidTolerance { value: -1.0 })
668 );
669 assert_matches!(
670 LaError::validate_tolerance(f64::NAN),
671 Err(LaError::InvalidTolerance { value }) if value.is_nan()
672 );
673 assert_eq!(
674 LaError::validate_tolerance(f64::INFINITY),
675 Err(LaError::InvalidTolerance {
676 value: f64::INFINITY,
677 })
678 );
679 assert_eq!(
680 LaError::validate_tolerance(f64::NEG_INFINITY),
681 Err(LaError::InvalidTolerance {
682 value: f64::NEG_INFINITY,
683 })
684 );
685 }
686
687 #[test]
688 fn laerror_display_formats_asymmetric() {
689 let err = LaError::Asymmetric {
690 row: 0,
691 col: 2,
692 dim: 3,
693 };
694 assert_eq!(
695 err.to_string(),
696 "matrix is not symmetric for dimension 3: asymmetric pair (0, 2)"
697 );
698 }
699
700 #[test]
701 fn laerror_display_formats_not_positive_semidefinite() {
702 let err = LaError::NotPositiveSemidefinite {
703 pivot_col: 1,
704 value: -3.0,
705 };
706 assert_eq!(
707 err.to_string(),
708 "matrix is not positive semidefinite at LDLT pivot column 1: diagonal value -3 < 0"
709 );
710 }
711
712 #[test]
713 fn laerror_is_std_error_with_no_source() {
714 let err = LaError::Singular { pivot_col: 0 };
715 let e: &dyn std::error::Error = &err;
716 assert!(e.source().is_none());
717 }
718}