Skip to main content

solver/
validation.rs

1//! Canonical numerical error metrics shared across solver tests and benches.
2//!
3//! This module is the single place where numerical-versus-reference error is
4//! measured. It defines a normalized relative error and a tolerance check so
5//! every benchmark and integration test reports accuracy on the same scale.
6//!
7//! The metric is deliberately scale-free: dividing by the reference magnitude
8//! makes a $0.1\%$ tolerance mean the same thing whether the reference is a
9//! portfolio value of order $1$ or an inventory spread of order $0.1$. An
10//! absolute floor prevents division by zero for references at or near zero,
11//! where a relative error is undefined.
12
13/// Smallest reference magnitude considered non-degenerate.
14///
15/// References below this threshold fall back to absolute error. Kept
16/// independent of the caller so results remain comparable across problems.
17pub const DEFAULT_ABS_FLOOR: f64 = 1e-12;
18
19/// Normalized relative error between a numerical value and a reference.
20///
21/// Returns `|numerical - reference| / max(|reference|, floor)`. With the
22/// default floor this equals the signed relative error whenever the reference
23/// is not degenerate, and collapses to an absolute error when the reference is
24/// near zero.
25///
26/// # Examples
27///
28/// ```
29/// use solver::validation::relative_error;
30///
31/// assert!((relative_error(1.01, 1.0) - 0.01).abs() < 1e-15);
32/// assert!((relative_error(0.0, 0.0)).abs() < 1e-15);
33/// ```
34pub fn relative_error(numerical: f64, reference: f64) -> f64 {
35    relative_error_with_floor(numerical, reference, DEFAULT_ABS_FLOOR)
36}
37
38/// Normalized relative error with an explicit absolute floor.
39///
40/// `floor` must be non-negative. Use this only when a problem has a meaningful
41/// non-default scale; otherwise prefer [`relative_error`].
42///
43/// # Examples
44///
45/// ```
46/// use solver::validation::relative_error_with_floor;
47///
48/// let e = relative_error_with_floor(0.0002, 0.0, 1e-3);
49/// assert!((e - 0.2).abs() < 1e-15);
50/// ```
51pub fn relative_error_with_floor(numerical: f64, reference: f64, floor: f64) -> f64 {
52    let scale = reference.abs().max(floor.max(0.0));
53    (numerical - reference).abs() / scale
54}
55
56/// Percentage relative error.
57///
58/// `100.0 * relative_error(numerical, reference)`.
59///
60/// # Examples
61///
62/// ```
63/// use solver::validation::percent_error;
64///
65/// assert!((percent_error(0.99, 1.0) - 1.0).abs() < 1e-12);
66/// ```
67pub fn percent_error(numerical: f64, reference: f64) -> f64 {
68    100.0 * relative_error(numerical, reference)
69}
70
71/// Asserts that `numerical` is within `tolerance` relative error of `reference`.
72///
73/// `tolerance` is interpreted on the same scale as [`relative_error`]
74/// (fractional, not percent). Panics with a descriptive message on failure.
75///
76/// # Panics
77///
78/// Panics if `relative_error(numerical, reference) > tolerance`.
79///
80/// # Examples
81///
82/// ```
83/// use solver::validation::assert_within;
84///
85/// assert_within(1.005, 1.0, 0.01, "trivial check");
86/// ```
87pub fn assert_within(numerical: f64, reference: f64, tolerance: f64, label: &str) {
88    let err = relative_error(numerical, reference);
89    assert!(
90        err <= tolerance,
91        "{label}: numerical={numerical} reference={reference} rel_err={err} exceeds tolerance {tolerance}"
92    );
93}
94
95/// Asserts that `numerical` matches `reference` within an absolute or relative
96/// tolerance.
97///
98/// Passes when either `|numerical - reference| <= abs_tol` or
99/// `relative_error(numerical, reference) <= rel_tol`. This is the combined
100/// check appropriate for references that may be exactly zero, where a pure
101/// relative error is undefined and floating-point round-off must be absorbed
102/// by an absolute floor.
103///
104/// # Examples
105///
106/// ```
107/// use solver::validation::assert_close;
108///
109/// assert_close(1e-9, 0.0, 1e-6, 1e-6, "near-zero reference");
110/// assert_close(1.01, 1.0, 0.0, 0.02, "relative check");
111/// ```
112pub fn assert_close(numerical: f64, reference: f64, abs_tol: f64, rel_tol: f64, label: &str) {
113    let abs_err = (numerical - reference).abs();
114    let rel_err = relative_error(numerical, reference);
115    assert!(
116        abs_err <= abs_tol || rel_err <= rel_tol,
117        "{label}: numerical={numerical} reference={reference} abs_err={abs_err} rel_err={rel_err}"
118    );
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn relative_error_is_scale_free() {
127        assert!((relative_error(1.01, 1.0) - 0.01).abs() < 1e-15);
128        assert!((relative_error(10.1, 10.0) - 0.01).abs() < 1e-15);
129        assert!((relative_error(0.0101, 0.01) - 0.01).abs() < 1e-12);
130    }
131
132    #[test]
133    fn zero_reference_falls_back_to_absolute_error() {
134        let err = relative_error_with_floor(0.0002, 0.0, 1e-3);
135        assert!((err - 0.2).abs() < 1e-15);
136    }
137
138    #[test]
139    fn both_zero_gives_zero_error() {
140        assert_eq!(relative_error(0.0, 0.0), 0.0);
141    }
142
143    #[test]
144    fn percent_error_is_one_hundred_times_relative() {
145        assert!((percent_error(0.99, 1.0) - 1.0).abs() < 1e-12);
146    }
147
148    #[test]
149    #[should_panic]
150    fn assert_within_panics_on_violation() {
151        assert_within(1.05, 1.0, 0.01, "must fail");
152    }
153}