pounce_algorithm/infeasibility_saddle.rs
1//! Testing a local-infeasibility verdict for a nearby point with less violation.
2//!
3//! # Why this exists
4//!
5//! `Infeasible_Problem_Detected` claims the solve stopped at a local minimizer
6//! of the constraint violation. Every gate that issues it checks that claim to
7//! first order only: the gradient of the violation vanishes, or a step along
8//! its negative does not help (`IpoptCq::infeasibility_descent_available`).
9//! A first-order test cannot tell a minimizer from a saddle or a maximizer,
10//! and nonconvex models have saddles of the violation that the iterates are
11//! attracted to.
12//!
13//! Vanderbei `discs` is the measured case. It places 12 discs subject to
14//! tangency equalities `(x_i - x_j)^2 + (y_i - y_j)^2 = (r_i + r_j)^2`. A
15//! solve that perturbs the trajectory (a different FERAL ordering, `mc64`
16//! scaling, or a different symbolic analysis) can end with discs 8 and 9 at
17//! the same centre. There the row `cons1[9,8]` reads `0 = 4`. Its gradient
18//! with respect to the centres is exactly zero, and the radii sit on their
19//! lower bound of 1, so every first-order test passes and the solve reports
20//! infeasibility. Upstream Ipopt 3.14.19 with MUMPS reports the same verdict
21//! at default options. But separating the two discs by `delta` lowers that
22//! row's violation to `4 - |delta|^2`: the point is a maximizer of the
23//! violation along every separating direction.
24//!
25//! # What is tested
26//!
27//! The max-norm violation `theta(x) = max_i dist(g_i(x), [g_l_i, g_u_i])`, at
28//! seeded random points near the verdict's point and clipped into the
29//! variable box. If one of them has less violation, beyond a margin that
30//! evaluation noise cannot reach, the verdict's point is not a local
31//! minimizer of `theta`.
32//!
33//! The norm is deliberate. At the `discs` point the ℓ₁ violation *is* locally
34//! minimal: moving one disc breaks its satisfied tangency rows at first order,
35//! and the sum of those exceeds the second-order gain on the coincident pair.
36//! Under the max norm those first-order breaks stay below the violated row's
37//! 4.0 for small steps, so the gain shows. A random probe can therefore
38//! refute a degenerate row's minimality without knowing which row it is.
39//!
40//! # Direction
41//!
42//! The probe changes no verdict by itself. It only decides whether the
43//! second-opinion ladder spends one more solve, started from the point the
44//! probe found, and that solve is promoted only if it converges, which no
45//! infeasible model can do. A genuinely infeasible model whose verdict point
46//! is a strict local minimizer of `theta` produces no escape point and pays
47//! nothing; one whose verdict point is a saddle pays one re-solve and keeps
48//! its verdict. Any failure to evaluate declines to produce a point.
49
50use pounce_common::types::Number;
51use pounce_nlp::tnlp::{BoundsInfo, TNLP};
52use std::cell::RefCell;
53use std::rc::Rc;
54
55/// The second-opinion rung that re-solves from [`SaddleEscape::x`].
56pub const SADDLE_ESCAPE_RUNG_LABEL: &str = "restart_from_infeasibility_saddle";
57
58/// Relative step sizes, largest first. Each variable moves by
59/// `step * max(1, |x_j|) * u_j` with `u_j` uniform in `[-1, 1)`.
60///
61/// Largest first because the re-solve starts from the returned point, and a
62/// point barely off the saddle has a violation gradient of the same small
63/// size, which a fresh barrier start can walk straight back from. The smaller
64/// steps are there for models whose first-order breaks of the satisfied rows
65/// outgrow the second-order gain at the larger ones.
66const STEPS: [Number; 3] = [1e-2, 1e-3, 1e-4];
67
68/// Random directions per step, each probed with both signs.
69const DIRECTIONS: usize = 8;
70
71/// Doublings along the direction that found a lower violation, at most. Ten
72/// reach `1024` probe steps, about `10x` the variable's own scale from the
73/// largest step.
74const EXTRAPOLATIONS: usize = 10;
75
76/// A trial counts only if it lowers `theta` by this fraction of its value at
77/// the verdict's point. Rounding moves a row evaluation by a few ulps of the
78/// row's terms; `1e-9` sits far above that and far below the gain on the
79/// measured case (`2.5e-5` of `theta` at the largest step on `discs`).
80const MARGIN: Number = 1e-9;
81
82/// Fixed seed: the probe has to reproduce on every platform and every run.
83const SEED: u64 = 0x5AD0_1E5C_A9E0_0001;
84
85/// A point near a local-infeasibility verdict's point with less max-norm
86/// violation, showing the verdict's point is not a local minimizer of it.
87#[derive(Debug, Clone)]
88pub struct SaddleEscape {
89 /// The point, in the user's variable order, inside the variable box.
90 pub x: Vec<Number>,
91 /// Max-norm violation at the verdict's point (clipped into the box).
92 pub theta_at_verdict: Number,
93 /// Max-norm violation at [`Self::x`].
94 pub theta_escaped: Number,
95 /// The relative step that found it, one of [`STEPS`].
96 pub step: Number,
97 /// How far [`Self::x`] lies along the probe's direction, in probe steps:
98 /// `1` for the probe point itself, `2^k` after `k` accepted doublings.
99 pub multiple: Number,
100}
101
102/// Probe near `x_verdict` for a point with less max-norm constraint
103/// violation.
104///
105/// Returns `None` when the model has no constraints, when the verdict's
106/// point has no measurable violation, when no probe lowers it beyond
107/// [`MARGIN`], or when the model cannot be evaluated. `None` means "no escape
108/// point found", never "the verdict is confirmed".
109///
110/// `lower_bound_inf` / `upper_bound_inf` are the solver's
111/// `nlp_lower_bound_inf` / `nlp_upper_bound_inf`: a bound at or beyond them is
112/// absent, both for clipping and for measuring a row's violation.
113pub fn infeasibility_saddle_escape(
114 tnlp: &Rc<RefCell<dyn TNLP>>,
115 x_verdict: &[Number],
116 lower_bound_inf: Number,
117 upper_bound_inf: Number,
118) -> Option<SaddleEscape> {
119 let info = tnlp.borrow_mut().get_nlp_info()?;
120 let n = info.n.max(0) as usize;
121 let m = info.m.max(0) as usize;
122 if n == 0 || m == 0 || x_verdict.len() != n {
123 return None;
124 }
125 let mut x_l = vec![0.0; n];
126 let mut x_u = vec![0.0; n];
127 let mut g_l = vec![0.0; m];
128 let mut g_u = vec![0.0; m];
129 if !tnlp.borrow_mut().get_bounds_info(BoundsInfo {
130 x_l: &mut x_l,
131 x_u: &mut x_u,
132 g_l: &mut g_l,
133 g_u: &mut g_u,
134 }) {
135 return None;
136 }
137 let lo_present = |b: Number| b.is_finite() && b > lower_bound_inf;
138 let hi_present = |b: Number| b.is_finite() && b < upper_bound_inf;
139 for j in 0..n {
140 // A crossed box is presolve's business, as in `infeasibility_refutation`.
141 if lo_present(x_l[j]) && hi_present(x_u[j]) && x_l[j] > x_u[j] {
142 return None;
143 }
144 }
145 let clip = |j: usize, v: Number| -> Number {
146 let mut v = v;
147 if lo_present(x_l[j]) && v < x_l[j] {
148 v = x_l[j];
149 }
150 if hi_present(x_u[j]) && v > x_u[j] {
151 v = x_u[j];
152 }
153 v
154 };
155
156 let mut g = vec![0.0; m];
157 let mut theta = |x: &[Number]| -> Option<Number> {
158 if !tnlp.borrow_mut().eval_g(x, true, &mut g) {
159 return None;
160 }
161 let mut worst: Number = 0.0;
162 for i in 0..m {
163 let v = g[i];
164 if !v.is_finite() {
165 return None;
166 }
167 let below = if lo_present(g_l[i]) { g_l[i] - v } else { 0.0 };
168 let above = if hi_present(g_u[i]) { v - g_u[i] } else { 0.0 };
169 worst = worst.max(below).max(above);
170 }
171 Some(worst)
172 };
173
174 // The reported point may sit a rounding distance outside the box
175 // (`honor_original_bounds` projects, the IPM's own iterate does not), and
176 // every trial below is clipped, so the reference is clipped too: otherwise
177 // the clip alone could read as a gain.
178 if x_verdict.iter().any(|v| !v.is_finite()) {
179 return None;
180 }
181 let base: Vec<Number> = (0..n).map(|j| clip(j, x_verdict[j])).collect();
182 let theta0 = theta(&base)?;
183 if !(theta0 > 0.0) {
184 return None;
185 }
186 let threshold = theta0 * (1.0 - MARGIN);
187
188 let mut state = SEED;
189 let mut u = vec![0.0; n];
190 let mut trial = vec![0.0; n];
191 for &step in &STEPS {
192 let mut best: Option<(Number, Vec<Number>)> = None;
193 for _ in 0..DIRECTIONS {
194 // A variable within one step of a present bound stays put, so
195 // the probe moves on the face of the box the point sits on and
196 // never clips. Clipping would break the symmetry the two-sided
197 // test relies on: on `discs` the radii sit on their lower bound,
198 // a step that grows one worsens the violated row at first order,
199 // and the step that would shrink it is clipped to nothing. A
200 // minimizer over the box is a minimizer over that face, so
201 // fixing these variables cannot manufacture a decrease.
202 for (j, uj) in u.iter_mut().enumerate() {
203 let draw = unit_symmetric(&mut state);
204 let h = step * base[j].abs().max(1.0);
205 let near_lo = lo_present(x_l[j]) && base[j] - x_l[j] < h;
206 let near_hi = hi_present(x_u[j]) && x_u[j] - base[j] < h;
207 *uj = if near_lo || near_hi { 0.0 } else { draw };
208 }
209 // Both signs must lower the violation. A first-order decrease
210 // along `+u` is an increase along `-u`, so this rejects what a
211 // one-sided test cannot: a verdict point that is a minimizer of
212 // the solver's own measure (ℓ₁ or ℓ₂) but not of the max norm,
213 // and one converged only to tolerance. A decrease even in the
214 // step is curvature, which is the claim being refuted.
215 let mut pair: [Option<(Number, Vec<Number>)>; 2] = [None, None];
216 for (k, sign) in [1.0, -1.0].into_iter().enumerate() {
217 for j in 0..n {
218 let h = step * base[j].abs().max(1.0);
219 trial[j] = clip(j, base[j] + sign * h * u[j]);
220 }
221 match theta(&trial) {
222 Some(t) if t < threshold => pair[k] = Some((t, trial.clone())),
223 _ => break,
224 }
225 }
226 let [Some(plus), Some(minus)] = pair else {
227 continue;
228 };
229 let lower = if plus.0 <= minus.0 { plus } else { minus };
230 if best.as_ref().is_none_or(|(b, _)| lower.0 < *b) {
231 best = Some(lower);
232 }
233 }
234 if let Some((mut t, mut x)) = best {
235 // Carry on along the direction that found it, doubling while the
236 // violation keeps falling. A point one probe step off the saddle
237 // has a violation gradient of the same small size, and a fresh
238 // barrier start walks straight back from it: measured on `discs`,
239 // a restart `1e-2` off the coincident pair re-collapsed it and
240 // ended infeasible again in 31 iterations.
241 let dir: Vec<Number> = (0..n).map(|j| x[j] - base[j]).collect();
242 let mut multiple = 1.0;
243 for _ in 0..EXTRAPOLATIONS {
244 let next = 2.0 * multiple;
245 for j in 0..n {
246 trial[j] = clip(j, base[j] + next * dir[j]);
247 }
248 match theta(&trial) {
249 Some(tt) if tt < t => {
250 t = tt;
251 x.copy_from_slice(&trial);
252 multiple = next;
253 }
254 _ => break,
255 }
256 }
257 return Some(SaddleEscape {
258 x,
259 theta_at_verdict: theta0,
260 theta_escaped: t,
261 step,
262 multiple,
263 });
264 }
265 }
266 None
267}
268
269/// SplitMix64, as in `pounce_nlp::start_conditioner`: seedable and identical
270/// on every platform.
271fn splitmix64(state: &mut u64) -> u64 {
272 *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
273 let mut z = *state;
274 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
275 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
276 z ^ (z >> 31)
277}
278
279/// Uniform in `[-1, 1)` from 53 random bits.
280fn unit_symmetric(state: &mut u64) -> Number {
281 let bits = splitmix64(state) >> 11;
282 (bits as Number) / ((1u64 << 53) as Number) * 2.0 - 1.0
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use pounce_nlp::tnlp::{
289 IndexStyle, IpoptCq, IpoptData, NlpInfo, Solution, SparsityRequest, StartingPoint,
290 };
291
292 const LO_INF: Number = -1e19;
293 const UP_INF: Number = 1e19;
294
295 /// Rows given as closures of `x`, with bounds.
296 struct Rows {
297 lo: Vec<Number>,
298 hi: Vec<Number>,
299 g_l: Vec<Number>,
300 g_u: Vec<Number>,
301 g: fn(&[Number], &mut [Number]),
302 /// Multiplies every row value; bounds are scaled by the caller.
303 scale: Number,
304 }
305
306 impl Rows {
307 fn scaled(mut self, s: Number) -> Self {
308 self.scale = s;
309 self
310 }
311 }
312
313 impl TNLP for Rows {
314 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
315 Some(NlpInfo {
316 n: self.lo.len() as i32,
317 m: self.g_l.len() as i32,
318 nnz_jac_g: 0,
319 nnz_h_lag: 0,
320 index_style: IndexStyle::C,
321 })
322 }
323 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
324 b.x_l.copy_from_slice(&self.lo);
325 b.x_u.copy_from_slice(&self.hi);
326 b.g_l.copy_from_slice(&self.g_l);
327 b.g_u.copy_from_slice(&self.g_u);
328 true
329 }
330 fn get_starting_point(&mut self, _sp: StartingPoint<'_>) -> bool {
331 false
332 }
333 fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
334 Some(0.0)
335 }
336 fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, grad_f: &mut [Number]) -> bool {
337 grad_f.fill(0.0);
338 true
339 }
340 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
341 (self.g)(x, g);
342 for v in g.iter_mut() {
343 *v *= self.scale;
344 }
345 true
346 }
347 fn eval_jac_g(
348 &mut self,
349 _x: Option<&[Number]>,
350 _new_x: bool,
351 _mode: SparsityRequest<'_>,
352 ) -> bool {
353 true
354 }
355 fn finalize_solution(&mut self, _s: Solution<'_>, _d: &IpoptData, _c: &IpoptCq) {}
356 }
357
358 fn probe(t: Rows, x: &[Number]) -> Option<SaddleEscape> {
359 let rc: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(t));
360 infeasibility_saddle_escape(&rc, x, LO_INF, UP_INF)
361 }
362
363 /// Two unit discs on a line that must touch, `(a - b)^2 = 4`, stopped
364 /// with both centres at 0: the `discs` trap in one dimension. Separating
365 /// them lowers the violation, so the probe must find a point.
366 #[test]
367 fn coincident_tangent_discs_are_a_saddle_of_the_violation() {
368 let rows = Rows {
369 lo: vec![LO_INF, LO_INF],
370 hi: vec![UP_INF, UP_INF],
371 g_l: vec![4.0],
372 g_u: vec![4.0],
373 scale: 1.0,
374 g: |x, g| g[0] = (x[0] - x[1]).powi(2),
375 };
376 let e = probe(rows, &[0.0, 0.0]).expect("separating the discs lowers the violation");
377 assert_eq!(e.theta_at_verdict, 4.0);
378 assert!(e.theta_escaped < 4.0);
379 assert_eq!(e.step, STEPS[0]);
380 }
381
382 /// The same trap with a satisfied tangency beside it, which every move of
383 /// `a` breaks at first order. The ℓ₁ violation is locally minimal here;
384 /// the max norm is not, and that is the one the probe uses.
385 #[test]
386 fn a_satisfied_row_broken_at_first_order_does_not_hide_the_saddle() {
387 let rows = Rows {
388 lo: vec![LO_INF; 3],
389 hi: vec![UP_INF; 3],
390 g_l: vec![4.0, 4.0],
391 g_u: vec![4.0, 4.0],
392 // a = x0, b = x1 coincide; c = x2 sits tangent to a.
393 scale: 1.0,
394 g: |x, g| {
395 g[0] = (x[0] - x[1]).powi(2);
396 g[1] = (x[0] - x[2]).powi(2);
397 },
398 };
399 assert!(probe(rows, &[0.0, 0.0, 2.0]).is_some());
400 }
401
402 /// `x^2 + 1 = 0` at `x = 0` is a strict local minimizer of the violation
403 /// of a genuinely infeasible model. No probe can lower it.
404 #[test]
405 fn a_strict_minimizer_of_the_violation_produces_no_point() {
406 let rows = Rows {
407 lo: vec![LO_INF],
408 hi: vec![UP_INF],
409 g_l: vec![0.0],
410 g_u: vec![0.0],
411 scale: 1.0,
412 g: |x, g| g[0] = x[0] * x[0] + 1.0,
413 };
414 assert!(probe(rows, &[0.0]).is_none());
415 }
416
417 /// `x >= 0.7` over `x ∈ [0, 0.6]`, stopped at the upper bound: the only
418 /// descent leaves the box, and clipping must not count it.
419 #[test]
420 fn descent_that_only_exists_outside_the_box_is_not_counted() {
421 let rows = Rows {
422 lo: vec![0.0],
423 hi: vec![0.6],
424 g_l: vec![0.7],
425 g_u: vec![UP_INF],
426 scale: 1.0,
427 g: |x, g| g[0] = x[0],
428 };
429 assert!(probe(rows, &[0.6]).is_none());
430 }
431
432 /// The `discs` shape: the tangency row also depends on two radii pinned
433 /// at their lower bound, and growing either worsens the row at first
434 /// order. The probe must move on the face the point sits on.
435 #[test]
436 fn variables_on_a_bound_stay_put_so_the_saddle_still_shows() {
437 let rows = Rows {
438 lo: vec![LO_INF, LO_INF, 1.0, 1.0],
439 hi: vec![UP_INF; 4],
440 g_l: vec![0.0],
441 g_u: vec![0.0],
442 scale: 1.0,
443 // (a - b)^2 - (r1 + r2)^2 = 0 at a = b = 0, r1 = r2 = 1.
444 g: |x, g| g[0] = (x[0] - x[1]).powi(2) - (x[2] + x[3]).powi(2),
445 };
446 let e = probe(rows, &[0.0, 0.0, 1.0, 1.0]).expect("the centres can separate");
447 assert_eq!(&e.x[2..], &[1.0, 1.0], "the radii stay on their bound");
448 }
449
450 /// Two contradictory rows of different scale, `x = 0` and `2x = 2`. The
451 /// ℓ₁ violation is minimized at `x = 1`, where the max norm still falls
452 /// to the left at first order — and rises to the right. That is a norm
453 /// mismatch, not a saddle, and the two-sided test must not take it.
454 #[test]
455 fn a_first_order_decrease_on_one_side_only_is_not_a_saddle() {
456 let rows = Rows {
457 lo: vec![LO_INF],
458 hi: vec![UP_INF],
459 g_l: vec![0.0, 2.0],
460 g_u: vec![0.0, 2.0],
461 scale: 1.0,
462 g: |x, g| {
463 g[0] = x[0];
464 g[1] = 2.0 * x[0];
465 },
466 };
467 assert!(probe(rows, &[1.0]).is_none());
468 }
469
470 /// A point with no violation is not an infeasibility verdict's point.
471 #[test]
472 fn a_feasible_point_produces_no_point() {
473 let rows = Rows {
474 lo: vec![LO_INF, LO_INF],
475 hi: vec![UP_INF, UP_INF],
476 g_l: vec![4.0],
477 g_u: vec![4.0],
478 scale: 1.0,
479 g: |x, g| g[0] = (x[0] - x[1]).powi(2),
480 };
481 assert!(probe(rows, &[0.0, 2.0]).is_none());
482 }
483
484 /// The probe is seeded and must give the same point on every call.
485 #[test]
486 fn the_probe_is_deterministic() {
487 let mk = || Rows {
488 lo: vec![LO_INF, LO_INF],
489 hi: vec![UP_INF, UP_INF],
490 g_l: vec![4.0],
491 g_u: vec![4.0],
492 scale: 1.0,
493 g: |x, g| g[0] = (x[0] - x[1]).powi(2),
494 };
495 let a = probe(mk(), &[0.0, 0.0]).unwrap();
496 let b = probe(mk(), &[0.0, 0.0]).unwrap();
497 assert_eq!(a.x, b.x);
498 }
499
500 /// Row scaling cancels out of the ratio: the trap is found, and the
501 /// strict minimizer is not, at every row scaling.
502 #[test]
503 fn the_verdict_does_not_depend_on_row_scaling() {
504 for k in -8..=8 {
505 let s = 10f64.powi(k);
506 let saddle = Rows {
507 lo: vec![LO_INF, LO_INF],
508 hi: vec![UP_INF, UP_INF],
509 g_l: vec![4.0 * s],
510 g_u: vec![4.0 * s],
511 scale: 1.0,
512 g: |x, g| g[0] = (x[0] - x[1]).powi(2),
513 }
514 .scaled(s);
515 assert!(
516 probe(saddle, &[0.0, 0.0]).is_some(),
517 "saddle at scale 10^{k}"
518 );
519 let minimizer = Rows {
520 lo: vec![LO_INF],
521 hi: vec![UP_INF],
522 g_l: vec![0.0],
523 g_u: vec![0.0],
524 scale: 1.0,
525 g: |x, g| g[0] = x[0] * x[0] + 1.0,
526 }
527 .scaled(s);
528 assert!(
529 probe(minimizer, &[0.0]).is_none(),
530 "minimizer at scale 10^{k}"
531 );
532 }
533 }
534}