Skip to main content

regit_curves/math/
mod.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! Hand-rolled numerical primitives — no external math dependencies.
5//!
6//! The crate is zero-dependency, so every solver and root-finder is
7//! implemented from its primary source. All routines are pure functions,
8//! deterministic (same input produces bit-identical output), and `std`-only.
9//!
10//! # Contents
11//!
12//! - [`linear_solve::solve`] — dense Gaussian elimination with partial
13//!   pivoting (Golub & Van Loan §3.4).
14//! - [`linear_solve::solve_spd`] — symmetric positive-definite solve by
15//!   Cholesky decomposition (Golub & Van Loan §4.2).
16//! - [`tridiag::thomas`] — `O(n)` tridiagonal solve by the Thomas algorithm.
17//! - [`brent::brent_root`] — bracketed root-finder by Brent's method
18//!   (Brent 1973).
19//!
20//! Errors from these primitives surface through the local [`MathError`] enum;
21//! `From<MathError> for CurveError` is provided so curve-level callers can
22//! propagate with `?`.
23//!
24//! # References
25//!
26//! - Golub, G. H. & Van Loan, C. F., *Matrix Computations*, 4th ed.,
27//!   Johns Hopkins (2013), Chapters 3 and 4.
28//! - Brent, R. P., *Algorithms for Minimization Without Derivatives*,
29//!   Prentice-Hall (1973), Chapter 4.
30//! - Thomas, L. H., *Elliptic Problems in Linear Difference Equations over a
31//!   Network*, Watson Sci. Comput. Lab. Report (Columbia 1949).
32
33use core::fmt;
34
35use crate::errors::CurveError;
36
37pub mod brent;
38pub mod linear_solve;
39pub mod tridiag;
40
41pub use brent::{BrentConfig, brent_root};
42pub use linear_solve::{solve, solve_spd};
43pub use tridiag::thomas;
44
45/// Errors raised by the numerical primitives in [`math`](self).
46///
47/// These are domain errors — every routine is a pure function and never
48/// panics on the inputs it accepts; "domain" means the input violates the
49/// algorithm's preconditions (singular matrix, non-SPD matrix, bracket fails
50/// to straddle a root, etc.).
51///
52/// # Examples
53///
54/// ```
55/// use regit_curves::math::MathError;
56///
57/// let err = MathError::Singular;
58/// assert_eq!(format!("{err}"), "matrix is singular");
59/// ```
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub enum MathError {
62    /// A pivot collapsed below tolerance during elimination — the matrix is
63    /// singular (up to round-off).
64    Singular,
65    /// Cholesky decomposition encountered a non-positive pivot — the matrix
66    /// is not symmetric positive-definite.
67    NotSpd,
68    /// Inputs to a solver have inconsistent dimensions.
69    DimensionMismatch,
70    /// Iterative algorithm did not converge to the requested tolerance
71    /// within the iteration cap.
72    NoConvergence,
73    /// `f(a)` and `f(b)` do not straddle zero, so a bracketed root-finder
74    /// cannot make progress.
75    BracketNotStraddling,
76}
77
78impl fmt::Display for MathError {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            Self::Singular => write!(f, "matrix is singular"),
82            Self::NotSpd => write!(f, "matrix is not symmetric positive-definite"),
83            Self::DimensionMismatch => write!(f, "inputs have inconsistent dimensions"),
84            Self::NoConvergence => write!(f, "iterative algorithm did not converge"),
85            Self::BracketNotStraddling => {
86                write!(f, "f(a) and f(b) do not straddle zero")
87            }
88        }
89    }
90}
91
92impl std::error::Error for MathError {}
93
94impl From<MathError> for CurveError {
95    fn from(e: MathError) -> Self {
96        // Map every algorithmic failure to a curve-level error that the
97        // outer caller can surface. The closest fit at the curve level is
98        // `DuplicateNode { t: NaN }` for `Singular` (a duplicated time
99        // typically produces a singular interpolation matrix); but more
100        // generally these are programmer-side conditions, so we map to
101        // `InvalidTime { t: NaN }` as a catch-all "the math could not be
102        // performed" signal.
103        match e {
104            MathError::DimensionMismatch => Self::TooFewNodes { found: 0 },
105            _ => Self::InvalidTime { t: f64::NAN },
106        }
107    }
108}
109
110/// Converts a `usize` index or count to `f64` losslessly.
111///
112/// Splits the value into a high and low `u32` half and recombines through
113/// `f64::from`, both of which are exact conversions. The result is exact for
114/// every `usize` below `2^53` (the `f64` mantissa width) — i.e. for every
115/// grid index and count this crate produces — and avoids the precision-loss
116/// `as`-cast lint entirely.
117///
118/// # Examples
119///
120/// ```
121/// use regit_curves::math::index_to_f64;
122///
123/// assert_eq!(index_to_f64(0), 0.0);
124/// assert_eq!(index_to_f64(42), 42.0);
125/// assert_eq!(index_to_f64(1 << 30), (1u64 << 30) as f64);
126/// ```
127#[inline]
128#[must_use]
129pub fn index_to_f64(i: usize) -> f64 {
130    let value = i as u64;
131    let high = u32::try_from(value >> 32).unwrap_or(u32::MAX);
132    let low = u32::try_from(value & 0xFFFF_FFFF).unwrap_or(u32::MAX);
133    f64::from(high) * 4_294_967_296.0 + f64::from(low)
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn math_error_display_all_variants() {
142        assert_eq!(format!("{}", MathError::Singular), "matrix is singular");
143        assert!(format!("{}", MathError::NotSpd).contains("positive-definite"));
144        assert!(format!("{}", MathError::DimensionMismatch).contains("dimensions"));
145        assert!(format!("{}", MathError::NoConvergence).contains("converge"));
146        assert!(format!("{}", MathError::BracketNotStraddling).contains("straddle"));
147    }
148
149    #[test]
150    fn math_error_is_error_trait() {
151        let err: &dyn std::error::Error = &MathError::Singular;
152        assert!(err.source().is_none());
153    }
154
155    #[test]
156    fn math_error_copy_eq_hash() {
157        let err = MathError::NotSpd;
158        let copy = err;
159        assert_eq!(err, copy);
160        let mut set = std::collections::HashSet::new();
161        set.insert(err);
162        assert!(set.contains(&copy));
163    }
164
165    #[test]
166    fn math_error_debug() {
167        assert!(format!("{:?}", MathError::Singular).contains("Singular"));
168    }
169
170    #[test]
171    fn curve_error_from_math_error_singular() {
172        let ce: CurveError = MathError::Singular.into();
173        assert!(matches!(ce, CurveError::InvalidTime { .. }));
174    }
175
176    #[test]
177    fn curve_error_from_math_error_dim_mismatch() {
178        let ce: CurveError = MathError::DimensionMismatch.into();
179        assert!(matches!(ce, CurveError::TooFewNodes { .. }));
180    }
181
182    #[test]
183    fn index_to_f64_basic() {
184        assert!((index_to_f64(0) - 0.0).abs() < f64::EPSILON);
185        assert!((index_to_f64(1) - 1.0).abs() < f64::EPSILON);
186        assert!((index_to_f64(1000) - 1000.0).abs() < f64::EPSILON);
187    }
188
189    #[test]
190    fn index_to_f64_large() {
191        let large = 1usize << 40;
192        // 2^40 is exactly representable in `f64`, expressed here without an
193        // `as` cast (which clippy `cast_precision_loss` flags).
194        let expected = f64::from(1u32 << 30) * 1024.0;
195        assert!((index_to_f64(large) - expected).abs() < f64::EPSILON);
196    }
197}