pounce_common/tolerance.rs
1//! Scale-aware significance tests for feasibility and optimality decisions.
2//!
3//! # Why this exists
4//!
5//! Multiplying a constraint row by a positive constant leaves the feasible set
6//! *exactly* unchanged — it is the same problem written differently. So a
7//! solver's verdict must not depend on it. Comparing a scale-*dependent*
8//! quantity (a constraint residual) against an *absolute* threshold breaks that
9//! invariant, and has produced defects in the restoration gates, presolve
10//! certification, and the solution verifier.
11//!
12//! The sharpest example: `x >= 2` over `x ∈ [0, 1]` reports
13//! `Infeasible_Problem_Detected` as written, and `Solve_Succeeded` when every
14//! row is multiplied by `1e-12` — because at that scale the residual falls
15//! under an absolute tolerance. Same empty feasible set, opposite verdicts.
16//!
17//! # The rule
18//!
19//! Compare a residual against `tol * scale`, where `scale` is the quantity's
20//! own natural magnitude. Both sides then move together under row scaling, so
21//! the test is invariant.
22//!
23//! A clamped form — `tol * max(scale, 1)` — looks safer and is **wrong**: the
24//! clamp reinstates the absolute floor for `scale < 1`, which is exactly the
25//! down-scaled direction that fails. This was measured, not assumed:
26//!
27//! ```text
28//! k residual scale tol*max(s,1) fires? | tol*s fires?
29//! -12 1.00e-12 1.00e-12 1.00e-08 false | 1.00e-20 true
30//! -8 1.00e-08 1.00e-08 1.00e-08 false | 1.00e-16 true
31//! 0 1.00e+00 1.00e+00 1.00e-08 true | 1.00e-08 true
32//! 12 1.00e+12 1.00e+12 1.00e+04 true | 1.00e+04 true
33//! ```
34//!
35//! # Direction of failure
36//!
37//! Both functions **fail closed**: a residual that cannot be judged (`NaN`, or
38//! non-finite) is reported *not* significant. For an infeasibility test that is
39//! the safe direction — it withholds a verdict rather than fabricating one. A
40//! caller that needs the opposite polarity must handle non-finite values itself
41//! rather than inverting the result.
42
43use crate::types::Number;
44
45/// Whether `value` is large enough, relative to its own natural magnitude, to
46/// be treated as a real quantity rather than numerical noise.
47///
48/// The threshold is `tol * |scale|`. When `scale` is zero or non-finite the
49/// relative test is undefined, so it degrades to the absolute `tol` — that case
50/// is a degenerate row with no magnitude, where there is nothing to be relative
51/// to.
52///
53/// Returns `false` for a non-finite `value` (see the module note on failing
54/// closed).
55///
56/// ```
57/// use pounce_common::tolerance::is_significant;
58/// // Same model at three row scalings: the verdict must not move.
59/// assert!(is_significant(1.0e-12, 1.0e-12, 1e-8));
60/// assert!(is_significant(1.0, 1.0, 1e-8));
61/// assert!(is_significant(1.0e12, 1.0e12, 1e-8));
62/// // Noise at any scale is still noise.
63/// assert!(!is_significant(1.0e-20, 1.0, 1e-8));
64/// ```
65pub fn is_significant(value: Number, scale: Number, tol: Number) -> bool {
66 if !value.is_finite() {
67 return false;
68 }
69 let s = scale.abs();
70 let threshold = if s.is_finite() && s > 0.0 {
71 tol * s
72 } else {
73 tol
74 };
75 value.abs() > threshold
76}
77
78/// Whether `value` is small enough, relative to its own natural magnitude, to
79/// be treated as satisfied — the **accepting** direction.
80///
81/// Threshold is `tol * max(|scale|, 1)`: never *stricter* than the plain
82/// absolute `tol`, but scaled up for a large row.
83///
84/// # Why this is not the negation of [`is_significant`]
85///
86/// The two answer different questions and are conservative in opposite
87/// directions, so they need different thresholds:
88///
89/// * *"Is this residual small enough to call the point feasible?"* — must never
90/// demand more precision than the solver promised. A solver converges to
91/// **absolute** residuals, so on a row of magnitude `1e-3` a residual of
92/// `1e-8` is a converged solution; a pure relative test with `tol = 1e-6`
93/// would reject it at a `1e-9` threshold. Hence the clamp.
94/// * *"Is this residual large enough to prove no point is feasible?"* — must not
95/// depend on how the model is written, so it uses [`is_significant`]'s pure
96/// `tol * scale`. A clamp there would reinstate the absolute floor and miss
97/// the down-scaled direction entirely.
98///
99/// Using one form for both was measured and fails: pure relative in the
100/// accepting direction rejects genuine solutions on small-magnitude rows.
101///
102/// ```
103/// use pounce_common::tolerance::is_negligible;
104/// // A converged absolute residual stays acceptable on a small row.
105/// assert!(is_negligible(1e-8, 1e-3, 1e-6));
106/// // On a row near 5e13, a residual of 3.15e3 is eleven relative digits.
107/// assert!(is_negligible(3.15e3, 5e13, 1e-6));
108/// // Genuine violations are still caught.
109/// assert!(!is_negligible(0.5, 1.0, 1e-6));
110/// ```
111pub fn is_negligible(value: Number, scale: Number, tol: Number) -> bool {
112 if !value.is_finite() {
113 return false;
114 }
115 let s = scale.abs();
116 let widened = if s.is_finite() { s.max(1.0) } else { 1.0 };
117 value.abs() <= tol * widened
118}
119
120/// The natural magnitude of a row, from the NLP scaling factor applied to it.
121///
122/// POUNCE's scaling picks `dc_i` so that `dc_i * c_i` is O(1); the row's own
123/// magnitude is therefore `1 / dc_i`. Using the factor the solver already
124/// computed avoids inventing a second, possibly disagreeing, notion of scale —
125/// `c_scale_vec` / `d_scale_vec` are the authority.
126///
127/// A missing, zero, or non-finite factor means "no scaling applied", which is
128/// magnitude `1.0`.
129///
130/// ```
131/// use pounce_common::tolerance::row_scale_from_factor;
132/// assert_eq!(row_scale_from_factor(1.0), 1.0);
133/// assert_eq!(row_scale_from_factor(1e-6), 1e6); // row shrunk by 1e-6 => magnitude 1e6
134/// assert_eq!(row_scale_from_factor(0.0), 1.0); // degenerate => unscaled
135/// ```
136pub fn row_scale_from_factor(factor: Number) -> Number {
137 if factor.is_finite() && factor > 0.0 {
138 1.0 / factor
139 } else {
140 1.0
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 /// The property the whole module exists for: scaling a residual and its
149 /// magnitude together must not change the answer.
150 #[test]
151 fn verdict_is_invariant_under_row_scaling() {
152 let tol = 1e-8;
153 for k in -12..=12 {
154 let s = 10f64.powi(k);
155 assert!(
156 is_significant(1.0 * s, s, tol),
157 "a unit violation at scale 10^{k} must stay significant"
158 );
159 assert!(
160 !is_significant(1e-12 * s, s, tol),
161 "noise at scale 10^{k} must stay insignificant"
162 );
163 }
164 }
165
166 /// Pins the bug in the clamped form, so nobody reintroduces it.
167 #[test]
168 fn clamped_form_would_lose_the_down_scaled_direction() {
169 let tol = 1e-8;
170 let (value, scale) = (1e-12, 1e-12); // a full unit violation at 1e-12 scale
171 assert!(is_significant(value, scale, tol));
172 // What `tol * max(scale, 1)` would have concluded:
173 assert!(
174 !(value.abs() > tol * scale.abs().max(1.0)),
175 "the clamped form misses this — that is why it is not used"
176 );
177 }
178
179 #[test]
180 fn degenerate_scale_falls_back_to_absolute() {
181 let tol = 1e-8;
182 assert!(is_significant(1e-6, 0.0, tol));
183 assert!(!is_significant(1e-10, 0.0, tol));
184 assert!(is_significant(1e-6, f64::INFINITY, tol));
185 assert!(is_significant(1e-6, f64::NAN, tol));
186 }
187
188 #[test]
189 fn non_finite_value_is_not_evidence() {
190 let tol = 1e-8;
191 assert!(!is_significant(f64::NAN, 1.0, tol));
192 assert!(!is_significant(f64::INFINITY, 1.0, tol));
193 assert!(!is_significant(f64::NEG_INFINITY, 1.0, tol));
194 }
195
196 #[test]
197 fn exactly_at_threshold_is_not_significant() {
198 // Strict `>` keeps the boundary on the conservative side.
199 assert!(!is_significant(1e-8, 1.0, 1e-8));
200 assert!(is_significant(1.0000001e-8, 1.0, 1e-8));
201 }
202
203 #[test]
204 fn accepting_direction_never_stricter_than_absolute() {
205 let tol = 1e-6;
206 // The case that made a pure relative test reject genuine solutions:
207 // a converged absolute residual on a small-magnitude row.
208 assert!(is_negligible(1e-8, 1e-3, tol));
209 // Pure relative would have rejected it:
210 assert!(
211 1e-8f64 > tol * 1e-3,
212 "pure relative flags it — that is the bug"
213 );
214 }
215
216 #[test]
217 fn accepting_direction_scales_up_for_large_rows() {
218 let tol = 1e-6;
219 // Eleven relative digits on a 5e13 row: acceptable.
220 assert!(is_negligible(3.15e3, 5e13, tol));
221 // A real violation at the same scale is not.
222 assert!(!is_negligible(1e10, 5e13, tol));
223 }
224
225 #[test]
226 fn the_two_directions_are_not_negations() {
227 let tol = 1e-6;
228 let (value, scale) = (1e-8, 1e-3);
229 // Both can be true at once: too small to accept as a violation, and
230 // too small to prove infeasibility from. They are different questions.
231 assert!(is_negligible(value, scale, tol));
232 assert!(is_significant(value, scale, tol));
233 }
234
235 #[test]
236 fn non_finite_is_never_negligible() {
237 let tol = 1e-6;
238 assert!(!is_negligible(f64::NAN, 1.0, tol));
239 assert!(!is_negligible(f64::INFINITY, 1.0, tol));
240 }
241
242 #[test]
243 fn row_scale_inverts_the_factor() {
244 assert_eq!(row_scale_from_factor(1.0), 1.0);
245 assert_eq!(row_scale_from_factor(1e-6), 1e6);
246 assert_eq!(row_scale_from_factor(1e6), 1e-6);
247 // Degenerate factors mean "unscaled".
248 assert_eq!(row_scale_from_factor(0.0), 1.0);
249 assert_eq!(row_scale_from_factor(-1.0), 1.0);
250 assert_eq!(row_scale_from_factor(f64::NAN), 1.0);
251 assert_eq!(row_scale_from_factor(f64::INFINITY), 1.0);
252 }
253
254 /// End-to-end: a factor from `c_scale_vec` feeding the significance test.
255 #[test]
256 fn factor_and_significance_compose() {
257 let tol = 1e-8;
258 // Solver shrank this row by 1e-6, so its natural magnitude is 1e6.
259 let scale = row_scale_from_factor(1e-6);
260 assert_eq!(scale, 1e6);
261 // Threshold is tol * scale = 1e-8 * 1e6 = 1e-2.
262 assert!(
263 !is_significant(1e-3, scale, tol),
264 "1e-3 is below the 1e-2 threshold"
265 );
266 assert!(is_significant(1e-1, scale, tol), "1e-1 is above it");
267 }
268}