Skip to main content

pounce_algorithm/
inf_pr_floor.rs

1//! How long a solve has sat at a constraint violation it could not get
2//! below — the evidence a locally-infeasible verdict actually rests on.
3//!
4//! The five *reconstructed* locally-infeasible gates in restoration
5//! (`pounce_restoration::resto_inner_solver::run_inner_resto`) all claim
6//! "the solve stalled at a violation it could not improve on", and gh#661
7//! is what happens when nothing measures that claim: they test only that
8//! the recovered violation is *large*, which a diverging restoration
9//! satisfies ever more emphatically the worse it gets.
10//!
11//! The gh#661 divergence guard withholds those gates' verdict when the
12//! restoration sub-solve ends far worse than the violation it was
13//! *entered* at. That guard needs an exemption for one shape of run: a
14//! solve that provably could not get below some floor, and whose
15//! restoration then blew up over a handful of iterations at the end. The
16//! blow-up is the tail of that trajectory, not a description of it.
17//!
18//! gh#664 keyed that exemption on `inner_iter_count >= 1000`, which is a
19//! proxy for "it ran out of room" and a doubly loose one. A count is not a
20//! stall test — that substitution is the same class of error gh#661 fixed,
21//! where a *size* stood in for a stall test. And the counter it read is
22//! not what its comments claimed: the inner IPM's `iter_count` is seeded
23//! from the outer's (`IpRestoMinC_1Nrm.cpp:181`), so `1019` on
24//! `issue_508_infeasible_gap_1em2` is 1015 outer iterations plus a
25//! *four*-iteration sub-solve, not a sub-solve that ran a thousand times.
26//!
27//! [`InfPrFloor`] measures the property directly, at the scope where the
28//! long trajectory actually lives: the outer solve. It watches the
29//! original NLP's scaled primal infeasibility at each outer iterate and
30//! counts how many of them sat within [`FLOOR_BAND`] of the floor the
31//! count is being measured against. A solve still finding its way down
32//! keeps clearing the band and restarting that count; one that is out of
33//! room accumulates it.
34//!
35//! Two choices in that sentence are load-bearing rather than
36//! simplifications, and both were arrived at by measuring:
37//!
38//! *Cumulative, not consecutive.* A real trajectory pinned at a floor
39//! does not sit there quietly. `issue_508_infeasible_gap_1em2` returns to
40//! `1.0e-2` over and over across 1016 outer iterations while excursions to
41//! `9.56e1` break every run in between; simulated over its printed
42//! iteration trace, its *longest consecutive* stay is 39 — against 19 for
43//! `pooling_rt2stp`, which is feasible and must not be exempted. The
44//! consecutive measure does not separate them at all. Time spent at the
45//! floor does, by two orders of magnitude: 943 against 7.
46//!
47//! *Pinned reference, not running minimum.* Measuring the band against
48//! the running minimum lets it chase the iterates, so a solve creeping
49//! downward by 0.9x per iteration is forever within a decade of its own
50//! previous best. Under that reading a 2000-iteration grind — which
51//! reduces the violation by eighty-eight orders of magnitude, i.e. is
52//! working — accumulates all 2000 and buys the exemption. Pinned, it
53//! accumulates one decade's worth and resets.
54
55use pounce_common::types::{Index, Number};
56
57/// How far above the best violation seen so far an iterate may sit and
58/// still count as sitting *at* that floor.
59///
60/// An order of magnitude, matching
61/// `pounce_restoration::resto_inner_solver::RESTO_DIVERGENCE_HEADROOM`
62/// and for the same reason: a floor a solve keeps returning to is not a
63/// fixed point, and a tighter band reads ordinary wander as the solve
64/// having left it. The trajectories this must tell apart move by four to
65/// twelve orders of magnitude, so the band has room to be generous.
66const FLOOR_BAND: Number = 10.0;
67
68/// Running evidence, over one solve, that it found a floor on the
69/// constraint violation and could not get below it.
70///
71/// Fed once per outer iteration from the value already computed for the
72/// `inf_pr` column, so it costs no function evaluations. The restoration
73/// sub-IPM has its own [`InfPrFloor`] on its own `IpoptData`, measuring
74/// its own (restoration) NLP; the guard reads the outer one.
75#[derive(Debug, Clone, Copy, PartialEq)]
76pub struct InfPrFloor {
77    /// The violation the current count is being measured against.
78    ///
79    /// Deliberately *not* the running minimum. Measuring the band
80    /// against the running minimum lets it chase the iterates: a solve
81    /// creeping down by 0.9x per iteration is always within a decade of
82    /// its own previous best, so a 2000-iteration grind that reduced the
83    /// violation by eighty-eight orders of magnitude would have read as
84    /// 2000 iterations sitting at a floor. Pinned to where the count
85    /// started, the same grind reads 20 — one decade's worth — and then
86    /// resets, which is the honest description of a solve that is still
87    /// getting somewhere.
88    floor: Number,
89    /// How many iterates sat within [`FLOOR_BAND`] of `floor`.
90    ///
91    /// Reset to 1 — not 0 — whenever the solve gets a full band below
92    /// `floor`, because the iterate that got there is itself the first
93    /// one sitting at the new floor.
94    iters_at_floor: Index,
95    /// How many iterates were observed at all. Diagnostic: it separates
96    /// "this solve demonstrated no floor" from "nothing was sampled",
97    /// which read identically from [`Self::iters_at_floor`] alone.
98    samples: Index,
99}
100
101impl Default for InfPrFloor {
102    fn default() -> Self {
103        Self {
104            floor: Number::INFINITY,
105            iters_at_floor: 0,
106            samples: 0,
107        }
108    }
109}
110
111impl InfPrFloor {
112    /// Record the scaled primal infeasibility at one iterate.
113    pub fn observe(&mut self, inf_pr: Number) {
114        self.samples += 1;
115
116        // A non-finite iterate is not sitting at anything, and must not
117        // be allowed to move `floor` — a `NaN` reaching it would poison
118        // every later comparison.
119        if !inf_pr.is_finite() {
120            return;
121        }
122
123        if inf_pr * FLOOR_BAND < self.floor {
124            // A full band below the reference: whatever was being
125            // measured was not the floor. The count restarts from this
126            // iterate, which is the first one at the new one.
127            self.floor = inf_pr;
128            self.iters_at_floor = 1;
129        } else if inf_pr <= self.floor * FLOOR_BAND {
130            // Within the band of the floor already found. Note `floor`
131            // is left alone: a gain that does not clear the band is
132            // wander, not progress, and letting it ratchet the reference
133            // down is exactly the chase this field exists to avoid.
134            self.iters_at_floor += 1;
135        }
136        // Otherwise: above the band. Not evidence of a floor, but not
137        // evidence against one either — the solve is free to come back,
138        // and on the trajectories this exists for it repeatedly does
139        // (`issue_508_infeasible_gap_1em2` excurses to `9.56e1` between
140        // returns to `1.0e-2`). So the count is held, not reset.
141    }
142
143    /// How many iterates sat within an order of magnitude of the floor
144    /// this solve settled at. `0` when nothing was observed.
145    pub fn iters_at_floor(&self) -> Index {
146        self.iters_at_floor
147    }
148
149    /// The violation the count is measured against, or `INFINITY` if
150    /// nothing was observed. Diagnostic only.
151    pub fn floor(&self) -> Number {
152        self.floor
153    }
154
155    /// How many iterates were observed. Diagnostic only.
156    pub fn samples(&self) -> Index {
157        self.samples
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    fn feed(values: &[Number]) -> InfPrFloor {
166        let mut f = InfPrFloor::default();
167        for &v in values {
168            f.observe(v);
169        }
170        f
171    }
172
173    /// Nothing observed is not evidence of a floor, and must not read as
174    /// one: the exemption is granted on a *count*, so an unfed tracker
175    /// has to be indistinguishable from a solve that demonstrated
176    /// nothing.
177    #[test]
178    fn an_unobserved_solve_demonstrates_nothing() {
179        let f = InfPrFloor::default();
180        assert_eq!(f.iters_at_floor(), 0);
181        assert_eq!(f.samples(), 0);
182        assert!(f.floor().is_infinite());
183    }
184
185    /// The shape the exemption exists for, and why the measure is
186    /// cumulative rather than a longest-consecutive-run. Measured over
187    /// the real outer trajectories, longest-consecutive-stay gives 39 for
188    /// `issue_508_infeasible_gap_1em2` against 19 for the *feasible*
189    /// `pooling_rt2stp` — no separation at all, because a trajectory
190    /// pinned at a floor does not sit there quietly. This fixture bottoms
191    /// out at `1.0e-2` early and returns to it across 1016 outer
192    /// iterations while excursions to `9.56e1` break every run in
193    /// between. Held-not-reset, time at the floor accumulates anyway.
194    #[test]
195    fn a_floor_returned_to_across_excursions_still_accumulates() {
196        let mut vals = vec![2.65e-1, 6.89e-2, 2.01e-2, 1.00e-2];
197        for _ in 0..500 {
198            vals.push(1.04e-2);
199            vals.push(9.56e1); // excursion, well outside the band
200        }
201        let f = feed(&vals);
202        // `2.01e-2` set the reference (a full decade under `2.65e-1`);
203        // `1.00e-2` and every return to `1.04e-2` sit inside its band.
204        assert_eq!(f.floor(), 2.01e-2);
205        assert_eq!(f.iters_at_floor(), 502);
206        assert_eq!(f.samples(), 1004);
207    }
208
209    /// The counterpart: `pooling_rt2stp` is feasible and must not be
210    /// exempted. Its outer solve is 20 iterations long, so even if every
211    /// one of them sat at the floor it comes nowhere near evidence of
212    /// being out of room. Short solves cannot buy the exemption.
213    #[test]
214    fn a_short_solve_cannot_accumulate_a_long_floor() {
215        let vals: Vec<Number> = (0..20).map(|k| 2.72e-1 * (1.0 + k as Number)).collect();
216        assert!(feed(&vals).iters_at_floor() <= 20);
217    }
218
219    /// The hole that killed measuring the band against the running
220    /// *minimum*: a solve creeping down by 0.9x per iteration is forever
221    /// within a decade of its own previous best. Under that reading a
222    /// 2000-iteration grind — which reduces the violation by eighty-eight
223    /// orders of magnitude, i.e. is working perfectly — accumulated all
224    /// 2000 and would have been handed the exemption. Pinned to the
225    /// reference it accumulates one decade's worth and resets.
226    #[test]
227    fn a_slow_steady_grind_downwards_is_not_a_floor() {
228        let vals: Vec<Number> = (0..2000).map(|k| 1.0e3 * 0.9_f64.powi(k)).collect();
229        let f = feed(&vals);
230        assert_eq!(f.samples(), 2000);
231        assert!(
232            f.iters_at_floor() <= 25,
233            "a solve still descending must not accumulate a floor: {}",
234            f.iters_at_floor()
235        );
236    }
237
238    /// Monotone divergence — the failure gh#661 is about — accumulates
239    /// exactly one: the opening iterate, trivially the best seen. Nothing
240    /// after it is ever within a decade of that, however long it runs.
241    #[test]
242    fn monotone_divergence_accumulates_nothing_however_long_it_runs() {
243        let vals: Vec<Number> = (0..5000).map(|k| 1.0e-3 * 100.0_f64.powi(k)).collect();
244        let f = feed(&vals);
245        assert_eq!(f.samples(), 5000);
246        assert_eq!(f.iters_at_floor(), 1);
247    }
248
249    /// Wander inside the band counts and leaves the reference alone; a
250    /// drop that clears the band is a new floor and restarts the count at
251    /// the iterate that found it.
252    #[test]
253    fn the_band_holds_wander_and_a_real_drop_restarts_the_count() {
254        let f = feed(&[1.0e6, 9.0e5, 3.0e6, 8.0e5]);
255        assert_eq!(f.floor(), 1.0e6);
256        assert_eq!(f.iters_at_floor(), 4);
257
258        let g = feed(&[1.0e6, 1.0e6, 1.0e6, 1.0e1]);
259        assert_eq!(g.floor(), 1.0e1);
260        assert_eq!(g.iters_at_floor(), 1);
261    }
262
263    /// Non-finite iterates must neither count nor corrupt the reference.
264    /// A diverging solve reaches `inf` and `NaN` routinely.
265    #[test]
266    fn non_finite_iterates_neither_count_nor_corrupt_the_floor() {
267        let f = feed(&[5.0, Number::NAN, Number::INFINITY, 5.0]);
268        assert_eq!(f.floor(), 5.0);
269        assert_eq!(f.iters_at_floor(), 2);
270        assert_eq!(f.samples(), 4);
271    }
272}