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}