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(©));
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}