pounce_algorithm/conv_check/opt_error.rs
1//! Optimal-error convergence check — port of
2//! `Algorithm/IpOptErrorConvCheck.{hpp,cpp}`.
3//!
4//! Tolerance state machine over `(nlp_err, iter_count)` plus
5//! per-component infeasibilities pulled directly from
6//! [`IpoptCalculatedQuantities`]. The scalar
7//! [`Self::check_convergence`] entry point only gates on
8//! `nlp_err <= tol` (matching upstream when the per-component
9//! tolerances are at their `+∞` sentinels); the state-aware
10//! [`Self::check_convergence_with_state`] adds the
11//! `dual_inf_tol` / `constr_viol_tol` / `compl_inf_tol` gates that
12//! mirror upstream `OptimalityErrorConvergenceCheck::CheckConvergence`.
13
14use crate::conv_check::r#trait::{ConvCheck, ConvergenceStatus};
15use crate::ipopt_cq::IpoptCqHandle;
16use crate::ipopt_data::IpoptDataHandle;
17use pounce_common::types::{Index, Number};
18
19#[derive(Clone)]
20pub struct OptErrorConvCheck {
21 pub tol: Number,
22 pub dual_inf_tol: Number,
23 pub constr_viol_tol: Number,
24 pub compl_inf_tol: Number,
25 pub acceptable_tol: Number,
26 pub acceptable_dual_inf_tol: Number,
27 pub acceptable_constr_viol_tol: Number,
28 pub acceptable_compl_inf_tol: Number,
29 pub acceptable_obj_change_tol: Number,
30 pub acceptable_iter: Index,
31 pub max_iter: Index,
32 pub max_cpu_time: Number,
33 pub max_wall_time: Number,
34 pub acceptable_count: Index,
35 /// Objective value at the last iterate the main loop stashed via
36 /// `set_curr_acceptable_obj`. Used by the
37 /// `acceptable_obj_change_tol` cross-check. `None` until an
38 /// acceptable point has been recorded.
39 pub last_acceptable_obj: Option<Number>,
40 /// Tolerance on the scaled infeasibility stationarity
41 /// `‖Jᵀc‖/max(1,‖c‖)`. An iterate counts toward the infeasibility
42 /// streak when this ratio is at or below this value while the
43 /// constraint violation stays bounded away from zero. Rapid
44 /// infeasibility detection is disabled when this is non-positive.
45 pub infeas_stationarity_tol: Number,
46 /// Multiple of `constr_viol_tol` the constraint violation must
47 /// exceed before an iterate can count as infeasible-stationary —
48 /// keeps detection from firing on nearly-feasible flat spots. Floored
49 /// at [`MIN_INFEAS_VIOL_FLOOR`]; see
50 /// [`OptErrorConvCheck::absolute_viol_threshold`].
51 pub infeas_viol_kappa: Number,
52 /// Consecutive infeasible-stationary iterations required before
53 /// terminating with `LocallyInfeasible`. Non-positive disables
54 /// rapid infeasibility detection.
55 pub infeas_max_streak: Index,
56 /// Running count of consecutive infeasible-stationary iterations.
57 pub infeas_streak: Index,
58 /// Objective-scale floor below which a strict certificate is refused
59 /// while the *unscaled* KKT error is still above `acceptable_tol`
60 /// (gh #200). See [`certificate_masked`]. `0` disables the mechanism
61 /// entirely, restoring bit-for-bit upstream-Ipopt behaviour.
62 pub obj_scale_certificate_threshold: Number,
63 /// Safety factor on the per-row noise floor the **strict** gate judges the
64 /// primal term against (gh #528). `0` disables the floor entirely,
65 /// restoring upstream Ipopt's bare-absolute primal residual.
66 pub primal_noise_floor_kappa: Number,
67 /// Fraction of `acceptable_tol` the KKT error — and, relative to the
68 /// objective's own size, the objective — may drift across the
69 /// acceptable-level streak's window while the streak still counts as
70 /// *settled* (gh #533). See [`Self::streak_has_flattened`]. `0` disables
71 /// the progress test, leaving acceptable-level termination the bare
72 /// consecutive-count criterion upstream Ipopt uses.
73 pub acceptable_progress_kappa: Number,
74 /// Trailing `(nlp_err, f)` samples of the current acceptable-level streak,
75 /// oldest first, at most [`Self::progress_window_len`] entries. Cleared
76 /// whenever the streak breaks — the window describes *this* streak.
77 pub acceptable_window: std::collections::VecDeque<(Number, Number)>,
78 /// Acceptable-level terminations the gh #533 progress test has refused so
79 /// far this solve. Bounded by [`ACCEPTABLE_PROGRESS_MAX_REFUSALS`], past
80 /// which the test stands aside and the streak terminates as it would
81 /// without it.
82 pub acceptable_progress_refusals: Index,
83 /// Safety factor on the scale-relative floor the **strict** gate judges
84 /// `dual_inf` against (gh #532); see [`Self::dual_inf_bound`]. `0` disables
85 /// the floor, restoring upstream Ipopt's bare-absolute `dual_inf_tol`.
86 pub dual_inf_scale_kappa: Number,
87 /// Whether the gh #532 scale-relative dual floor has already been reported
88 /// this solve. Diagnostic only — the certificate below carries a dual
89 /// infeasibility above `dual_inf_tol`, which is worth saying once and not
90 /// once per iteration.
91 pub dual_floor_reported: bool,
92 /// Whether a masked **strict** certificate was ever refused this solve.
93 pub veto_fired: bool,
94 /// Whether a masked **acceptable-level** termination was ever refused.
95 ///
96 /// Tracked separately because the two refusals must be undone differently:
97 /// a refused strict certificate restores as `Success`, a refused
98 /// acceptable-level one as `StopAtAcceptablePoint`. Conflating them would
99 /// either over-claim a status or, as originally written, leave the
100 /// acceptable-level refusal with no safety net at all.
101 ///
102 /// Set by **both** refusal arms — the gh #200 masked-scale veto and the
103 /// gh #533 progress test — because both need the same undo. What the
104 /// masked veto's own iteration budget counts is
105 /// [`Self::masked_acceptable_veto_fired`].
106 pub acceptable_veto_fired: bool,
107 /// Whether the *masked-scale* (gh #200) arm specifically refused an
108 /// acceptable-level termination.
109 ///
110 /// [`VETO_MAX_EXTRA_ITERS`] is the masked veto's budget, so only the masked
111 /// arms may spend it. Counting the gh #533 progress refusals against it too
112 /// would silently disarm the masked veto 60 iterations into any solve whose
113 /// acceptable streak was progress-refused — a different mechanism's bug
114 /// coming back for reasons having nothing to do with objective scaling.
115 pub masked_acceptable_veto_fired: bool,
116 /// Iterations spent since the veto first refused a certificate.
117 ///
118 /// The veto is a bet that continuing reaches a better point. Some problems
119 /// never let it pay off — an unscaled error pinned above `acceptable_tol`
120 /// by an unbounded direction keeps the veto engaged until `max_iter`,
121 /// turning a 40-iteration solve into a 300-iteration one for nothing. Past
122 /// [`VETO_MAX_EXTRA_ITERS`] the bet is called off and the run is allowed to
123 /// terminate normally; correctness does not depend on the cap, because the
124 /// refused certificate is restored either way.
125 pub veto_extra_iters: Index,
126 /// Iterations on which the scale-relative feasibility veto blocked a
127 /// certificate (strict or acceptable) that the absolute tolerances had
128 /// passed. Bounded by [`VETO_MAX_EXTRA_ITERS`]; past the budget the veto
129 /// disengages and the run terminates as it would have without it, so the
130 /// worst case is a bounded number of extra iterations, never a lost
131 /// verdict. See [`Self::relative_viol_threshold`].
132 ///
133 /// Read by the *certificate* arm only. The acceptable-point stash's gate
134 /// in [`ConvCheck::current_is_acceptable_with_state`] is deliberately
135 /// unbudgeted (gh #693) — declining to stash spends no iterations, so a
136 /// budget there bounds nothing and only ever expired, at which point the
137 /// point the veto exists to reject became the rollback target.
138 pub rel_infeas_extra_iters: Index,
139 /// Relative primal infeasibility at the previous
140 /// [`Self::note_infeasible_stationary`] call — the progress signal for the
141 /// relative arm's streak (see that method). `NAN` until first set, which
142 /// compares as "not improving" and lets the first iterate count.
143 pub prev_rel_viol: Number,
144}
145
146/// How many iterations the veto may spend before its bet is called off.
147///
148/// Generous relative to what a successful rescue costs — the reported quartics
149/// reach the true minimum in 11-15 extra iterations — but bounded, so a veto
150/// that can never lift (an unscaled error pinned above `acceptable_tol` by an
151/// unbounded direction) cannot run to `max_iter`. Correctness does not rest on
152/// this number: whatever happens after the budget is spent, the refused
153/// certificate is still restored if the run ends without a better one.
154const VETO_MAX_EXTRA_ITERS: Index = 60;
155
156/// How many acceptable-level terminations the gh #533 progress test may refuse
157/// before it stands aside for the rest of the solve.
158///
159/// The test is already self-limiting — it only refuses while the streak's own
160/// window shows the solve still moving, and a solve that stops moving flattens
161/// the window within `acceptable_iter` iterations — so this bounds only the
162/// pathological case: a solve that wanders inside the acceptable band without
163/// ever settling and without ever reaching `tol`. Left unbounded that solve
164/// would run to `max_iter` (returning the refused point, so no *verdict* is
165/// lost, but spending up to 3000 iterations to say what it could have said at
166/// 40).
167///
168/// The number has to clear the widest measured rescue: `kissing` needed 447
169/// iterations past the refusal (103 → 550) to reach its strict certificate, so
170/// anything below that cannot fix the reported case. `1000` clears it with room
171/// to spare and still stops well short of the default `max_iter = 3000`. Note
172/// that only iterations on which a termination is actually *refused* are
173/// counted, not every iteration after the first refusal — a streak broken by an
174/// iterate outside the band costs nothing here.
175const ACCEPTABLE_PROGRESS_MAX_REFUSALS: Index = 1000;
176
177/// Longest trailing streak window the progress test will keep samples for.
178///
179/// The window is `acceptable_iter` long (the streak's own length), which is 15
180/// by default. The cap exists because `acceptable_iter` is a user option with no
181/// upper bound, and the window is a live allocation. Past the cap the test
182/// judges flatness over the trailing `ACCEPTABLE_PROGRESS_WINDOW_MAX` iterates
183/// of the streak instead of all of it — a strictly more permissive reading (a
184/// shorter window can only contain less movement), so the cap can never make
185/// the mechanism fire where the full window would not have.
186const ACCEPTABLE_PROGRESS_WINDOW_MAX: usize = 256;
187
188/// Smallest constraint violation rapid infeasibility detection will ever treat
189/// as "bounded away from feasible" (gh #519).
190///
191/// Both arms of [`OptErrorConvCheck::is_infeasible_stationary`] scale their
192/// violation floor with `constr_viol_tol`, which is a *feasibility* tolerance:
193/// left unclamped, tightening it widens the set of points the detector is
194/// willing to convict, so asking for a stricter feasibility standard makes the
195/// solver more eager to answer "locally infeasible". That inversion is the bug
196/// this floor exists to prevent — at `constr_viol_tol = 1e-6` the absolute arm's
197/// floor fell to `1e-4` and @bernalde's `f=1` model (gh #505), plateaued at an
198/// unscaled violation of `1.94e-4` with a scaled NLP error of `4.89e-10`, was
199/// reported infeasible at iteration 27 instead of "Solved To Acceptable Level"
200/// at 37. The flip tracked `100 · constr_viol_tol` to three significant figures.
201///
202/// `1e-2` is the default `acceptable_constr_viol_tol`, so the floor also states
203/// the intended rule directly: never convict a point of infeasibility while its
204/// violation sits inside the band the defaults call acceptable. The two forms
205/// coincide out of the box, which is why the defect was invisible there.
206///
207/// Erring loose is the safe direction — a withheld verdict costs iterations and
208/// ends at `MaxIterExceeded` or an acceptable point, while a fabricated one is
209/// a wrong answer. `infeas_viol_kappa` still raises the floor above this; the
210/// disable switch remains `infeas_stationarity_tol = 0` (or
211/// `infeas_max_streak = 0`), not a floor small enough to never bind.
212const MIN_INFEAS_VIOL_FLOOR: Number = 1e-2;
213
214/// Is a passing strict certificate *masked* by an extreme objective scale
215/// (gh #200)?
216///
217/// Gradient-based scaling picks `df = nlp_scaling_max_gradient / max‖∇f‖`,
218/// floored at `nlp_scaling_min_value = 1e-8`. On a flat quartic the initial
219/// gradient is enormous (`quartc`: ~4e12 → `df` pinned at the floor), and the
220/// strict test then runs on the *scaled* aggregate. Because a quartic's
221/// gradient vanishes cubically toward its minimum while `df` stays fixed at its
222/// initial value, the scaled error crosses `tol` roughly 30% of the way in: the
223/// solver certifies optimality at `quartc` objective 248.88 when the true
224/// minimum is ~0, with an unscaled dual infeasibility of 0.84.
225///
226/// This predicate deliberately does **not** try to decide whether the stop is
227/// genuinely false — it only asks whether the conditions that make a false stop
228/// *possible* are present. Distinguishing a masked certificate from an honest
229/// one at a small scale cannot be done from the residual magnitude: `meyer3`
230/// sits at the same 1e-8 scale floor as `quartc` while being genuinely
231/// converged, and the unscaled error is a *dimensional* quantity, so any
232/// absolute cutoff separating them would move if the objective were rescaled —
233/// precisely the sensitivity this bug is about. An earlier revision of this
234/// work did exactly that (a 5e-2 bar fitted to the gap in one benchmark suite);
235/// it is not defensible and was removed.
236///
237/// Instead the caller *tests* the hypothesis: it refuses to stop, continues,
238/// and sees whether the iterates actually go anywhere. If they do, the stop was
239/// false. If they do not, the certificate is honoured unchanged — so the
240/// mechanism is never worse than not having it (see `terminate_vetoed_or`).
241pub fn certificate_masked(
242 obj_scale: Number,
243 unscaled_err: Number,
244 threshold: Number,
245 acceptable_tol: Number,
246) -> bool {
247 // A non-positive threshold is the documented opt-out; NaN is treated the
248 // same way rather than silently enabling the mechanism.
249 if threshold.is_nan() || threshold <= 0.0 {
250 return false;
251 }
252 // Magnitude, not signed value: a negative `obj_scaling_factor` (the
253 // documented way to maximize) is trivially below any positive threshold,
254 // which would arm this on every maximization regardless of scale.
255 obj_scale.abs() < threshold && unscaled_err > acceptable_tol
256}
257
258impl Default for OptErrorConvCheck {
259 fn default() -> Self {
260 // Defaults from `IpOptErrorConvCheck.cpp:RegisterOptions`.
261 Self {
262 tol: 1e-8,
263 dual_inf_tol: 1.0,
264 constr_viol_tol: 1e-4,
265 compl_inf_tol: 1e-4,
266 acceptable_tol: 1e-6,
267 acceptable_dual_inf_tol: 1e10,
268 acceptable_constr_viol_tol: 1e-2,
269 acceptable_compl_inf_tol: 1e-2,
270 acceptable_obj_change_tol: 1e20,
271 acceptable_iter: 15,
272 max_iter: 3000,
273 max_cpu_time: 1e6,
274 max_wall_time: 1e6,
275 acceptable_count: 0,
276 last_acceptable_obj: None,
277 infeas_stationarity_tol: 1e-8,
278 infeas_viol_kappa: 1e2,
279 infeas_max_streak: 5,
280 infeas_streak: 0,
281 // 1e-4 separates the falsely-certified problems (objective scale
282 // pinned at the 1e-8 floor) from every recorded collateral case
283 // (`hs1`/`hs38` at ~4e-2, the 19-problem list at ~1e-2). See
284 // [`certificate_masked`].
285 obj_scale_certificate_threshold: 1e-4,
286 primal_noise_floor_kappa: 64.0,
287 // A tenth of the acceptable band. See `streak_has_flattened` for
288 // why the band is the right yardstick and why a tenth of it is the
289 // conservative end of the range.
290 acceptable_progress_kappa: 1e-1,
291 acceptable_window: std::collections::VecDeque::new(),
292 acceptable_progress_refusals: 0,
293 dual_inf_scale_kappa: 1.0,
294 dual_floor_reported: false,
295 veto_fired: false,
296 acceptable_veto_fired: false,
297 masked_acceptable_veto_fired: false,
298 veto_extra_iters: 0,
299 rel_infeas_extra_iters: 0,
300 prev_rel_viol: Number::NAN,
301 }
302 }
303}
304
305impl OptErrorConvCheck {
306 pub fn new() -> Self {
307 Self::default()
308 }
309
310 /// Pure helper for the per-component upstream gate. Returns `true`
311 /// iff every supplied residual sits at or below its tolerance.
312 /// Factored out so tests can exercise the gating logic without
313 /// constructing a full `IpoptCq`.
314 ///
315 /// `dual_scale` is the magnitude of the terms `∇L` is assembled from
316 /// ([`IpoptCalculatedQuantities::curr_unscaled_dual_infeasibility_scale_max`]),
317 /// which sets the scale-relative floor under `dual_inf_tol` — see
318 /// [`Self::dual_inf_bound`]. Pass `0` for the bare absolute bound.
319 fn passes_component_tols(
320 &self,
321 overall: Number,
322 dual_inf: Number,
323 constr_viol: Number,
324 compl_inf: Number,
325 dual_scale: Number,
326 primal_resolvable: bool,
327 ) -> bool {
328 overall <= self.tol
329 && dual_inf <= self.dual_inf_bound(dual_scale)
330 && self.primal_component_passes(constr_viol, primal_resolvable)
331 && compl_inf <= self.compl_inf_tol
332 }
333
334 /// Whether the unscaled constraint violation clears the **strict** gate's
335 /// primal component (gh #590): `constr_viol <= constr_viol_tol`, or the
336 /// iterate placed every constraint row at or below the finest residual that
337 /// row can represent in floating point.
338 ///
339 /// `primal_resolvable` is the caller's answer to
340 /// [`IpoptCalculatedQuantities::curr_primal_infeasibility_above_noise`] —
341 /// `false` only when that accessor returned exactly `0`, meaning *no* row
342 /// rose above its own noise floor. It is a boolean rather than a floored
343 /// magnitude on purpose. The floor is computed on the internally scaled
344 /// residual while `constr_viol` is unscaled, so the two are not comparable
345 /// as numbers, and comparing them anyway is precisely the units error
346 /// `infeasible_status_tol_invariance` exists to pin down. The *abstention
347 /// verdict* is scale-invariant by construction — the floor carries each
348 /// row's `dc_i` exactly as the residual and the declared magnitude do — so
349 /// the boolean transfers between unit systems even though the magnitude
350 /// does not.
351 ///
352 /// gh #528 gave the strict **aggregate** this floor and deliberately left
353 /// the component on the raw residual, reasoning that any realistic quantum
354 /// sits far below the tolerances. gh #590 is the model where that premise
355 /// fails. LyoPRONTO's pseudosteady lyophilisation OCP is written in Landau
356 /// coordinates, so its conduction rows carry `1/(H − S)²` and reach
357 /// magnitudes near `1e8`: one ulp of those rows is `~1e-2`, which is not
358 /// two decades under `acceptable_constr_viol_tol`, it *is*
359 /// `acceptable_constr_viol_tol`. At the converged point the scaled KKT
360 /// error is `4.3e-10` against `tol = 1e-6`, every row's residual is at or
361 /// below its own floor, and the unscaled violation reads `1.62e-2` — pure
362 /// quantisation. Ipopt 3.14.16 lands on the same point with `8.06e-3` and
363 /// calls it `Solved To Acceptable Level`; which side of `1e-2` a run falls
364 /// on there is arithmetic luck, not a property of the iterate.
365 ///
366 /// Leaving the component unfloored while the aggregate is floored is also
367 /// incoherent on its own terms: the component test is a refinement of the
368 /// aggregate, and an unfloored component can veto a certificate the floored
369 /// aggregate has already granted. That is exactly what happened here.
370 ///
371 /// The relaxation is confined to the all-noise case — one resolvable row
372 /// anywhere and the raw comparison stands — and it cannot fabricate a
373 /// success on a genuinely infeasible model: such a model's violation is
374 /// pinned at its infeasibility gap, orders above `eps ·` the row's own
375 /// magnitude, so the accessor returns a positive value and nothing here
376 /// engages. The scale-relative veto below is untouched and still sees the
377 /// raw `rel_viol`. `primal_noise_floor_kappa = 0` opts out, as it does for
378 /// the aggregate.
379 fn primal_component_passes(&self, constr_viol: Number, primal_resolvable: bool) -> bool {
380 constr_viol <= self.constr_viol_tol || !primal_resolvable
381 }
382
383 /// The bound the **strict** gate judges the unscaled dual infeasibility
384 /// against: `max(dual_inf_tol, dual_inf_scale_kappa · tol · dual_scale)`
385 /// (gh #532).
386 ///
387 /// `dual_inf_tol` is a bare absolute bound on a quantity the aggregate KKT
388 /// error normalises. The aggregate's dual term is `‖∇L‖_∞ / s_d`, and `s_d`
389 /// grows with the mean magnitude of the multipliers, so on a model whose
390 /// gradients live at `1e10` the two are judging one quantity by two
391 /// standards ten orders apart: Vanderbei's `orthrds2` reaches `s_d ≈ 1.6e10`
392 /// with `‖∇L‖_∞ = 89.7`, an aggregate dual term of `5.6e-09` — comfortably
393 /// inside the default `tol = 1e-8` — and the component gate refused it
394 /// against `1.0`, so a solve stationary to nine digits exited
395 /// `Solved_To_Acceptable_Level` holding the answer. `1.0` is a reasonable
396 /// absolute bound when `‖∇f‖` is `O(1)`; it is meaningless when `‖∇f‖` is
397 /// `1e10`, and the same LP with its objective multiplied by a positive
398 /// constant — which changes no feasible point, no solution and no active
399 /// set — crossed it.
400 ///
401 /// The floor is stated relative to the terms `∇L` is *made of*
402 /// (`dual_scale`), not to `s_d`. Both remove the asymmetry the issue
403 /// reports, but `s_d` is built from multiplier magnitudes alone and does not
404 /// see `∇f`: a model with tiny constraint gradients and huge multipliers
405 /// (`‖J‖ ~ 1e-12`, `‖y‖ ~ 1e12`, so every term of `∇L` is `O(1)`) has
406 /// `s_d ~ 1e10` and would have its genuinely non-stationary residual
407 /// forgiven — exactly the user-space drift the unscaled component gate was
408 /// added for (pounce#173). `dual_scale` cannot be fooled that way, because
409 /// `dual_inf / dual_scale` is the fraction of the terms that failed to
410 /// cancel.
411 ///
412 /// So the relaxation only ever forgives a residual that is small *relative
413 /// to the problem's own scale*, and it is bounded twice over: the aggregate
414 /// `overall <= tol` gate still has to pass on the same iterate, and at the
415 /// default `kappa = 1` the floor only rises above `dual_inf_tol` once
416 /// `dual_scale` exceeds `dual_inf_tol / tol = 1e8`. A genuinely
417 /// non-stationary point has `dual_inf ≈ dual_scale` (nothing cancelled) and
418 /// is refused by eight orders of magnitude — `min -exp(x) s.t. x >= 0`
419 /// reaching `inf_du = 8.8e+47` with `∇f = −8.8e47` stays refused, which is
420 /// the case any such rule has to keep rejecting.
421 ///
422 /// A user who tightens `dual_inf_tol` below the floor is asking for an
423 /// absolute standard the floor may override; `dual_inf_scale_kappa = 0`
424 /// switches it off and restores upstream's bare comparison. Non-finite or
425 /// non-positive scales are read as "nothing can be said", which is the
426 /// absolute bound.
427 fn dual_inf_bound(&self, dual_scale: Number) -> Number {
428 if self.dual_inf_scale_kappa.is_nan()
429 || self.dual_inf_scale_kappa <= 0.0
430 || !dual_scale.is_finite()
431 || dual_scale <= 0.0
432 {
433 return self.dual_inf_tol;
434 }
435 self.dual_inf_tol
436 .max(self.dual_inf_scale_kappa * self.tol * dual_scale)
437 }
438
439 /// The aggregate KKT error the **strict** gate judges against `tol`
440 /// (gh #528): [`IpoptCalculatedQuantities::curr_nlp_error_above_primal_noise`],
441 /// which is `nlp_err` with each constraint row's residual counted only
442 /// where it rises above what that row's residual can represent in floating
443 /// point.
444 ///
445 /// The primal term of the KKT error is the one term Ipopt leaves as a bare
446 /// absolute residual (the other two carry `s_d` / `s_c`), and it is
447 /// quantised in units of `eps ·` the rows' own magnitude. Once that quantum
448 /// exceeds `tol` — constraint values past `~4.5e7` at the `1e-8` default —
449 /// `nlp_err <= tol` stops being a statement about the iterate: it asks the
450 /// residual to land on an exact `0` rather than on one ulp, which is
451 /// arithmetic luck, and every iterate that misses keeps the solve running
452 /// at a point it cannot improve until the step collapses
453 /// (`Search_Direction_Becomes_Too_Small`, on LPs whose optimum POUNCE
454 /// already had to 8 significant figures).
455 ///
456 /// The per-component `constr_viol` gate reads the same floor, but as a
457 /// boolean and only in the all-noise case — see `primal_component_passes`,
458 /// which is gh #590's correction to this method's original scope. The
459 /// scale-relative veto still sees the raw `rel_viol`, so a row violated by
460 /// a meaningful fraction of its own magnitude is refused by that arm no
461 /// matter what either floor says. The acceptable-level band is left on the
462 /// raw `nlp_err`: once the strict gate understands the floor, a solve that
463 /// is converged-to-noise takes the strict exit and never reaches the band.
464 ///
465 /// A non-finite `nlp_err` is passed through untouched — `f64::min` returns
466 /// the *other* operand at `NaN`, which would launder exactly the
467 /// `Invalid_Number_Detected` signal gh #292 built `curr_nlp_error`'s
468 /// `has_valid_numbers` sweep to raise.
469 ///
470 /// On finite input the `min` is belt-and-braces rather than a live choice:
471 /// `nlp_error(true)` shares its dual and complementarity terms with
472 /// `nlp_error(false)` and `amax_above_floor` returns at most the vector's
473 /// own `amax` on every path including its fallbacks, so
474 /// `above_primal_noise <= nlp_err` always. It is kept so that the gate
475 /// cannot be loosened by a future change to either accessor without that
476 /// change being deliberate.
477 /// Whether the gh #528 primal noise floor is live. `0` (or a negative
478 /// value, which the option's lower bound already refuses) is the opt-out
479 /// back to upstream Ipopt's bare-absolute primal term; the accessor is not
480 /// even called then, so the opt-out costs nothing as well as changing
481 /// nothing.
482 fn noise_floor_enabled(&self) -> bool {
483 self.primal_noise_floor_kappa > 0.0
484 }
485
486 fn strict_overall(nlp_err: Number, above_primal_noise: Number) -> Number {
487 if !nlp_err.is_finite() {
488 return nlp_err;
489 }
490 nlp_err.min(above_primal_noise)
491 }
492
493 /// Pure helper mirroring upstream
494 /// `OptimalityErrorConvergenceCheck::CurrentIsAcceptable`. Tests
495 /// the per-component `acceptable_*_tol` triplet plus the optional
496 /// `acceptable_obj_change_tol` stability cross-check.
497 fn passes_acceptable_tols(
498 &self,
499 overall: Number,
500 dual_inf: Number,
501 constr_viol: Number,
502 compl_inf: Number,
503 curr_f: Number,
504 ) -> bool {
505 // A point is never acceptable if the scaled error metric or the
506 // objective itself is non-finite. Without the `curr_f` guard a NaN/Inf
507 // objective with otherwise-small infeasibility (e.g. CUTE `himmelbj`,
508 // where f evaluates to NaN at a near-feasible point) would be recorded
509 // as the acceptable rollback point and reported under
510 // `Solved_To_Acceptable_Level` with a `nan` objective.
511 if !overall.is_finite() || !curr_f.is_finite() {
512 return false;
513 }
514 let component_ok = overall <= self.acceptable_tol
515 && dual_inf <= self.acceptable_dual_inf_tol
516 && constr_viol <= self.acceptable_constr_viol_tol
517 && compl_inf <= self.acceptable_compl_inf_tol;
518 if !component_ok {
519 return false;
520 }
521 // Upstream `IpOptErrorConvCheck.cpp:CurrentIsAcceptable` — when
522 // an acceptable point has already been recorded and the user
523 // tightened `acceptable_obj_change_tol` below the 1e20
524 // sentinel, the iterate is only re-acceptable if `f` has moved
525 // by less than `tol * max(1, |f|)` relative to the recorded
526 // value. Skipped when no prior point exists or the cross-check
527 // is disabled.
528 if self.acceptable_obj_change_tol < 1e20 {
529 if let Some(prev) = self.last_acceptable_obj {
530 let denom = curr_f.abs().max(1.0);
531 if (prev - curr_f).abs() >= self.acceptable_obj_change_tol * denom {
532 return false;
533 }
534 }
535 }
536 true
537 }
538
539 /// Advance the acceptable-level streak, returning whether the run should
540 /// terminate with `ConvergedToAcceptable`.
541 ///
542 /// Acceptable-level termination is **count-based**: it needs
543 /// `acceptable_iter` *consecutive* qualifying iterates. The masked-scale
544 /// veto (gh #200) suppresses that termination, so the count has to keep
545 /// running underneath the suppression — otherwise the mechanism cannot know
546 /// where the unvetoed run would have stopped.
547 ///
548 /// The subtle part, and an earlier bug: `masked` is **not constant over a
549 /// run**. `obj_scale` is fixed, but the veto's other condition is
550 /// `unscaled_err > acceptable_tol`, and that quantity crosses the bar
551 /// during the endgame — the crossing *is* the veto lifting. A streak can
552 /// therefore straddle the boundary. Keeping two disjoint counters (a real
553 /// one and a shadow), each reset by the other's phase, silently discarded a
554 /// streak the unvetoed run would have kept: fourteen unmasked qualifying
555 /// iterates followed by one masked qualifying iterate left the real count at
556 /// zero, where the baseline would have reached fifteen and stopped. The run
557 /// then fell through to `max_iter` — with no snapshot armed, because the
558 /// shadow had only just started — and returned a bare failure where the
559 /// baseline returned `Solved_To_Acceptable_Level`. That is precisely the
560 /// "never worse" guarantee failing.
561 ///
562 /// So there is **one** counter, advanced on `acceptable_now` regardless of
563 /// `masked`. `masked` decides only what happens when it crosses the
564 /// threshold: terminate, or record that a termination was refused here —
565 /// which is exactly the iterate the unvetoed run would have returned.
566 ///
567 /// The gh #533 progress test is the second thing that can refuse at the
568 /// crossing, and it is undone by the same machinery — see
569 /// [`Self::streak_has_flattened`]. Everything about the count is unchanged
570 /// by it: the streak advances on the band test alone, so a progress refusal
571 /// still records exactly the iterate the unvetoed run would have returned.
572 fn note_acceptable(
573 &mut self,
574 acceptable_now: bool,
575 masked: bool,
576 nlp_err: Number,
577 curr_f: Number,
578 ) -> bool {
579 if !acceptable_now {
580 self.acceptable_count = 0;
581 self.acceptable_window.clear();
582 return false;
583 }
584 self.acceptable_count += 1;
585 self.push_progress_sample(nlp_err, curr_f);
586 if self.acceptable_count < self.acceptable_iter {
587 return false;
588 }
589 if masked {
590 self.acceptable_veto_fired = true;
591 self.masked_acceptable_veto_fired = true;
592 return false;
593 }
594 // gh #533: the streak says the error has been inside the band for
595 // `acceptable_iter` iterations; it says nothing about whether the solve
596 // has stopped moving. Refuse the termination while the window shows it
597 // has not, and let the run continue — the refusal is recorded, so a run
598 // that goes nowhere still ends at this point under this status.
599 if !self.streak_has_flattened()
600 && self.acceptable_progress_refusals < ACCEPTABLE_PROGRESS_MAX_REFUSALS
601 {
602 if !self.acceptable_veto_fired {
603 tracing::info!(
604 nlp_err,
605 obj = curr_f,
606 acceptable_tol = self.acceptable_tol,
607 window = self.acceptable_window.len(),
608 kappa = self.acceptable_progress_kappa,
609 "refusing an acceptable-level termination: the error has been inside \
610 the acceptable band for the whole streak but is still moving across \
611 it, so the streak has not flattened; continuing \
612 (acceptable_progress_kappa=0 disables)"
613 );
614 }
615 self.acceptable_progress_refusals += 1;
616 self.acceptable_veto_fired = true;
617 return false;
618 }
619 true
620 }
621
622 /// Length of the streak window the gh #533 progress test judges: the
623 /// streak's own length, clamped to `1..=`[`ACCEPTABLE_PROGRESS_WINDOW_MAX`].
624 ///
625 /// A length of 1 is representable and means the test is inert:
626 /// [`Self::streak_has_flattened`] declines to judge a window that short,
627 /// because a single iterate carries no progress information. So
628 /// `acceptable_iter = 1` never refuses, which is right — the user asked to
629 /// stop at the first qualifying iterate.
630 fn progress_window_len(&self) -> usize {
631 (self.acceptable_iter.max(1) as usize).clamp(1, ACCEPTABLE_PROGRESS_WINDOW_MAX)
632 }
633
634 /// Record one qualifying iterate in the streak window, evicting the oldest
635 /// sample once the window is full.
636 fn push_progress_sample(&mut self, nlp_err: Number, curr_f: Number) {
637 let cap = self.progress_window_len();
638 self.acceptable_window.push_back((nlp_err, curr_f));
639 while self.acceptable_window.len() > cap {
640 self.acceptable_window.pop_front();
641 }
642 }
643
644 /// Has the solve actually *flattened* over the iterates that made up the
645 /// acceptable-level streak (gh #533)?
646 ///
647 /// The streak criterion on its own is a band test repeated
648 /// `acceptable_iter` times: it asks whether the KKT error is small, never
649 /// whether anything has stopped moving. Those come apart, and when they do
650 /// the solve stops at a point that is near-stationary *for the current
651 /// barrier subproblem* — a much weaker statement than near-KKT for the NLP —
652 /// and returns a worse answer under a weaker status than continuing would
653 /// have reached. Measured on two corpus models at `main @ 880b360b`:
654 /// `kissing` (Vanderbei) stopped at iteration 103 with objective
655 /// `1.00000108` and `Solved_To_Acceptable_Level`, where continuing reaches
656 /// `0.84544259` and a strict certificate at 550 — 18% high, and Ipopt's own
657 /// answer to eight figures is the lower one; `NARX_CFy` (Mittelmann)
658 /// stopped at 565 with both residuals near `1e-7`, where 60 more iterations
659 /// (25 s, inside the benchmark's 300 s limit) collapse them by five orders
660 /// and beat both its own acceptable answer and Ipopt's.
661 ///
662 /// So: flat means *neither the error nor the objective moved* across the
663 /// window, and the yardstick for both is a fraction
664 /// `acceptable_progress_kappa` of `acceptable_tol` —
665 ///
666 /// - the error's absolute spread `max − min` against
667 /// `kappa · acceptable_tol`;
668 /// - the objective's spread against `kappa · acceptable_tol · max(1, |f|)`,
669 /// the same relative form upstream's own `acceptable_obj_change_tol`
670 /// cross-check uses.
671 ///
672 /// **Spread, not trend, and either one alone is enough to refuse.** Both
673 /// choices are load-bearing, and `kissing` is why:
674 ///
675 /// - Its `inf_du` over the last four iterates of the streak ran `3.35e-08 →
676 /// 8.18e-08 → 1.08e-07 → 4.15e-07` — the error the solver stopped on was
677 /// an order of magnitude *worse* than one it had already achieved inside
678 /// the same streak. A trend test reads that as "not improving" and stops;
679 /// a spread test reads it as what it is, an iterate still wandering
680 /// across the band, and keeps going. The same holds in the other
681 /// direction: an error still descending through the band has not settled
682 /// either, and a solve that is still descending is one that may yet
683 /// certify.
684 /// - Its objective was flat to all eight printed figures over those same
685 /// iterates (`1.0000011e+00` throughout) while the continued run moved it
686 /// by 15%. Requiring *both* signals to show movement before refusing
687 /// would therefore have stopped exactly where it stopped before.
688 ///
689 /// The band is the right yardstick because the question is scoped to it:
690 /// the point is being certified as good to `acceptable_tol`, so "settled"
691 /// has to mean settled on that scale. It also gets the user-intent
692 /// monotonicity right in the one direction that matters — a *widened*
693 /// `acceptable_tol` widens the flat bar with it, so a user who asked for an
694 /// early exit at a loose band keeps getting one. Tightening
695 /// `acceptable_tol` makes the test more eager to keep solving, which is the
696 /// direction that cannot fabricate a verdict: a refusal is always undone at
697 /// the end of a run that fails to do better (see
698 /// `IpoptAlgorithm::honour_refused_certificate`), so its worst case is
699 /// spent iterations, never a wrong answer.
700 ///
701 /// Returns `true` — flat, terminate — whenever the test cannot see enough
702 /// to judge: `acceptable_progress_kappa <= 0` (the documented opt-out) or
703 /// `NaN`, a window not yet full, a window of one, or any non-finite sample.
704 /// Refusing on missing evidence would spend iterations for no stated reason.
705 fn streak_has_flattened(&self) -> bool {
706 if self.acceptable_progress_kappa.is_nan() || self.acceptable_progress_kappa <= 0.0 {
707 return true;
708 }
709 // A partial window is not evidence of movement. (Unreachable from
710 // `note_acceptable`, which only asks once the count has reached
711 // `acceptable_iter` and pushes one sample per count, but the predicate
712 // must not depend on that coincidence.)
713 if self.acceptable_window.len() < self.progress_window_len()
714 || self.acceptable_window.len() < 2
715 {
716 return true;
717 }
718 let bar = self.acceptable_progress_kappa * self.acceptable_tol;
719 let (mut err_lo, mut err_hi) = (Number::INFINITY, Number::NEG_INFINITY);
720 let (mut f_lo, mut f_hi) = (Number::INFINITY, Number::NEG_INFINITY);
721 for &(err, f) in &self.acceptable_window {
722 if !err.is_finite() || !f.is_finite() {
723 return true;
724 }
725 err_lo = err_lo.min(err);
726 err_hi = err_hi.max(err);
727 f_lo = f_lo.min(f);
728 f_hi = f_hi.max(f);
729 }
730 // `f` from the newest sample, matching `passes_acceptable_tols`'
731 // `max(1, |f|)` denominator convention.
732 let f_curr = self.acceptable_window.back().map_or(0.0, |&(_, f)| f);
733 let err_flat = err_hi - err_lo <= bar;
734 let obj_flat = f_hi - f_lo <= bar * f_curr.abs().max(1.0);
735 err_flat && obj_flat
736 }
737
738 /// Fraction of a row's own magnitude a violation must exceed before the
739 /// scale-relative machinery treats the row as genuinely violated —
740 /// used both to veto a success certificate and as an alternative
741 /// violation floor for rapid infeasibility detection.
742 ///
743 /// `max(100·constr_viol_tol, 1e-2)`: at the default `constr_viol_tol =
744 /// 1e-4` this is 1% — a row eaten to 1% of everything it is made of is not
745 /// a satisfied row at any scale. The `1e-2` floor is deliberate slack for
746 /// the accepting direction: an interior-point run converges inequality
747 /// residuals to *absolute* levels, so on a row of magnitude `1e-6` a
748 /// converged residual near `1e-9` is a solved row at 0.1% relative — a
749 /// tighter relative bar would veto genuine solutions on small-magnitude
750 /// rows, the exact failure the clamped form in
751 /// `pounce_common::tolerance::is_negligible` exists to avoid. The scale
752 /// non-invariance this leaves (`x >= 0.7` at row scale `1e-12` is violated
753 /// by 14%, well above any plausible bar; a knife-edge 0.9% violation is
754 /// not) is the conservative direction: too-loose withholds a verdict,
755 /// too-tight fabricates one.
756 fn relative_viol_threshold(&self) -> Number {
757 (100.0 * self.constr_viol_tol).max(MIN_INFEAS_VIOL_FLOOR)
758 }
759
760 /// Absolute violation floor for rapid infeasibility detection:
761 /// `max(infeas_viol_kappa · constr_viol_tol, 1e-2)`.
762 ///
763 /// The same shape as [`Self::relative_viol_threshold`] and clamped for the
764 /// same reason (gh #519): the product alone slides with the user's
765 /// feasibility tolerance, so a *tighter* `constr_viol_tol` admitted smaller
766 /// and smaller violations as evidence of infeasibility — the one direction
767 /// a feasibility tolerance must never move this predicate. See
768 /// [`MIN_INFEAS_VIOL_FLOOR`]. Raising `infeas_viol_kappa` still raises the
769 /// floor; the clamp only stops it from falling below what the defaults
770 /// consider an acceptable violation.
771 fn absolute_viol_threshold(&self) -> Number {
772 (self.infeas_viol_kappa * self.constr_viol_tol).max(MIN_INFEAS_VIOL_FLOOR)
773 }
774
775 /// Pure predicate for a single infeasible-stationary iterate: the
776 /// constraint violation is bounded away from zero — absolutely
777 /// (`constr_viol` above [`Self::absolute_viol_threshold`]) **or relative to
778 /// the violated row's own magnitude** (`rel_viol` above
779 /// [`Self::relative_viol_threshold`]; a row violated by 10% of everything
780 /// it is made of is bounded away from feasible no matter how small its
781 /// numbers are) — and the scaled infeasibility gradient `‖Jᵀc‖/max(1,‖c‖)`
782 /// is at or below `infeas_stationarity_tol`. Returns `false` when rapid
783 /// infeasibility detection is disabled (either knob non-positive).
784 ///
785 /// The relative arm changes only this pre-filter; the verdict still
786 /// requires the direct no-descent confirmation in
787 /// `check_convergence_with_state`, which is what protects against the
788 /// false-infeasibility failures the surrogate alone was measured to
789 /// produce.
790 fn is_infeasible_stationary(
791 &self,
792 constr_viol: Number,
793 rel_viol: Number,
794 stationarity: Number,
795 primal_resolvable: bool,
796 ) -> bool {
797 if self.infeas_stationarity_tol <= 0.0 || self.infeas_max_streak <= 0 {
798 return false;
799 }
800 // gh #590: the absolute arm additionally requires the violation to be
801 // something the model's own arithmetic can resolve. `primal_resolvable`
802 // is `false` only when every constraint row sits at or below its own
803 // floating-point noise floor (see `primal_component_passes`), and a
804 // residual no iterate could place is not evidence of infeasibility — it
805 // is the quantum the row is measured in. Convicting on it is the worst
806 // failure this predicate has: `Infeasible_Problem_Detected` is a
807 // *confident* verdict, and downstream it becomes an AMPL 200 / Pyomo
808 // `infeasible`, indistinguishable from a real proof. The relative arm
809 // is deliberately left alone; it is already a ratio against the row's
810 // own magnitude, so it cannot mistake a quantum for a violation.
811 let absolute_arm = constr_viol > self.absolute_viol_threshold() && primal_resolvable;
812 (absolute_arm || rel_viol > self.relative_viol_threshold())
813 && stationarity <= self.infeas_stationarity_tol
814 }
815
816 /// Advance the rapid-infeasibility-detection streak by one
817 /// iteration. An infeasible-stationary iterate (see
818 /// [`Self::is_infeasible_stationary`]) increments the streak; any
819 /// other iterate resets it to zero. Returns `true` once the streak
820 /// reaches `infeas_max_streak`, signalling the caller to terminate
821 /// with `ConvergenceStatus::LocallyInfeasible`. The streak guards
822 /// against firing on a transient flat spot.
823 ///
824 /// The **relative** arm additionally requires the relative violation to
825 /// have stopped improving — "bounded away from feasible" must mean *not
826 /// still converging*. The no-descent confirmation cannot provide that
827 /// guard here: it compares violations absolutely, so in the small-scale
828 /// regime the relative arm targets (violation ~1e-9 and falling), no
829 /// "materially less-violating" point registers and the confirmation is
830 /// vacuous. Measured on QSCORPIO: the detector fired at iteration 57 with
831 /// the endgame still cutting the violation 16× over its last five
832 /// iterations (4.6e-9 → 2.9e-10 relative 4.6e-2 → 2.9e-3); five more
833 /// iterations reached `Optimal Solution Found`. An iterate that improved
834 /// the relative violation by more than 10% since the previous check
835 /// therefore resets the streak; a genuinely infeasible row's violation is
836 /// pinned at its infeasibility gap and cannot improve at all.
837 fn note_infeasible_stationary(
838 &mut self,
839 constr_viol: Number,
840 rel_viol: Number,
841 stationarity: Number,
842 primal_resolvable: bool,
843 ) -> bool {
844 let still_improving = rel_viol < 0.9 * self.prev_rel_viol;
845 self.prev_rel_viol = rel_viol;
846 // Only the relative arm is progress-gated; the absolute arm keeps its
847 // own guard (the direct no-descent confirmation, which is meaningful
848 // at absolute violation scales).
849 let effective_rel = if still_improving { 0.0 } else { rel_viol };
850 if self.is_infeasible_stationary(
851 constr_viol,
852 effective_rel,
853 stationarity,
854 primal_resolvable,
855 ) {
856 self.infeas_streak += 1;
857 self.infeas_streak >= self.infeas_max_streak
858 } else {
859 self.infeas_streak = 0;
860 false
861 }
862 }
863}
864
865impl ConvCheck for OptErrorConvCheck {
866 /// Snapshot every counter, run the real check, put them back. The
867 /// struct is `Clone` for this and only this — the alternative is a
868 /// hand-maintained list of the mutable fields, and this type has
869 /// grown a new one for roughly every issue it has been through
870 /// (`infeas_streak`, `veto_extra_iters`, `acceptable_window`,
871 /// `acceptable_progress_refusals`, `rel_infeas_extra_iters`,
872 /// `prev_rel_viol`, ...). A list like that is wrong the first time
873 /// someone adds a field and does not think of this method.
874 fn probe_convergence(
875 &mut self,
876 nlp_err: Number,
877 iter_count: Index,
878 data: &IpoptDataHandle,
879 cq: &IpoptCqHandle,
880 ) -> ConvergenceStatus {
881 let saved = self.clone();
882 let status = self.check_convergence_with_state(nlp_err, iter_count, data, cq);
883 *self = saved;
884 status
885 }
886
887 fn certificate_vetoed(&self) -> bool {
888 self.veto_fired
889 }
890
891 fn acceptable_certificate_vetoed(&self) -> bool {
892 self.acceptable_veto_fired
893 }
894
895 fn check_convergence(&mut self, nlp_err: Number, iter_count: Index) -> ConvergenceStatus {
896 if nlp_err <= self.tol {
897 return ConvergenceStatus::Converged;
898 }
899 // `acceptable_iter == 0` disables acceptable-level termination,
900 // mirroring upstream `IpOptErrorConvCheck.cpp:241`
901 // (`if( acceptable_iter_ > 0 && CurrentIsAcceptable() )`). Without
902 // the `> 0` guard, a zero would make `acceptable_count >= 0` fire on
903 // the first acceptable iterate — the opposite of "disabled".
904 //
905 // The gh #533 progress test deliberately does NOT live here. It needs
906 // the objective, which this entry point does not receive, and its two
907 // callers do not want it: unit tests exercising the scalar state
908 // machine, and `RestoConvCheckAdapter`, whose inner acceptable-level
909 // answer feeds the "may the trial point leave restoration" decision
910 // rather than a user-facing verdict — and which has no refused-
911 // certificate fallback of its own to undo a refusal with.
912 if self.acceptable_iter > 0 && nlp_err <= self.acceptable_tol {
913 self.acceptable_count += 1;
914 if self.acceptable_count >= self.acceptable_iter {
915 return ConvergenceStatus::ConvergedToAcceptable;
916 }
917 } else {
918 self.acceptable_count = 0;
919 }
920 if iter_count >= self.max_iter {
921 return ConvergenceStatus::MaxIterExceeded;
922 }
923 ConvergenceStatus::Continue
924 }
925
926 fn check_convergence_with_state(
927 &mut self,
928 nlp_err: Number,
929 iter_count: Index,
930 data: &IpoptDataHandle,
931 cq: &IpoptCqHandle,
932 ) -> ConvergenceStatus {
933 // Mirror upstream `IpOptErrorConvCheck.cpp::CheckConvergence`:
934 // the scaled scalar `nlp_err` must drop below `tol` AND each
935 // per-component value must sit under its own tolerance. The
936 // component tolerances (`dual_inf_tol`/`constr_viol_tol`/
937 // `compl_inf_tol`) are defined on the *unscaled* (user-original)
938 // residuals — both upstream and per pounce's own option help text
939 // — so we gate on the unscaled accessors. This resolves the former
940 // M1 deviation (gating on internally-scaled residuals), which let
941 // an ill-conditioned, nlp_scaling-deflated solve report
942 // `Solve_Succeeded` while the user-space duals had drifted
943 // (pounce#173). When no scaling is active the unscaled accessors
944 // return the scaled values unchanged, so behaviour is identical on
945 // the common path.
946 let cq_ref = cq.borrow();
947 let dual_inf = cq_ref.curr_unscaled_dual_infeasibility_max();
948 let constr_viol = cq_ref.curr_unscaled_primal_infeasibility_max();
949 let compl_inf = cq_ref.curr_unscaled_complementarity_max();
950 let rel_viol = cq_ref.curr_relative_primal_infeasibility_max();
951 let curr_f = cq_ref.curr_f();
952 let unscaled_err = cq_ref.curr_unscaled_nlp_error();
953 // gh #528 — see `strict_overall`. Only the strict gate below reads
954 // this; `nlp_err` itself carries on to the acceptable-level band, the
955 // rapid-infeasibility pre-filter and everything downstream unchanged.
956 //
957 // Computed only on the iterations where it can change the verdict.
958 // That laziness is doing real work, because the accessor is not two
959 // extra Jacobian sweeps on top of a cached number — it is
960 // `nlp_error(true)`, a second evaluation of the *whole* KKT error:
961 // `optimality_error_scaling`, `curr_grad_lag_x`/`_s` (each a fresh
962 // allocation plus two mat-vecs, and uncached — `nlp_error` has no
963 // entry among the caches in `ipopt_cq.rs`), all four complementarity
964 // vectors and the `has_valid_numbers` sweep, plus the two
965 // `compute_row_amax` sweeps the floors need. Anyone reusing this
966 // accessor anywhere hotter should read that cost first.
967 //
968 // The laziness is exact, not an approximation: below `tol` the floored
969 // value is smaller still and the gate passes either way, and with any
970 // component tolerance already blown `passes_component_tols` is false
971 // whatever the aggregate says.
972 //
973 // gh #532 — the scale-relative floor under `dual_inf_tol`. Computed on
974 // the same terms `dual_inf` was assembled from, and only where it can
975 // change the verdict: below `dual_inf_tol` the absolute arm has already
976 // passed and the floor can only be looser, and with the primal or
977 // complementarity component already blown no floor on the dual makes a
978 // certificate. That laziness matters because the accessor repeats
979 // `curr_grad_lag_x`'s `∇f` and two transpose products.
980 // gh #590 — the primal component's noise floor; see
981 // `primal_component_passes`. Computed only where it can change the
982 // verdict: with `constr_viol` already inside its tolerance the raw
983 // comparison has passed, and with complementarity blown no floor on the
984 // primal makes a certificate. That leaves the tail of a solve that is
985 // complementarity-converged but primal-blown, which is the regime this
986 // is for. The accessor is two `compute_row_amax` sweeps — an order
987 // cheaper than the `curr_nlp_error_above_primal_noise` below, which is
988 // a second evaluation of the whole KKT error — so this guard can afford
989 // to be the looser of the two.
990 let primal_resolvable = !(self.noise_floor_enabled()
991 && constr_viol > self.constr_viol_tol
992 && compl_inf <= self.compl_inf_tol
993 && cq_ref.curr_primal_infeasibility_above_noise(self.primal_noise_floor_kappa) == 0.0);
994 let primal_compl_pass = self.primal_component_passes(constr_viol, primal_resolvable)
995 && compl_inf <= self.compl_inf_tol;
996 let dual_scale =
997 if primal_compl_pass && dual_inf > self.dual_inf_tol && self.dual_inf_scale_kappa > 0.0
998 {
999 cq_ref.curr_unscaled_dual_infeasibility_scale_max()
1000 } else {
1001 0.0
1002 };
1003 let components_pass = primal_compl_pass && dual_inf <= self.dual_inf_bound(dual_scale);
1004 let strict_err = if nlp_err <= self.tol || !components_pass || !self.noise_floor_enabled() {
1005 nlp_err
1006 } else {
1007 Self::strict_overall(
1008 nlp_err,
1009 cq_ref.curr_nlp_error_above_primal_noise(self.primal_noise_floor_kappa),
1010 )
1011 };
1012 // The gate asks whether *our* scaling clamped, not how the user chose
1013 // to scale their objective — see `certificate_masked`.
1014 let obj_scale = cq_ref.computed_obj_scaling_factor();
1015 drop(cq_ref);
1016
1017 // Scale-relative feasibility veto (#385 Step 6; extended to equality
1018 // rows by #390, which plumbs the pre-fold RHS back so `|c_i|` has a
1019 // declared magnitude to be relative to). The absolute
1020 // `constr_viol_tol` gate cannot tell "satisfied" from "violated by 14%
1021 // of everything the row is" once the row's numbers are small: `x >= 0.7`
1022 // written as `1e-12·x >= 0.7e-12` has an absolute violation of `1e-13`
1023 // at `x = 0.6` — under every absolute tolerance, while the same empty
1024 // feasible set written at unit scale is reported infeasible. Refuse a
1025 // certificate whose point still has a constraint row violated by more
1026 // than `relative_viol_threshold` of its own magnitude, and let the run
1027 // continue: for a genuinely infeasible model the rapid-infeasibility
1028 // detection below then reaches the honest verdict (its violation floor
1029 // understands the same relative measure), and for anything else the
1030 // budget bounds the cost — after `VETO_MAX_EXTRA_ITERS` blocked
1031 // iterations the veto disengages and the run terminates exactly as it
1032 // would have, so no verdict is ever lost to it.
1033 let rel_veto = rel_viol > self.relative_viol_threshold()
1034 && self.rel_infeas_extra_iters < VETO_MAX_EXTRA_ITERS;
1035 let mut rel_veto_blocked = false;
1036
1037 // gh #200: refuse a certificate the objective scaling has masked, and
1038 // keep iterating. A constant objective scale cancels out of the Newton
1039 // step and every line-search test is scale-invariant, so the continued
1040 // run follows exactly the trajectory an unscaled run would and reaches
1041 // the true minimum — at which point the unscaled error falls under
1042 // `acceptable_tol`, the veto lifts, and an honest strict certificate is
1043 // issued. Refusing to stop early is the whole intervention; the strict
1044 // tolerance in scaled space is untouched.
1045 // Only the masked arms spend the masked veto's budget — see
1046 // `masked_acceptable_veto_fired`.
1047 if self.veto_fired || self.masked_acceptable_veto_fired {
1048 self.veto_extra_iters += 1;
1049 }
1050 // Call the bet off once it has plainly not paid off, so a veto that can
1051 // never lift cannot cost an unbounded number of iterations. The refused
1052 // certificate is restored regardless, so this bounds cost, not
1053 // correctness.
1054 let budget_spent = self.veto_extra_iters > VETO_MAX_EXTRA_ITERS;
1055 // A non-finite objective disqualifies the veto outright. `passes_component_tols`
1056 // never inspects `f`, so a strict certificate can pass at an iterate whose
1057 // objective is NaN while its residuals are finite and tiny — and the unvetoed
1058 // run returns exactly that, NaN objective and all. Refusing it would arm a
1059 // snapshot the restore then declines (`honour_refused_certificate` requires a
1060 // finite objective), surfacing a failure where the baseline reported success.
1061 // Declining to engage keeps that case bit-identical to the baseline instead.
1062 // The acceptable-level side already had this property: finite `f` is a
1063 // precondition of qualifying there.
1064 let masked = curr_f.is_finite()
1065 && !budget_spent
1066 && certificate_masked(
1067 obj_scale,
1068 unscaled_err,
1069 self.obj_scale_certificate_threshold,
1070 self.acceptable_tol,
1071 );
1072 // Record a refusal only when a strict certificate was genuinely on the
1073 // table. `masked` alone is far broader — it holds on ordinary iterates
1074 // long before convergence — and using it would arm the fallback (and
1075 // snapshot an arbitrary mid-solve iterate) on runs that were never
1076 // about to stop.
1077 let refusing_strict = masked
1078 && self.passes_component_tols(
1079 strict_err,
1080 dual_inf,
1081 constr_viol,
1082 compl_inf,
1083 dual_scale,
1084 primal_resolvable,
1085 );
1086 if refusing_strict && !self.veto_fired {
1087 self.veto_fired = true;
1088 tracing::info!(
1089 obj_scale,
1090 unscaled_kkt_error = unscaled_err,
1091 scaled_nlp_error = nlp_err,
1092 threshold = self.obj_scale_certificate_threshold,
1093 "refusing a termination certificate masked by an extreme objective scale; \
1094 continuing toward the true minimum (obj_scale_certificate_threshold=0 disables)"
1095 );
1096 }
1097
1098 if !masked
1099 && self.passes_component_tols(
1100 strict_err,
1101 dual_inf,
1102 constr_viol,
1103 compl_inf,
1104 dual_scale,
1105 primal_resolvable,
1106 )
1107 {
1108 if rel_veto {
1109 rel_veto_blocked = true;
1110 if self.rel_infeas_extra_iters == 0 {
1111 tracing::info!(
1112 rel_viol,
1113 constr_viol,
1114 threshold = self.relative_viol_threshold(),
1115 "refusing a success certificate: a constraint row is still \
1116 violated by more than the scale-relative threshold of its own \
1117 magnitude; continuing (bounded by the veto budget)"
1118 );
1119 }
1120 } else {
1121 // The certificate is going out with a dual infeasibility above
1122 // `dual_inf_tol`, which the end-of-run summary will print
1123 // beside `EXIT: Optimal Solution Found`. Say why, once.
1124 if dual_inf > self.dual_inf_tol && !self.dual_floor_reported {
1125 self.dual_floor_reported = true;
1126 tracing::info!(
1127 dual_inf,
1128 dual_scale,
1129 dual_inf_tol = self.dual_inf_tol,
1130 bound = self.dual_inf_bound(dual_scale),
1131 "certifying with a dual infeasibility above dual_inf_tol: it is \
1132 within the scale-relative floor set by the terms the Lagrangian \
1133 gradient is built from (dual_inf_scale_kappa=0 disables)"
1134 );
1135 }
1136 return ConvergenceStatus::Converged;
1137 }
1138 }
1139 // `acceptable_iter == 0` disables acceptable-level termination
1140 // (upstream `IpOptErrorConvCheck.cpp:241`). See `check_convergence`.
1141 // The veto covers this branch too, so a refused strict certificate is
1142 // not merely swapped for an acceptable-level one at the same wrong
1143 // point. Acceptable-point *storage* is deliberately left un-vetoed —
1144 // that stashed point is the rollback target if the run later stalls.
1145 let mut acceptable_now = self.acceptable_iter > 0
1146 && self.passes_acceptable_tols(nlp_err, dual_inf, constr_viol, compl_inf, curr_f);
1147 // The scale-relative veto covers the acceptable band for the same
1148 // reason the masked-scale veto does: a refused strict certificate must
1149 // not be swapped for an acceptable-level one at the same wrong point.
1150 if acceptable_now && rel_veto {
1151 acceptable_now = false;
1152 rel_veto_blocked = true;
1153 }
1154 if rel_veto_blocked {
1155 self.rel_infeas_extra_iters += 1;
1156 }
1157 if self.note_acceptable(acceptable_now, masked, nlp_err, curr_f) {
1158 return ConvergenceStatus::ConvergedToAcceptable;
1159 }
1160 if iter_count >= self.max_iter {
1161 return ConvergenceStatus::MaxIterExceeded;
1162 }
1163 // Rapid infeasibility detection — recognise an iterate
1164 // converging to a stationary point of the constraint
1165 // violation with the violation bounded away from zero, and
1166 // exit with `LocallyInfeasible` instead of grinding to
1167 // `max_iter` or thrashing restoration. Gated behind an
1168 // `infeas_max_streak`-iteration streak to avoid firing on a
1169 // transient flat spot. The outer guard skips the two
1170 // transpose-products when detection is disabled.
1171 if self.infeas_stationarity_tol > 0.0 && self.infeas_max_streak > 0 {
1172 // The surrogate here is a cheap PRE-FILTER, not the verdict. It is
1173 // a threshold on `||J^T c|| / max(1, ||c||)`, which is not
1174 // scale-invariant: under a row scaling `dc` the numerator carries
1175 // `dc^2` while the denominator clamps at 1, so an aggressive scaling
1176 // drives it to zero regardless of where the iterate is. That is how
1177 // HS13 from x0 = (1e4, 1e4) reached `5e-14` at a point whose
1178 // constraint violation was 0.51, and got reported infeasible.
1179 //
1180 // Retuning does not fix it. Measured over 800 corpus models, every
1181 // tolerance that fires on genuinely infeasible problems also
1182 // introduces new false infeasibility (>= 3 models at the smallest
1183 // viable value), and measuring the surrogate unscaled or
1184 // scale-invariantly does not separate the cases either. So the
1185 // surrogate stays as-is, and the claim the status actually makes --
1186 // that no local move reduces the violation -- is confirmed directly
1187 // before the verdict is issued.
1188 let stationarity = cq.borrow().curr_infeasibility_stationarity();
1189 // gh #590 — evaluated only when the absolute arm is what would
1190 // convict: both its own threshold and the stationarity test have to
1191 // be armed already, which on a healthy solve is never, and on a
1192 // genuinely infeasible one the accessor returns a positive value on
1193 // the first call and the verdict proceeds unchanged.
1194 let primal_resolvable = !(self.noise_floor_enabled()
1195 && constr_viol > self.absolute_viol_threshold()
1196 && stationarity <= self.infeas_stationarity_tol
1197 && cq
1198 .borrow()
1199 .curr_primal_infeasibility_above_noise(self.primal_noise_floor_kappa)
1200 == 0.0);
1201 if self.note_infeasible_stationary(
1202 constr_viol,
1203 rel_viol,
1204 stationarity,
1205 primal_resolvable,
1206 ) {
1207 if cq.borrow().infeasibility_descent_available() {
1208 // Descent exists: not a stationary point of the violation,
1209 // so the surrogate was wrong here. Drop the streak and keep
1210 // solving.
1211 self.infeas_streak = 0;
1212 } else {
1213 return ConvergenceStatus::LocallyInfeasible;
1214 }
1215 }
1216 }
1217 // Time-budget gates. When the application installed a shared
1218 // [`Deadline`] (pounce#242) it is authoritative: it measures
1219 // global elapsed time from a fixed start instant, so it fires
1220 // correctly even inside the restoration inner IPM, whose fresh
1221 // `timing.overall_alg` is never started. Absent a deadline (the
1222 // direct-driver / unit-test path), fall back to the `overall_alg`
1223 // timer, which `IpoptApplication` starts at the top of
1224 // `optimize_constrained`; `live_*` returns the running elapsed
1225 // without forcing a `start/end` cycle. Upstream
1226 // `IpOptErrorConvCheck.cpp::CheckConvergence` reads the
1227 // application-level start time similarly.
1228 let d = data.borrow();
1229 if let Some(deadline) = d.deadline.as_ref() {
1230 match deadline.exceeded() {
1231 Some(pounce_common::timing::DeadlineKind::Cpu) => {
1232 return ConvergenceStatus::CpuTimeExceeded;
1233 }
1234 Some(pounce_common::timing::DeadlineKind::Wall) => {
1235 return ConvergenceStatus::WallTimeExceeded;
1236 }
1237 None => {}
1238 }
1239 } else {
1240 let timing = &d.timing;
1241 if timing.overall_alg.live_cpu_time() >= self.max_cpu_time {
1242 return ConvergenceStatus::CpuTimeExceeded;
1243 }
1244 if timing.overall_alg.live_wallclock_time() >= self.max_wall_time {
1245 return ConvergenceStatus::WallTimeExceeded;
1246 }
1247 }
1248 ConvergenceStatus::Continue
1249 }
1250
1251 fn current_passes_strict(
1252 &self,
1253 nlp_err: Number,
1254 _data: &IpoptDataHandle,
1255 cq: &IpoptCqHandle,
1256 ) -> bool {
1257 // The strict per-component gate of `check_convergence_with_state`, minus
1258 // the masking veto — see the trait doc. Unscaled per-component residuals,
1259 // matching that method (the `*_tol` triplet is defined on the
1260 // user-original residuals).
1261 let cq_ref = cq.borrow();
1262 let dual_inf = cq_ref.curr_unscaled_dual_infeasibility_max();
1263 let constr_viol = cq_ref.curr_unscaled_primal_infeasibility_max();
1264 let compl_inf = cq_ref.curr_unscaled_complementarity_max();
1265 // Same noise-floored aggregate the strict gate uses (gh #528) — this
1266 // predicate exists to answer "would that gate have passed here?", so it
1267 // has to ask the same question. Same scale-relative dual floor
1268 // (gh #532), and lazily for the same reason.
1269 let strict_err = if self.noise_floor_enabled() {
1270 Self::strict_overall(
1271 nlp_err,
1272 cq_ref.curr_nlp_error_above_primal_noise(self.primal_noise_floor_kappa),
1273 )
1274 } else {
1275 nlp_err
1276 };
1277 let dual_scale = if dual_inf > self.dual_inf_tol && self.dual_inf_scale_kappa > 0.0 {
1278 cq_ref.curr_unscaled_dual_infeasibility_scale_max()
1279 } else {
1280 0.0
1281 };
1282 // Same primal noise floor the strict gate uses (gh #590), for the same
1283 // reason this predicate reuses the floored aggregate.
1284 let primal_resolvable = !(self.noise_floor_enabled()
1285 && constr_viol > self.constr_viol_tol
1286 && cq_ref.curr_primal_infeasibility_above_noise(self.primal_noise_floor_kappa) == 0.0);
1287 drop(cq_ref);
1288 self.passes_component_tols(
1289 strict_err,
1290 dual_inf,
1291 constr_viol,
1292 compl_inf,
1293 dual_scale,
1294 primal_resolvable,
1295 )
1296 }
1297
1298 fn tol_or_default(&self) -> Number {
1299 self.tol
1300 }
1301
1302 fn constr_viol_tol_or_default(&self) -> Number {
1303 self.constr_viol_tol
1304 }
1305
1306 fn acceptable_constr_viol_tol_or_default(&self) -> Number {
1307 self.acceptable_constr_viol_tol
1308 }
1309
1310 fn set_tolerance(&mut self, name: &str, value: Number) -> bool {
1311 match name {
1312 "tol" => self.tol = value,
1313 "dual_inf_tol" => self.dual_inf_tol = value,
1314 "constr_viol_tol" => self.constr_viol_tol = value,
1315 "compl_inf_tol" => self.compl_inf_tol = value,
1316 "acceptable_tol" => self.acceptable_tol = value,
1317 "acceptable_dual_inf_tol" => self.acceptable_dual_inf_tol = value,
1318 "acceptable_constr_viol_tol" => self.acceptable_constr_viol_tol = value,
1319 "acceptable_compl_inf_tol" => self.acceptable_compl_inf_tol = value,
1320 "acceptable_obj_change_tol" => self.acceptable_obj_change_tol = value,
1321 _ => return false,
1322 }
1323 true
1324 }
1325
1326 fn current_is_acceptable(&self, nlp_err: Number) -> bool {
1327 // Scalar fallback used when the caller has no `IpoptCq` handle
1328 // (e.g. unit tests). The state-aware variant
1329 // [`Self::current_is_acceptable_with_state`] mirrors upstream
1330 // more faithfully by gating on the per-component
1331 // `acceptable_*_tol` triplet plus the obj-change cross-check.
1332 nlp_err.is_finite() && nlp_err <= self.acceptable_tol
1333 }
1334
1335 fn current_is_acceptable_with_state(
1336 &self,
1337 nlp_err: Number,
1338 _data: &IpoptDataHandle,
1339 cq: &IpoptCqHandle,
1340 ) -> bool {
1341 let cq_ref = cq.borrow();
1342 // Unscaled per-component residuals — see `check_convergence_with_state`
1343 // (the `acceptable_*_tol` triplet is likewise defined on the
1344 // user-original residuals).
1345 let dual_inf = cq_ref.curr_unscaled_dual_infeasibility_max();
1346 let constr_viol = cq_ref.curr_unscaled_primal_infeasibility_max();
1347 let compl_inf = cq_ref.curr_unscaled_complementarity_max();
1348 let rel_viol = cq_ref.curr_relative_primal_infeasibility_max();
1349 let curr_f = cq_ref.curr_f();
1350 drop(cq_ref);
1351 // The scale-relative veto reaches acceptable-point *storage* too,
1352 // unlike the masked-scale (#200) veto above it. That veto refuses a
1353 // possibly-premature stop at a point that is still genuinely feasible,
1354 // so the stash stays a legitimate rollback target. Here the point has
1355 // a constraint row violated by more than the relative threshold of
1356 // its own magnitude — it is not acceptable in any honest sense, and a
1357 // stall later in the run must not roll back to it and surface
1358 // `Solved_To_Acceptable_Level` on an infeasible model (measured: an
1359 // infeasible row at scale `1e-10`, 100% violated, exited exactly that
1360 // way through this stash).
1361 //
1362 // gh #693: unlike the certificate veto above, this gate is **not**
1363 // budget-aware, and deliberately so. `VETO_MAX_EXTRA_ITERS` exists to
1364 // bound the *iterations* a veto can spend refusing to stop — the
1365 // certificate veto keeps the run going, so it can cost wall clock, and
1366 // the budget caps that. Declining to stash costs nothing: the stash is
1367 // a side effect of an iteration the run was taking anyway, so a budget
1368 // here bounds no cost. What it did do was expire, and once expired the
1369 // very point the veto exists to reject was written into the rollback
1370 // target — and `ConvergenceStatus::LocallyInfeasible` consults that
1371 // stash (gh #505, `ipopt_alg.rs`), so the honest infeasibility verdict
1372 // came back out as `Solved_To_Acceptable_Level`.
1373 //
1374 // Measured on `x >= 2` over `x in [0, 1]` with every row scaled by
1375 // `1e-8` (`test_scale_invariance.py::_inf_clear`, and `_inf_two`
1376 // likewise): the `feral_scaling=mc64` leg spends 288 iterations, blows
1377 // the 60-iteration budget around iteration 60, stashes an iterate whose
1378 // single row is violated by 99.998% of its own magnitude, then rolls
1379 // back to it on the infeasibility exit. The comment in `ipopt_alg.rs`
1380 // asserting this gate makes the stash "inert on genuinely infeasible
1381 // models" was true only for runs that convict inside 60 iterations.
1382 if rel_viol > self.relative_viol_threshold() {
1383 return false;
1384 }
1385 self.passes_acceptable_tols(nlp_err, dual_inf, constr_viol, compl_inf, curr_f)
1386 }
1387
1388 fn set_curr_acceptable_obj(&mut self, obj: Number) {
1389 self.last_acceptable_obj = Some(obj);
1390 }
1391}
1392
1393#[cfg(test)]
1394mod tests {
1395 use super::*;
1396
1397 #[test]
1398 fn converges_at_tol() {
1399 let mut c = OptErrorConvCheck::new();
1400 assert_eq!(c.check_convergence(1e-9, 0), ConvergenceStatus::Converged);
1401 }
1402
1403 /// The scale-relative arm of rapid infeasibility detection (#385 Step 6):
1404 /// a row violated by a large fraction of its own magnitude is bounded away
1405 /// from feasible no matter how small its numbers are, so the pre-filter
1406 /// must fire even when the absolute violation is far below
1407 /// `infeas_viol_kappa * constr_viol_tol`.
1408 #[test]
1409 fn relative_violation_arms_the_infeasibility_prefilter() {
1410 let c = OptErrorConvCheck::new();
1411 // `x >= 0.7` at row scale 1e-12: absolute violation 1e-13 (invisible
1412 // to the absolute arm, floor is 1e-2), relative violation 0.14.
1413 assert!(c.is_infeasible_stationary(1e-13, 0.14, 1e-9, true));
1414 // The same iterate without the relative signal must NOT fire — this
1415 // is exactly the old behaviour.
1416 assert!(!c.is_infeasible_stationary(1e-13, 0.0, 1e-9, true));
1417 // A converged small-magnitude row (residual 1e-9 on a 1e-6-bound row,
1418 // 0.1% relative) stays under the 1% threshold.
1419 assert!(!c.is_infeasible_stationary(1e-9, 1e-3, 1e-9, true));
1420 }
1421
1422 /// The relative arm's streak resets while the relative violation is still
1423 /// improving — "bounded away from feasible" must mean *not still
1424 /// converging*. QSCORPIO's endgame was cutting its violation 16× over
1425 /// five iterations when the un-guarded arm declared it locally
1426 /// infeasible; five more iterations reached the optimum.
1427 #[test]
1428 fn improving_relative_violation_resets_the_streak() {
1429 let mut c = OptErrorConvCheck::new();
1430 c.infeas_max_streak = 3;
1431 // A pinned relative violation (an infeasibility gap) accumulates.
1432 assert!(!c.note_infeasible_stationary(1e-13, 0.14, 1e-9, true));
1433 assert!(!c.note_infeasible_stationary(1e-13, 0.14, 1e-9, true));
1434 assert!(c.note_infeasible_stationary(1e-13, 0.14, 1e-9, true));
1435 // A geometrically shrinking one (a converging endgame) never fires.
1436 let mut c = OptErrorConvCheck::new();
1437 c.infeas_max_streak = 3;
1438 let mut rel = 0.5;
1439 for _ in 0..20 {
1440 assert!(
1441 !c.note_infeasible_stationary(1e-13, rel, 1e-9, true),
1442 "a converging endgame must not be declared infeasible"
1443 );
1444 rel *= 0.5;
1445 }
1446 }
1447
1448 /// The relative-violation veto blocks a strict certificate the absolute
1449 /// tolerances would grant, and its budget bounds the cost: once spent,
1450 /// the certificate goes through exactly as before.
1451 #[test]
1452 fn relative_viol_threshold_is_floored() {
1453 let mut c = OptErrorConvCheck::new();
1454 // Default constr_viol_tol = 1e-4 -> threshold 1e-2.
1455 assert_eq!(c.relative_viol_threshold(), 1e-2);
1456 // A loosened constr_viol_tol loosens the relative bar with it.
1457 c.constr_viol_tol = 1e-3;
1458 assert_eq!(c.relative_viol_threshold(), 1e-1);
1459 // A tightened one must not push the relative bar below 1% — an
1460 // interior-point run converges inequality residuals to absolute
1461 // levels, and a tighter relative bar vetoes genuine solutions on
1462 // small-magnitude rows.
1463 c.constr_viol_tol = 1e-8;
1464 assert_eq!(c.relative_viol_threshold(), 1e-2);
1465 }
1466
1467 #[test]
1468 fn acceptable_iter_count_threshold() {
1469 let mut c = OptErrorConvCheck {
1470 acceptable_iter: 3,
1471 ..Default::default()
1472 };
1473 // nlp_err between tol (1e-8) and acceptable (1e-6).
1474 assert_eq!(c.check_convergence(1e-7, 0), ConvergenceStatus::Continue);
1475 assert_eq!(c.check_convergence(1e-7, 1), ConvergenceStatus::Continue);
1476 assert_eq!(
1477 c.check_convergence(1e-7, 2),
1478 ConvergenceStatus::ConvergedToAcceptable
1479 );
1480 }
1481
1482 #[test]
1483 fn acceptable_iter_zero_disables_acceptable_termination() {
1484 // Upstream `IpOptErrorConvCheck.cpp:241` gates the acceptable
1485 // counter on `acceptable_iter_ > 0`, so a zero disables the
1486 // acceptable-level exit entirely. Before the guard, `>= 0` made
1487 // pounce fire on the FIRST acceptable iterate (the opposite).
1488 let mut c = OptErrorConvCheck {
1489 acceptable_iter: 0,
1490 ..Default::default()
1491 };
1492 // Many iterates parked between tol (1e-8) and acceptable (1e-6)
1493 // must never trigger ConvergedToAcceptable; the run continues
1494 // until tol or max_iter.
1495 for k in 0..50 {
1496 assert_eq!(
1497 c.check_convergence(1e-7, k),
1498 ConvergenceStatus::Continue,
1499 "acceptable_iter=0 must not stop at the acceptable level (iter {k})"
1500 );
1501 }
1502 // tol is still honored regardless.
1503 assert_eq!(c.check_convergence(1e-9, 51), ConvergenceStatus::Converged);
1504 }
1505
1506 #[test]
1507 fn streak_resets_when_above_acceptable() {
1508 let mut c = OptErrorConvCheck {
1509 acceptable_iter: 3,
1510 ..Default::default()
1511 };
1512 assert_eq!(c.check_convergence(1e-7, 0), ConvergenceStatus::Continue);
1513 // Above acceptable resets the counter.
1514 assert_eq!(c.check_convergence(1e-3, 1), ConvergenceStatus::Continue);
1515 assert_eq!(c.check_convergence(1e-7, 2), ConvergenceStatus::Continue);
1516 assert_eq!(c.check_convergence(1e-7, 3), ConvergenceStatus::Continue);
1517 assert_eq!(
1518 c.check_convergence(1e-7, 4),
1519 ConvergenceStatus::ConvergedToAcceptable
1520 );
1521 }
1522
1523 #[test]
1524 fn passes_acceptable_tols_gates_on_per_component_triplet() {
1525 let c = OptErrorConvCheck {
1526 acceptable_tol: 1e-6,
1527 acceptable_dual_inf_tol: 1e-3,
1528 acceptable_constr_viol_tol: 1e-3,
1529 acceptable_compl_inf_tol: 1e-3,
1530 ..Default::default()
1531 };
1532 assert!(c.passes_acceptable_tols(1e-7, 1e-4, 1e-4, 1e-4, 0.0));
1533 // dual_inf above its acceptable threshold blocks.
1534 assert!(!c.passes_acceptable_tols(1e-7, 1.0, 1e-4, 1e-4, 0.0));
1535 // overall above acceptable_tol blocks.
1536 assert!(!c.passes_acceptable_tols(1e-5, 1e-4, 1e-4, 1e-4, 0.0));
1537 }
1538
1539 #[test]
1540 fn passes_acceptable_tols_honors_obj_change_tol() {
1541 let mut c = OptErrorConvCheck {
1542 acceptable_tol: 1e-6,
1543 acceptable_dual_inf_tol: 1.0,
1544 acceptable_constr_viol_tol: 1.0,
1545 acceptable_compl_inf_tol: 1.0,
1546 acceptable_obj_change_tol: 0.1,
1547 ..Default::default()
1548 };
1549 // First call always acceptable (no prior obj).
1550 assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 10.0));
1551 c.set_curr_acceptable_obj(10.0);
1552 // Same f → change well under threshold → still acceptable.
1553 assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 10.0));
1554 // f moved by 2.0 with threshold 0.1 * max(1, |11.0|) = 1.1 →
1555 // absolute change 1.0 < 1.1: acceptable.
1556 assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 11.0));
1557 // f moved by 5.0 — absolute change 5.0 > 1.5 = 0.1 * 15 →
1558 // rejected (the stability cross-check fires).
1559 assert!(!c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 15.0));
1560 }
1561
1562 use crate::conv_check::r#trait::ConvCheck;
1563
1564 #[test]
1565 fn set_curr_acceptable_obj_records_for_cross_check() {
1566 let mut c = OptErrorConvCheck::new();
1567 assert!(c.last_acceptable_obj.is_none());
1568 ConvCheck::set_curr_acceptable_obj(&mut c, 4.2);
1569 assert_eq!(c.last_acceptable_obj, Some(4.2));
1570 }
1571
1572 #[test]
1573 fn a_non_finite_objective_disqualifies_the_veto() {
1574 // `passes_component_tols` never inspects `f`, so a strict certificate can
1575 // pass at an iterate whose objective is NaN while its residuals are finite
1576 // and tiny — and the unvetoed run returns exactly that. Refusing it would
1577 // arm a snapshot that the restore then declines (it requires a finite
1578 // objective), surfacing a failure where the baseline reported success:
1579 // a never-worse violation, on the one path where the objective is not
1580 // usable as a tiebreak.
1581 let c = OptErrorConvCheck {
1582 tol: 1e-8,
1583 dual_inf_tol: 1.0,
1584 constr_viol_tol: 1e-4,
1585 compl_inf_tol: 1e-4,
1586 ..Default::default()
1587 };
1588 // The residuals alone say "converged"; the objective says nothing usable.
1589 assert!(c.passes_component_tols(1e-12, 1e-9, 0.0, 0.0, 0.0, true));
1590 // The masked predicate itself is unchanged — the finiteness gate lives at
1591 // the call site, where `curr_f` is in hand.
1592 assert!(certificate_masked(
1593 1e-8,
1594 8.4e-1,
1595 c.obj_scale_certificate_threshold,
1596 c.acceptable_tol
1597 ));
1598 // Both the guard's inputs behave as the call site composes them.
1599 for bad in [Number::NAN, Number::INFINITY, Number::NEG_INFINITY] {
1600 assert!(!bad.is_finite(), "{bad} should disqualify the veto");
1601 }
1602 assert!((1.0_f64).is_finite());
1603 }
1604
1605 #[test]
1606 fn acceptable_streak_survives_a_masked_boundary_mid_streak() {
1607 // gh #200. `masked` is not constant over a run: it also depends on the
1608 // unscaled error crossing `acceptable_tol`, and that crossing is exactly
1609 // what happens during the endgame. So an acceptable-level streak can
1610 // straddle the boundary.
1611 //
1612 // The earlier implementation kept two disjoint counters, each reset by
1613 // the other's phase. Fourteen unmasked qualifying iterates followed by
1614 // one masked qualifying iterate left the real count at 0 while the
1615 // unvetoed run would have reached 15 and stopped — so the run fell
1616 // through to `max_iter` and returned a bare failure where the baseline
1617 // returned `Solved_To_Acceptable_Level`, with no snapshot armed to roll
1618 // back to. Never-worse, violated.
1619 //
1620 // Every iterate here is a *settled* one — same error, same objective —
1621 // so the gh #533 progress test is flat throughout and this test sees
1622 // only the masked-veto behaviour it is about. The progress test's own
1623 // arm is exercised in `a_wandering_streak_refuses_acceptable_termination`.
1624 const ERR: Number = 1e-7;
1625 const OBJ: Number = 1.0;
1626 let mut c = OptErrorConvCheck {
1627 acceptable_iter: 15,
1628 ..Default::default()
1629 };
1630 // 14 qualifying iterates while unmasked: no termination yet.
1631 for i in 0..14 {
1632 assert!(
1633 !c.note_acceptable(true, false, ERR, OBJ),
1634 "terminated early at {i}"
1635 );
1636 }
1637 // The 15th qualifies too, but the veto is now engaged. The streak must
1638 // be honoured — recorded as a refused termination, not discarded.
1639 assert!(
1640 !c.note_acceptable(true, true, ERR, OBJ),
1641 "a masked iterate must not terminate the run"
1642 );
1643 assert!(
1644 c.acceptable_veto_fired,
1645 "the streak crossed `acceptable_iter` while masked, so a termination was \
1646 refused here and must be recorded — otherwise the fallback has nothing to \
1647 restore and the run returns a bare failure"
1648 );
1649 assert!(
1650 c.masked_acceptable_veto_fired,
1651 "a masked refusal must be attributed to the masked arm — it is what spends \
1652 the masked veto's iteration budget"
1653 );
1654
1655 // The mirror direction: a streak that begins masked and finishes
1656 // unmasked must terminate on the same iterate the baseline would.
1657 let mut c = OptErrorConvCheck {
1658 acceptable_iter: 15,
1659 ..Default::default()
1660 };
1661 for _ in 0..14 {
1662 assert!(!c.note_acceptable(true, true, ERR, OBJ));
1663 }
1664 assert!(
1665 c.note_acceptable(true, false, ERR, OBJ),
1666 "the veto lifted with the streak already at 14; the 15th qualifying iterate \
1667 must terminate exactly as it would without the mechanism"
1668 );
1669
1670 // And a non-qualifying iterate still breaks the streak, in either phase.
1671 let mut c = OptErrorConvCheck {
1672 acceptable_iter: 3,
1673 ..Default::default()
1674 };
1675 assert!(!c.note_acceptable(true, false, ERR, OBJ));
1676 assert!(!c.note_acceptable(false, true, ERR, OBJ));
1677 assert_eq!(
1678 c.acceptable_count, 0,
1679 "a non-qualifying iterate resets the streak"
1680 );
1681 assert!(
1682 c.acceptable_window.is_empty(),
1683 "and clears the streak window"
1684 );
1685 assert!(!c.note_acceptable(true, false, ERR, OBJ));
1686 assert!(!c.note_acceptable(true, false, ERR, OBJ));
1687 assert!(
1688 c.note_acceptable(true, false, ERR, OBJ),
1689 "3 consecutive qualifying iterates terminate"
1690 );
1691 }
1692
1693 /// gh #533. The reported `kissing` streak: fifteen iterates all inside the
1694 /// acceptable band, but with the KKT error wandering across it — the
1695 /// iterate the solve stopped on had an error an order of magnitude *worse*
1696 /// than one it had already reached in the same streak. The count alone
1697 /// stops there (objective `1.00000108`, `Solved_To_Acceptable_Level`);
1698 /// continuing reaches `0.84544259` with a strict certificate.
1699 #[test]
1700 fn a_wandering_streak_refuses_acceptable_termination() {
1701 // The tail of the reported trace (`main @ 880b360b`, default options):
1702 // inf_du 3.35e-08 → 8.18e-08 → 1.08e-07 → 4.15e-07 with the objective
1703 // flat to all eight printed figures throughout.
1704 let kissing_tail = [3.35e-08, 8.18e-08, 1.08e-07, 4.15e-07];
1705 let mut c = OptErrorConvCheck {
1706 acceptable_iter: 4,
1707 ..Default::default()
1708 };
1709 for (i, &err) in kissing_tail.iter().enumerate() {
1710 assert!(
1711 !c.note_acceptable(true, false, err, 1.0000011),
1712 "the streak must not terminate at iterate {i}: the error is still \
1713 wandering across the acceptable band"
1714 );
1715 }
1716 assert!(
1717 c.acceptable_veto_fired,
1718 "the refusal must be recorded, or the run has nothing to fall back to"
1719 );
1720 assert!(
1721 !c.masked_acceptable_veto_fired,
1722 "a progress refusal is not a masked one and must not spend the masked \
1723 veto's budget"
1724 );
1725 // The count keeps running underneath the refusal — it is what identifies
1726 // the iterate the unvetoed run would have returned.
1727 assert_eq!(c.acceptable_count, 4);
1728
1729 // Once the error settles, the window flattens — after the four-iterate
1730 // window has slid clear of the wandering tail — and the streak
1731 // terminates exactly as it would have without the mechanism.
1732 for _ in 0..2 {
1733 assert!(!c.note_acceptable(true, false, 4.15e-07, 1.0000011));
1734 }
1735 assert!(
1736 c.note_acceptable(true, false, 4.15e-07, 1.0000011),
1737 "a window of four identical iterates is settled; nothing is left to refuse"
1738 );
1739 }
1740
1741 /// The other reported signal: `NARX_CFy`'s objective was still descending
1742 /// through the streak (`8.6579696e-03` at the stop, `8.6445195e-03` sixty
1743 /// iterations later) even where its error spread was small. Either signal
1744 /// alone must be enough to keep solving.
1745 #[test]
1746 fn a_still_descending_objective_refuses_acceptable_termination() {
1747 let mut c = OptErrorConvCheck {
1748 acceptable_iter: 4,
1749 ..Default::default()
1750 };
1751 // A perfectly steady error — only the objective is moving, by ~3e-6
1752 // over the window against a bar of 1e-1 · 1e-6 · max(1, |f|) = 1e-7.
1753 let objs = [8.6592e-03, 8.6588e-03, 8.6584e-03, 8.6580e-03];
1754 for (i, &f) in objs.iter().enumerate() {
1755 assert!(
1756 !c.note_acceptable(true, false, 1.5e-07, f),
1757 "the streak must not terminate at iterate {i}: the objective is still \
1758 descending"
1759 );
1760 }
1761 assert!(c.acceptable_veto_fired);
1762 }
1763
1764 /// The opt-out is real: `acceptable_progress_kappa = 0` restores the bare
1765 /// consecutive-count criterion, wandering error and all.
1766 #[test]
1767 fn zero_progress_kappa_restores_the_bare_count() {
1768 let mut c = OptErrorConvCheck {
1769 acceptable_iter: 4,
1770 acceptable_progress_kappa: 0.0,
1771 ..Default::default()
1772 };
1773 let kissing_tail = [3.35e-08, 8.18e-08, 1.08e-07, 4.15e-07];
1774 for (i, &err) in kissing_tail.iter().enumerate() {
1775 let terminated = c.note_acceptable(true, false, err, 1.0000011);
1776 assert_eq!(
1777 terminated,
1778 i == 3,
1779 "with the progress test off, iterate {i} must behave exactly as upstream"
1780 );
1781 }
1782 assert!(!c.acceptable_veto_fired);
1783 }
1784
1785 /// The refusal budget bounds the cost of a solve that never settles: past
1786 /// [`ACCEPTABLE_PROGRESS_MAX_REFUSALS`] the test stands aside and the streak
1787 /// terminates as it would have without it, so the worst case is bounded
1788 /// extra iterations rather than a run to `max_iter`.
1789 #[test]
1790 fn the_progress_refusal_budget_is_bounded() {
1791 let mut c = OptErrorConvCheck {
1792 acceptable_iter: 2,
1793 ..Default::default()
1794 };
1795 // A permanent two-cycle inside the band: never flat, never converging.
1796 let mut terminated_at = None;
1797 for k in 0..(ACCEPTABLE_PROGRESS_MAX_REFUSALS + 10) {
1798 let err = if k % 2 == 0 { 1e-7 } else { 9e-7 };
1799 if c.note_acceptable(true, false, err, 1.0) {
1800 terminated_at = Some(k);
1801 break;
1802 }
1803 }
1804 assert_eq!(
1805 c.acceptable_progress_refusals, ACCEPTABLE_PROGRESS_MAX_REFUSALS,
1806 "the budget must be spent, not exceeded"
1807 );
1808 assert!(
1809 terminated_at.is_some(),
1810 "a never-settling solve must still terminate at the acceptable level once \
1811 the budget is spent"
1812 );
1813 }
1814
1815 /// Flatness is judged over the streak's own window, and the window slides:
1816 /// a transient early in a solve must not block termination forever.
1817 #[test]
1818 fn the_flatness_window_slides_past_a_transient() {
1819 let mut c = OptErrorConvCheck {
1820 acceptable_iter: 3,
1821 ..Default::default()
1822 };
1823 // Entering the band while still descending: refused.
1824 assert!(!c.note_acceptable(true, false, 9e-7, 1.0));
1825 assert!(!c.note_acceptable(true, false, 5e-7, 1.0));
1826 assert!(!c.note_acceptable(true, false, 2e-7, 1.0));
1827 assert!(c.acceptable_veto_fired);
1828 // Then it plateaus. Two iterates later the descent has slid out of the
1829 // three-long window and the solve is judged settled.
1830 assert!(!c.note_acceptable(true, false, 2e-7, 1.0));
1831 assert!(
1832 c.note_acceptable(true, false, 2e-7, 1.0),
1833 "the window must slide, or an early transient blocks every later termination"
1834 );
1835 }
1836
1837 /// `acceptable_iter = 1` asks to stop at the first qualifying iterate, and
1838 /// a one-iterate window carries no progress information — so the progress
1839 /// test must never refuse there.
1840 #[test]
1841 fn a_single_iterate_streak_carries_no_progress_signal() {
1842 let mut c = OptErrorConvCheck {
1843 acceptable_iter: 1,
1844 ..Default::default()
1845 };
1846 assert!(c.note_acceptable(true, false, 4.15e-07, 1.0));
1847 assert!(!c.acceptable_veto_fired);
1848 }
1849
1850 /// A non-finite sample must not be read as movement — the mechanism spends
1851 /// iterations, so it may only fire on evidence it actually has.
1852 #[test]
1853 fn non_finite_samples_do_not_refuse() {
1854 for bad in [Number::NAN, Number::INFINITY] {
1855 let mut c = OptErrorConvCheck {
1856 acceptable_iter: 2,
1857 ..Default::default()
1858 };
1859 assert!(!c.note_acceptable(true, false, bad, 1.0));
1860 assert!(
1861 c.note_acceptable(true, false, 1e-7, 1.0),
1862 "a {bad} sample in the window must not be treated as a progress signal"
1863 );
1864 }
1865 }
1866
1867 #[test]
1868 fn certificate_masked_needs_both_an_extreme_scale_and_a_non_stationary_point() {
1869 // gh #200. Both conditions are load-bearing, and each was independently
1870 // shown to be insufficient on the benchmark suite.
1871 let (th, atol) = (1e-4, 1e-6);
1872
1873 // The reported failure: scale pinned at the 1e-8 floor, unscaled error
1874 // 0.84 — the strict test passed in scaled space at `quartc` obj 248.88.
1875 assert!(certificate_masked(1e-8, 8.4e-1, th, atol));
1876
1877 // An ordinary objective scale is never second-guessed, however large
1878 // the unscaled error. Keying on the error alone effectively tightens
1879 // `tol` by `1/df` and regressed hs1/hs38 (scale ~4e-2).
1880 assert!(!certificate_masked(4e-2, 8.4e-1, th, atol));
1881 assert!(!certificate_masked(1.0, 1e3, th, atol));
1882
1883 // An extreme scale at a point that really is stationary is fine — this
1884 // is what lifts the veto once the continued run reaches the minimum.
1885 assert!(!certificate_masked(1e-8, 1e-9, th, atol));
1886
1887 // Boundaries: strictly below the scale threshold, strictly above the
1888 // error tolerance.
1889 assert!(!certificate_masked(th, 1.0, th, atol));
1890 assert!(!certificate_masked(1e-8, atol, th, atol));
1891
1892 // `0` disables the mechanism outright (the documented opt-out) — the
1893 // most extreme possible inputs must not trip it.
1894 assert!(!certificate_masked(1e-30, 1e30, 0.0, atol));
1895 // A negative threshold is treated as disabled rather than as "always".
1896 assert!(!certificate_masked(1e-30, 1e30, -1.0, atol));
1897 }
1898
1899 #[test]
1900 fn veto_blocks_both_strict_and_acceptable_termination() {
1901 // A refused strict certificate must not simply reappear as an
1902 // acceptable-level one at the same wrong point, so the veto covers both
1903 // branches. Exercised through the pure predicates the two branches
1904 // share, since a full `check_convergence_with_state` needs a live cq.
1905 let c = OptErrorConvCheck {
1906 tol: 1e-8,
1907 acceptable_tol: 1e-6,
1908 dual_inf_tol: 1.0,
1909 constr_viol_tol: 1e-4,
1910 compl_inf_tol: 1e-4,
1911 ..Default::default()
1912 };
1913 // The gh #200 iterate: passes the strict test in scaled space...
1914 assert!(c.passes_component_tols(1e-9, 8.4e-1, 0.0, 0.0, 0.0, true));
1915 // ...and the veto is what withholds it.
1916 assert!(certificate_masked(
1917 1e-8,
1918 8.4e-1,
1919 c.obj_scale_certificate_threshold,
1920 c.acceptable_tol
1921 ));
1922 // Default threshold is the documented 1e-4, and the veto starts clear.
1923 assert_eq!(c.obj_scale_certificate_threshold, 1e-4);
1924 assert!(!c.veto_fired);
1925 assert!(!ConvCheck::certificate_vetoed(&c));
1926 }
1927
1928 #[test]
1929 fn passes_component_tols_requires_all_under_threshold() {
1930 let c = OptErrorConvCheck {
1931 tol: 1e-8,
1932 dual_inf_tol: 1.0,
1933 constr_viol_tol: 1e-4,
1934 compl_inf_tol: 1e-4,
1935 ..Default::default()
1936 };
1937 // All under threshold → converged.
1938 assert!(c.passes_component_tols(1e-9, 0.5, 1e-5, 1e-5, 0.0, true));
1939 // dual_inf above its tolerance blocks even when nlp_err is tiny.
1940 assert!(!c.passes_component_tols(1e-12, 2.0, 1e-5, 1e-5, 0.0, true));
1941 // compl_inf above its tolerance blocks.
1942 assert!(!c.passes_component_tols(1e-12, 0.0, 0.0, 1e-2, 0.0, true));
1943 // constr_viol above its tolerance blocks.
1944 assert!(!c.passes_component_tols(1e-12, 0.0, 1e-2, 0.0, 0.0, true));
1945 }
1946
1947 /// gh #590: the strict primal component forgives a violation only when
1948 /// *no* constraint row rose above its own floating-point noise floor. One
1949 /// resolvable row anywhere and `constr_viol_tol` is back in charge.
1950 #[test]
1951 fn primal_component_passes_only_forgives_an_unresolvable_residual() {
1952 let c = OptErrorConvCheck {
1953 constr_viol_tol: 1e-6,
1954 ..Default::default()
1955 };
1956 // Inside the tolerance: passes either way, the floor is not consulted.
1957 assert!(c.primal_component_passes(1e-9, true));
1958 assert!(c.primal_component_passes(1e-9, false));
1959 // The reported point: 1.62e-2 of violation, no row above its floor.
1960 assert!(c.primal_component_passes(1.620_777e-2, false));
1961 // The same violation with one row genuinely above its floor is refused.
1962 assert!(!c.primal_component_passes(1.620_777e-2, true));
1963 // The forgiveness is not bounded above — that is deliberate. What
1964 // bounds it is `primal_resolvable`, which no real violation can clear:
1965 // a row short by 1e6 is short by ~1e22 ulps of itself.
1966 assert!(c.primal_component_passes(1e6, false));
1967 }
1968
1969 /// gh #590: the rapid-infeasibility detector's **absolute** arm carries the
1970 /// same requirement. Convicting on a residual the model's arithmetic cannot
1971 /// resolve is the worst failure this predicate has — the verdict is
1972 /// confident, and downstream it is indistinguishable from a real proof.
1973 /// The **relative** arm is untouched: it is already a ratio against the
1974 /// row's own magnitude, so it cannot mistake a quantum for a violation.
1975 #[test]
1976 fn the_absolute_infeasibility_arm_needs_a_resolvable_violation() {
1977 let c = OptErrorConvCheck {
1978 constr_viol_tol: 1e-6,
1979 infeas_stationarity_tol: 1e-8,
1980 infeas_max_streak: 5,
1981 ..Default::default()
1982 };
1983 assert_eq!(c.absolute_viol_threshold(), MIN_INFEAS_VIOL_FLOOR);
1984
1985 // The reported iterate: violation above the absolute floor, gradient
1986 // flat, relative violation negligible — and every row inside its own
1987 // noise floor. Before the fix this armed the detector.
1988 assert!(!c.is_infeasible_stationary(1.620_777e-2, 1e-8, 1e-9, false));
1989 // Identical, except one row is resolvable: the verdict stands.
1990 assert!(c.is_infeasible_stationary(1.620_777e-2, 1e-8, 1e-9, true));
1991 // The relative arm does not depend on the flag — a row violated by
1992 // more than the relative threshold of its own magnitude arms the
1993 // detector whatever the absolute floor says.
1994 assert!(c.is_infeasible_stationary(0.0, 1.0, 1e-9, false));
1995 }
1996
1997 #[test]
1998 fn infeasible_stationary_requires_violation_and_flat_gradient() {
1999 let c = OptErrorConvCheck {
2000 constr_viol_tol: 1e-4,
2001 infeas_viol_kappa: 1e2, // violation threshold = 1e-2
2002 infeas_stationarity_tol: 1e-8,
2003 infeas_max_streak: 5,
2004 ..Default::default()
2005 };
2006 // Violation well above 1e-2 and the infeasibility gradient
2007 // essentially zero → counts as infeasible-stationary.
2008 assert!(c.is_infeasible_stationary(1e-1, 0.0, 1e-9, true));
2009 // Violation above threshold but the gradient is not flat →
2010 // still making feasibility progress, does not count.
2011 assert!(!c.is_infeasible_stationary(1e-1, 0.0, 1e-3, true));
2012 // Gradient flat but violation below threshold → nearly
2013 // feasible, does not count.
2014 assert!(!c.is_infeasible_stationary(1e-3, 0.0, 1e-9, true));
2015 }
2016
2017 /// gh #519: tightening `constr_viol_tol` must never widen the set of
2018 /// points the detector is willing to call infeasible. The absolute arm's
2019 /// floor used to be `infeas_viol_kappa · constr_viol_tol` unclamped, so at
2020 /// `constr_viol_tol = 1e-6` it fell to `1e-4` — and @bernalde's `f=1`
2021 /// model (gh #505), plateaued at an unscaled violation of `1.943e-4`, was
2022 /// convicted at a point its own run reported as acceptable.
2023 #[test]
2024 fn tightening_constr_viol_tol_never_arms_the_absolute_arm_lower() {
2025 let plateau_viol = 1.9430136821e-4; // the measured `f=1` plateau
2026 // Every value at or below the default: tightening from here must not
2027 // move the floor at all, and certainly not downward.
2028 for &cvt in &[1e-4, 1e-5, 1.94e-6, 1e-7, 1e-9, 1e-12] {
2029 let c = OptErrorConvCheck {
2030 constr_viol_tol: cvt,
2031 ..Default::default()
2032 };
2033 let floor = c.absolute_viol_threshold();
2034 assert_eq!(
2035 floor, MIN_INFEAS_VIOL_FLOOR,
2036 "constr_viol_tol={cvt} moved the absolute floor to {floor}"
2037 );
2038 // The `f=1` plateau is a nearly-feasible flat spot at every one
2039 // of these tolerances, so no `constr_viol_tol` may arm the
2040 // absolute arm on it (the relative signal is 0 here: the row is
2041 // unit-scale, so only the absolute arm is in play).
2042 assert!(
2043 !c.is_infeasible_stationary(plateau_viol, 0.0, 1e-9, true),
2044 "constr_viol_tol={cvt} armed the detector on the {plateau_viol} plateau"
2045 );
2046 }
2047 }
2048
2049 /// The clamp is a floor, not a cap: `infeas_viol_kappa` still raises the
2050 /// absolute threshold, and a violation genuinely bounded away from
2051 /// feasible still arms the detector at any `constr_viol_tol`.
2052 #[test]
2053 fn absolute_viol_floor_is_a_floor_not_a_cap() {
2054 let strict = OptErrorConvCheck {
2055 constr_viol_tol: 1e-9,
2056 ..Default::default()
2057 };
2058 assert_eq!(strict.absolute_viol_threshold(), 1e-2);
2059 assert!(strict.is_infeasible_stationary(0.5, 0.0, 1e-9, true));
2060 // Raising kappa above the floor still moves the threshold.
2061 let wide = OptErrorConvCheck {
2062 constr_viol_tol: 1e-4,
2063 infeas_viol_kappa: 1e4, // 1e0, well above the 1e-2 floor
2064 ..Default::default()
2065 };
2066 assert_eq!(wide.absolute_viol_threshold(), 1.0);
2067 assert!(!wide.is_infeasible_stationary(0.5, 0.0, 1e-9, true));
2068 assert!(wide.is_infeasible_stationary(2.0, 0.0, 1e-9, true));
2069 // Loosening `constr_viol_tol` past the floor moves it too — the floor
2070 // only binds from below.
2071 let loose = OptErrorConvCheck {
2072 constr_viol_tol: 1e-2,
2073 ..Default::default()
2074 };
2075 assert_eq!(loose.absolute_viol_threshold(), 1.0);
2076 }
2077
2078 /// gh #508: the status-decision sites that ask "is this violation real"
2079 /// read `constr_viol_tol` off the policy, so a user setting has to reach
2080 /// them — the defect was a threshold built from `tol` that no
2081 /// `constr_viol_tol` value could move. `set_tolerance` is the debugger's
2082 /// live hot-swap path and must be visible through the accessor too.
2083 #[test]
2084 fn constr_viol_tol_accessor_tracks_the_option() {
2085 let mut c = OptErrorConvCheck {
2086 tol: 1e-6,
2087 constr_viol_tol: 1e-3,
2088 ..Default::default()
2089 };
2090 assert_eq!(c.constr_viol_tol_or_default(), 1e-3);
2091 // Independent of `tol` — retuning convergence must not retune what
2092 // counts as a violated constraint.
2093 c.tol = 1e-10;
2094 assert_eq!(c.constr_viol_tol_or_default(), 1e-3);
2095 assert!(c.set_tolerance("constr_viol_tol", 1e-7));
2096 assert_eq!(c.constr_viol_tol_or_default(), 1e-7);
2097 }
2098
2099 #[test]
2100 fn infeasible_stationary_disabled_by_nonpositive_knobs() {
2101 let off_tol = OptErrorConvCheck {
2102 infeas_stationarity_tol: 0.0,
2103 infeas_max_streak: 5,
2104 ..Default::default()
2105 };
2106 assert!(!off_tol.is_infeasible_stationary(1e9, 0.0, 0.0, true));
2107 let off_streak = OptErrorConvCheck {
2108 infeas_stationarity_tol: 1e-8,
2109 infeas_max_streak: 0,
2110 ..Default::default()
2111 };
2112 assert!(!off_streak.is_infeasible_stationary(1e9, 0.0, 0.0, true));
2113 }
2114
2115 #[test]
2116 fn infeasible_stationary_streak_fires_only_after_max_streak() {
2117 let mut c = OptErrorConvCheck {
2118 constr_viol_tol: 1e-4,
2119 infeas_viol_kappa: 1e2, // violation threshold = 1e-2
2120 infeas_stationarity_tol: 1e-8,
2121 infeas_max_streak: 3,
2122 ..Default::default()
2123 };
2124 // Infeasible-stationary iterate: violation 1e-1 > 1e-2, flat
2125 // gradient. Streak accrues but does not fire until the third.
2126 assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9, true));
2127 assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9, true));
2128 assert!(c.note_infeasible_stationary(1e-1, 0.0, 1e-9, true));
2129 }
2130
2131 #[test]
2132 fn infeasible_stationary_streak_resets_on_feasibility_progress() {
2133 let mut c = OptErrorConvCheck {
2134 constr_viol_tol: 1e-4,
2135 infeas_viol_kappa: 1e2,
2136 infeas_stationarity_tol: 1e-8,
2137 infeas_max_streak: 3,
2138 ..Default::default()
2139 };
2140 assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9, true));
2141 assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9, true));
2142 // A non-stationary iterate (gradient not flat) resets the streak.
2143 assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-3, true));
2144 assert_eq!(c.infeas_streak, 0);
2145 // The streak must rebuild from scratch — no carry-over credit.
2146 assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9, true));
2147 assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9, true));
2148 assert!(c.note_infeasible_stationary(1e-1, 0.0, 1e-9, true));
2149 }
2150
2151 #[test]
2152 fn infeasible_stationary_streak_never_fires_when_disabled() {
2153 let mut c = OptErrorConvCheck {
2154 infeas_stationarity_tol: 0.0,
2155 infeas_max_streak: 5,
2156 ..Default::default()
2157 };
2158 for _ in 0..20 {
2159 assert!(!c.note_infeasible_stationary(1e9, 0.0, 0.0, true));
2160 }
2161 assert_eq!(c.infeas_streak, 0);
2162 }
2163
2164 /// gh #532. The scale-relative floor under `dual_inf_tol`, on the numbers
2165 /// that produced the report: `orthrds2` must pass, and the runaway
2166 /// `min -exp(x) s.t. x >= 0` must not.
2167 #[test]
2168 fn dual_inf_bound_forgives_a_relatively_stationary_residual_only() {
2169 let c = OptErrorConvCheck::new();
2170 assert_eq!(c.dual_inf_tol, 1.0);
2171 assert_eq!(c.dual_inf_scale_kappa, 1.0);
2172
2173 // `orthrds2`: ‖∇L‖_∞ = 89.7 against terms of magnitude ~1.6e12 (the
2174 // mean multiplier magnitude behind its `s_d ≈ 1.6e10`) — stationary to
2175 // nine digits relative to what it is made of, and refused by the bare
2176 // `1.0` before the fix.
2177 let (orthrds2_dual_inf, orthrds2_scale) = (89.669_051_358_301_67, 1.6e12);
2178 assert!(orthrds2_dual_inf > c.dual_inf_tol, "the reported refusal");
2179 assert!(orthrds2_dual_inf <= c.dual_inf_bound(orthrds2_scale));
2180 assert!(c.passes_component_tols(
2181 5.537e-9,
2182 orthrds2_dual_inf,
2183 1.741e-8,
2184 0.0,
2185 orthrds2_scale,
2186 true
2187 ));
2188
2189 // `min -exp(x) s.t. x >= 0` running away: `∇f = −8.8e47` with no
2190 // multiplier to meet it, so nothing cancelled and the residual IS the
2191 // scale. Refused by eight orders — the case any such rule has to keep
2192 // rejecting.
2193 let runaway = 8.8e47;
2194 assert!(runaway > c.dual_inf_bound(runaway));
2195 assert!(!c.passes_component_tols(1e-12, runaway, 1.7e-10, 0.0, runaway, true));
2196
2197 // The floor is a floor, never a tightening: below `dual_inf_tol` the
2198 // absolute arm decides, at any scale.
2199 assert_eq!(c.dual_inf_bound(1.0), c.dual_inf_tol);
2200 assert_eq!(c.dual_inf_bound(0.0), c.dual_inf_tol);
2201 assert_eq!(c.dual_inf_bound(1e-30), c.dual_inf_tol);
2202 // ...and it only lifts off `dual_inf_tol` once the scale passes
2203 // `dual_inf_tol / (kappa · tol)` = 1e8, so every `O(1)` model keeps the
2204 // upstream comparison bit for bit.
2205 assert_eq!(c.dual_inf_bound(1e7), c.dual_inf_tol);
2206 assert!(c.dual_inf_bound(1e10) > c.dual_inf_tol);
2207
2208 // Non-finite scales say nothing and must not widen anything.
2209 for bad in [Number::NAN, Number::INFINITY, Number::NEG_INFINITY] {
2210 assert_eq!(c.dual_inf_bound(bad), c.dual_inf_tol, "scale {bad}");
2211 }
2212 }
2213
2214 /// The floor tracks `tol`: asking for a stricter solve tightens the dual
2215 /// component gate in proportion, and `dual_inf_scale_kappa = 0` is the
2216 /// documented opt-out back to upstream's bare absolute bound.
2217 #[test]
2218 fn dual_inf_bound_tracks_tol_and_honours_the_opt_out() {
2219 let mut c = OptErrorConvCheck::new();
2220 assert_eq!(c.dual_inf_bound(1e12), 1e4);
2221 c.tol = 1e-10;
2222 assert_eq!(c.dual_inf_bound(1e12), 1e2);
2223 // Kappa scales the floor as advertised.
2224 c.tol = 1e-8;
2225 c.dual_inf_scale_kappa = 10.0;
2226 assert_eq!(c.dual_inf_bound(1e12), 1e5);
2227 // `0` (and, defensively, a negative or NaN value the option's own lower
2228 // bound already refuses) disables it outright — the most extreme scale
2229 // must not move the bound.
2230 for off in [0.0, -1.0, Number::NAN] {
2231 c.dual_inf_scale_kappa = off;
2232 assert_eq!(c.dual_inf_bound(1e30), c.dual_inf_tol, "kappa {off}");
2233 assert!(!c.passes_component_tols(1e-12, 89.7, 0.0, 0.0, 1.6e12, true));
2234 }
2235 }
2236
2237 /// gh #528. The strict gate reads the noise-floored aggregate when that is
2238 /// the smaller of the two, and is otherwise untouched — the floored value
2239 /// can never *raise* the error.
2240 #[test]
2241 fn strict_overall_takes_the_noise_floored_aggregate() {
2242 // The reported case: KKT error pinned one ulp of `|b| ~ 1e8` above
2243 // `tol`, with the primal residual entirely inside its own resolution.
2244 assert_eq!(
2245 OptErrorConvCheck::strict_overall(1.49e-8, 9.09e-10),
2246 9.09e-10
2247 );
2248 // Nothing at its resolution limit: the two agree and the gate is the
2249 // upstream one, bit for bit.
2250 assert_eq!(OptErrorConvCheck::strict_overall(1e-9, 1e-9), 1e-9);
2251 }
2252
2253 /// A non-finite KKT error must survive the floor untouched. `f64::min`
2254 /// returns the *other* operand at `NaN`, so a bare `min` would launder the
2255 /// `Invalid_Number_Detected` signal `curr_nlp_error`'s `has_valid_numbers`
2256 /// sweep exists to raise (gh #292).
2257 #[test]
2258 fn strict_overall_passes_a_non_finite_error_through() {
2259 assert!(OptErrorConvCheck::strict_overall(Number::NAN, 1e-12).is_nan());
2260 assert_eq!(
2261 OptErrorConvCheck::strict_overall(Number::INFINITY, 1e-12),
2262 Number::INFINITY
2263 );
2264 }
2265
2266 #[test]
2267 fn max_iter_exceeded() {
2268 let mut c = OptErrorConvCheck {
2269 max_iter: 5,
2270 ..Default::default()
2271 };
2272 assert_eq!(
2273 c.check_convergence(1.0, 5),
2274 ConvergenceStatus::MaxIterExceeded
2275 );
2276 }
2277}