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}