Skip to main content

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}