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                // gh#1146: `constr_viol` is the violation of the internal
1208                // slack reformulation, `max(‖c‖, ‖d − s‖)`, and the slack can
1209                // drift from `d(x)` while `d(x)` itself stays inside its
1210                // declared bounds — `s` is confined to `[d_l, d_u]`, so a gap
1211                // `d − s` never says `d` left them. An infeasibility verdict
1212                // is a claim about the *model*, and a point that satisfies
1213                // every declared row and the declared box to `constr_viol_tol`
1214                // refutes it outright, whatever the slack says. Measured: a
1215                // 1-variable `log(x)` model from `x0 = 0` under L-BFGS, whose
1216                // `log` row was scaled by ~1e-8 at the start, so the scaled
1217                // gap (1.4e-10) looked stationary while the unscaled one
1218                // (1.4e-2) cleared the floor — at `x = 0.444`, inside every
1219                // row, where the `inf_pr` column already printed `0.00`.
1220                //
1221                // Feasible means feasible on *both* arms the detector
1222                // convicts on. `rel_viol` is already a property of the
1223                // point (`d(x)` against its declared bounds, `c(x)` against
1224                // its right-hand side — no slack), so it is reused as is: a
1225                // model whose rows all live below `constr_viol_tol`
1226                // (`infeasible_square_scaled_1em4`, rows scaled by 1e-4) is
1227                // infeasible only relative to the rows' own magnitude, and
1228                // the absolute test alone would acquit it.
1229                let point_feasible = rel_viol <= self.relative_viol_threshold()
1230                    && cq.borrow().curr_declared_primal_violation_max() < self.constr_viol_tol;
1231                if point_feasible {
1232                    tracing::debug!(
1233                        target: "pounce::algorithm",
1234                        constr_viol,
1235                        "rapid infeasibility detection: refusing the verdict, the iterate \
1236                         satisfies every declared row and bound (the violation is the \
1237                         slack gap d(x) - s only); continuing (gh#1146)"
1238                    );
1239                    self.infeas_streak = 0;
1240                } else if cq.borrow().infeasibility_descent_available() {
1241                    // Descent exists: not a stationary point of the violation,
1242                    // so the surrogate was wrong here. Drop the streak and keep
1243                    // solving.
1244                    self.infeas_streak = 0;
1245                } else {
1246                    return ConvergenceStatus::LocallyInfeasible;
1247                }
1248            }
1249        }
1250        // Time-budget gates. When the application installed a shared
1251        // [`Deadline`] (pounce#242) it is authoritative: it measures
1252        // global elapsed time from a fixed start instant, so it fires
1253        // correctly even inside the restoration inner IPM, whose fresh
1254        // `timing.overall_alg` is never started. Absent a deadline (the
1255        // direct-driver / unit-test path), fall back to the `overall_alg`
1256        // timer, which `IpoptApplication` starts at the top of
1257        // `optimize_constrained`; `live_*` returns the running elapsed
1258        // without forcing a `start/end` cycle. Upstream
1259        // `IpOptErrorConvCheck.cpp::CheckConvergence` reads the
1260        // application-level start time similarly.
1261        let d = data.borrow();
1262        if let Some(deadline) = d.deadline.as_ref() {
1263            match deadline.exceeded() {
1264                Some(pounce_common::timing::DeadlineKind::Cpu) => {
1265                    return ConvergenceStatus::CpuTimeExceeded;
1266                }
1267                Some(pounce_common::timing::DeadlineKind::Wall) => {
1268                    return ConvergenceStatus::WallTimeExceeded;
1269                }
1270                None => {}
1271            }
1272        } else {
1273            let timing = &d.timing;
1274            if timing.overall_alg.live_cpu_time() >= self.max_cpu_time {
1275                return ConvergenceStatus::CpuTimeExceeded;
1276            }
1277            if timing.overall_alg.live_wallclock_time() >= self.max_wall_time {
1278                return ConvergenceStatus::WallTimeExceeded;
1279            }
1280        }
1281        ConvergenceStatus::Continue
1282    }
1283
1284    fn current_passes_strict(
1285        &self,
1286        nlp_err: Number,
1287        _data: &IpoptDataHandle,
1288        cq: &IpoptCqHandle,
1289    ) -> bool {
1290        // The strict per-component gate of `check_convergence_with_state`, minus
1291        // the masking veto — see the trait doc. Unscaled per-component residuals,
1292        // matching that method (the `*_tol` triplet is defined on the
1293        // user-original residuals).
1294        let cq_ref = cq.borrow();
1295        let dual_inf = cq_ref.curr_unscaled_dual_infeasibility_max();
1296        let constr_viol = cq_ref.curr_unscaled_primal_infeasibility_max();
1297        let compl_inf = cq_ref.curr_unscaled_complementarity_max();
1298        // Same noise-floored aggregate the strict gate uses (gh #528) — this
1299        // predicate exists to answer "would that gate have passed here?", so it
1300        // has to ask the same question. Same scale-relative dual floor
1301        // (gh #532), and lazily for the same reason.
1302        let strict_err = if self.noise_floor_enabled() {
1303            Self::strict_overall(
1304                nlp_err,
1305                cq_ref.curr_nlp_error_above_primal_noise(self.primal_noise_floor_kappa),
1306            )
1307        } else {
1308            nlp_err
1309        };
1310        let dual_scale = if dual_inf > self.dual_inf_tol && self.dual_inf_scale_kappa > 0.0 {
1311            cq_ref.curr_unscaled_dual_infeasibility_scale_max()
1312        } else {
1313            0.0
1314        };
1315        // Same primal noise floor the strict gate uses (gh #590), for the same
1316        // reason this predicate reuses the floored aggregate.
1317        let primal_resolvable = !(self.noise_floor_enabled()
1318            && constr_viol > self.constr_viol_tol
1319            && cq_ref.curr_primal_infeasibility_above_noise(self.primal_noise_floor_kappa) == 0.0);
1320        drop(cq_ref);
1321        self.passes_component_tols(
1322            strict_err,
1323            dual_inf,
1324            constr_viol,
1325            compl_inf,
1326            dual_scale,
1327            primal_resolvable,
1328        )
1329    }
1330
1331    fn tol_or_default(&self) -> Number {
1332        self.tol
1333    }
1334
1335    fn constr_viol_tol_or_default(&self) -> Number {
1336        self.constr_viol_tol
1337    }
1338
1339    fn acceptable_constr_viol_tol_or_default(&self) -> Number {
1340        self.acceptable_constr_viol_tol
1341    }
1342
1343    fn set_tolerance(&mut self, name: &str, value: Number) -> bool {
1344        match name {
1345            "tol" => self.tol = value,
1346            "dual_inf_tol" => self.dual_inf_tol = value,
1347            "constr_viol_tol" => self.constr_viol_tol = value,
1348            "compl_inf_tol" => self.compl_inf_tol = value,
1349            "acceptable_tol" => self.acceptable_tol = value,
1350            "acceptable_dual_inf_tol" => self.acceptable_dual_inf_tol = value,
1351            "acceptable_constr_viol_tol" => self.acceptable_constr_viol_tol = value,
1352            "acceptable_compl_inf_tol" => self.acceptable_compl_inf_tol = value,
1353            "acceptable_obj_change_tol" => self.acceptable_obj_change_tol = value,
1354            _ => return false,
1355        }
1356        true
1357    }
1358
1359    fn current_is_acceptable(&self, nlp_err: Number) -> bool {
1360        // Scalar fallback used when the caller has no `IpoptCq` handle
1361        // (e.g. unit tests). The state-aware variant
1362        // [`Self::current_is_acceptable_with_state`] mirrors upstream
1363        // more faithfully by gating on the per-component
1364        // `acceptable_*_tol` triplet plus the obj-change cross-check.
1365        nlp_err.is_finite() && nlp_err <= self.acceptable_tol
1366    }
1367
1368    fn current_is_acceptable_with_state(
1369        &self,
1370        nlp_err: Number,
1371        _data: &IpoptDataHandle,
1372        cq: &IpoptCqHandle,
1373    ) -> bool {
1374        let cq_ref = cq.borrow();
1375        // Unscaled per-component residuals — see `check_convergence_with_state`
1376        // (the `acceptable_*_tol` triplet is likewise defined on the
1377        // user-original residuals).
1378        let dual_inf = cq_ref.curr_unscaled_dual_infeasibility_max();
1379        let constr_viol = cq_ref.curr_unscaled_primal_infeasibility_max();
1380        let compl_inf = cq_ref.curr_unscaled_complementarity_max();
1381        let rel_viol = cq_ref.curr_relative_primal_infeasibility_max();
1382        let curr_f = cq_ref.curr_f();
1383        drop(cq_ref);
1384        // The scale-relative veto reaches acceptable-point *storage* too,
1385        // unlike the masked-scale (#200) veto above it. That veto refuses a
1386        // possibly-premature stop at a point that is still genuinely feasible,
1387        // so the stash stays a legitimate rollback target. Here the point has
1388        // a constraint row violated by more than the relative threshold of
1389        // its own magnitude — it is not acceptable in any honest sense, and a
1390        // stall later in the run must not roll back to it and surface
1391        // `Solved_To_Acceptable_Level` on an infeasible model (measured: an
1392        // infeasible row at scale `1e-10`, 100% violated, exited exactly that
1393        // way through this stash).
1394        //
1395        // gh #693: unlike the certificate veto above, this gate is **not**
1396        // budget-aware, and deliberately so. `VETO_MAX_EXTRA_ITERS` exists to
1397        // bound the *iterations* a veto can spend refusing to stop — the
1398        // certificate veto keeps the run going, so it can cost wall clock, and
1399        // the budget caps that. Declining to stash costs nothing: the stash is
1400        // a side effect of an iteration the run was taking anyway, so a budget
1401        // here bounds no cost. What it did do was expire, and once expired the
1402        // very point the veto exists to reject was written into the rollback
1403        // target — and `ConvergenceStatus::LocallyInfeasible` consults that
1404        // stash (gh #505, `ipopt_alg.rs`), so the honest infeasibility verdict
1405        // came back out as `Solved_To_Acceptable_Level`.
1406        //
1407        // Measured on `x >= 2` over `x in [0, 1]` with every row scaled by
1408        // `1e-8` (`test_scale_invariance.py::_inf_clear`, and `_inf_two`
1409        // likewise): the `feral_scaling=mc64` leg spends 288 iterations, blows
1410        // the 60-iteration budget around iteration 60, stashes an iterate whose
1411        // single row is violated by 99.998% of its own magnitude, then rolls
1412        // back to it on the infeasibility exit. The comment in `ipopt_alg.rs`
1413        // asserting this gate makes the stash "inert on genuinely infeasible
1414        // models" was true only for runs that convict inside 60 iterations.
1415        if rel_viol > self.relative_viol_threshold() {
1416            return false;
1417        }
1418        self.passes_acceptable_tols(nlp_err, dual_inf, constr_viol, compl_inf, curr_f)
1419    }
1420
1421    fn set_curr_acceptable_obj(&mut self, obj: Number) {
1422        self.last_acceptable_obj = Some(obj);
1423    }
1424}
1425
1426#[cfg(test)]
1427mod tests {
1428    use super::*;
1429
1430    #[test]
1431    fn converges_at_tol() {
1432        let mut c = OptErrorConvCheck::new();
1433        assert_eq!(c.check_convergence(1e-9, 0), ConvergenceStatus::Converged);
1434    }
1435
1436    /// The scale-relative arm of rapid infeasibility detection (#385 Step 6):
1437    /// a row violated by a large fraction of its own magnitude is bounded away
1438    /// from feasible no matter how small its numbers are, so the pre-filter
1439    /// must fire even when the absolute violation is far below
1440    /// `infeas_viol_kappa * constr_viol_tol`.
1441    #[test]
1442    fn relative_violation_arms_the_infeasibility_prefilter() {
1443        let c = OptErrorConvCheck::new();
1444        // `x >= 0.7` at row scale 1e-12: absolute violation 1e-13 (invisible
1445        // to the absolute arm, floor is 1e-2), relative violation 0.14.
1446        assert!(c.is_infeasible_stationary(1e-13, 0.14, 1e-9, true));
1447        // The same iterate without the relative signal must NOT fire — this
1448        // is exactly the old behaviour.
1449        assert!(!c.is_infeasible_stationary(1e-13, 0.0, 1e-9, true));
1450        // A converged small-magnitude row (residual 1e-9 on a 1e-6-bound row,
1451        // 0.1% relative) stays under the 1% threshold.
1452        assert!(!c.is_infeasible_stationary(1e-9, 1e-3, 1e-9, true));
1453    }
1454
1455    /// The relative arm's streak resets while the relative violation is still
1456    /// improving — "bounded away from feasible" must mean *not still
1457    /// converging*. QSCORPIO's endgame was cutting its violation 16× over
1458    /// five iterations when the un-guarded arm declared it locally
1459    /// infeasible; five more iterations reached the optimum.
1460    #[test]
1461    fn improving_relative_violation_resets_the_streak() {
1462        let mut c = OptErrorConvCheck::new();
1463        c.infeas_max_streak = 3;
1464        // A pinned relative violation (an infeasibility gap) accumulates.
1465        assert!(!c.note_infeasible_stationary(1e-13, 0.14, 1e-9, true));
1466        assert!(!c.note_infeasible_stationary(1e-13, 0.14, 1e-9, true));
1467        assert!(c.note_infeasible_stationary(1e-13, 0.14, 1e-9, true));
1468        // A geometrically shrinking one (a converging endgame) never fires.
1469        let mut c = OptErrorConvCheck::new();
1470        c.infeas_max_streak = 3;
1471        let mut rel = 0.5;
1472        for _ in 0..20 {
1473            assert!(
1474                !c.note_infeasible_stationary(1e-13, rel, 1e-9, true),
1475                "a converging endgame must not be declared infeasible"
1476            );
1477            rel *= 0.5;
1478        }
1479    }
1480
1481    /// The relative-violation veto blocks a strict certificate the absolute
1482    /// tolerances would grant, and its budget bounds the cost: once spent,
1483    /// the certificate goes through exactly as before.
1484    #[test]
1485    fn relative_viol_threshold_is_floored() {
1486        let mut c = OptErrorConvCheck::new();
1487        // Default constr_viol_tol = 1e-4 -> threshold 1e-2.
1488        assert_eq!(c.relative_viol_threshold(), 1e-2);
1489        // A loosened constr_viol_tol loosens the relative bar with it.
1490        c.constr_viol_tol = 1e-3;
1491        assert_eq!(c.relative_viol_threshold(), 1e-1);
1492        // A tightened one must not push the relative bar below 1% — an
1493        // interior-point run converges inequality residuals to absolute
1494        // levels, and a tighter relative bar vetoes genuine solutions on
1495        // small-magnitude rows.
1496        c.constr_viol_tol = 1e-8;
1497        assert_eq!(c.relative_viol_threshold(), 1e-2);
1498    }
1499
1500    #[test]
1501    fn acceptable_iter_count_threshold() {
1502        let mut c = OptErrorConvCheck {
1503            acceptable_iter: 3,
1504            ..Default::default()
1505        };
1506        // nlp_err between tol (1e-8) and acceptable (1e-6).
1507        assert_eq!(c.check_convergence(1e-7, 0), ConvergenceStatus::Continue);
1508        assert_eq!(c.check_convergence(1e-7, 1), ConvergenceStatus::Continue);
1509        assert_eq!(
1510            c.check_convergence(1e-7, 2),
1511            ConvergenceStatus::ConvergedToAcceptable
1512        );
1513    }
1514
1515    #[test]
1516    fn acceptable_iter_zero_disables_acceptable_termination() {
1517        // Upstream `IpOptErrorConvCheck.cpp:241` gates the acceptable
1518        // counter on `acceptable_iter_ > 0`, so a zero disables the
1519        // acceptable-level exit entirely. Before the guard, `>= 0` made
1520        // pounce fire on the FIRST acceptable iterate (the opposite).
1521        let mut c = OptErrorConvCheck {
1522            acceptable_iter: 0,
1523            ..Default::default()
1524        };
1525        // Many iterates parked between tol (1e-8) and acceptable (1e-6)
1526        // must never trigger ConvergedToAcceptable; the run continues
1527        // until tol or max_iter.
1528        for k in 0..50 {
1529            assert_eq!(
1530                c.check_convergence(1e-7, k),
1531                ConvergenceStatus::Continue,
1532                "acceptable_iter=0 must not stop at the acceptable level (iter {k})"
1533            );
1534        }
1535        // tol is still honored regardless.
1536        assert_eq!(c.check_convergence(1e-9, 51), ConvergenceStatus::Converged);
1537    }
1538
1539    #[test]
1540    fn streak_resets_when_above_acceptable() {
1541        let mut c = OptErrorConvCheck {
1542            acceptable_iter: 3,
1543            ..Default::default()
1544        };
1545        assert_eq!(c.check_convergence(1e-7, 0), ConvergenceStatus::Continue);
1546        // Above acceptable resets the counter.
1547        assert_eq!(c.check_convergence(1e-3, 1), ConvergenceStatus::Continue);
1548        assert_eq!(c.check_convergence(1e-7, 2), ConvergenceStatus::Continue);
1549        assert_eq!(c.check_convergence(1e-7, 3), ConvergenceStatus::Continue);
1550        assert_eq!(
1551            c.check_convergence(1e-7, 4),
1552            ConvergenceStatus::ConvergedToAcceptable
1553        );
1554    }
1555
1556    #[test]
1557    fn passes_acceptable_tols_gates_on_per_component_triplet() {
1558        let c = OptErrorConvCheck {
1559            acceptable_tol: 1e-6,
1560            acceptable_dual_inf_tol: 1e-3,
1561            acceptable_constr_viol_tol: 1e-3,
1562            acceptable_compl_inf_tol: 1e-3,
1563            ..Default::default()
1564        };
1565        assert!(c.passes_acceptable_tols(1e-7, 1e-4, 1e-4, 1e-4, 0.0));
1566        // dual_inf above its acceptable threshold blocks.
1567        assert!(!c.passes_acceptable_tols(1e-7, 1.0, 1e-4, 1e-4, 0.0));
1568        // overall above acceptable_tol blocks.
1569        assert!(!c.passes_acceptable_tols(1e-5, 1e-4, 1e-4, 1e-4, 0.0));
1570    }
1571
1572    #[test]
1573    fn passes_acceptable_tols_honors_obj_change_tol() {
1574        let mut c = OptErrorConvCheck {
1575            acceptable_tol: 1e-6,
1576            acceptable_dual_inf_tol: 1.0,
1577            acceptable_constr_viol_tol: 1.0,
1578            acceptable_compl_inf_tol: 1.0,
1579            acceptable_obj_change_tol: 0.1,
1580            ..Default::default()
1581        };
1582        // First call always acceptable (no prior obj).
1583        assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 10.0));
1584        c.set_curr_acceptable_obj(10.0);
1585        // Same f → change well under threshold → still acceptable.
1586        assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 10.0));
1587        // f moved by 2.0 with threshold 0.1 * max(1, |11.0|) = 1.1 →
1588        // absolute change 1.0 < 1.1: acceptable.
1589        assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 11.0));
1590        // f moved by 5.0 — absolute change 5.0 > 1.5 = 0.1 * 15 →
1591        // rejected (the stability cross-check fires).
1592        assert!(!c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 15.0));
1593    }
1594
1595    use crate::conv_check::r#trait::ConvCheck;
1596
1597    #[test]
1598    fn set_curr_acceptable_obj_records_for_cross_check() {
1599        let mut c = OptErrorConvCheck::new();
1600        assert!(c.last_acceptable_obj.is_none());
1601        ConvCheck::set_curr_acceptable_obj(&mut c, 4.2);
1602        assert_eq!(c.last_acceptable_obj, Some(4.2));
1603    }
1604
1605    #[test]
1606    fn a_non_finite_objective_disqualifies_the_veto() {
1607        // `passes_component_tols` never inspects `f`, so a strict certificate can
1608        // pass at an iterate whose objective is NaN while its residuals are finite
1609        // and tiny — and the unvetoed run returns exactly that. Refusing it would
1610        // arm a snapshot that the restore then declines (it requires a finite
1611        // objective), surfacing a failure where the baseline reported success:
1612        // a never-worse violation, on the one path where the objective is not
1613        // usable as a tiebreak.
1614        let c = OptErrorConvCheck {
1615            tol: 1e-8,
1616            dual_inf_tol: 1.0,
1617            constr_viol_tol: 1e-4,
1618            compl_inf_tol: 1e-4,
1619            ..Default::default()
1620        };
1621        // The residuals alone say "converged"; the objective says nothing usable.
1622        assert!(c.passes_component_tols(1e-12, 1e-9, 0.0, 0.0, 0.0, true));
1623        // The masked predicate itself is unchanged — the finiteness gate lives at
1624        // the call site, where `curr_f` is in hand.
1625        assert!(certificate_masked(
1626            1e-8,
1627            8.4e-1,
1628            c.obj_scale_certificate_threshold,
1629            c.acceptable_tol
1630        ));
1631        // Both the guard's inputs behave as the call site composes them.
1632        for bad in [Number::NAN, Number::INFINITY, Number::NEG_INFINITY] {
1633            assert!(!bad.is_finite(), "{bad} should disqualify the veto");
1634        }
1635        assert!((1.0_f64).is_finite());
1636    }
1637
1638    #[test]
1639    fn acceptable_streak_survives_a_masked_boundary_mid_streak() {
1640        // gh #200. `masked` is not constant over a run: it also depends on the
1641        // unscaled error crossing `acceptable_tol`, and that crossing is exactly
1642        // what happens during the endgame. So an acceptable-level streak can
1643        // straddle the boundary.
1644        //
1645        // The earlier implementation kept two disjoint counters, each reset by
1646        // the other's phase. Fourteen unmasked qualifying iterates followed by
1647        // one masked qualifying iterate left the real count at 0 while the
1648        // unvetoed run would have reached 15 and stopped — so the run fell
1649        // through to `max_iter` and returned a bare failure where the baseline
1650        // returned `Solved_To_Acceptable_Level`, with no snapshot armed to roll
1651        // back to. Never-worse, violated.
1652        //
1653        // Every iterate here is a *settled* one — same error, same objective —
1654        // so the gh #533 progress test is flat throughout and this test sees
1655        // only the masked-veto behaviour it is about. The progress test's own
1656        // arm is exercised in `a_wandering_streak_refuses_acceptable_termination`.
1657        const ERR: Number = 1e-7;
1658        const OBJ: Number = 1.0;
1659        let mut c = OptErrorConvCheck {
1660            acceptable_iter: 15,
1661            ..Default::default()
1662        };
1663        // 14 qualifying iterates while unmasked: no termination yet.
1664        for i in 0..14 {
1665            assert!(
1666                !c.note_acceptable(true, false, ERR, OBJ),
1667                "terminated early at {i}"
1668            );
1669        }
1670        // The 15th qualifies too, but the veto is now engaged. The streak must
1671        // be honoured — recorded as a refused termination, not discarded.
1672        assert!(
1673            !c.note_acceptable(true, true, ERR, OBJ),
1674            "a masked iterate must not terminate the run"
1675        );
1676        assert!(
1677            c.acceptable_veto_fired,
1678            "the streak crossed `acceptable_iter` while masked, so a termination was \
1679             refused here and must be recorded — otherwise the fallback has nothing to \
1680             restore and the run returns a bare failure"
1681        );
1682        assert!(
1683            c.masked_acceptable_veto_fired,
1684            "a masked refusal must be attributed to the masked arm — it is what spends \
1685             the masked veto's iteration budget"
1686        );
1687
1688        // The mirror direction: a streak that begins masked and finishes
1689        // unmasked must terminate on the same iterate the baseline would.
1690        let mut c = OptErrorConvCheck {
1691            acceptable_iter: 15,
1692            ..Default::default()
1693        };
1694        for _ in 0..14 {
1695            assert!(!c.note_acceptable(true, true, ERR, OBJ));
1696        }
1697        assert!(
1698            c.note_acceptable(true, false, ERR, OBJ),
1699            "the veto lifted with the streak already at 14; the 15th qualifying iterate \
1700             must terminate exactly as it would without the mechanism"
1701        );
1702
1703        // And a non-qualifying iterate still breaks the streak, in either phase.
1704        let mut c = OptErrorConvCheck {
1705            acceptable_iter: 3,
1706            ..Default::default()
1707        };
1708        assert!(!c.note_acceptable(true, false, ERR, OBJ));
1709        assert!(!c.note_acceptable(false, true, ERR, OBJ));
1710        assert_eq!(
1711            c.acceptable_count, 0,
1712            "a non-qualifying iterate resets the streak"
1713        );
1714        assert!(
1715            c.acceptable_window.is_empty(),
1716            "and clears the streak window"
1717        );
1718        assert!(!c.note_acceptable(true, false, ERR, OBJ));
1719        assert!(!c.note_acceptable(true, false, ERR, OBJ));
1720        assert!(
1721            c.note_acceptable(true, false, ERR, OBJ),
1722            "3 consecutive qualifying iterates terminate"
1723        );
1724    }
1725
1726    /// gh #533. The reported `kissing` streak: fifteen iterates all inside the
1727    /// acceptable band, but with the KKT error wandering across it — the
1728    /// iterate the solve stopped on had an error an order of magnitude *worse*
1729    /// than one it had already reached in the same streak. The count alone
1730    /// stops there (objective `1.00000108`, `Solved_To_Acceptable_Level`);
1731    /// continuing reaches `0.84544259` with a strict certificate.
1732    #[test]
1733    fn a_wandering_streak_refuses_acceptable_termination() {
1734        // The tail of the reported trace (`main @ 880b360b`, default options):
1735        // inf_du 3.35e-08 → 8.18e-08 → 1.08e-07 → 4.15e-07 with the objective
1736        // flat to all eight printed figures throughout.
1737        let kissing_tail = [3.35e-08, 8.18e-08, 1.08e-07, 4.15e-07];
1738        let mut c = OptErrorConvCheck {
1739            acceptable_iter: 4,
1740            ..Default::default()
1741        };
1742        for (i, &err) in kissing_tail.iter().enumerate() {
1743            assert!(
1744                !c.note_acceptable(true, false, err, 1.0000011),
1745                "the streak must not terminate at iterate {i}: the error is still \
1746                 wandering across the acceptable band"
1747            );
1748        }
1749        assert!(
1750            c.acceptable_veto_fired,
1751            "the refusal must be recorded, or the run has nothing to fall back to"
1752        );
1753        assert!(
1754            !c.masked_acceptable_veto_fired,
1755            "a progress refusal is not a masked one and must not spend the masked \
1756             veto's budget"
1757        );
1758        // The count keeps running underneath the refusal — it is what identifies
1759        // the iterate the unvetoed run would have returned.
1760        assert_eq!(c.acceptable_count, 4);
1761
1762        // Once the error settles, the window flattens — after the four-iterate
1763        // window has slid clear of the wandering tail — and the streak
1764        // terminates exactly as it would have without the mechanism.
1765        for _ in 0..2 {
1766            assert!(!c.note_acceptable(true, false, 4.15e-07, 1.0000011));
1767        }
1768        assert!(
1769            c.note_acceptable(true, false, 4.15e-07, 1.0000011),
1770            "a window of four identical iterates is settled; nothing is left to refuse"
1771        );
1772    }
1773
1774    /// The other reported signal: `NARX_CFy`'s objective was still descending
1775    /// through the streak (`8.6579696e-03` at the stop, `8.6445195e-03` sixty
1776    /// iterations later) even where its error spread was small. Either signal
1777    /// alone must be enough to keep solving.
1778    #[test]
1779    fn a_still_descending_objective_refuses_acceptable_termination() {
1780        let mut c = OptErrorConvCheck {
1781            acceptable_iter: 4,
1782            ..Default::default()
1783        };
1784        // A perfectly steady error — only the objective is moving, by ~3e-6
1785        // over the window against a bar of 1e-1 · 1e-6 · max(1, |f|) = 1e-7.
1786        let objs = [8.6592e-03, 8.6588e-03, 8.6584e-03, 8.6580e-03];
1787        for (i, &f) in objs.iter().enumerate() {
1788            assert!(
1789                !c.note_acceptable(true, false, 1.5e-07, f),
1790                "the streak must not terminate at iterate {i}: the objective is still \
1791                 descending"
1792            );
1793        }
1794        assert!(c.acceptable_veto_fired);
1795    }
1796
1797    /// The opt-out is real: `acceptable_progress_kappa = 0` restores the bare
1798    /// consecutive-count criterion, wandering error and all.
1799    #[test]
1800    fn zero_progress_kappa_restores_the_bare_count() {
1801        let mut c = OptErrorConvCheck {
1802            acceptable_iter: 4,
1803            acceptable_progress_kappa: 0.0,
1804            ..Default::default()
1805        };
1806        let kissing_tail = [3.35e-08, 8.18e-08, 1.08e-07, 4.15e-07];
1807        for (i, &err) in kissing_tail.iter().enumerate() {
1808            let terminated = c.note_acceptable(true, false, err, 1.0000011);
1809            assert_eq!(
1810                terminated,
1811                i == 3,
1812                "with the progress test off, iterate {i} must behave exactly as upstream"
1813            );
1814        }
1815        assert!(!c.acceptable_veto_fired);
1816    }
1817
1818    /// The refusal budget bounds the cost of a solve that never settles: past
1819    /// [`ACCEPTABLE_PROGRESS_MAX_REFUSALS`] the test stands aside and the streak
1820    /// terminates as it would have without it, so the worst case is bounded
1821    /// extra iterations rather than a run to `max_iter`.
1822    #[test]
1823    fn the_progress_refusal_budget_is_bounded() {
1824        let mut c = OptErrorConvCheck {
1825            acceptable_iter: 2,
1826            ..Default::default()
1827        };
1828        // A permanent two-cycle inside the band: never flat, never converging.
1829        let mut terminated_at = None;
1830        for k in 0..(ACCEPTABLE_PROGRESS_MAX_REFUSALS + 10) {
1831            let err = if k % 2 == 0 { 1e-7 } else { 9e-7 };
1832            if c.note_acceptable(true, false, err, 1.0) {
1833                terminated_at = Some(k);
1834                break;
1835            }
1836        }
1837        assert_eq!(
1838            c.acceptable_progress_refusals, ACCEPTABLE_PROGRESS_MAX_REFUSALS,
1839            "the budget must be spent, not exceeded"
1840        );
1841        assert!(
1842            terminated_at.is_some(),
1843            "a never-settling solve must still terminate at the acceptable level once \
1844             the budget is spent"
1845        );
1846    }
1847
1848    /// Flatness is judged over the streak's own window, and the window slides:
1849    /// a transient early in a solve must not block termination forever.
1850    #[test]
1851    fn the_flatness_window_slides_past_a_transient() {
1852        let mut c = OptErrorConvCheck {
1853            acceptable_iter: 3,
1854            ..Default::default()
1855        };
1856        // Entering the band while still descending: refused.
1857        assert!(!c.note_acceptable(true, false, 9e-7, 1.0));
1858        assert!(!c.note_acceptable(true, false, 5e-7, 1.0));
1859        assert!(!c.note_acceptable(true, false, 2e-7, 1.0));
1860        assert!(c.acceptable_veto_fired);
1861        // Then it plateaus. Two iterates later the descent has slid out of the
1862        // three-long window and the solve is judged settled.
1863        assert!(!c.note_acceptable(true, false, 2e-7, 1.0));
1864        assert!(
1865            c.note_acceptable(true, false, 2e-7, 1.0),
1866            "the window must slide, or an early transient blocks every later termination"
1867        );
1868    }
1869
1870    /// `acceptable_iter = 1` asks to stop at the first qualifying iterate, and
1871    /// a one-iterate window carries no progress information — so the progress
1872    /// test must never refuse there.
1873    #[test]
1874    fn a_single_iterate_streak_carries_no_progress_signal() {
1875        let mut c = OptErrorConvCheck {
1876            acceptable_iter: 1,
1877            ..Default::default()
1878        };
1879        assert!(c.note_acceptable(true, false, 4.15e-07, 1.0));
1880        assert!(!c.acceptable_veto_fired);
1881    }
1882
1883    /// A non-finite sample must not be read as movement — the mechanism spends
1884    /// iterations, so it may only fire on evidence it actually has.
1885    #[test]
1886    fn non_finite_samples_do_not_refuse() {
1887        for bad in [Number::NAN, Number::INFINITY] {
1888            let mut c = OptErrorConvCheck {
1889                acceptable_iter: 2,
1890                ..Default::default()
1891            };
1892            assert!(!c.note_acceptable(true, false, bad, 1.0));
1893            assert!(
1894                c.note_acceptable(true, false, 1e-7, 1.0),
1895                "a {bad} sample in the window must not be treated as a progress signal"
1896            );
1897        }
1898    }
1899
1900    #[test]
1901    fn certificate_masked_needs_both_an_extreme_scale_and_a_non_stationary_point() {
1902        // gh #200. Both conditions are load-bearing, and each was independently
1903        // shown to be insufficient on the benchmark suite.
1904        let (th, atol) = (1e-4, 1e-6);
1905
1906        // The reported failure: scale pinned at the 1e-8 floor, unscaled error
1907        // 0.84 — the strict test passed in scaled space at `quartc` obj 248.88.
1908        assert!(certificate_masked(1e-8, 8.4e-1, th, atol));
1909
1910        // An ordinary objective scale is never second-guessed, however large
1911        // the unscaled error. Keying on the error alone effectively tightens
1912        // `tol` by `1/df` and regressed hs1/hs38 (scale ~4e-2).
1913        assert!(!certificate_masked(4e-2, 8.4e-1, th, atol));
1914        assert!(!certificate_masked(1.0, 1e3, th, atol));
1915
1916        // An extreme scale at a point that really is stationary is fine — this
1917        // is what lifts the veto once the continued run reaches the minimum.
1918        assert!(!certificate_masked(1e-8, 1e-9, th, atol));
1919
1920        // Boundaries: strictly below the scale threshold, strictly above the
1921        // error tolerance.
1922        assert!(!certificate_masked(th, 1.0, th, atol));
1923        assert!(!certificate_masked(1e-8, atol, th, atol));
1924
1925        // `0` disables the mechanism outright (the documented opt-out) — the
1926        // most extreme possible inputs must not trip it.
1927        assert!(!certificate_masked(1e-30, 1e30, 0.0, atol));
1928        // A negative threshold is treated as disabled rather than as "always".
1929        assert!(!certificate_masked(1e-30, 1e30, -1.0, atol));
1930    }
1931
1932    #[test]
1933    fn veto_blocks_both_strict_and_acceptable_termination() {
1934        // A refused strict certificate must not simply reappear as an
1935        // acceptable-level one at the same wrong point, so the veto covers both
1936        // branches. Exercised through the pure predicates the two branches
1937        // share, since a full `check_convergence_with_state` needs a live cq.
1938        let c = OptErrorConvCheck {
1939            tol: 1e-8,
1940            acceptable_tol: 1e-6,
1941            dual_inf_tol: 1.0,
1942            constr_viol_tol: 1e-4,
1943            compl_inf_tol: 1e-4,
1944            ..Default::default()
1945        };
1946        // The gh #200 iterate: passes the strict test in scaled space...
1947        assert!(c.passes_component_tols(1e-9, 8.4e-1, 0.0, 0.0, 0.0, true));
1948        // ...and the veto is what withholds it.
1949        assert!(certificate_masked(
1950            1e-8,
1951            8.4e-1,
1952            c.obj_scale_certificate_threshold,
1953            c.acceptable_tol
1954        ));
1955        // Default threshold is the documented 1e-4, and the veto starts clear.
1956        assert_eq!(c.obj_scale_certificate_threshold, 1e-4);
1957        assert!(!c.veto_fired);
1958        assert!(!ConvCheck::certificate_vetoed(&c));
1959    }
1960
1961    #[test]
1962    fn passes_component_tols_requires_all_under_threshold() {
1963        let c = OptErrorConvCheck {
1964            tol: 1e-8,
1965            dual_inf_tol: 1.0,
1966            constr_viol_tol: 1e-4,
1967            compl_inf_tol: 1e-4,
1968            ..Default::default()
1969        };
1970        // All under threshold → converged.
1971        assert!(c.passes_component_tols(1e-9, 0.5, 1e-5, 1e-5, 0.0, true));
1972        // dual_inf above its tolerance blocks even when nlp_err is tiny.
1973        assert!(!c.passes_component_tols(1e-12, 2.0, 1e-5, 1e-5, 0.0, true));
1974        // compl_inf above its tolerance blocks.
1975        assert!(!c.passes_component_tols(1e-12, 0.0, 0.0, 1e-2, 0.0, true));
1976        // constr_viol above its tolerance blocks.
1977        assert!(!c.passes_component_tols(1e-12, 0.0, 1e-2, 0.0, 0.0, true));
1978    }
1979
1980    /// gh #590: the strict primal component forgives a violation only when
1981    /// *no* constraint row rose above its own floating-point noise floor. One
1982    /// resolvable row anywhere and `constr_viol_tol` is back in charge.
1983    #[test]
1984    fn primal_component_passes_only_forgives_an_unresolvable_residual() {
1985        let c = OptErrorConvCheck {
1986            constr_viol_tol: 1e-6,
1987            ..Default::default()
1988        };
1989        // Inside the tolerance: passes either way, the floor is not consulted.
1990        assert!(c.primal_component_passes(1e-9, true));
1991        assert!(c.primal_component_passes(1e-9, false));
1992        // The reported point: 1.62e-2 of violation, no row above its floor.
1993        assert!(c.primal_component_passes(1.620_777e-2, false));
1994        // The same violation with one row genuinely above its floor is refused.
1995        assert!(!c.primal_component_passes(1.620_777e-2, true));
1996        // The forgiveness is not bounded above — that is deliberate. What
1997        // bounds it is `primal_resolvable`, which no real violation can clear:
1998        // a row short by 1e6 is short by ~1e22 ulps of itself.
1999        assert!(c.primal_component_passes(1e6, false));
2000    }
2001
2002    /// gh #590: the rapid-infeasibility detector's **absolute** arm carries the
2003    /// same requirement. Convicting on a residual the model's arithmetic cannot
2004    /// resolve is the worst failure this predicate has — the verdict is
2005    /// confident, and downstream it is indistinguishable from a real proof.
2006    /// The **relative** arm is untouched: it is already a ratio against the
2007    /// row's own magnitude, so it cannot mistake a quantum for a violation.
2008    #[test]
2009    fn the_absolute_infeasibility_arm_needs_a_resolvable_violation() {
2010        let c = OptErrorConvCheck {
2011            constr_viol_tol: 1e-6,
2012            infeas_stationarity_tol: 1e-8,
2013            infeas_max_streak: 5,
2014            ..Default::default()
2015        };
2016        assert_eq!(c.absolute_viol_threshold(), MIN_INFEAS_VIOL_FLOOR);
2017
2018        // The reported iterate: violation above the absolute floor, gradient
2019        // flat, relative violation negligible — and every row inside its own
2020        // noise floor. Before the fix this armed the detector.
2021        assert!(!c.is_infeasible_stationary(1.620_777e-2, 1e-8, 1e-9, false));
2022        // Identical, except one row is resolvable: the verdict stands.
2023        assert!(c.is_infeasible_stationary(1.620_777e-2, 1e-8, 1e-9, true));
2024        // The relative arm does not depend on the flag — a row violated by
2025        // more than the relative threshold of its own magnitude arms the
2026        // detector whatever the absolute floor says.
2027        assert!(c.is_infeasible_stationary(0.0, 1.0, 1e-9, false));
2028    }
2029
2030    #[test]
2031    fn infeasible_stationary_requires_violation_and_flat_gradient() {
2032        let c = OptErrorConvCheck {
2033            constr_viol_tol: 1e-4,
2034            infeas_viol_kappa: 1e2, // violation threshold = 1e-2
2035            infeas_stationarity_tol: 1e-8,
2036            infeas_max_streak: 5,
2037            ..Default::default()
2038        };
2039        // Violation well above 1e-2 and the infeasibility gradient
2040        // essentially zero → counts as infeasible-stationary.
2041        assert!(c.is_infeasible_stationary(1e-1, 0.0, 1e-9, true));
2042        // Violation above threshold but the gradient is not flat →
2043        // still making feasibility progress, does not count.
2044        assert!(!c.is_infeasible_stationary(1e-1, 0.0, 1e-3, true));
2045        // Gradient flat but violation below threshold → nearly
2046        // feasible, does not count.
2047        assert!(!c.is_infeasible_stationary(1e-3, 0.0, 1e-9, true));
2048    }
2049
2050    /// gh #519: tightening `constr_viol_tol` must never widen the set of
2051    /// points the detector is willing to call infeasible. The absolute arm's
2052    /// floor used to be `infeas_viol_kappa · constr_viol_tol` unclamped, so at
2053    /// `constr_viol_tol = 1e-6` it fell to `1e-4` — and @bernalde's `f=1`
2054    /// model (gh #505), plateaued at an unscaled violation of `1.943e-4`, was
2055    /// convicted at a point its own run reported as acceptable.
2056    #[test]
2057    fn tightening_constr_viol_tol_never_arms_the_absolute_arm_lower() {
2058        let plateau_viol = 1.9430136821e-4; // the measured `f=1` plateau
2059        // Every value at or below the default: tightening from here must not
2060        // move the floor at all, and certainly not downward.
2061        for &cvt in &[1e-4, 1e-5, 1.94e-6, 1e-7, 1e-9, 1e-12] {
2062            let c = OptErrorConvCheck {
2063                constr_viol_tol: cvt,
2064                ..Default::default()
2065            };
2066            let floor = c.absolute_viol_threshold();
2067            assert_eq!(
2068                floor, MIN_INFEAS_VIOL_FLOOR,
2069                "constr_viol_tol={cvt} moved the absolute floor to {floor}"
2070            );
2071            // The `f=1` plateau is a nearly-feasible flat spot at every one
2072            // of these tolerances, so no `constr_viol_tol` may arm the
2073            // absolute arm on it (the relative signal is 0 here: the row is
2074            // unit-scale, so only the absolute arm is in play).
2075            assert!(
2076                !c.is_infeasible_stationary(plateau_viol, 0.0, 1e-9, true),
2077                "constr_viol_tol={cvt} armed the detector on the {plateau_viol} plateau"
2078            );
2079        }
2080    }
2081
2082    /// The clamp is a floor, not a cap: `infeas_viol_kappa` still raises the
2083    /// absolute threshold, and a violation genuinely bounded away from
2084    /// feasible still arms the detector at any `constr_viol_tol`.
2085    #[test]
2086    fn absolute_viol_floor_is_a_floor_not_a_cap() {
2087        let strict = OptErrorConvCheck {
2088            constr_viol_tol: 1e-9,
2089            ..Default::default()
2090        };
2091        assert_eq!(strict.absolute_viol_threshold(), 1e-2);
2092        assert!(strict.is_infeasible_stationary(0.5, 0.0, 1e-9, true));
2093        // Raising kappa above the floor still moves the threshold.
2094        let wide = OptErrorConvCheck {
2095            constr_viol_tol: 1e-4,
2096            infeas_viol_kappa: 1e4, // 1e0, well above the 1e-2 floor
2097            ..Default::default()
2098        };
2099        assert_eq!(wide.absolute_viol_threshold(), 1.0);
2100        assert!(!wide.is_infeasible_stationary(0.5, 0.0, 1e-9, true));
2101        assert!(wide.is_infeasible_stationary(2.0, 0.0, 1e-9, true));
2102        // Loosening `constr_viol_tol` past the floor moves it too — the floor
2103        // only binds from below.
2104        let loose = OptErrorConvCheck {
2105            constr_viol_tol: 1e-2,
2106            ..Default::default()
2107        };
2108        assert_eq!(loose.absolute_viol_threshold(), 1.0);
2109    }
2110
2111    /// gh #508: the status-decision sites that ask "is this violation real"
2112    /// read `constr_viol_tol` off the policy, so a user setting has to reach
2113    /// them — the defect was a threshold built from `tol` that no
2114    /// `constr_viol_tol` value could move. `set_tolerance` is the debugger's
2115    /// live hot-swap path and must be visible through the accessor too.
2116    #[test]
2117    fn constr_viol_tol_accessor_tracks_the_option() {
2118        let mut c = OptErrorConvCheck {
2119            tol: 1e-6,
2120            constr_viol_tol: 1e-3,
2121            ..Default::default()
2122        };
2123        assert_eq!(c.constr_viol_tol_or_default(), 1e-3);
2124        // Independent of `tol` — retuning convergence must not retune what
2125        // counts as a violated constraint.
2126        c.tol = 1e-10;
2127        assert_eq!(c.constr_viol_tol_or_default(), 1e-3);
2128        assert!(c.set_tolerance("constr_viol_tol", 1e-7));
2129        assert_eq!(c.constr_viol_tol_or_default(), 1e-7);
2130    }
2131
2132    #[test]
2133    fn infeasible_stationary_disabled_by_nonpositive_knobs() {
2134        let off_tol = OptErrorConvCheck {
2135            infeas_stationarity_tol: 0.0,
2136            infeas_max_streak: 5,
2137            ..Default::default()
2138        };
2139        assert!(!off_tol.is_infeasible_stationary(1e9, 0.0, 0.0, true));
2140        let off_streak = OptErrorConvCheck {
2141            infeas_stationarity_tol: 1e-8,
2142            infeas_max_streak: 0,
2143            ..Default::default()
2144        };
2145        assert!(!off_streak.is_infeasible_stationary(1e9, 0.0, 0.0, true));
2146    }
2147
2148    #[test]
2149    fn infeasible_stationary_streak_fires_only_after_max_streak() {
2150        let mut c = OptErrorConvCheck {
2151            constr_viol_tol: 1e-4,
2152            infeas_viol_kappa: 1e2, // violation threshold = 1e-2
2153            infeas_stationarity_tol: 1e-8,
2154            infeas_max_streak: 3,
2155            ..Default::default()
2156        };
2157        // Infeasible-stationary iterate: violation 1e-1 > 1e-2, flat
2158        // gradient. Streak accrues but does not fire until the third.
2159        assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9, true));
2160        assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9, true));
2161        assert!(c.note_infeasible_stationary(1e-1, 0.0, 1e-9, true));
2162    }
2163
2164    #[test]
2165    fn infeasible_stationary_streak_resets_on_feasibility_progress() {
2166        let mut c = OptErrorConvCheck {
2167            constr_viol_tol: 1e-4,
2168            infeas_viol_kappa: 1e2,
2169            infeas_stationarity_tol: 1e-8,
2170            infeas_max_streak: 3,
2171            ..Default::default()
2172        };
2173        assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9, true));
2174        assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9, true));
2175        // A non-stationary iterate (gradient not flat) resets the streak.
2176        assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-3, true));
2177        assert_eq!(c.infeas_streak, 0);
2178        // The streak must rebuild from scratch — no carry-over credit.
2179        assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9, true));
2180        assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9, true));
2181        assert!(c.note_infeasible_stationary(1e-1, 0.0, 1e-9, true));
2182    }
2183
2184    #[test]
2185    fn infeasible_stationary_streak_never_fires_when_disabled() {
2186        let mut c = OptErrorConvCheck {
2187            infeas_stationarity_tol: 0.0,
2188            infeas_max_streak: 5,
2189            ..Default::default()
2190        };
2191        for _ in 0..20 {
2192            assert!(!c.note_infeasible_stationary(1e9, 0.0, 0.0, true));
2193        }
2194        assert_eq!(c.infeas_streak, 0);
2195    }
2196
2197    /// gh #532. The scale-relative floor under `dual_inf_tol`, on the numbers
2198    /// that produced the report: `orthrds2` must pass, and the runaway
2199    /// `min -exp(x) s.t. x >= 0` must not.
2200    #[test]
2201    fn dual_inf_bound_forgives_a_relatively_stationary_residual_only() {
2202        let c = OptErrorConvCheck::new();
2203        assert_eq!(c.dual_inf_tol, 1.0);
2204        assert_eq!(c.dual_inf_scale_kappa, 1.0);
2205
2206        // `orthrds2`: ‖∇L‖_∞ = 89.7 against terms of magnitude ~1.6e12 (the
2207        // mean multiplier magnitude behind its `s_d ≈ 1.6e10`) — stationary to
2208        // nine digits relative to what it is made of, and refused by the bare
2209        // `1.0` before the fix.
2210        let (orthrds2_dual_inf, orthrds2_scale) = (89.669_051_358_301_67, 1.6e12);
2211        assert!(orthrds2_dual_inf > c.dual_inf_tol, "the reported refusal");
2212        assert!(orthrds2_dual_inf <= c.dual_inf_bound(orthrds2_scale));
2213        assert!(c.passes_component_tols(
2214            5.537e-9,
2215            orthrds2_dual_inf,
2216            1.741e-8,
2217            0.0,
2218            orthrds2_scale,
2219            true
2220        ));
2221
2222        // `min -exp(x) s.t. x >= 0` running away: `∇f = −8.8e47` with no
2223        // multiplier to meet it, so nothing cancelled and the residual IS the
2224        // scale. Refused by eight orders — the case any such rule has to keep
2225        // rejecting.
2226        let runaway = 8.8e47;
2227        assert!(runaway > c.dual_inf_bound(runaway));
2228        assert!(!c.passes_component_tols(1e-12, runaway, 1.7e-10, 0.0, runaway, true));
2229
2230        // The floor is a floor, never a tightening: below `dual_inf_tol` the
2231        // absolute arm decides, at any scale.
2232        assert_eq!(c.dual_inf_bound(1.0), c.dual_inf_tol);
2233        assert_eq!(c.dual_inf_bound(0.0), c.dual_inf_tol);
2234        assert_eq!(c.dual_inf_bound(1e-30), c.dual_inf_tol);
2235        // ...and it only lifts off `dual_inf_tol` once the scale passes
2236        // `dual_inf_tol / (kappa · tol)` = 1e8, so every `O(1)` model keeps the
2237        // upstream comparison bit for bit.
2238        assert_eq!(c.dual_inf_bound(1e7), c.dual_inf_tol);
2239        assert!(c.dual_inf_bound(1e10) > c.dual_inf_tol);
2240
2241        // Non-finite scales say nothing and must not widen anything.
2242        for bad in [Number::NAN, Number::INFINITY, Number::NEG_INFINITY] {
2243            assert_eq!(c.dual_inf_bound(bad), c.dual_inf_tol, "scale {bad}");
2244        }
2245    }
2246
2247    /// The floor tracks `tol`: asking for a stricter solve tightens the dual
2248    /// component gate in proportion, and `dual_inf_scale_kappa = 0` is the
2249    /// documented opt-out back to upstream's bare absolute bound.
2250    #[test]
2251    fn dual_inf_bound_tracks_tol_and_honours_the_opt_out() {
2252        let mut c = OptErrorConvCheck::new();
2253        assert_eq!(c.dual_inf_bound(1e12), 1e4);
2254        c.tol = 1e-10;
2255        assert_eq!(c.dual_inf_bound(1e12), 1e2);
2256        // Kappa scales the floor as advertised.
2257        c.tol = 1e-8;
2258        c.dual_inf_scale_kappa = 10.0;
2259        assert_eq!(c.dual_inf_bound(1e12), 1e5);
2260        // `0` (and, defensively, a negative or NaN value the option's own lower
2261        // bound already refuses) disables it outright — the most extreme scale
2262        // must not move the bound.
2263        for off in [0.0, -1.0, Number::NAN] {
2264            c.dual_inf_scale_kappa = off;
2265            assert_eq!(c.dual_inf_bound(1e30), c.dual_inf_tol, "kappa {off}");
2266            assert!(!c.passes_component_tols(1e-12, 89.7, 0.0, 0.0, 1.6e12, true));
2267        }
2268    }
2269
2270    /// gh #528. The strict gate reads the noise-floored aggregate when that is
2271    /// the smaller of the two, and is otherwise untouched — the floored value
2272    /// can never *raise* the error.
2273    #[test]
2274    fn strict_overall_takes_the_noise_floored_aggregate() {
2275        // The reported case: KKT error pinned one ulp of `|b| ~ 1e8` above
2276        // `tol`, with the primal residual entirely inside its own resolution.
2277        assert_eq!(
2278            OptErrorConvCheck::strict_overall(1.49e-8, 9.09e-10),
2279            9.09e-10
2280        );
2281        // Nothing at its resolution limit: the two agree and the gate is the
2282        // upstream one, bit for bit.
2283        assert_eq!(OptErrorConvCheck::strict_overall(1e-9, 1e-9), 1e-9);
2284    }
2285
2286    /// A non-finite KKT error must survive the floor untouched. `f64::min`
2287    /// returns the *other* operand at `NaN`, so a bare `min` would launder the
2288    /// `Invalid_Number_Detected` signal `curr_nlp_error`'s `has_valid_numbers`
2289    /// sweep exists to raise (gh #292).
2290    #[test]
2291    fn strict_overall_passes_a_non_finite_error_through() {
2292        assert!(OptErrorConvCheck::strict_overall(Number::NAN, 1e-12).is_nan());
2293        assert_eq!(
2294            OptErrorConvCheck::strict_overall(Number::INFINITY, 1e-12),
2295            Number::INFINITY
2296        );
2297    }
2298
2299    #[test]
2300    fn max_iter_exceeded() {
2301        let mut c = OptErrorConvCheck {
2302            max_iter: 5,
2303            ..Default::default()
2304        };
2305        assert_eq!(
2306            c.check_convergence(1.0, 5),
2307            ConvergenceStatus::MaxIterExceeded
2308        );
2309    }
2310}