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}