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