Skip to content

Commit 3ae3781

Browse files
authored
Merge pull request #128 from acgetchell/fix/94-tolerance-invariants
fix(matrix): reject overflowed symmetry tolerance scaling
2 parents 1dac6cc + a7b052a commit 3ae3781

6 files changed

Lines changed: 201 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- Feat!(matrix): enforce fallible matrix invariants [`e26c283`](https://github.com/acgetchell/la-stack/commit/e26c28358b2358100353b2895441b68892e92cd7)
13+
- Feat!(api): enforce fallible numeric invariants [`adfc33b`](https://github.com/acgetchell/la-stack/commit/adfc33b945b259721bd1067e797ed2e7d4ec0e6e)
1314

1415
### Changed
1516

examples/exact_det_3x3.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
1010
use la_stack::prelude::*;
1111

12-
fn main() {
12+
fn main() -> Result<(), LaError> {
1313
// Base matrix: rows in arithmetic progression → exactly singular (det = 0).
1414
// [[1, 2, 3],
1515
// [4, 5, 6],
@@ -24,9 +24,11 @@ fn main() {
2424
[7.0, 8.0, 9.0],
2525
]);
2626

27-
let det_f64_approx = m.det_direct().unwrap().unwrap();
28-
let det_exact = m.det_exact().unwrap();
29-
let det_exact_as_f64 = m.det_exact_f64().unwrap();
27+
let Some(det_f64_approx) = m.det_direct()? else {
28+
unreachable!("D=3 is supported by det_direct");
29+
};
30+
let det_exact = m.det_exact()?;
31+
let det_exact_as_f64 = m.det_exact_f64()?;
3032

3133
println!("Near-singular 3×3 matrix (perturbation = 2^-50 ≈ {perturbation:.2e}):");
3234
for r in 0..3 {
@@ -35,7 +37,7 @@ fn main() {
3537
if c > 0 {
3638
print!(", ");
3739
}
38-
print!("{:22.18}", m.get(r, c).unwrap());
40+
print!("{:22.18}", m.get_checked(r, c)?);
3941
}
4042
println!("]");
4143
}
@@ -45,4 +47,5 @@ fn main() {
4547
println!("det_exact_f64() = {det_exact_as_f64:+.6e}");
4648
println!();
4749
println!("The exact determinant is −3/2^50 ≈ −2.66e-15.");
50+
Ok(())
4851
}

examples/exact_solve_3x3.rs

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
1010
use la_stack::prelude::*;
1111

12-
fn main() {
12+
fn main() -> Result<(), LaError> {
1313
// Near-singular 3×3 system.
1414
//
1515
// The base matrix [[1,2,3],[4,5,6],[7,8,9]] is exactly singular (rows in
@@ -26,16 +26,11 @@ fn main() {
2626

2727
// f64 LU solve (using zero pivot tolerance since the matrix is nearly singular
2828
// and would be rejected by DEFAULT_PIVOT_TOL).
29-
let lu_x = a
30-
.lu(Tolerance::new(0.0).unwrap())
31-
.unwrap()
32-
.solve_vec(b)
33-
.unwrap()
34-
.into_array();
29+
let lu_x = a.lu(Tolerance::new(0.0)?)?.solve_vec(b)?.into_array();
3530

3631
// Exact solve.
37-
let exact_x = a.solve_exact(b).unwrap();
38-
let exact_x_f64 = a.solve_exact_f64(b).unwrap().into_array();
32+
let exact_x = a.solve_exact(b)?;
33+
let exact_x_f64 = a.solve_exact_f64(b)?.into_array();
3934

4035
println!("Near-singular 3×3 system (perturbation = 2^-50 ≈ {perturbation:.2e}):");
4136
for r in 0..3 {
@@ -44,7 +39,7 @@ fn main() {
4439
if c > 0 {
4540
print!(", ");
4641
}
47-
print!("{:22.18}", a.get(r, c).unwrap());
42+
print!("{:22.18}", a.get_checked(r, c)?);
4843
}
4944
println!("]");
5045
}
@@ -67,4 +62,5 @@ fn main() {
6762
"solve_exact_f64(): x = [{:+.6e}, {:+.6e}, {:+.6e}]",
6863
exact_x_f64[0], exact_x_f64[1], exact_x_f64[2]
6964
);
65+
Ok(())
7066
}

src/lib.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,9 @@ pub const MAX_STACK_MATRIX_DISPATCH_DIM: usize = 7;
197197
/// Construct with [`Tolerance::new`] when accepting raw caller input. Once
198198
/// constructed, the stored value is guaranteed to be finite and `>= 0`, so
199199
/// downstream algorithms do not need to revalidate the tolerance.
200+
///
201+
/// This is the crate-wide tolerance contract: raw negative, NaN, and infinite
202+
/// values are rejected with [`LaError::InvalidTolerance`] at construction time.
200203
#[must_use]
201204
#[derive(Clone, Copy, Debug, PartialEq)]
202205
pub struct Tolerance {
@@ -488,6 +491,11 @@ impl LaError {
488491
/// use la_stack::prelude::*;
489492
///
490493
/// assert_eq!(LaError::validate_tolerance(1e-12)?.get(), 1e-12);
494+
///
495+
/// let raw = 0.0;
496+
/// let tol = LaError::validate_tolerance(raw)?;
497+
/// let _lu = Matrix::<2>::identity().lu(tol)?;
498+
///
491499
/// assert_eq!(
492500
/// LaError::validate_tolerance(-1.0),
493501
/// Err(LaError::InvalidTolerance { value: -1.0 })
@@ -667,6 +675,8 @@ pub mod prelude {
667675

668676
#[cfg(test)]
669677
mod tests {
678+
use core::assert_matches;
679+
670680
use super::*;
671681

672682
use approx::assert_abs_diff_eq;
@@ -681,6 +691,74 @@ mod tests {
681691
);
682692
}
683693

694+
#[test]
695+
fn tolerance_new_accepts_finite_non_negative_values() {
696+
assert_eq!(
697+
Tolerance::new(0.0).unwrap().get().to_bits(),
698+
0.0f64.to_bits()
699+
);
700+
assert_eq!(
701+
Tolerance::new(1e-12).unwrap().get().to_bits(),
702+
1e-12f64.to_bits()
703+
);
704+
assert_eq!(
705+
Tolerance::new(f64::MAX).unwrap().get().to_bits(),
706+
f64::MAX.to_bits()
707+
);
708+
}
709+
710+
#[test]
711+
fn tolerance_new_rejects_negative_nan_and_infinity() {
712+
assert_eq!(
713+
Tolerance::new(-1.0),
714+
Err(LaError::InvalidTolerance { value: -1.0 })
715+
);
716+
assert_matches!(
717+
Tolerance::new(f64::NAN),
718+
Err(LaError::InvalidTolerance { value }) if value.is_nan()
719+
);
720+
assert_eq!(
721+
Tolerance::new(f64::INFINITY),
722+
Err(LaError::InvalidTolerance {
723+
value: f64::INFINITY,
724+
})
725+
);
726+
assert_eq!(
727+
Tolerance::new(f64::NEG_INFINITY),
728+
Err(LaError::InvalidTolerance {
729+
value: f64::NEG_INFINITY,
730+
})
731+
);
732+
}
733+
734+
#[test]
735+
fn validate_tolerance_matches_tolerance_new() {
736+
for value in [0.0, 1e-12, f64::MAX] {
737+
assert_eq!(LaError::validate_tolerance(value), Tolerance::new(value));
738+
}
739+
740+
assert_eq!(
741+
LaError::validate_tolerance(-1.0),
742+
Err(LaError::InvalidTolerance { value: -1.0 })
743+
);
744+
assert_matches!(
745+
LaError::validate_tolerance(f64::NAN),
746+
Err(LaError::InvalidTolerance { value }) if value.is_nan()
747+
);
748+
assert_eq!(
749+
LaError::validate_tolerance(f64::INFINITY),
750+
Err(LaError::InvalidTolerance {
751+
value: f64::INFINITY,
752+
})
753+
);
754+
assert_eq!(
755+
LaError::validate_tolerance(f64::NEG_INFINITY),
756+
Err(LaError::InvalidTolerance {
757+
value: f64::NEG_INFINITY,
758+
})
759+
);
760+
}
761+
684762
#[test]
685763
fn laerror_display_formats_singular() {
686764
let err = LaError::Singular { pivot_col: 3 };

src/matrix.rs

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ impl<const D: usize> Matrix<D> {
195195
/// Non-finite entries are rejected with source coordinates instead of
196196
/// silently propagating NaN or infinity through the norm.
197197
///
198+
/// Row sums are accumulated in `f64` with ordinary addition. This method
199+
/// checks for non-finite inputs and overflowed accumulators, but it does not
200+
/// provide a certified absolute rounding bound for the returned norm.
201+
///
198202
/// # Examples
199203
/// ```
200204
/// use la_stack::prelude::*;
@@ -263,9 +267,17 @@ impl<const D: usize> Matrix<D> {
263267
/// Use [`first_asymmetry`](Self::first_asymmetry) to locate the first
264268
/// offending pair when this returns `Ok(false)`.
265269
///
270+
/// The `rel_tol` argument is a [`Tolerance`], so raw caller input must be
271+
/// finite and non-negative before it can reach this predicate. Use
272+
/// [`Tolerance::new`] or [`LaError::validate_tolerance`] when accepting a
273+
/// raw `f64`; negative, NaN, and infinite tolerances return
274+
/// [`LaError::InvalidTolerance`].
275+
///
266276
/// # NaN / infinity handling
267277
/// Stored NaN or ±∞ entries return [`LaError::NonFinite`] with the
268-
/// offending matrix coordinates. If both stored entries are finite but
278+
/// offending matrix coordinates. A finite matrix can still return
279+
/// [`LaError::NonFinite`] if computing the scaled symmetry tolerance
280+
/// overflows to NaN or infinity. If both stored entries are finite but
269281
/// their difference overflows to ±∞, the pair is reported as asymmetric.
270282
///
271283
/// # Examples
@@ -284,7 +296,9 @@ impl<const D: usize> Matrix<D> {
284296
/// ```
285297
///
286298
/// # Errors
287-
/// Returns [`LaError::NonFinite`] when any matrix entry is NaN or infinite.
299+
/// Returns [`LaError::NonFinite`] when any matrix entry is NaN or infinite,
300+
/// or when computing the scaled symmetry tolerance overflows to NaN or
301+
/// infinity.
288302
#[inline]
289303
pub fn is_symmetric(&self, rel_tol: Tolerance) -> Result<bool, LaError> {
290304
Ok(self.first_asymmetry(rel_tol)?.is_none())
@@ -299,6 +313,18 @@ impl<const D: usize> Matrix<D> {
299313
/// predicate is the same as [`is_symmetric`](Self::is_symmetric):
300314
/// `|self[r][c] - self[c][r]| <= rel_tol * max(1.0, inf_norm(self))`.
301315
///
316+
/// Stored NaN or ±∞ entries return [`LaError::NonFinite`] with the
317+
/// offending matrix coordinates. A finite matrix can still return
318+
/// [`LaError::NonFinite`] if computing the scaled symmetry tolerance
319+
/// overflows to NaN or infinity. If both stored entries are finite but
320+
/// their difference overflows to ±∞, the pair is reported as asymmetric.
321+
///
322+
/// The `rel_tol` argument is a [`Tolerance`], so raw caller input must be
323+
/// finite and non-negative before it can reach this predicate. Use
324+
/// [`Tolerance::new`] or [`LaError::validate_tolerance`] when accepting a
325+
/// raw `f64`; negative, NaN, and infinite tolerances return
326+
/// [`LaError::InvalidTolerance`].
327+
///
302328
/// # Examples
303329
/// ```
304330
/// use la_stack::prelude::*;
@@ -317,7 +343,9 @@ impl<const D: usize> Matrix<D> {
317343
/// ```
318344
///
319345
/// # Errors
320-
/// Returns [`LaError::NonFinite`] when any matrix entry is NaN or infinite.
346+
/// Returns [`LaError::NonFinite`] when any matrix entry is NaN or infinite,
347+
/// or when computing the scaled symmetry tolerance overflows to NaN or
348+
/// infinity.
321349
#[inline]
322350
pub fn first_asymmetry(&self, rel_tol: Tolerance) -> Result<Option<(usize, usize)>, LaError> {
323351
let eps = self.symmetry_epsilon(rel_tol)?;
@@ -351,7 +379,9 @@ impl<const D: usize> Matrix<D> {
351379
/// off-diagonal mismatches.
352380
///
353381
/// # Errors
354-
/// Returns [`LaError::NonFinite`] when any matrix entry is NaN or infinite.
382+
/// Returns [`LaError::NonFinite`] when any matrix entry is NaN or infinite,
383+
/// or when computing the scaled symmetry tolerance overflows to NaN or
384+
/// infinity.
355385
fn symmetry_epsilon(&self, rel_tol: Tolerance) -> Result<f64, LaError> {
356386
let rel_tol = rel_tol.get();
357387
let mut eps = rel_tol;
@@ -365,6 +395,10 @@ impl<const D: usize> Matrix<D> {
365395
return Err(LaError::non_finite_cell(r, c));
366396
}
367397
row_eps = rel_tol.mul_add(entry.abs(), row_eps);
398+
if !row_eps.is_finite() {
399+
cold_path();
400+
return Err(LaError::non_finite_at(c));
401+
}
368402
}
369403
if row_eps > eps {
370404
eps = row_eps;
@@ -393,6 +427,12 @@ impl<const D: usize> Matrix<D> {
393427
/// # }
394428
/// ```
395429
///
430+
/// The `tol` argument is a [`Tolerance`], so raw caller input must be
431+
/// finite and non-negative before it can reach factorization. Use
432+
/// [`Tolerance::new`] or [`LaError::validate_tolerance`] when accepting a
433+
/// raw `f64`; negative, NaN, and infinite tolerances return
434+
/// [`LaError::InvalidTolerance`].
435+
///
396436
/// # Errors
397437
/// Returns [`LaError::Singular`] if, for some column `k`, the largest-magnitude candidate pivot
398438
/// in that column satisfies `|pivot| <= tol` (so no numerically usable pivot exists).
@@ -416,6 +456,12 @@ impl<const D: usize> Matrix<D> {
416456
/// general-purpose factorization that tolerates non-symmetric inputs, use
417457
/// [`lu`](Self::lu) instead.
418458
///
459+
/// The `tol` argument is a [`Tolerance`], so raw caller input must be
460+
/// finite and non-negative before it can reach factorization. Use
461+
/// [`Tolerance::new`] or [`LaError::validate_tolerance`] when accepting a
462+
/// raw `f64`; negative, NaN, and infinite tolerances return
463+
/// [`LaError::InvalidTolerance`].
464+
///
419465
/// # Examples
420466
/// ```
421467
/// use la_stack::prelude::*;
@@ -569,6 +615,12 @@ impl<const D: usize> Matrix<D> {
569615
/// speedup (see [`det_direct`](Self::det_direct)). The `tol` parameter is only used
570616
/// by the LU fallback path for D ≥ 5.
571617
///
618+
/// The `tol` argument is a [`Tolerance`], so raw caller input must be
619+
/// finite and non-negative before it can reach the determinant path. Use
620+
/// [`Tolerance::new`] or [`LaError::validate_tolerance`] when accepting a
621+
/// raw `f64`; negative, NaN, and infinite tolerances return
622+
/// [`LaError::InvalidTolerance`].
623+
///
572624
/// # Examples
573625
/// ```
574626
/// use la_stack::prelude::*;
@@ -1464,6 +1516,21 @@ mod tests {
14641516
assert!(!a.is_symmetric(Tolerance::new(0.0).unwrap()).unwrap());
14651517
}
14661518

1519+
#[test]
1520+
fn first_asymmetry_rejects_scaled_epsilon_overflow() {
1521+
let a = Matrix::<2>::from_rows([[2.0, 0.0], [0.0, 1.0]]);
1522+
let tol = Tolerance::new(f64::MAX).unwrap();
1523+
1524+
assert_eq!(
1525+
a.first_asymmetry(tol),
1526+
Err(LaError::NonFinite { row: None, col: 0 })
1527+
);
1528+
assert_eq!(
1529+
a.is_symmetric(tol),
1530+
Err(LaError::NonFinite { row: None, col: 0 })
1531+
);
1532+
}
1533+
14671534
#[test]
14681535
fn first_asymmetry_flags_overflowed_finite_difference() {
14691536
let a = Matrix::<2>::from_rows([[1.0, f64::MAX], [-f64::MAX, 1.0]]);

0 commit comments

Comments
 (0)