Skip to main content

pounce_algorithm/conv_check/
opt_error.rs

1//! Optimal-error convergence check — port of
2//! `Algorithm/IpOptErrorConvCheck.{hpp,cpp}`.
3//!
4//! Tolerance state machine over `(nlp_err, iter_count)` plus
5//! per-component infeasibilities pulled directly from
6//! [`IpoptCalculatedQuantities`]. The scalar
7//! [`Self::check_convergence`] entry point only gates on
8//! `nlp_err <= tol` (matching upstream when the per-component
9//! tolerances are at their `+∞` sentinels); the state-aware
10//! [`Self::check_convergence_with_state`] adds the
11//! `dual_inf_tol` / `constr_viol_tol` / `compl_inf_tol` gates that
12//! mirror upstream `OptimalityErrorConvergenceCheck::CheckConvergence`.
13
14use crate::conv_check::r#trait::{ConvCheck, ConvergenceStatus};
15use crate::ipopt_cq::IpoptCqHandle;
16use crate::ipopt_data::IpoptDataHandle;
17use pounce_common::types::{Index, Number};
18
19pub struct OptErrorConvCheck {
20    pub tol: Number,
21    pub dual_inf_tol: Number,
22    pub constr_viol_tol: Number,
23    pub compl_inf_tol: Number,
24    pub acceptable_tol: Number,
25    pub acceptable_dual_inf_tol: Number,
26    pub acceptable_constr_viol_tol: Number,
27    pub acceptable_compl_inf_tol: Number,
28    pub acceptable_obj_change_tol: Number,
29    pub acceptable_iter: Index,
30    pub max_iter: Index,
31    pub max_cpu_time: Number,
32    pub max_wall_time: Number,
33    pub acceptable_count: Index,
34    /// Objective value at the last iterate the main loop stashed via
35    /// `set_curr_acceptable_obj`. Used by the
36    /// `acceptable_obj_change_tol` cross-check. `None` until an
37    /// acceptable point has been recorded.
38    pub last_acceptable_obj: Option<Number>,
39    /// Tolerance on the scaled infeasibility stationarity
40    /// `‖Jᵀc‖/max(1,‖c‖)`. An iterate counts toward the infeasibility
41    /// streak when this ratio is at or below this value while the
42    /// constraint violation stays bounded away from zero. Rapid
43    /// infeasibility detection is disabled when this is non-positive.
44    pub infeas_stationarity_tol: Number,
45    /// Multiple of `constr_viol_tol` the constraint violation must
46    /// exceed before an iterate can count as infeasible-stationary —
47    /// keeps detection from firing on nearly-feasible flat spots.
48    pub infeas_viol_kappa: Number,
49    /// Consecutive infeasible-stationary iterations required before
50    /// terminating with `LocallyInfeasible`. Non-positive disables
51    /// rapid infeasibility detection.
52    pub infeas_max_streak: Index,
53    /// Running count of consecutive infeasible-stationary iterations.
54    pub infeas_streak: Index,
55    /// Objective-scale floor below which a strict certificate is refused
56    /// while the *unscaled* KKT error is still above `acceptable_tol`
57    /// (gh #200). See [`certificate_masked`]. `0` disables the mechanism
58    /// entirely, restoring bit-for-bit upstream-Ipopt behaviour.
59    pub obj_scale_certificate_threshold: Number,
60    /// Whether a masked **strict** certificate was ever refused this solve.
61    pub veto_fired: bool,
62    /// Whether a masked **acceptable-level** termination was ever refused.
63    ///
64    /// Tracked separately because the two refusals must be undone differently:
65    /// a refused strict certificate restores as `Success`, a refused
66    /// acceptable-level one as `StopAtAcceptablePoint`. Conflating them would
67    /// either over-claim a status or, as originally written, leave the
68    /// acceptable-level refusal with no safety net at all.
69    pub acceptable_veto_fired: bool,
70    /// Iterations spent since the veto first refused a certificate.
71    ///
72    /// The veto is a bet that continuing reaches a better point. Some problems
73    /// never let it pay off — an unscaled error pinned above `acceptable_tol`
74    /// by an unbounded direction keeps the veto engaged until `max_iter`,
75    /// turning a 40-iteration solve into a 300-iteration one for nothing. Past
76    /// [`VETO_MAX_EXTRA_ITERS`] the bet is called off and the run is allowed to
77    /// terminate normally; correctness does not depend on the cap, because the
78    /// refused certificate is restored either way.
79    pub veto_extra_iters: Index,
80}
81
82/// How many iterations the veto may spend before its bet is called off.
83///
84/// Generous relative to what a successful rescue costs — the reported quartics
85/// reach the true minimum in 11-15 extra iterations — but bounded, so a veto
86/// that can never lift (an unscaled error pinned above `acceptable_tol` by an
87/// unbounded direction) cannot run to `max_iter`. Correctness does not rest on
88/// this number: whatever happens after the budget is spent, the refused
89/// certificate is still restored if the run ends without a better one.
90const VETO_MAX_EXTRA_ITERS: Index = 60;
91
92/// Is a passing strict certificate *masked* by an extreme objective scale
93/// (gh #200)?
94///
95/// Gradient-based scaling picks `df = nlp_scaling_max_gradient / max‖∇f‖`,
96/// floored at `nlp_scaling_min_value = 1e-8`. On a flat quartic the initial
97/// gradient is enormous (`quartc`: ~4e12 → `df` pinned at the floor), and the
98/// strict test then runs on the *scaled* aggregate. Because a quartic's
99/// gradient vanishes cubically toward its minimum while `df` stays fixed at its
100/// initial value, the scaled error crosses `tol` roughly 30% of the way in: the
101/// solver certifies optimality at `quartc` objective 248.88 when the true
102/// minimum is ~0, with an unscaled dual infeasibility of 0.84.
103///
104/// This predicate deliberately does **not** try to decide whether the stop is
105/// genuinely false — it only asks whether the conditions that make a false stop
106/// *possible* are present. Distinguishing a masked certificate from an honest
107/// one at a small scale cannot be done from the residual magnitude: `meyer3`
108/// sits at the same 1e-8 scale floor as `quartc` while being genuinely
109/// converged, and the unscaled error is a *dimensional* quantity, so any
110/// absolute cutoff separating them would move if the objective were rescaled —
111/// precisely the sensitivity this bug is about. An earlier revision of this
112/// work did exactly that (a 5e-2 bar fitted to the gap in one benchmark suite);
113/// it is not defensible and was removed.
114///
115/// Instead the caller *tests* the hypothesis: it refuses to stop, continues,
116/// and sees whether the iterates actually go anywhere. If they do, the stop was
117/// false. If they do not, the certificate is honoured unchanged — so the
118/// mechanism is never worse than not having it (see `terminate_vetoed_or`).
119pub fn certificate_masked(
120    obj_scale: Number,
121    unscaled_err: Number,
122    threshold: Number,
123    acceptable_tol: Number,
124) -> bool {
125    // A non-positive threshold is the documented opt-out; NaN is treated the
126    // same way rather than silently enabling the mechanism.
127    if threshold.is_nan() || threshold <= 0.0 {
128        return false;
129    }
130    // Magnitude, not signed value: a negative `obj_scaling_factor` (the
131    // documented way to maximize) is trivially below any positive threshold,
132    // which would arm this on every maximization regardless of scale.
133    obj_scale.abs() < threshold && unscaled_err > acceptable_tol
134}
135
136impl Default for OptErrorConvCheck {
137    fn default() -> Self {
138        // Defaults from `IpOptErrorConvCheck.cpp:RegisterOptions`.
139        Self {
140            tol: 1e-8,
141            dual_inf_tol: 1.0,
142            constr_viol_tol: 1e-4,
143            compl_inf_tol: 1e-4,
144            acceptable_tol: 1e-6,
145            acceptable_dual_inf_tol: 1e10,
146            acceptable_constr_viol_tol: 1e-2,
147            acceptable_compl_inf_tol: 1e-2,
148            acceptable_obj_change_tol: 1e20,
149            acceptable_iter: 15,
150            max_iter: 3000,
151            max_cpu_time: 1e6,
152            max_wall_time: 1e6,
153            acceptable_count: 0,
154            last_acceptable_obj: None,
155            infeas_stationarity_tol: 1e-8,
156            infeas_viol_kappa: 1e2,
157            infeas_max_streak: 5,
158            infeas_streak: 0,
159            // 1e-4 separates the falsely-certified problems (objective scale
160            // pinned at the 1e-8 floor) from every recorded collateral case
161            // (`hs1`/`hs38` at ~4e-2, the 19-problem list at ~1e-2). See
162            // [`certificate_masked`].
163            obj_scale_certificate_threshold: 1e-4,
164            veto_fired: false,
165            acceptable_veto_fired: false,
166            veto_extra_iters: 0,
167        }
168    }
169}
170
171impl OptErrorConvCheck {
172    pub fn new() -> Self {
173        Self::default()
174    }
175
176    /// Pure helper for the per-component upstream gate. Returns `true`
177    /// iff every supplied residual sits at or below its tolerance.
178    /// Factored out so tests can exercise the gating logic without
179    /// constructing a full `IpoptCq`.
180    fn passes_component_tols(
181        &self,
182        overall: Number,
183        dual_inf: Number,
184        constr_viol: Number,
185        compl_inf: Number,
186    ) -> bool {
187        overall <= self.tol
188            && dual_inf <= self.dual_inf_tol
189            && constr_viol <= self.constr_viol_tol
190            && compl_inf <= self.compl_inf_tol
191    }
192
193    /// Pure helper mirroring upstream
194    /// `OptimalityErrorConvergenceCheck::CurrentIsAcceptable`. Tests
195    /// the per-component `acceptable_*_tol` triplet plus the optional
196    /// `acceptable_obj_change_tol` stability cross-check.
197    fn passes_acceptable_tols(
198        &self,
199        overall: Number,
200        dual_inf: Number,
201        constr_viol: Number,
202        compl_inf: Number,
203        curr_f: Number,
204    ) -> bool {
205        // A point is never acceptable if the scaled error metric or the
206        // objective itself is non-finite. Without the `curr_f` guard a NaN/Inf
207        // objective with otherwise-small infeasibility (e.g. CUTE `himmelbj`,
208        // where f evaluates to NaN at a near-feasible point) would be recorded
209        // as the acceptable rollback point and reported under
210        // `Solved_To_Acceptable_Level` with a `nan` objective.
211        if !overall.is_finite() || !curr_f.is_finite() {
212            return false;
213        }
214        let component_ok = overall <= self.acceptable_tol
215            && dual_inf <= self.acceptable_dual_inf_tol
216            && constr_viol <= self.acceptable_constr_viol_tol
217            && compl_inf <= self.acceptable_compl_inf_tol;
218        if !component_ok {
219            return false;
220        }
221        // Upstream `IpOptErrorConvCheck.cpp:CurrentIsAcceptable` — when
222        // an acceptable point has already been recorded and the user
223        // tightened `acceptable_obj_change_tol` below the 1e20
224        // sentinel, the iterate is only re-acceptable if `f` has moved
225        // by less than `tol * max(1, |f|)` relative to the recorded
226        // value. Skipped when no prior point exists or the cross-check
227        // is disabled.
228        if self.acceptable_obj_change_tol < 1e20 {
229            if let Some(prev) = self.last_acceptable_obj {
230                let denom = curr_f.abs().max(1.0);
231                if (prev - curr_f).abs() >= self.acceptable_obj_change_tol * denom {
232                    return false;
233                }
234            }
235        }
236        true
237    }
238
239    /// Advance the acceptable-level streak, returning whether the run should
240    /// terminate with `ConvergedToAcceptable`.
241    ///
242    /// Acceptable-level termination is **count-based**: it needs
243    /// `acceptable_iter` *consecutive* qualifying iterates. The masked-scale
244    /// veto (gh #200) suppresses that termination, so the count has to keep
245    /// running underneath the suppression — otherwise the mechanism cannot know
246    /// where the unvetoed run would have stopped.
247    ///
248    /// The subtle part, and an earlier bug: `masked` is **not constant over a
249    /// run**. `obj_scale` is fixed, but the veto's other condition is
250    /// `unscaled_err > acceptable_tol`, and that quantity crosses the bar
251    /// during the endgame — the crossing *is* the veto lifting. A streak can
252    /// therefore straddle the boundary. Keeping two disjoint counters (a real
253    /// one and a shadow), each reset by the other's phase, silently discarded a
254    /// streak the unvetoed run would have kept: fourteen unmasked qualifying
255    /// iterates followed by one masked qualifying iterate left the real count at
256    /// zero, where the baseline would have reached fifteen and stopped. The run
257    /// then fell through to `max_iter` — with no snapshot armed, because the
258    /// shadow had only just started — and returned a bare failure where the
259    /// baseline returned `Solved_To_Acceptable_Level`. That is precisely the
260    /// "never worse" guarantee failing.
261    ///
262    /// So there is **one** counter, advanced on `acceptable_now` regardless of
263    /// `masked`. `masked` decides only what happens when it crosses the
264    /// threshold: terminate, or record that a termination was refused here —
265    /// which is exactly the iterate the unvetoed run would have returned.
266    fn note_acceptable(&mut self, acceptable_now: bool, masked: bool) -> bool {
267        if !acceptable_now {
268            self.acceptable_count = 0;
269            return false;
270        }
271        self.acceptable_count += 1;
272        if self.acceptable_count < self.acceptable_iter {
273            return false;
274        }
275        if masked {
276            self.acceptable_veto_fired = true;
277            false
278        } else {
279            true
280        }
281    }
282
283    /// Pure predicate for a single infeasible-stationary iterate: the
284    /// constraint violation is bounded away from zero
285    /// (`constr_viol > infeas_viol_kappa · constr_viol_tol`) and the
286    /// scaled infeasibility gradient `‖Jᵀc‖/max(1,‖c‖)` is at or below
287    /// `infeas_stationarity_tol`. Returns `false` when rapid
288    /// infeasibility detection is disabled (either knob non-positive).
289    fn is_infeasible_stationary(&self, constr_viol: Number, stationarity: Number) -> bool {
290        if self.infeas_stationarity_tol <= 0.0 || self.infeas_max_streak <= 0 {
291            return false;
292        }
293        constr_viol > self.infeas_viol_kappa * self.constr_viol_tol
294            && stationarity <= self.infeas_stationarity_tol
295    }
296
297    /// Advance the rapid-infeasibility-detection streak by one
298    /// iteration. An infeasible-stationary iterate (see
299    /// [`Self::is_infeasible_stationary`]) increments the streak; any
300    /// other iterate resets it to zero. Returns `true` once the streak
301    /// reaches `infeas_max_streak`, signalling the caller to terminate
302    /// with `ConvergenceStatus::LocallyInfeasible`. The streak guards
303    /// against firing on a transient flat spot.
304    fn note_infeasible_stationary(&mut self, constr_viol: Number, stationarity: Number) -> bool {
305        if self.is_infeasible_stationary(constr_viol, stationarity) {
306            self.infeas_streak += 1;
307            self.infeas_streak >= self.infeas_max_streak
308        } else {
309            self.infeas_streak = 0;
310            false
311        }
312    }
313}
314
315impl ConvCheck for OptErrorConvCheck {
316    fn certificate_vetoed(&self) -> bool {
317        self.veto_fired
318    }
319
320    fn acceptable_certificate_vetoed(&self) -> bool {
321        self.acceptable_veto_fired
322    }
323
324    fn check_convergence(&mut self, nlp_err: Number, iter_count: Index) -> ConvergenceStatus {
325        if nlp_err <= self.tol {
326            return ConvergenceStatus::Converged;
327        }
328        // `acceptable_iter == 0` disables acceptable-level termination,
329        // mirroring upstream `IpOptErrorConvCheck.cpp:241`
330        // (`if( acceptable_iter_ > 0 && CurrentIsAcceptable() )`). Without
331        // the `> 0` guard, a zero would make `acceptable_count >= 0` fire on
332        // the first acceptable iterate — the opposite of "disabled".
333        if self.acceptable_iter > 0 && nlp_err <= self.acceptable_tol {
334            self.acceptable_count += 1;
335            if self.acceptable_count >= self.acceptable_iter {
336                return ConvergenceStatus::ConvergedToAcceptable;
337            }
338        } else {
339            self.acceptable_count = 0;
340        }
341        if iter_count >= self.max_iter {
342            return ConvergenceStatus::MaxIterExceeded;
343        }
344        ConvergenceStatus::Continue
345    }
346
347    fn check_convergence_with_state(
348        &mut self,
349        nlp_err: Number,
350        iter_count: Index,
351        data: &IpoptDataHandle,
352        cq: &IpoptCqHandle,
353    ) -> ConvergenceStatus {
354        // Mirror upstream `IpOptErrorConvCheck.cpp::CheckConvergence`:
355        // the scaled scalar `nlp_err` must drop below `tol` AND each
356        // per-component value must sit under its own tolerance. The
357        // component tolerances (`dual_inf_tol`/`constr_viol_tol`/
358        // `compl_inf_tol`) are defined on the *unscaled* (user-original)
359        // residuals — both upstream and per pounce's own option help text
360        // — so we gate on the unscaled accessors. This resolves the former
361        // M1 deviation (gating on internally-scaled residuals), which let
362        // an ill-conditioned, nlp_scaling-deflated solve report
363        // `Solve_Succeeded` while the user-space duals had drifted
364        // (pounce#173). When no scaling is active the unscaled accessors
365        // return the scaled values unchanged, so behaviour is identical on
366        // the common path.
367        let cq_ref = cq.borrow();
368        let dual_inf = cq_ref.curr_unscaled_dual_infeasibility_max();
369        let constr_viol = cq_ref.curr_unscaled_primal_infeasibility_max();
370        let compl_inf = cq_ref.curr_unscaled_complementarity_max();
371        let curr_f = cq_ref.curr_f();
372        let unscaled_err = cq_ref.curr_unscaled_nlp_error();
373        // The gate asks whether *our* scaling clamped, not how the user chose
374        // to scale their objective — see `certificate_masked`.
375        let obj_scale = cq_ref.computed_obj_scaling_factor();
376        drop(cq_ref);
377
378        // gh #200: refuse a certificate the objective scaling has masked, and
379        // keep iterating. A constant objective scale cancels out of the Newton
380        // step and every line-search test is scale-invariant, so the continued
381        // run follows exactly the trajectory an unscaled run would and reaches
382        // the true minimum — at which point the unscaled error falls under
383        // `acceptable_tol`, the veto lifts, and an honest strict certificate is
384        // issued. Refusing to stop early is the whole intervention; the strict
385        // tolerance in scaled space is untouched.
386        if self.veto_fired || self.acceptable_veto_fired {
387            self.veto_extra_iters += 1;
388        }
389        // Call the bet off once it has plainly not paid off, so a veto that can
390        // never lift cannot cost an unbounded number of iterations. The refused
391        // certificate is restored regardless, so this bounds cost, not
392        // correctness.
393        let budget_spent = self.veto_extra_iters > VETO_MAX_EXTRA_ITERS;
394        // A non-finite objective disqualifies the veto outright. `passes_component_tols`
395        // never inspects `f`, so a strict certificate can pass at an iterate whose
396        // objective is NaN while its residuals are finite and tiny — and the unvetoed
397        // run returns exactly that, NaN objective and all. Refusing it would arm a
398        // snapshot the restore then declines (`honour_refused_certificate` requires a
399        // finite objective), surfacing a failure where the baseline reported success.
400        // Declining to engage keeps that case bit-identical to the baseline instead.
401        // The acceptable-level side already had this property: finite `f` is a
402        // precondition of qualifying there.
403        let masked = curr_f.is_finite()
404            && !budget_spent
405            && certificate_masked(
406                obj_scale,
407                unscaled_err,
408                self.obj_scale_certificate_threshold,
409                self.acceptable_tol,
410            );
411        // Record a refusal only when a strict certificate was genuinely on the
412        // table. `masked` alone is far broader — it holds on ordinary iterates
413        // long before convergence — and using it would arm the fallback (and
414        // snapshot an arbitrary mid-solve iterate) on runs that were never
415        // about to stop.
416        let refusing_strict =
417            masked && self.passes_component_tols(nlp_err, dual_inf, constr_viol, compl_inf);
418        if refusing_strict && !self.veto_fired {
419            self.veto_fired = true;
420            tracing::info!(
421                obj_scale,
422                unscaled_kkt_error = unscaled_err,
423                scaled_nlp_error = nlp_err,
424                threshold = self.obj_scale_certificate_threshold,
425                "refusing a termination certificate masked by an extreme objective scale; \
426                 continuing toward the true minimum (obj_scale_certificate_threshold=0 disables)"
427            );
428        }
429
430        if !masked && self.passes_component_tols(nlp_err, dual_inf, constr_viol, compl_inf) {
431            return ConvergenceStatus::Converged;
432        }
433        // `acceptable_iter == 0` disables acceptable-level termination
434        // (upstream `IpOptErrorConvCheck.cpp:241`). See `check_convergence`.
435        // The veto covers this branch too, so a refused strict certificate is
436        // not merely swapped for an acceptable-level one at the same wrong
437        // point. Acceptable-point *storage* is deliberately left un-vetoed —
438        // that stashed point is the rollback target if the run later stalls.
439        let acceptable_now = self.acceptable_iter > 0
440            && self.passes_acceptable_tols(nlp_err, dual_inf, constr_viol, compl_inf, curr_f);
441        if self.note_acceptable(acceptable_now, masked) {
442            return ConvergenceStatus::ConvergedToAcceptable;
443        }
444        if iter_count >= self.max_iter {
445            return ConvergenceStatus::MaxIterExceeded;
446        }
447        // Rapid infeasibility detection — recognise an iterate
448        // converging to a stationary point of the constraint
449        // violation with the violation bounded away from zero, and
450        // exit with `LocallyInfeasible` instead of grinding to
451        // `max_iter` or thrashing restoration. Gated behind an
452        // `infeas_max_streak`-iteration streak to avoid firing on a
453        // transient flat spot. The outer guard skips the two
454        // transpose-products when detection is disabled.
455        if self.infeas_stationarity_tol > 0.0 && self.infeas_max_streak > 0 {
456            // The surrogate here is a cheap PRE-FILTER, not the verdict. It is
457            // a threshold on `||J^T c|| / max(1, ||c||)`, which is not
458            // scale-invariant: under a row scaling `dc` the numerator carries
459            // `dc^2` while the denominator clamps at 1, so an aggressive scaling
460            // drives it to zero regardless of where the iterate is. That is how
461            // HS13 from x0 = (1e4, 1e4) reached `5e-14` at a point whose
462            // constraint violation was 0.51, and got reported infeasible.
463            //
464            // Retuning does not fix it. Measured over 800 corpus models, every
465            // tolerance that fires on genuinely infeasible problems also
466            // introduces new false infeasibility (>= 3 models at the smallest
467            // viable value), and measuring the surrogate unscaled or
468            // scale-invariantly does not separate the cases either. So the
469            // surrogate stays as-is, and the claim the status actually makes --
470            // that no local move reduces the violation -- is confirmed directly
471            // before the verdict is issued.
472            let stationarity = cq.borrow().curr_infeasibility_stationarity();
473            if self.note_infeasible_stationary(constr_viol, stationarity) {
474                if cq.borrow().infeasibility_descent_available() {
475                    // Descent exists: not a stationary point of the violation,
476                    // so the surrogate was wrong here. Drop the streak and keep
477                    // solving.
478                    self.infeas_streak = 0;
479                } else {
480                    return ConvergenceStatus::LocallyInfeasible;
481                }
482            }
483        }
484        // Time-budget gates. When the application installed a shared
485        // [`Deadline`] (pounce#242) it is authoritative: it measures
486        // global elapsed time from a fixed start instant, so it fires
487        // correctly even inside the restoration inner IPM, whose fresh
488        // `timing.overall_alg` is never started. Absent a deadline (the
489        // direct-driver / unit-test path), fall back to the `overall_alg`
490        // timer, which `IpoptApplication` starts at the top of
491        // `optimize_constrained`; `live_*` returns the running elapsed
492        // without forcing a `start/end` cycle. Upstream
493        // `IpOptErrorConvCheck.cpp::CheckConvergence` reads the
494        // application-level start time similarly.
495        let d = data.borrow();
496        if let Some(deadline) = d.deadline.as_ref() {
497            match deadline.exceeded() {
498                Some(pounce_common::timing::DeadlineKind::Cpu) => {
499                    return ConvergenceStatus::CpuTimeExceeded;
500                }
501                Some(pounce_common::timing::DeadlineKind::Wall) => {
502                    return ConvergenceStatus::WallTimeExceeded;
503                }
504                None => {}
505            }
506        } else {
507            let timing = &d.timing;
508            if timing.overall_alg.live_cpu_time() >= self.max_cpu_time {
509                return ConvergenceStatus::CpuTimeExceeded;
510            }
511            if timing.overall_alg.live_wallclock_time() >= self.max_wall_time {
512                return ConvergenceStatus::WallTimeExceeded;
513            }
514        }
515        ConvergenceStatus::Continue
516    }
517
518    fn current_passes_strict(
519        &self,
520        nlp_err: Number,
521        _data: &IpoptDataHandle,
522        cq: &IpoptCqHandle,
523    ) -> bool {
524        // The strict per-component gate of `check_convergence_with_state`, minus
525        // the masking veto — see the trait doc. Unscaled per-component residuals,
526        // matching that method (the `*_tol` triplet is defined on the
527        // user-original residuals).
528        let cq_ref = cq.borrow();
529        let dual_inf = cq_ref.curr_unscaled_dual_infeasibility_max();
530        let constr_viol = cq_ref.curr_unscaled_primal_infeasibility_max();
531        let compl_inf = cq_ref.curr_unscaled_complementarity_max();
532        drop(cq_ref);
533        self.passes_component_tols(nlp_err, dual_inf, constr_viol, compl_inf)
534    }
535
536    fn tol_or_default(&self) -> Number {
537        self.tol
538    }
539
540    fn acceptable_constr_viol_tol_or_default(&self) -> Number {
541        self.acceptable_constr_viol_tol
542    }
543
544    fn set_tolerance(&mut self, name: &str, value: Number) -> bool {
545        match name {
546            "tol" => self.tol = value,
547            "dual_inf_tol" => self.dual_inf_tol = value,
548            "constr_viol_tol" => self.constr_viol_tol = value,
549            "compl_inf_tol" => self.compl_inf_tol = value,
550            "acceptable_tol" => self.acceptable_tol = value,
551            "acceptable_dual_inf_tol" => self.acceptable_dual_inf_tol = value,
552            "acceptable_constr_viol_tol" => self.acceptable_constr_viol_tol = value,
553            "acceptable_compl_inf_tol" => self.acceptable_compl_inf_tol = value,
554            "acceptable_obj_change_tol" => self.acceptable_obj_change_tol = value,
555            _ => return false,
556        }
557        true
558    }
559
560    fn current_is_acceptable(&self, nlp_err: Number) -> bool {
561        // Scalar fallback used when the caller has no `IpoptCq` handle
562        // (e.g. unit tests). The state-aware variant
563        // [`Self::current_is_acceptable_with_state`] mirrors upstream
564        // more faithfully by gating on the per-component
565        // `acceptable_*_tol` triplet plus the obj-change cross-check.
566        nlp_err.is_finite() && nlp_err <= self.acceptable_tol
567    }
568
569    fn current_is_acceptable_with_state(
570        &self,
571        nlp_err: Number,
572        _data: &IpoptDataHandle,
573        cq: &IpoptCqHandle,
574    ) -> bool {
575        let cq_ref = cq.borrow();
576        // Unscaled per-component residuals — see `check_convergence_with_state`
577        // (the `acceptable_*_tol` triplet is likewise defined on the
578        // user-original residuals).
579        let dual_inf = cq_ref.curr_unscaled_dual_infeasibility_max();
580        let constr_viol = cq_ref.curr_unscaled_primal_infeasibility_max();
581        let compl_inf = cq_ref.curr_unscaled_complementarity_max();
582        let curr_f = cq_ref.curr_f();
583        drop(cq_ref);
584        self.passes_acceptable_tols(nlp_err, dual_inf, constr_viol, compl_inf, curr_f)
585    }
586
587    fn set_curr_acceptable_obj(&mut self, obj: Number) {
588        self.last_acceptable_obj = Some(obj);
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595
596    #[test]
597    fn converges_at_tol() {
598        let mut c = OptErrorConvCheck::new();
599        assert_eq!(c.check_convergence(1e-9, 0), ConvergenceStatus::Converged);
600    }
601
602    #[test]
603    fn acceptable_iter_count_threshold() {
604        let mut c = OptErrorConvCheck {
605            acceptable_iter: 3,
606            ..Default::default()
607        };
608        // nlp_err between tol (1e-8) and acceptable (1e-6).
609        assert_eq!(c.check_convergence(1e-7, 0), ConvergenceStatus::Continue);
610        assert_eq!(c.check_convergence(1e-7, 1), ConvergenceStatus::Continue);
611        assert_eq!(
612            c.check_convergence(1e-7, 2),
613            ConvergenceStatus::ConvergedToAcceptable
614        );
615    }
616
617    #[test]
618    fn acceptable_iter_zero_disables_acceptable_termination() {
619        // Upstream `IpOptErrorConvCheck.cpp:241` gates the acceptable
620        // counter on `acceptable_iter_ > 0`, so a zero disables the
621        // acceptable-level exit entirely. Before the guard, `>= 0` made
622        // pounce fire on the FIRST acceptable iterate (the opposite).
623        let mut c = OptErrorConvCheck {
624            acceptable_iter: 0,
625            ..Default::default()
626        };
627        // Many iterates parked between tol (1e-8) and acceptable (1e-6)
628        // must never trigger ConvergedToAcceptable; the run continues
629        // until tol or max_iter.
630        for k in 0..50 {
631            assert_eq!(
632                c.check_convergence(1e-7, k),
633                ConvergenceStatus::Continue,
634                "acceptable_iter=0 must not stop at the acceptable level (iter {k})"
635            );
636        }
637        // tol is still honored regardless.
638        assert_eq!(c.check_convergence(1e-9, 51), ConvergenceStatus::Converged);
639    }
640
641    #[test]
642    fn streak_resets_when_above_acceptable() {
643        let mut c = OptErrorConvCheck {
644            acceptable_iter: 3,
645            ..Default::default()
646        };
647        assert_eq!(c.check_convergence(1e-7, 0), ConvergenceStatus::Continue);
648        // Above acceptable resets the counter.
649        assert_eq!(c.check_convergence(1e-3, 1), ConvergenceStatus::Continue);
650        assert_eq!(c.check_convergence(1e-7, 2), ConvergenceStatus::Continue);
651        assert_eq!(c.check_convergence(1e-7, 3), ConvergenceStatus::Continue);
652        assert_eq!(
653            c.check_convergence(1e-7, 4),
654            ConvergenceStatus::ConvergedToAcceptable
655        );
656    }
657
658    #[test]
659    fn passes_acceptable_tols_gates_on_per_component_triplet() {
660        let c = OptErrorConvCheck {
661            acceptable_tol: 1e-6,
662            acceptable_dual_inf_tol: 1e-3,
663            acceptable_constr_viol_tol: 1e-3,
664            acceptable_compl_inf_tol: 1e-3,
665            ..Default::default()
666        };
667        assert!(c.passes_acceptable_tols(1e-7, 1e-4, 1e-4, 1e-4, 0.0));
668        // dual_inf above its acceptable threshold blocks.
669        assert!(!c.passes_acceptable_tols(1e-7, 1.0, 1e-4, 1e-4, 0.0));
670        // overall above acceptable_tol blocks.
671        assert!(!c.passes_acceptable_tols(1e-5, 1e-4, 1e-4, 1e-4, 0.0));
672    }
673
674    #[test]
675    fn passes_acceptable_tols_honors_obj_change_tol() {
676        let mut c = OptErrorConvCheck {
677            acceptable_tol: 1e-6,
678            acceptable_dual_inf_tol: 1.0,
679            acceptable_constr_viol_tol: 1.0,
680            acceptable_compl_inf_tol: 1.0,
681            acceptable_obj_change_tol: 0.1,
682            ..Default::default()
683        };
684        // First call always acceptable (no prior obj).
685        assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 10.0));
686        c.set_curr_acceptable_obj(10.0);
687        // Same f → change well under threshold → still acceptable.
688        assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 10.0));
689        // f moved by 2.0 with threshold 0.1 * max(1, |11.0|) = 1.1 →
690        // absolute change 1.0 < 1.1: acceptable.
691        assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 11.0));
692        // f moved by 5.0 — absolute change 5.0 > 1.5 = 0.1 * 15 →
693        // rejected (the stability cross-check fires).
694        assert!(!c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 15.0));
695    }
696
697    use crate::conv_check::r#trait::ConvCheck;
698
699    #[test]
700    fn set_curr_acceptable_obj_records_for_cross_check() {
701        let mut c = OptErrorConvCheck::new();
702        assert!(c.last_acceptable_obj.is_none());
703        ConvCheck::set_curr_acceptable_obj(&mut c, 4.2);
704        assert_eq!(c.last_acceptable_obj, Some(4.2));
705    }
706
707    #[test]
708    fn a_non_finite_objective_disqualifies_the_veto() {
709        // `passes_component_tols` never inspects `f`, so a strict certificate can
710        // pass at an iterate whose objective is NaN while its residuals are finite
711        // and tiny — and the unvetoed run returns exactly that. Refusing it would
712        // arm a snapshot that the restore then declines (it requires a finite
713        // objective), surfacing a failure where the baseline reported success:
714        // a never-worse violation, on the one path where the objective is not
715        // usable as a tiebreak.
716        let c = OptErrorConvCheck {
717            tol: 1e-8,
718            dual_inf_tol: 1.0,
719            constr_viol_tol: 1e-4,
720            compl_inf_tol: 1e-4,
721            ..Default::default()
722        };
723        // The residuals alone say "converged"; the objective says nothing usable.
724        assert!(c.passes_component_tols(1e-12, 1e-9, 0.0, 0.0));
725        // The masked predicate itself is unchanged — the finiteness gate lives at
726        // the call site, where `curr_f` is in hand.
727        assert!(certificate_masked(
728            1e-8,
729            8.4e-1,
730            c.obj_scale_certificate_threshold,
731            c.acceptable_tol
732        ));
733        // Both the guard's inputs behave as the call site composes them.
734        for bad in [Number::NAN, Number::INFINITY, Number::NEG_INFINITY] {
735            assert!(!bad.is_finite(), "{bad} should disqualify the veto");
736        }
737        assert!((1.0_f64).is_finite());
738    }
739
740    #[test]
741    fn acceptable_streak_survives_a_masked_boundary_mid_streak() {
742        // gh #200. `masked` is not constant over a run: it also depends on the
743        // unscaled error crossing `acceptable_tol`, and that crossing is exactly
744        // what happens during the endgame. So an acceptable-level streak can
745        // straddle the boundary.
746        //
747        // The earlier implementation kept two disjoint counters, each reset by
748        // the other's phase. Fourteen unmasked qualifying iterates followed by
749        // one masked qualifying iterate left the real count at 0 while the
750        // unvetoed run would have reached 15 and stopped — so the run fell
751        // through to `max_iter` and returned a bare failure where the baseline
752        // returned `Solved_To_Acceptable_Level`, with no snapshot armed to roll
753        // back to. Never-worse, violated.
754        let mut c = OptErrorConvCheck {
755            acceptable_iter: 15,
756            ..Default::default()
757        };
758        // 14 qualifying iterates while unmasked: no termination yet.
759        for i in 0..14 {
760            assert!(!c.note_acceptable(true, false), "terminated early at {i}");
761        }
762        // The 15th qualifies too, but the veto is now engaged. The streak must
763        // be honoured — recorded as a refused termination, not discarded.
764        assert!(
765            !c.note_acceptable(true, true),
766            "a masked iterate must not terminate the run"
767        );
768        assert!(
769            c.acceptable_veto_fired,
770            "the streak crossed `acceptable_iter` while masked, so a termination was \
771             refused here and must be recorded — otherwise the fallback has nothing to \
772             restore and the run returns a bare failure"
773        );
774
775        // The mirror direction: a streak that begins masked and finishes
776        // unmasked must terminate on the same iterate the baseline would.
777        let mut c = OptErrorConvCheck {
778            acceptable_iter: 15,
779            ..Default::default()
780        };
781        for _ in 0..14 {
782            assert!(!c.note_acceptable(true, true));
783        }
784        assert!(
785            c.note_acceptable(true, false),
786            "the veto lifted with the streak already at 14; the 15th qualifying iterate \
787             must terminate exactly as it would without the mechanism"
788        );
789
790        // And a non-qualifying iterate still breaks the streak, in either phase.
791        let mut c = OptErrorConvCheck {
792            acceptable_iter: 3,
793            ..Default::default()
794        };
795        assert!(!c.note_acceptable(true, false));
796        assert!(!c.note_acceptable(false, true));
797        assert_eq!(
798            c.acceptable_count, 0,
799            "a non-qualifying iterate resets the streak"
800        );
801        assert!(!c.note_acceptable(true, false));
802        assert!(!c.note_acceptable(true, false));
803        assert!(
804            c.note_acceptable(true, false),
805            "3 consecutive qualifying iterates terminate"
806        );
807    }
808
809    #[test]
810    fn certificate_masked_needs_both_an_extreme_scale_and_a_non_stationary_point() {
811        // gh #200. Both conditions are load-bearing, and each was independently
812        // shown to be insufficient on the benchmark suite.
813        let (th, atol) = (1e-4, 1e-6);
814
815        // The reported failure: scale pinned at the 1e-8 floor, unscaled error
816        // 0.84 — the strict test passed in scaled space at `quartc` obj 248.88.
817        assert!(certificate_masked(1e-8, 8.4e-1, th, atol));
818
819        // An ordinary objective scale is never second-guessed, however large
820        // the unscaled error. Keying on the error alone effectively tightens
821        // `tol` by `1/df` and regressed hs1/hs38 (scale ~4e-2).
822        assert!(!certificate_masked(4e-2, 8.4e-1, th, atol));
823        assert!(!certificate_masked(1.0, 1e3, th, atol));
824
825        // An extreme scale at a point that really is stationary is fine — this
826        // is what lifts the veto once the continued run reaches the minimum.
827        assert!(!certificate_masked(1e-8, 1e-9, th, atol));
828
829        // Boundaries: strictly below the scale threshold, strictly above the
830        // error tolerance.
831        assert!(!certificate_masked(th, 1.0, th, atol));
832        assert!(!certificate_masked(1e-8, atol, th, atol));
833
834        // `0` disables the mechanism outright (the documented opt-out) — the
835        // most extreme possible inputs must not trip it.
836        assert!(!certificate_masked(1e-30, 1e30, 0.0, atol));
837        // A negative threshold is treated as disabled rather than as "always".
838        assert!(!certificate_masked(1e-30, 1e30, -1.0, atol));
839    }
840
841    #[test]
842    fn veto_blocks_both_strict_and_acceptable_termination() {
843        // A refused strict certificate must not simply reappear as an
844        // acceptable-level one at the same wrong point, so the veto covers both
845        // branches. Exercised through the pure predicates the two branches
846        // share, since a full `check_convergence_with_state` needs a live cq.
847        let c = OptErrorConvCheck {
848            tol: 1e-8,
849            acceptable_tol: 1e-6,
850            dual_inf_tol: 1.0,
851            constr_viol_tol: 1e-4,
852            compl_inf_tol: 1e-4,
853            ..Default::default()
854        };
855        // The gh #200 iterate: passes the strict test in scaled space...
856        assert!(c.passes_component_tols(1e-9, 8.4e-1, 0.0, 0.0));
857        // ...and the veto is what withholds it.
858        assert!(certificate_masked(
859            1e-8,
860            8.4e-1,
861            c.obj_scale_certificate_threshold,
862            c.acceptable_tol
863        ));
864        // Default threshold is the documented 1e-4, and the veto starts clear.
865        assert_eq!(c.obj_scale_certificate_threshold, 1e-4);
866        assert!(!c.veto_fired);
867        assert!(!ConvCheck::certificate_vetoed(&c));
868    }
869
870    #[test]
871    fn passes_component_tols_requires_all_under_threshold() {
872        let c = OptErrorConvCheck {
873            tol: 1e-8,
874            dual_inf_tol: 1.0,
875            constr_viol_tol: 1e-4,
876            compl_inf_tol: 1e-4,
877            ..Default::default()
878        };
879        // All under threshold → converged.
880        assert!(c.passes_component_tols(1e-9, 0.5, 1e-5, 1e-5));
881        // dual_inf above its tolerance blocks even when nlp_err is tiny.
882        assert!(!c.passes_component_tols(1e-12, 2.0, 1e-5, 1e-5));
883        // compl_inf above its tolerance blocks.
884        assert!(!c.passes_component_tols(1e-12, 0.0, 0.0, 1e-2));
885        // constr_viol above its tolerance blocks.
886        assert!(!c.passes_component_tols(1e-12, 0.0, 1e-2, 0.0));
887    }
888
889    #[test]
890    fn infeasible_stationary_requires_violation_and_flat_gradient() {
891        let c = OptErrorConvCheck {
892            constr_viol_tol: 1e-4,
893            infeas_viol_kappa: 1e2, // violation threshold = 1e-2
894            infeas_stationarity_tol: 1e-8,
895            infeas_max_streak: 5,
896            ..Default::default()
897        };
898        // Violation well above 1e-2 and the infeasibility gradient
899        // essentially zero → counts as infeasible-stationary.
900        assert!(c.is_infeasible_stationary(1e-1, 1e-9));
901        // Violation above threshold but the gradient is not flat →
902        // still making feasibility progress, does not count.
903        assert!(!c.is_infeasible_stationary(1e-1, 1e-3));
904        // Gradient flat but violation below threshold → nearly
905        // feasible, does not count.
906        assert!(!c.is_infeasible_stationary(1e-3, 1e-9));
907    }
908
909    #[test]
910    fn infeasible_stationary_disabled_by_nonpositive_knobs() {
911        let off_tol = OptErrorConvCheck {
912            infeas_stationarity_tol: 0.0,
913            infeas_max_streak: 5,
914            ..Default::default()
915        };
916        assert!(!off_tol.is_infeasible_stationary(1e9, 0.0));
917        let off_streak = OptErrorConvCheck {
918            infeas_stationarity_tol: 1e-8,
919            infeas_max_streak: 0,
920            ..Default::default()
921        };
922        assert!(!off_streak.is_infeasible_stationary(1e9, 0.0));
923    }
924
925    #[test]
926    fn infeasible_stationary_streak_fires_only_after_max_streak() {
927        let mut c = OptErrorConvCheck {
928            constr_viol_tol: 1e-4,
929            infeas_viol_kappa: 1e2, // violation threshold = 1e-2
930            infeas_stationarity_tol: 1e-8,
931            infeas_max_streak: 3,
932            ..Default::default()
933        };
934        // Infeasible-stationary iterate: violation 1e-1 > 1e-2, flat
935        // gradient. Streak accrues but does not fire until the third.
936        assert!(!c.note_infeasible_stationary(1e-1, 1e-9));
937        assert!(!c.note_infeasible_stationary(1e-1, 1e-9));
938        assert!(c.note_infeasible_stationary(1e-1, 1e-9));
939    }
940
941    #[test]
942    fn infeasible_stationary_streak_resets_on_feasibility_progress() {
943        let mut c = OptErrorConvCheck {
944            constr_viol_tol: 1e-4,
945            infeas_viol_kappa: 1e2,
946            infeas_stationarity_tol: 1e-8,
947            infeas_max_streak: 3,
948            ..Default::default()
949        };
950        assert!(!c.note_infeasible_stationary(1e-1, 1e-9));
951        assert!(!c.note_infeasible_stationary(1e-1, 1e-9));
952        // A non-stationary iterate (gradient not flat) resets the streak.
953        assert!(!c.note_infeasible_stationary(1e-1, 1e-3));
954        assert_eq!(c.infeas_streak, 0);
955        // The streak must rebuild from scratch — no carry-over credit.
956        assert!(!c.note_infeasible_stationary(1e-1, 1e-9));
957        assert!(!c.note_infeasible_stationary(1e-1, 1e-9));
958        assert!(c.note_infeasible_stationary(1e-1, 1e-9));
959    }
960
961    #[test]
962    fn infeasible_stationary_streak_never_fires_when_disabled() {
963        let mut c = OptErrorConvCheck {
964            infeas_stationarity_tol: 0.0,
965            infeas_max_streak: 5,
966            ..Default::default()
967        };
968        for _ in 0..20 {
969            assert!(!c.note_infeasible_stationary(1e9, 0.0));
970        }
971        assert_eq!(c.infeas_streak, 0);
972    }
973
974    #[test]
975    fn max_iter_exceeded() {
976        let mut c = OptErrorConvCheck {
977            max_iter: 5,
978            ..Default::default()
979        };
980        assert_eq!(
981            c.check_convergence(1.0, 5),
982            ConvergenceStatus::MaxIterExceeded
983        );
984    }
985}