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}
56
57impl Default for OptErrorConvCheck {
58    fn default() -> Self {
59        // Defaults from `IpOptErrorConvCheck.cpp:RegisterOptions`.
60        Self {
61            tol: 1e-8,
62            dual_inf_tol: 1.0,
63            constr_viol_tol: 1e-4,
64            compl_inf_tol: 1e-4,
65            acceptable_tol: 1e-6,
66            acceptable_dual_inf_tol: 1e10,
67            acceptable_constr_viol_tol: 1e-2,
68            acceptable_compl_inf_tol: 1e-2,
69            acceptable_obj_change_tol: 1e20,
70            acceptable_iter: 15,
71            max_iter: 3000,
72            max_cpu_time: 1e6,
73            max_wall_time: 1e6,
74            acceptable_count: 0,
75            last_acceptable_obj: None,
76            infeas_stationarity_tol: 1e-8,
77            infeas_viol_kappa: 1e2,
78            infeas_max_streak: 5,
79            infeas_streak: 0,
80        }
81    }
82}
83
84impl OptErrorConvCheck {
85    pub fn new() -> Self {
86        Self::default()
87    }
88
89    /// Pure helper for the per-component upstream gate. Returns `true`
90    /// iff every supplied residual sits at or below its tolerance.
91    /// Factored out so tests can exercise the gating logic without
92    /// constructing a full `IpoptCq`.
93    fn passes_component_tols(
94        &self,
95        overall: Number,
96        dual_inf: Number,
97        constr_viol: Number,
98        compl_inf: Number,
99    ) -> bool {
100        overall <= self.tol
101            && dual_inf <= self.dual_inf_tol
102            && constr_viol <= self.constr_viol_tol
103            && compl_inf <= self.compl_inf_tol
104    }
105
106    /// Pure helper mirroring upstream
107    /// `OptimalityErrorConvergenceCheck::CurrentIsAcceptable`. Tests
108    /// the per-component `acceptable_*_tol` triplet plus the optional
109    /// `acceptable_obj_change_tol` stability cross-check.
110    fn passes_acceptable_tols(
111        &self,
112        overall: Number,
113        dual_inf: Number,
114        constr_viol: Number,
115        compl_inf: Number,
116        curr_f: Number,
117    ) -> bool {
118        if !overall.is_finite() {
119            return false;
120        }
121        let component_ok = overall <= self.acceptable_tol
122            && dual_inf <= self.acceptable_dual_inf_tol
123            && constr_viol <= self.acceptable_constr_viol_tol
124            && compl_inf <= self.acceptable_compl_inf_tol;
125        if !component_ok {
126            return false;
127        }
128        // Upstream `IpOptErrorConvCheck.cpp:CurrentIsAcceptable` — when
129        // an acceptable point has already been recorded and the user
130        // tightened `acceptable_obj_change_tol` below the 1e20
131        // sentinel, the iterate is only re-acceptable if `f` has moved
132        // by less than `tol * max(1, |f|)` relative to the recorded
133        // value. Skipped when no prior point exists or the cross-check
134        // is disabled.
135        if self.acceptable_obj_change_tol < 1e20 {
136            if let Some(prev) = self.last_acceptable_obj {
137                let denom = curr_f.abs().max(1.0);
138                if (prev - curr_f).abs() >= self.acceptable_obj_change_tol * denom {
139                    return false;
140                }
141            }
142        }
143        true
144    }
145
146    /// Pure predicate for a single infeasible-stationary iterate: the
147    /// constraint violation is bounded away from zero
148    /// (`constr_viol > infeas_viol_kappa · constr_viol_tol`) and the
149    /// scaled infeasibility gradient `‖Jᵀc‖/max(1,‖c‖)` is at or below
150    /// `infeas_stationarity_tol`. Returns `false` when rapid
151    /// infeasibility detection is disabled (either knob non-positive).
152    fn is_infeasible_stationary(&self, constr_viol: Number, stationarity: Number) -> bool {
153        if self.infeas_stationarity_tol <= 0.0 || self.infeas_max_streak <= 0 {
154            return false;
155        }
156        constr_viol > self.infeas_viol_kappa * self.constr_viol_tol
157            && stationarity <= self.infeas_stationarity_tol
158    }
159
160    /// Advance the rapid-infeasibility-detection streak by one
161    /// iteration. An infeasible-stationary iterate (see
162    /// [`Self::is_infeasible_stationary`]) increments the streak; any
163    /// other iterate resets it to zero. Returns `true` once the streak
164    /// reaches `infeas_max_streak`, signalling the caller to terminate
165    /// with `ConvergenceStatus::LocallyInfeasible`. The streak guards
166    /// against firing on a transient flat spot.
167    fn note_infeasible_stationary(&mut self, constr_viol: Number, stationarity: Number) -> bool {
168        if self.is_infeasible_stationary(constr_viol, stationarity) {
169            self.infeas_streak += 1;
170            self.infeas_streak >= self.infeas_max_streak
171        } else {
172            self.infeas_streak = 0;
173            false
174        }
175    }
176}
177
178impl ConvCheck for OptErrorConvCheck {
179    fn check_convergence(&mut self, nlp_err: Number, iter_count: Index) -> ConvergenceStatus {
180        if nlp_err <= self.tol {
181            return ConvergenceStatus::Converged;
182        }
183        if nlp_err <= self.acceptable_tol {
184            self.acceptable_count += 1;
185            if self.acceptable_count >= self.acceptable_iter {
186                return ConvergenceStatus::ConvergedToAcceptable;
187            }
188        } else {
189            self.acceptable_count = 0;
190        }
191        if iter_count >= self.max_iter {
192            return ConvergenceStatus::MaxIterExceeded;
193        }
194        ConvergenceStatus::Continue
195    }
196
197    fn check_convergence_with_state(
198        &mut self,
199        nlp_err: Number,
200        iter_count: Index,
201        data: &IpoptDataHandle,
202        cq: &IpoptCqHandle,
203    ) -> ConvergenceStatus {
204        // Mirror upstream `IpOptErrorConvCheck.cpp::CheckConvergence`:
205        // the scaled scalar `nlp_err` must drop below `tol` AND each
206        // unscaled component must sit under its own tolerance. The
207        // `acceptable_*` machinery still runs off the scalar; that
208        // expansion lands with the `acceptable_*` option wiring.
209        let cq_ref = cq.borrow();
210        let dual_inf = cq_ref.curr_dual_infeasibility_max();
211        let constr_viol = cq_ref.curr_primal_infeasibility_max();
212        let compl_inf = cq_ref.curr_complementarity_max();
213        let curr_f = cq_ref.curr_f();
214        drop(cq_ref);
215
216        if self.passes_component_tols(nlp_err, dual_inf, constr_viol, compl_inf) {
217            return ConvergenceStatus::Converged;
218        }
219        if self.passes_acceptable_tols(nlp_err, dual_inf, constr_viol, compl_inf, curr_f) {
220            self.acceptable_count += 1;
221            if self.acceptable_count >= self.acceptable_iter {
222                return ConvergenceStatus::ConvergedToAcceptable;
223            }
224        } else {
225            self.acceptable_count = 0;
226        }
227        if iter_count >= self.max_iter {
228            return ConvergenceStatus::MaxIterExceeded;
229        }
230        // Rapid infeasibility detection — recognise an iterate
231        // converging to a stationary point of the constraint
232        // violation with the violation bounded away from zero, and
233        // exit with `LocallyInfeasible` instead of grinding to
234        // `max_iter` or thrashing restoration. Gated behind an
235        // `infeas_max_streak`-iteration streak to avoid firing on a
236        // transient flat spot. The outer guard skips the two
237        // transpose-products when detection is disabled.
238        if self.infeas_stationarity_tol > 0.0 && self.infeas_max_streak > 0 {
239            let stationarity = cq.borrow().curr_infeasibility_stationarity();
240            if self.note_infeasible_stationary(constr_viol, stationarity) {
241                return ConvergenceStatus::LocallyInfeasible;
242            }
243        }
244        // Time-budget gates. Upstream
245        // `IpOptErrorConvCheck.cpp::CheckConvergence` reads the
246        // application-level start time; pounce piggybacks on
247        // `data.timing.overall_alg`, which `IpoptApplication` starts
248        // at the top of `optimize_constrained`. `live_*` returns the
249        // running elapsed without forcing a `start/end` cycle.
250        let timing = &data.borrow().timing;
251        if timing.overall_alg.live_cpu_time() >= self.max_cpu_time {
252            return ConvergenceStatus::CpuTimeExceeded;
253        }
254        if timing.overall_alg.live_wallclock_time() >= self.max_wall_time {
255            return ConvergenceStatus::WallTimeExceeded;
256        }
257        ConvergenceStatus::Continue
258    }
259
260    fn tol_or_default(&self) -> Number {
261        self.tol
262    }
263
264    fn current_is_acceptable(&self, nlp_err: Number) -> bool {
265        // Scalar fallback used when the caller has no `IpoptCq` handle
266        // (e.g. unit tests). The state-aware variant
267        // [`Self::current_is_acceptable_with_state`] mirrors upstream
268        // more faithfully by gating on the per-component
269        // `acceptable_*_tol` triplet plus the obj-change cross-check.
270        nlp_err.is_finite() && nlp_err <= self.acceptable_tol
271    }
272
273    fn current_is_acceptable_with_state(
274        &self,
275        nlp_err: Number,
276        _data: &IpoptDataHandle,
277        cq: &IpoptCqHandle,
278    ) -> bool {
279        let cq_ref = cq.borrow();
280        let dual_inf = cq_ref.curr_dual_infeasibility_max();
281        let constr_viol = cq_ref.curr_primal_infeasibility_max();
282        let compl_inf = cq_ref.curr_complementarity_max();
283        let curr_f = cq_ref.curr_f();
284        drop(cq_ref);
285        self.passes_acceptable_tols(nlp_err, dual_inf, constr_viol, compl_inf, curr_f)
286    }
287
288    fn set_curr_acceptable_obj(&mut self, obj: Number) {
289        self.last_acceptable_obj = Some(obj);
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn converges_at_tol() {
299        let mut c = OptErrorConvCheck::new();
300        assert_eq!(c.check_convergence(1e-9, 0), ConvergenceStatus::Converged);
301    }
302
303    #[test]
304    fn acceptable_iter_count_threshold() {
305        let mut c = OptErrorConvCheck {
306            acceptable_iter: 3,
307            ..Default::default()
308        };
309        // nlp_err between tol (1e-8) and acceptable (1e-6).
310        assert_eq!(c.check_convergence(1e-7, 0), ConvergenceStatus::Continue);
311        assert_eq!(c.check_convergence(1e-7, 1), ConvergenceStatus::Continue);
312        assert_eq!(
313            c.check_convergence(1e-7, 2),
314            ConvergenceStatus::ConvergedToAcceptable
315        );
316    }
317
318    #[test]
319    fn streak_resets_when_above_acceptable() {
320        let mut c = OptErrorConvCheck {
321            acceptable_iter: 3,
322            ..Default::default()
323        };
324        assert_eq!(c.check_convergence(1e-7, 0), ConvergenceStatus::Continue);
325        // Above acceptable resets the counter.
326        assert_eq!(c.check_convergence(1e-3, 1), ConvergenceStatus::Continue);
327        assert_eq!(c.check_convergence(1e-7, 2), ConvergenceStatus::Continue);
328        assert_eq!(c.check_convergence(1e-7, 3), ConvergenceStatus::Continue);
329        assert_eq!(
330            c.check_convergence(1e-7, 4),
331            ConvergenceStatus::ConvergedToAcceptable
332        );
333    }
334
335    #[test]
336    fn passes_acceptable_tols_gates_on_per_component_triplet() {
337        let c = OptErrorConvCheck {
338            acceptable_tol: 1e-6,
339            acceptable_dual_inf_tol: 1e-3,
340            acceptable_constr_viol_tol: 1e-3,
341            acceptable_compl_inf_tol: 1e-3,
342            ..Default::default()
343        };
344        assert!(c.passes_acceptable_tols(1e-7, 1e-4, 1e-4, 1e-4, 0.0));
345        // dual_inf above its acceptable threshold blocks.
346        assert!(!c.passes_acceptable_tols(1e-7, 1.0, 1e-4, 1e-4, 0.0));
347        // overall above acceptable_tol blocks.
348        assert!(!c.passes_acceptable_tols(1e-5, 1e-4, 1e-4, 1e-4, 0.0));
349    }
350
351    #[test]
352    fn passes_acceptable_tols_honors_obj_change_tol() {
353        let mut c = OptErrorConvCheck {
354            acceptable_tol: 1e-6,
355            acceptable_dual_inf_tol: 1.0,
356            acceptable_constr_viol_tol: 1.0,
357            acceptable_compl_inf_tol: 1.0,
358            acceptable_obj_change_tol: 0.1,
359            ..Default::default()
360        };
361        // First call always acceptable (no prior obj).
362        assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 10.0));
363        c.set_curr_acceptable_obj(10.0);
364        // Same f → change well under threshold → still acceptable.
365        assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 10.0));
366        // f moved by 2.0 with threshold 0.1 * max(1, |11.0|) = 1.1 →
367        // absolute change 1.0 < 1.1: acceptable.
368        assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 11.0));
369        // f moved by 5.0 — absolute change 5.0 > 1.5 = 0.1 * 15 →
370        // rejected (the stability cross-check fires).
371        assert!(!c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 15.0));
372    }
373
374    use crate::conv_check::r#trait::ConvCheck;
375
376    #[test]
377    fn set_curr_acceptable_obj_records_for_cross_check() {
378        let mut c = OptErrorConvCheck::new();
379        assert!(c.last_acceptable_obj.is_none());
380        ConvCheck::set_curr_acceptable_obj(&mut c, 4.2);
381        assert_eq!(c.last_acceptable_obj, Some(4.2));
382    }
383
384    #[test]
385    fn passes_component_tols_requires_all_under_threshold() {
386        let c = OptErrorConvCheck {
387            tol: 1e-8,
388            dual_inf_tol: 1.0,
389            constr_viol_tol: 1e-4,
390            compl_inf_tol: 1e-4,
391            ..Default::default()
392        };
393        // All under threshold → converged.
394        assert!(c.passes_component_tols(1e-9, 0.5, 1e-5, 1e-5));
395        // dual_inf above its tolerance blocks even when nlp_err is tiny.
396        assert!(!c.passes_component_tols(1e-12, 2.0, 1e-5, 1e-5));
397        // compl_inf above its tolerance blocks.
398        assert!(!c.passes_component_tols(1e-12, 0.0, 0.0, 1e-2));
399        // constr_viol above its tolerance blocks.
400        assert!(!c.passes_component_tols(1e-12, 0.0, 1e-2, 0.0));
401    }
402
403    #[test]
404    fn infeasible_stationary_requires_violation_and_flat_gradient() {
405        let c = OptErrorConvCheck {
406            constr_viol_tol: 1e-4,
407            infeas_viol_kappa: 1e2, // violation threshold = 1e-2
408            infeas_stationarity_tol: 1e-8,
409            infeas_max_streak: 5,
410            ..Default::default()
411        };
412        // Violation well above 1e-2 and the infeasibility gradient
413        // essentially zero → counts as infeasible-stationary.
414        assert!(c.is_infeasible_stationary(1e-1, 1e-9));
415        // Violation above threshold but the gradient is not flat →
416        // still making feasibility progress, does not count.
417        assert!(!c.is_infeasible_stationary(1e-1, 1e-3));
418        // Gradient flat but violation below threshold → nearly
419        // feasible, does not count.
420        assert!(!c.is_infeasible_stationary(1e-3, 1e-9));
421    }
422
423    #[test]
424    fn infeasible_stationary_disabled_by_nonpositive_knobs() {
425        let off_tol = OptErrorConvCheck {
426            infeas_stationarity_tol: 0.0,
427            infeas_max_streak: 5,
428            ..Default::default()
429        };
430        assert!(!off_tol.is_infeasible_stationary(1e9, 0.0));
431        let off_streak = OptErrorConvCheck {
432            infeas_stationarity_tol: 1e-8,
433            infeas_max_streak: 0,
434            ..Default::default()
435        };
436        assert!(!off_streak.is_infeasible_stationary(1e9, 0.0));
437    }
438
439    #[test]
440    fn infeasible_stationary_streak_fires_only_after_max_streak() {
441        let mut c = OptErrorConvCheck {
442            constr_viol_tol: 1e-4,
443            infeas_viol_kappa: 1e2, // violation threshold = 1e-2
444            infeas_stationarity_tol: 1e-8,
445            infeas_max_streak: 3,
446            ..Default::default()
447        };
448        // Infeasible-stationary iterate: violation 1e-1 > 1e-2, flat
449        // gradient. Streak accrues but does not fire until the third.
450        assert!(!c.note_infeasible_stationary(1e-1, 1e-9));
451        assert!(!c.note_infeasible_stationary(1e-1, 1e-9));
452        assert!(c.note_infeasible_stationary(1e-1, 1e-9));
453    }
454
455    #[test]
456    fn infeasible_stationary_streak_resets_on_feasibility_progress() {
457        let mut c = OptErrorConvCheck {
458            constr_viol_tol: 1e-4,
459            infeas_viol_kappa: 1e2,
460            infeas_stationarity_tol: 1e-8,
461            infeas_max_streak: 3,
462            ..Default::default()
463        };
464        assert!(!c.note_infeasible_stationary(1e-1, 1e-9));
465        assert!(!c.note_infeasible_stationary(1e-1, 1e-9));
466        // A non-stationary iterate (gradient not flat) resets the streak.
467        assert!(!c.note_infeasible_stationary(1e-1, 1e-3));
468        assert_eq!(c.infeas_streak, 0);
469        // The streak must rebuild from scratch — no carry-over credit.
470        assert!(!c.note_infeasible_stationary(1e-1, 1e-9));
471        assert!(!c.note_infeasible_stationary(1e-1, 1e-9));
472        assert!(c.note_infeasible_stationary(1e-1, 1e-9));
473    }
474
475    #[test]
476    fn infeasible_stationary_streak_never_fires_when_disabled() {
477        let mut c = OptErrorConvCheck {
478            infeas_stationarity_tol: 0.0,
479            infeas_max_streak: 5,
480            ..Default::default()
481        };
482        for _ in 0..20 {
483            assert!(!c.note_infeasible_stationary(1e9, 0.0));
484        }
485        assert_eq!(c.infeas_streak, 0);
486    }
487
488    #[test]
489    fn max_iter_exceeded() {
490        let mut c = OptErrorConvCheck {
491            max_iter: 5,
492            ..Default::default()
493        };
494        assert_eq!(
495            c.check_convergence(1.0, 5),
496            ConvergenceStatus::MaxIterExceeded
497        );
498    }
499}