Skip to main content

pounce_algorithm/
infeasibility_refutation.rs

1//! Refuting an infeasibility verdict with a point that is actually feasible.
2//!
3//! # Why this exists
4//!
5//! `Infeasible_Problem_Detected` (AMPL `solve_result_num` 200, Pyomo
6//! `TerminationCondition.infeasible`) is the most consequential thing POUNCE
7//! can say: a caller told a feasible model is infeasible has no signal that
8//! anything went wrong. It fails silently and confidently, which is worse than
9//! an error.
10//!
11//! The numerical paths that produce that verdict — the restoration gates, the
12//! outer cycle detector, the SQP infeasible-subproblem exit, the ℓ₁ wrapper's
13//! uncollapsed-slack certificate — all reason from a *local* argument: the
14//! feasibility sub-problem stopped making progress at a point whose violation
15//! is bounded away from zero. That is evidence, not proof, and gh #379 is what
16//! it looks like when the evidence is wrong. On seed 294 of the
17//! feasible-by-construction property sweep
18//! (`pyomo-pounce/tests/test_infeasibility_no_false_positives.py`) the solver
19//! *starts* at a point that satisfies every row exactly, walks away from it
20//! (the model carries `±1e30` row coefficients, so the barrier's slack
21//! initialization moves the scaled slack far from what `x` can follow), burns
22//! the restoration budget, and reports the model infeasible.
23//!
24//! A concrete feasible point settles the question outright. If some `x` inside
25//! the variable box satisfies every constraint, the feasible set is not empty,
26//! whatever a local argument concluded. So: before any numerical path is
27//! allowed to say "infeasible", try to refute it.
28//!
29//! # Which point
30//!
31//! The model's own starting point, clamped into the variable box. Deliberately
32//! *only* that one, unlike the presolve-side refutation
33//! (`pounce_presolve::witness_refutes_infeasibility`), which also samples the
34//! box midpoint and corners.
35//!
36//! The two are answering different questions. Presolve claims a *proof* over
37//! the whole box from interval arithmetic, so probing the box is exactly the
38//! right counter-evidence. Here the claim is numerical and the refutation runs
39//! on every solve that ends in the infeasible band; widening it to sampled
40//! points would change verdicts on models this change cannot be validated
41//! against (the benchmark corpus is not in the tree). The starting point needs
42//! no such justification: a modeller who hands the solver a feasible point and
43//! is told the model is infeasible has been given a wrong answer under any
44//! reading.
45//!
46//! # Direction
47//!
48//! One-directional, like its presolve twin: this can only ever *withdraw* a
49//! verdict, never create one. A model with no feasible point cannot produce a
50//! witness, so a genuinely infeasible model is untouched — and any failure to
51//! evaluate (`eval_g` returning false, a non-finite value, a missing starting
52//! point) simply declines to refute.
53
54use pounce_common::tolerance::is_significant;
55use pounce_common::types::Number;
56use pounce_nlp::tnlp::{BoundsInfo, StartingPoint, TNLP};
57use std::cell::RefCell;
58use std::rc::Rc;
59
60/// A point that satisfies every constraint and bound, disproving a candidate
61/// infeasibility verdict.
62#[derive(Debug, Clone)]
63pub struct FeasibleWitness {
64    /// The witnessing point, in the user's variable order.
65    pub x: Vec<Number>,
66    /// Largest constraint violation at `x`, for the diagnostic message. Below
67    /// `tol` times the row's own magnitude by construction.
68    pub max_violation: Number,
69}
70
71/// Try to refute a candidate infeasibility verdict using the model's starting
72/// point.
73///
74/// Returns the witness when the starting point (clamped into the variable box)
75/// satisfies every constraint; `None` when it does not, or when the model
76/// cannot be evaluated. `None` means "no refutation", never "infeasible".
77///
78/// `lower_bound_inf` / `upper_bound_inf` are the solver's
79/// `nlp_lower_bound_inf` / `nlp_upper_bound_inf` sentinels: a bound at or
80/// beyond them is treated as absent, both for clamping and for sizing a row's
81/// magnitude. Letting the sentinel (~1e19) inform the scale would put the
82/// slack around 1e11 and make every row look satisfied — the same trap the
83/// presolve refutation documents.
84pub fn starting_point_refutes_infeasibility(
85    tnlp: &Rc<RefCell<dyn TNLP>>,
86    lower_bound_inf: Number,
87    upper_bound_inf: Number,
88    tol: Number,
89) -> Option<FeasibleWitness> {
90    let info = tnlp.borrow_mut().get_nlp_info()?;
91    let n = info.n.max(0) as usize;
92    let m = info.m.max(0) as usize;
93    if n == 0 {
94        return None;
95    }
96
97    let mut x_l = vec![0.0; n];
98    let mut x_u = vec![0.0; n];
99    let mut g_l = vec![0.0; m];
100    let mut g_u = vec![0.0; m];
101    if !tnlp.borrow_mut().get_bounds_info(BoundsInfo {
102        x_l: &mut x_l,
103        x_u: &mut x_u,
104        g_l: &mut g_l,
105        g_u: &mut g_u,
106    }) {
107        return None;
108    }
109
110    let mut x = vec![0.0; n];
111    let mut z_l = vec![0.0; n];
112    let mut z_u = vec![0.0; n];
113    let mut lambda = vec![0.0; m];
114    let have_x0 = tnlp.borrow_mut().get_starting_point(StartingPoint {
115        init_x: true,
116        x: &mut x,
117        init_z: false,
118        z_l: &mut z_l,
119        z_u: &mut z_u,
120        init_lambda: false,
121        lambda: &mut lambda,
122    });
123    if !have_x0 || x.iter().any(|v| !v.is_finite()) {
124        return None;
125    }
126
127    // Clamp into the box. The solver does this to `x0` itself before iterating,
128    // so a point outside the declared bounds is not a witness as given — but the
129    // clamped point still is one if it satisfies the rows, since it is inside
130    // the box by construction.
131    for j in 0..n {
132        let lo_present = x_l[j].is_finite() && x_l[j] > lower_bound_inf;
133        let hi_present = x_u[j].is_finite() && x_u[j] < upper_bound_inf;
134        // A crossed box (`x_l > x_u`) cannot be clamped into; that is presolve's
135        // territory and not something to refute from. The test is on *present*
136        // bounds only, matching the two clamps below and the row magnitudes
137        // farther down (gh #398): an absent lower bound sitting at the `-1e19`
138        // sentinel is not crossed with a real upper bound of `-5e20`, and
139        // bailing there would withhold a perfectly good witness.
140        if lo_present && hi_present && x_l[j] > x_u[j] {
141            return None;
142        }
143        if lo_present && x[j] < x_l[j] {
144            x[j] = x_l[j];
145        }
146        if hi_present && x[j] > x_u[j] {
147            x[j] = x_u[j];
148        }
149    }
150
151    // No constraints: the box alone defines the feasible set, and a point
152    // inside a non-crossed box is a witness.
153    let mut g = vec![0.0; m];
154    if m > 0 && !tnlp.borrow_mut().eval_g(&x, true, &mut g) {
155        return None;
156    }
157
158    let mut max_violation: Number = 0.0;
159    for i in 0..m {
160        let v = g[i];
161        if !v.is_finite() {
162            return None;
163        }
164        // Only *finite* bounds inform a row's magnitude — see the doc comment.
165        let finite_mag = |b: Number, is_lower: bool| -> Number {
166            let absent = if is_lower {
167                b <= lower_bound_inf
168            } else {
169                b >= upper_bound_inf
170            };
171            if b.is_finite() && !absent {
172                b.abs()
173            } else {
174                0.0
175            }
176        };
177        let scale = v
178            .abs()
179            .max(finite_mag(g_l[i], true))
180            .max(finite_mag(g_u[i], false));
181        let lo_viol = if g_l[i].is_finite() && g_l[i] > lower_bound_inf {
182            g_l[i] - v
183        } else {
184            0.0
185        };
186        let hi_viol = if g_u[i].is_finite() && g_u[i] < upper_bound_inf {
187            v - g_u[i]
188        } else {
189            0.0
190        };
191        let viol = lo_viol.max(hi_viol).max(0.0);
192        // Pure relative — `tol * scale`, via `is_significant` — and *not* the
193        // clamped accepting form `is_negligible`.
194        //
195        // The clamped form is right when the question is "did the solver
196        // converge well enough to call this feasible", because a solver
197        // converges to absolute residuals. It is wrong here, where the question
198        // is "is this residual real, or evaluation noise on a row of this
199        // magnitude". The clamp reinstates an absolute floor for `scale < 1`,
200        // and that is precisely the down-scaled direction this gate must not be
201        // fooled in.
202        //
203        // Measured, not assumed. With `is_negligible` the scale-invariance
204        // harness (`pyomo-pounce/tests/test_scale_invariance.py`) regressed on
205        // three genuinely infeasible models at row scalings `1e-12 … 1e-8`:
206        // `x >= 2` over `x ∈ [0, 1]`, multiplied through by `1e-12`, has a
207        // violation of `2e-12` against a row magnitude of `2e-12` — a full unit
208        // violation — but `tol * max(scale, 1)` is `1e-8`, so the starting point
209        // read as a witness and a correct infeasibility verdict was withdrawn.
210        // `tol * scale` is `2e-20` and the verdict stands.
211        if is_significant(viol, scale, tol) {
212            return None;
213        }
214        max_violation = max_violation.max(viol);
215    }
216
217    Some(FeasibleWitness { x, max_violation })
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use pounce_nlp::tnlp::{IndexStyle, IpoptCq, IpoptData, NlpInfo, Solution, SparsityRequest};
224
225    const LO_INF: Number = -1e19;
226    const UP_INF: Number = 1e19;
227
228    /// `min (x-x0)^2` over `x ∈ [lo, hi]` subject to one row `a·x ∈ [g_l, g_u]`.
229    struct OneRow {
230        x0: Vec<Number>,
231        lo: Vec<Number>,
232        hi: Vec<Number>,
233        a: Vec<Number>,
234        g_l: Number,
235        g_u: Number,
236        eval_g_ok: bool,
237        have_x0: bool,
238    }
239
240    impl OneRow {
241        fn new(
242            x0: Vec<Number>,
243            lo: Vec<Number>,
244            hi: Vec<Number>,
245            a: Vec<Number>,
246            g_l: Number,
247            g_u: Number,
248        ) -> Self {
249            Self {
250                x0,
251                lo,
252                hi,
253                a,
254                g_l,
255                g_u,
256                eval_g_ok: true,
257                have_x0: true,
258            }
259        }
260    }
261
262    impl TNLP for OneRow {
263        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
264            Some(NlpInfo {
265                n: self.x0.len() as i32,
266                m: 1,
267                nnz_jac_g: self.a.len() as i32,
268                nnz_h_lag: 0,
269                index_style: IndexStyle::C,
270            })
271        }
272        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
273            b.x_l.copy_from_slice(&self.lo);
274            b.x_u.copy_from_slice(&self.hi);
275            b.g_l[0] = self.g_l;
276            b.g_u[0] = self.g_u;
277            true
278        }
279        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
280            if !self.have_x0 {
281                return false;
282            }
283            sp.x.copy_from_slice(&self.x0);
284            true
285        }
286        fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
287            Some(0.0)
288        }
289        fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, grad_f: &mut [Number]) -> bool {
290            grad_f.fill(0.0);
291            true
292        }
293        fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
294            if !self.eval_g_ok {
295                return false;
296            }
297            g[0] = self.a.iter().zip(x).map(|(a, v)| a * v).sum();
298            true
299        }
300        fn eval_jac_g(
301            &mut self,
302            _x: Option<&[Number]>,
303            _new_x: bool,
304            _mode: SparsityRequest<'_>,
305        ) -> bool {
306            true
307        }
308        fn finalize_solution(&mut self, _s: Solution<'_>, _d: &IpoptData, _c: &IpoptCq) {}
309    }
310
311    fn refute(t: OneRow) -> Option<FeasibleWitness> {
312        let rc: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(t));
313        starting_point_refutes_infeasibility(&rc, LO_INF, UP_INF, 1e-8)
314    }
315
316    /// gh #379 seed 294 in miniature: the modeller's own starting point sits
317    /// exactly on a row whose coefficients are `±1e30`.
318    #[test]
319    fn extreme_scale_starting_point_refutes() {
320        let w = refute(OneRow::new(
321            vec![5e5, 5e5],
322            vec![0.0, 0.0],
323            vec![1e6, 1e6],
324            vec![-1e30, 1e30],
325            -1e-6,
326            UP_INF,
327        ))
328        .expect("x0 satisfies the row exactly — the verdict must be withdrawn");
329        assert_eq!(w.x, vec![5e5, 5e5]);
330        assert_eq!(w.max_violation, 0.0);
331    }
332
333    /// The whole point of the gate: a model with no feasible point cannot
334    /// produce a witness, so a correct verdict survives.
335    #[test]
336    fn genuinely_infeasible_model_is_not_refuted() {
337        // x ∈ [0, 0.6] with the row x >= 0.7 — gh #372's reproducer.
338        assert!(
339            refute(OneRow::new(
340                vec![0.3],
341                vec![0.0],
342                vec![0.6],
343                vec![1.0],
344                0.7,
345                UP_INF
346            ))
347            .is_none()
348        );
349    }
350
351    /// The verdict must not depend on how the model is written. `x >= 2` over
352    /// `x ∈ [0, 1]` is empty at every row scaling, so no scaling may produce a
353    /// witness.
354    ///
355    /// This is the case that rules out the clamped `is_negligible` form: at
356    /// `s = 1e-12` the violation and the row magnitude are both `2e-12`, which
357    /// `tol * max(scale, 1)` calls negligible and `tol * scale` does not. The
358    /// scale-invariance harness caught it on three models at once.
359    #[test]
360    fn a_down_scaled_infeasible_row_is_never_refuted() {
361        for k in -12..=12 {
362            let s = 10f64.powi(k);
363            assert!(
364                refute(OneRow::new(
365                    vec![0.5],
366                    vec![0.0],
367                    vec![1.0],
368                    vec![s],
369                    2.0 * s,
370                    UP_INF
371                ))
372                .is_none(),
373                "`x >= 2` over `x ∈ [0, 1]` is empty at row scaling 10^{k} too"
374            );
375        }
376    }
377
378    /// The feasible twin of the sweep above: a witness stays a witness at every
379    /// row scaling.
380    #[test]
381    fn a_scaled_feasible_row_is_refuted_at_every_scale() {
382        for k in -12..=12 {
383            let s = 10f64.powi(k);
384            assert!(
385                refute(OneRow::new(
386                    vec![0.5],
387                    vec![0.0],
388                    vec![1.0],
389                    vec![s],
390                    0.25 * s,
391                    UP_INF
392                ))
393                .is_some(),
394                "`x >= 0.25` at `x = 0.5` holds at row scaling 10^{k} too"
395            );
396        }
397    }
398
399    /// A starting point outside the box is clamped, and the clamped point is a
400    /// witness on its own terms.
401    #[test]
402    fn starting_point_is_clamped_into_the_box() {
403        let w = refute(OneRow::new(
404            vec![5.0],
405            vec![0.0],
406            vec![1.0],
407            vec![1.0],
408            0.0,
409            2.0,
410        ))
411        .expect("clamped to x = 1, which satisfies 0 <= x <= 2");
412        assert_eq!(w.x, vec![1.0]);
413    }
414
415    /// Clamping must not manufacture a witness for a row the clamped point
416    /// violates.
417    #[test]
418    fn clamping_does_not_manufacture_a_witness() {
419        assert!(
420            refute(OneRow::new(
421                vec![5.0],
422                vec![0.0],
423                vec![1.0],
424                vec![1.0],
425                3.0,
426                UP_INF
427            ))
428            .is_none(),
429            "clamped to x = 1, which violates x >= 3"
430        );
431    }
432
433    /// Failing to evaluate declines to refute — it never asserts infeasibility.
434    #[test]
435    fn unevaluable_model_declines_to_refute() {
436        let mut t = OneRow::new(vec![0.5], vec![0.0], vec![1.0], vec![1.0], 0.0, 2.0);
437        t.eval_g_ok = false;
438        assert!(refute(t).is_none());
439
440        let mut t = OneRow::new(vec![0.5], vec![0.0], vec![1.0], vec![1.0], 0.0, 2.0);
441        t.have_x0 = false;
442        assert!(refute(t).is_none());
443
444        let t = OneRow::new(vec![Number::NAN], vec![0.0], vec![1.0], vec![1.0], 0.0, 2.0);
445        assert!(refute(t).is_none());
446    }
447
448    /// An absent bound must not set the row's magnitude from the ~1e19
449    /// sentinel — that would make every row look satisfied.
450    #[test]
451    fn infinite_bound_sentinel_does_not_inflate_the_scale() {
452        // Row value 1.0 against `g <= 0.5`: a real violation of 0.5, on a row
453        // whose only finite bound is 0.5. The absent lower bound is the
454        // sentinel and must contribute nothing.
455        assert!(
456            refute(OneRow::new(
457                vec![1.0],
458                vec![0.0],
459                vec![2.0],
460                vec![1.0],
461                LO_INF,
462                0.5
463            ))
464            .is_none()
465        );
466    }
467
468    /// A crossed box is presolve's business, not something to refute from.
469    #[test]
470    fn crossed_box_declines_to_refute() {
471        assert!(
472            refute(OneRow::new(
473                vec![0.5],
474                vec![1.0],
475                vec![0.0],
476                vec![1.0],
477                LO_INF,
478                UP_INF
479            ))
480            .is_none()
481        );
482    }
483
484    /// **gh #398.** A box with no lower bound and a real upper bound past the
485    /// *opposite* sentinel is not crossed — it is `x <= -5e20`. Testing the raw
486    /// pair saw `LO_INF > -5e20` and bailed, withholding a witness the row
487    /// plainly admits, in the one file whose every other bound test is already
488    /// directional.
489    #[test]
490    fn an_upper_bound_past_the_lower_sentinel_is_not_a_crossed_box() {
491        let w = refute(OneRow::new(
492            vec![0.0],
493            vec![LO_INF],
494            vec![-5.0e20],
495            vec![1.0],
496            LO_INF,
497            -1.0e20,
498        ))
499        .expect("x0 clamps to -5e20, which satisfies x <= -1e20");
500        assert_eq!(w.x, vec![-5.0e20]);
501    }
502}