Skip to main content

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}