Skip to main content

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}