Skip to main content

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}