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