pounce_algorithm/conv_check/trait.rs
1//! `ConvCheck` trait — port of `IpConvCheck.hpp`.
2//!
3//! Upstream `ConvergenceCheck::CheckConvergence` reads the NLP error
4//! and iter count off `IpData()`/`IpCq()`. The default trait method
5//! pushes that read to the caller (the main loop in `ipopt_alg.rs`)
6//! so simple convergence policies stay pure scalar state machines
7//! over `(nlp_err, iter_count)`. The richer
8//! [`ConvCheck::check_convergence_with_state`] entry point exposes
9//! the live `(IpoptData, IpoptCq)` so policies that need iterate
10//! components — notably the restoration-side
11//! `RestoFilterConvergenceCheck::TestOrigProgress` — can read them.
12//! Default impl just delegates to the scalar method, preserving
13//! backwards compatibility for every existing impl.
14
15use crate::ipopt_cq::IpoptCqHandle;
16use crate::ipopt_data::IpoptDataHandle;
17use pounce_common::types::{Index, Number};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ConvergenceStatus {
21 Continue,
22 Converged,
23 /// Converged to the looser `acceptable_*` tolerance band rather
24 /// than the tight `tol` — upstream `CONVERGED_TO_ACCEPTABLE_POINT`.
25 /// Maps to `SolverReturn::StopAtAcceptablePoint` →
26 /// `ApplicationReturnStatus::SolvedToAcceptableLevel`.
27 ConvergedToAcceptable,
28 MaxIterExceeded,
29 /// `max_cpu_time` budget reached. Maps to
30 /// `SolverReturn::CpuTimeExceeded` → `MaximumCpuTimeExceeded`.
31 CpuTimeExceeded,
32 /// `max_wall_time` budget reached. Maps to
33 /// `SolverReturn::WallTimeExceeded` → `MaximumWallTimeExceeded`.
34 WallTimeExceeded,
35 /// Rapid infeasibility detection fired — the iterate is
36 /// converging to a stationary point of the constraint violation
37 /// with the violation bounded away from zero. Maps to
38 /// `SolverReturn::LocalInfeasibility`.
39 LocallyInfeasible,
40 Failed,
41}
42
43pub trait ConvCheck {
44 fn check_convergence(&mut self, nlp_err: Number, iter_count: Index) -> ConvergenceStatus;
45
46 /// State-aware convergence check. The main loop calls this on
47 /// every iteration so policies that need access to the iterate
48 /// (e.g. `RestoConvCheckAdapter`'s orig-NLP `inf_pr` evaluation
49 /// for the kappa-reduction early-exit) can read `data.curr` and
50 /// the cq layer. Default impl delegates to
51 /// [`Self::check_convergence`], so scalar-only policies don't
52 /// need to override.
53 /// Whether this policy ever refused a termination certificate it judged
54 /// masked by an extreme objective scale (gh #200).
55 ///
56 /// The main loop reads it on the failure exits: a run that was held back
57 /// from stopping must not end up *worse off* than it would have been, so
58 /// when it later stalls the stored acceptable point is restored rather
59 /// than a bare failure surfaced. Policies that never veto keep the
60 /// default `false` and the failure paths behave exactly as before.
61 fn certificate_vetoed(&self) -> bool {
62 false
63 }
64
65 /// Whether this policy ever refused an *acceptable-level* termination it
66 /// judged masked (gh #200). Undone differently from a strict refusal — see
67 /// `OptErrorConvCheck::acceptable_veto_fired`.
68 fn acceptable_certificate_vetoed(&self) -> bool {
69 false
70 }
71
72 fn check_convergence_with_state(
73 &mut self,
74 nlp_err: Number,
75 iter_count: Index,
76 _data: &IpoptDataHandle,
77 _cq: &IpoptCqHandle,
78 ) -> ConvergenceStatus {
79 self.check_convergence(nlp_err, iter_count)
80 }
81
82 /// Ask [`Self::check_convergence_with_state`] what it *would* say,
83 /// without letting the answer count as an iteration.
84 ///
85 /// `IpoptAlgorithm::ComputeFeasibilityMultipliers` calls the
86 /// convergence check twice on the same iterate — once to decide
87 /// whether re-estimating the multipliers is worth attempting
88 /// (`IpIpoptAlg.cpp:880`), once to decide whether to keep the result
89 /// (`cpp:924`) — on top of the main loop's own call. Upstream wears
90 /// the double-count because the only state it advances is
91 /// `acceptable_counter_`. pounce's check carries considerably more:
92 /// the rapid-infeasibility streak, the gh#200 veto budget, the gh#533
93 /// acceptable-progress window. Advancing those three times per
94 /// iteration is not upstream behaviour under a different name — it
95 /// changes when detectors fire. Measured on the gh#508 probe: the
96 /// rapid detector convicted at iteration 33 instead of 86, purely
97 /// from the extra increments.
98 ///
99 /// Default delegates, since a policy with no cross-iteration state
100 /// has nothing to protect.
101 fn probe_convergence(
102 &mut self,
103 nlp_err: Number,
104 iter_count: Index,
105 data: &IpoptDataHandle,
106 cq: &IpoptCqHandle,
107 ) -> ConvergenceStatus {
108 self.check_convergence_with_state(nlp_err, iter_count, data, cq)
109 }
110
111 /// Whether the current iterate passes the *strict* per-component convergence
112 /// tolerances — the [`Self::check_convergence_with_state`] strict test with
113 /// the masked-certificate veto (gh #200) removed. In other words: would this
114 /// iterate have certified `Success` were it not for the objective-scale
115 /// masking?
116 ///
117 /// gh #327 reads it on the veto's fallback path to distinguish a point the
118 /// veto refused *only because of masking* — a would-be strict certificate,
119 /// e.g. the true optimum a continued run reached but could never certify
120 /// there — from one that never converged at all (an unbounded / diverging
121 /// iterate whose gradient never vanishes). Only the former may displace the
122 /// point the baseline stopped at. Default `false` for policies that expose no
123 /// strict test.
124 fn current_passes_strict(
125 &self,
126 _nlp_err: Number,
127 _data: &IpoptDataHandle,
128 _cq: &IpoptCqHandle,
129 ) -> bool {
130 false
131 }
132
133 /// Whether the supplied `nlp_err` is at or below the acceptable
134 /// tolerance — port of upstream
135 /// `OptimalityErrorConvergenceCheck::CurrentIsAcceptable`. Used by
136 /// the main loop to gate `StoreAcceptablePoint` /
137 /// `RestoreAcceptablePoint`. Default returns `false` so policies
138 /// that don't track an acceptable level (e.g. resto-of-resto inner
139 /// adapters) silently skip the rollback machinery.
140 fn current_is_acceptable(&self, _nlp_err: Number) -> bool {
141 false
142 }
143
144 /// State-aware acceptance check. Mirrors upstream
145 /// `OptimalityErrorConvergenceCheck::CurrentIsAcceptable` which
146 /// reads the per-component residuals and current `f` to gate the
147 /// `acceptable_dual_inf_tol` / `acceptable_constr_viol_tol` /
148 /// `acceptable_compl_inf_tol` / `acceptable_obj_change_tol`
149 /// triplet. Default delegates to the scalar [`Self::current_is_acceptable`].
150 fn current_is_acceptable_with_state(
151 &self,
152 nlp_err: Number,
153 _data: &IpoptDataHandle,
154 _cq: &IpoptCqHandle,
155 ) -> bool {
156 self.current_is_acceptable(nlp_err)
157 }
158
159 /// Record the current objective at the iterate the main loop just
160 /// stashed as the latest "acceptable point" — mirrors upstream
161 /// `OptimalityErrorConvergenceCheck::SetCurrAcceptableF`. The
162 /// recorded value feeds the `acceptable_obj_change_tol` stability
163 /// cross-check on subsequent iterates. Default no-op for policies
164 /// that don't track acceptable points.
165 fn set_curr_acceptable_obj(&mut self, _obj: Number) {}
166
167 /// Outer NLP convergence tolerance, as used by the main loop's
168 /// almost-feasible bypass guard (port of
169 /// `IpBacktrackingLineSearch.cpp:580`). Default `1e-8` matches
170 /// upstream's default `tol`.
171 fn tol_or_default(&self) -> Number {
172 1e-8
173 }
174
175 /// Primal-feasibility tolerance `constr_viol_tol`, in the unscaled max-norm
176 /// space `curr_unscaled_primal_infeasibility_max` reports — the option that
177 /// declares what a *violated* constraint is.
178 ///
179 /// Read by the status-decision sites that have to answer "is this violation
180 /// real" (gh #508). That question is about the constraint violation, so it
181 /// must be asked with the constraint-violation tolerance; asking it with
182 /// `tol` — a tolerance on the **KKT error**, a different quantity in
183 /// different units — makes the answer move when the user retunes
184 /// convergence and stand still when they retune feasibility. Default `1e-4`
185 /// matches upstream's default `constr_viol_tol`; policies that track no such
186 /// tolerance keep it.
187 fn constr_viol_tol_or_default(&self) -> Number {
188 1e-4
189 }
190
191 /// Acceptable-level primal-feasibility band `acceptable_constr_viol_tol`, in
192 /// the unscaled max-norm space `curr_unscaled_primal_infeasibility_max`
193 /// reports. Read by the best-acceptable fallback's feasibility-aware ranking
194 /// (gh #267), which caps it at the upstream default so a user-widened band
195 /// cannot let the fallback spend feasibility to buy objective. Default
196 /// `1e-2` matches upstream's default `acceptable_constr_viol_tol`; policies
197 /// that track no such tolerance keep it.
198 fn acceptable_constr_viol_tol_or_default(&self) -> Number {
199 1e-2
200 }
201
202 /// Live-update a named convergence tolerance mid-solve, for the
203 /// debugger's in-place option hot-swap. Returns `true` if `name`
204 /// matched a tolerance this policy owns (so the caller can report
205 /// whether it took). Default: this policy exposes no live
206 /// tolerances → `false`.
207 fn set_tolerance(&mut self, _name: &str, _value: Number) -> bool {
208 false
209 }
210}