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        // A point is never acceptable if the scaled error metric or the
119        // objective itself is non-finite. Without the `curr_f` guard a NaN/Inf
120        // objective with otherwise-small infeasibility (e.g. CUTE `himmelbj`,
121        // where f evaluates to NaN at a near-feasible point) would be recorded
122        // as the acceptable rollback point and reported under
123        // `Solved_To_Acceptable_Level` with a `nan` objective.
124        if !overall.is_finite() || !curr_f.is_finite() {
125            return false;
126        }
127        let component_ok = overall <= self.acceptable_tol
128            && dual_inf <= self.acceptable_dual_inf_tol
129            && constr_viol <= self.acceptable_constr_viol_tol
130            && compl_inf <= self.acceptable_compl_inf_tol;
131        if !component_ok {
132            return false;
133        }
134        // Upstream `IpOptErrorConvCheck.cpp:CurrentIsAcceptable` — when
135        // an acceptable point has already been recorded and the user
136        // tightened `acceptable_obj_change_tol` below the 1e20
137        // sentinel, the iterate is only re-acceptable if `f` has moved
138        // by less than `tol * max(1, |f|)` relative to the recorded
139        // value. Skipped when no prior point exists or the cross-check
140        // is disabled.
141        if self.acceptable_obj_change_tol < 1e20 {
142            if let Some(prev) = self.last_acceptable_obj {
143                let denom = curr_f.abs().max(1.0);
144                if (prev - curr_f).abs() >= self.acceptable_obj_change_tol * denom {
145                    return false;
146                }
147            }
148        }
149        true
150    }
151
152    /// Pure predicate for a single infeasible-stationary iterate: the
153    /// constraint violation is bounded away from zero
154    /// (`constr_viol > infeas_viol_kappa · constr_viol_tol`) and the
155    /// scaled infeasibility gradient `‖Jᵀc‖/max(1,‖c‖)` is at or below
156    /// `infeas_stationarity_tol`. Returns `false` when rapid
157    /// infeasibility detection is disabled (either knob non-positive).
158    fn is_infeasible_stationary(&self, constr_viol: Number, stationarity: Number) -> bool {
159        if self.infeas_stationarity_tol <= 0.0 || self.infeas_max_streak <= 0 {
160            return false;
161        }
162        constr_viol > self.infeas_viol_kappa * self.constr_viol_tol
163            && stationarity <= self.infeas_stationarity_tol
164    }
165
166    /// Advance the rapid-infeasibility-detection streak by one
167    /// iteration. An infeasible-stationary iterate (see
168    /// [`Self::is_infeasible_stationary`]) increments the streak; any
169    /// other iterate resets it to zero. Returns `true` once the streak
170    /// reaches `infeas_max_streak`, signalling the caller to terminate
171    /// with `ConvergenceStatus::LocallyInfeasible`. The streak guards
172    /// against firing on a transient flat spot.
173    fn note_infeasible_stationary(&mut self, constr_viol: Number, stationarity: Number) -> bool {
174        if self.is_infeasible_stationary(constr_viol, stationarity) {
175            self.infeas_streak += 1;
176            self.infeas_streak >= self.infeas_max_streak
177        } else {
178            self.infeas_streak = 0;
179            false
180        }
181    }
182}
183
184impl ConvCheck for OptErrorConvCheck {
185    fn check_convergence(&mut self, nlp_err: Number, iter_count: Index) -> ConvergenceStatus {
186        if nlp_err <= self.tol {
187            return ConvergenceStatus::Converged;
188        }
189        // `acceptable_iter == 0` disables acceptable-level termination,
190        // mirroring upstream `IpOptErrorConvCheck.cpp:241`
191        // (`if( acceptable_iter_ > 0 && CurrentIsAcceptable() )`). Without
192        // the `> 0` guard, a zero would make `acceptable_count >= 0` fire on
193        // the first acceptable iterate — the opposite of "disabled".
194        if self.acceptable_iter > 0 && nlp_err <= self.acceptable_tol {
195            self.acceptable_count += 1;
196            if self.acceptable_count >= self.acceptable_iter {
197                return ConvergenceStatus::ConvergedToAcceptable;
198            }
199        } else {
200            self.acceptable_count = 0;
201        }
202        if iter_count >= self.max_iter {
203            return ConvergenceStatus::MaxIterExceeded;
204        }
205        ConvergenceStatus::Continue
206    }
207
208    fn check_convergence_with_state(
209        &mut self,
210        nlp_err: Number,
211        iter_count: Index,
212        data: &IpoptDataHandle,
213        cq: &IpoptCqHandle,
214    ) -> ConvergenceStatus {
215        // Mirror upstream `IpOptErrorConvCheck.cpp::CheckConvergence`:
216        // the scaled scalar `nlp_err` must drop below `tol` AND each
217        // per-component value must sit under its own tolerance. The
218        // component tolerances (`dual_inf_tol`/`constr_viol_tol`/
219        // `compl_inf_tol`) are defined on the *unscaled* (user-original)
220        // residuals — both upstream and per pounce's own option help text
221        // — so we gate on the unscaled accessors. This resolves the former
222        // M1 deviation (gating on internally-scaled residuals), which let
223        // an ill-conditioned, nlp_scaling-deflated solve report
224        // `Solve_Succeeded` while the user-space duals had drifted
225        // (pounce#173). When no scaling is active the unscaled accessors
226        // return the scaled values unchanged, so behaviour is identical on
227        // the common path.
228        let cq_ref = cq.borrow();
229        let dual_inf = cq_ref.curr_unscaled_dual_infeasibility_max();
230        let constr_viol = cq_ref.curr_unscaled_primal_infeasibility_max();
231        let compl_inf = cq_ref.curr_unscaled_complementarity_max();
232        let curr_f = cq_ref.curr_f();
233        drop(cq_ref);
234
235        if self.passes_component_tols(nlp_err, dual_inf, constr_viol, compl_inf) {
236            return ConvergenceStatus::Converged;
237        }
238        // `acceptable_iter == 0` disables acceptable-level termination
239        // (upstream `IpOptErrorConvCheck.cpp:241`). See `check_convergence`.
240        if self.acceptable_iter > 0
241            && self.passes_acceptable_tols(nlp_err, dual_inf, constr_viol, compl_inf, curr_f)
242        {
243            self.acceptable_count += 1;
244            if self.acceptable_count >= self.acceptable_iter {
245                return ConvergenceStatus::ConvergedToAcceptable;
246            }
247        } else {
248            self.acceptable_count = 0;
249        }
250        if iter_count >= self.max_iter {
251            return ConvergenceStatus::MaxIterExceeded;
252        }
253        // Rapid infeasibility detection — recognise an iterate
254        // converging to a stationary point of the constraint
255        // violation with the violation bounded away from zero, and
256        // exit with `LocallyInfeasible` instead of grinding to
257        // `max_iter` or thrashing restoration. Gated behind an
258        // `infeas_max_streak`-iteration streak to avoid firing on a
259        // transient flat spot. The outer guard skips the two
260        // transpose-products when detection is disabled.
261        if self.infeas_stationarity_tol > 0.0 && self.infeas_max_streak > 0 {
262            let stationarity = cq.borrow().curr_infeasibility_stationarity();
263            if self.note_infeasible_stationary(constr_viol, stationarity) {
264                return ConvergenceStatus::LocallyInfeasible;
265            }
266        }
267        // Time-budget gates. Upstream
268        // `IpOptErrorConvCheck.cpp::CheckConvergence` reads the
269        // application-level start time; pounce piggybacks on
270        // `data.timing.overall_alg`, which `IpoptApplication` starts
271        // at the top of `optimize_constrained`. `live_*` returns the
272        // running elapsed without forcing a `start/end` cycle.
273        let timing = &data.borrow().timing;
274        if timing.overall_alg.live_cpu_time() >= self.max_cpu_time {
275            return ConvergenceStatus::CpuTimeExceeded;
276        }
277        if timing.overall_alg.live_wallclock_time() >= self.max_wall_time {
278            return ConvergenceStatus::WallTimeExceeded;
279        }
280        ConvergenceStatus::Continue
281    }
282
283    fn tol_or_default(&self) -> Number {
284        self.tol
285    }
286
287    fn set_tolerance(&mut self, name: &str, value: Number) -> bool {
288        match name {
289            "tol" => self.tol = value,
290            "dual_inf_tol" => self.dual_inf_tol = value,
291            "constr_viol_tol" => self.constr_viol_tol = value,
292            "compl_inf_tol" => self.compl_inf_tol = value,
293            "acceptable_tol" => self.acceptable_tol = value,
294            "acceptable_dual_inf_tol" => self.acceptable_dual_inf_tol = value,
295            "acceptable_constr_viol_tol" => self.acceptable_constr_viol_tol = value,
296            "acceptable_compl_inf_tol" => self.acceptable_compl_inf_tol = value,
297            "acceptable_obj_change_tol" => self.acceptable_obj_change_tol = value,
298            _ => return false,
299        }
300        true
301    }
302
303    fn current_is_acceptable(&self, nlp_err: Number) -> bool {
304        // Scalar fallback used when the caller has no `IpoptCq` handle
305        // (e.g. unit tests). The state-aware variant
306        // [`Self::current_is_acceptable_with_state`] mirrors upstream
307        // more faithfully by gating on the per-component
308        // `acceptable_*_tol` triplet plus the obj-change cross-check.
309        nlp_err.is_finite() && nlp_err <= self.acceptable_tol
310    }
311
312    fn current_is_acceptable_with_state(
313        &self,
314        nlp_err: Number,
315        _data: &IpoptDataHandle,
316        cq: &IpoptCqHandle,
317    ) -> bool {
318        let cq_ref = cq.borrow();
319        // Unscaled per-component residuals — see `check_convergence_with_state`
320        // (the `acceptable_*_tol` triplet is likewise defined on the
321        // user-original residuals).
322        let dual_inf = cq_ref.curr_unscaled_dual_infeasibility_max();
323        let constr_viol = cq_ref.curr_unscaled_primal_infeasibility_max();
324        let compl_inf = cq_ref.curr_unscaled_complementarity_max();
325        let curr_f = cq_ref.curr_f();
326        drop(cq_ref);
327        self.passes_acceptable_tols(nlp_err, dual_inf, constr_viol, compl_inf, curr_f)
328    }
329
330    fn set_curr_acceptable_obj(&mut self, obj: Number) {
331        self.last_acceptable_obj = Some(obj);
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn converges_at_tol() {
341        let mut c = OptErrorConvCheck::new();
342        assert_eq!(c.check_convergence(1e-9, 0), ConvergenceStatus::Converged);
343    }
344
345    #[test]
346    fn acceptable_iter_count_threshold() {
347        let mut c = OptErrorConvCheck {
348            acceptable_iter: 3,
349            ..Default::default()
350        };
351        // nlp_err between tol (1e-8) and acceptable (1e-6).
352        assert_eq!(c.check_convergence(1e-7, 0), ConvergenceStatus::Continue);
353        assert_eq!(c.check_convergence(1e-7, 1), ConvergenceStatus::Continue);
354        assert_eq!(
355            c.check_convergence(1e-7, 2),
356            ConvergenceStatus::ConvergedToAcceptable
357        );
358    }
359
360    #[test]
361    fn acceptable_iter_zero_disables_acceptable_termination() {
362        // Upstream `IpOptErrorConvCheck.cpp:241` gates the acceptable
363        // counter on `acceptable_iter_ > 0`, so a zero disables the
364        // acceptable-level exit entirely. Before the guard, `>= 0` made
365        // pounce fire on the FIRST acceptable iterate (the opposite).
366        let mut c = OptErrorConvCheck {
367            acceptable_iter: 0,
368            ..Default::default()
369        };
370        // Many iterates parked between tol (1e-8) and acceptable (1e-6)
371        // must never trigger ConvergedToAcceptable; the run continues
372        // until tol or max_iter.
373        for k in 0..50 {
374            assert_eq!(
375                c.check_convergence(1e-7, k),
376                ConvergenceStatus::Continue,
377                "acceptable_iter=0 must not stop at the acceptable level (iter {k})"
378            );
379        }
380        // tol is still honored regardless.
381        assert_eq!(c.check_convergence(1e-9, 51), ConvergenceStatus::Converged);
382    }
383
384    #[test]
385    fn streak_resets_when_above_acceptable() {
386        let mut c = OptErrorConvCheck {
387            acceptable_iter: 3,
388            ..Default::default()
389        };
390        assert_eq!(c.check_convergence(1e-7, 0), ConvergenceStatus::Continue);
391        // Above acceptable resets the counter.
392        assert_eq!(c.check_convergence(1e-3, 1), ConvergenceStatus::Continue);
393        assert_eq!(c.check_convergence(1e-7, 2), ConvergenceStatus::Continue);
394        assert_eq!(c.check_convergence(1e-7, 3), ConvergenceStatus::Continue);
395        assert_eq!(
396            c.check_convergence(1e-7, 4),
397            ConvergenceStatus::ConvergedToAcceptable
398        );
399    }
400
401    #[test]
402    fn passes_acceptable_tols_gates_on_per_component_triplet() {
403        let c = OptErrorConvCheck {
404            acceptable_tol: 1e-6,
405            acceptable_dual_inf_tol: 1e-3,
406            acceptable_constr_viol_tol: 1e-3,
407            acceptable_compl_inf_tol: 1e-3,
408            ..Default::default()
409        };
410        assert!(c.passes_acceptable_tols(1e-7, 1e-4, 1e-4, 1e-4, 0.0));
411        // dual_inf above its acceptable threshold blocks.
412        assert!(!c.passes_acceptable_tols(1e-7, 1.0, 1e-4, 1e-4, 0.0));
413        // overall above acceptable_tol blocks.
414        assert!(!c.passes_acceptable_tols(1e-5, 1e-4, 1e-4, 1e-4, 0.0));
415    }
416
417    #[test]
418    fn passes_acceptable_tols_honors_obj_change_tol() {
419        let mut c = OptErrorConvCheck {
420            acceptable_tol: 1e-6,
421            acceptable_dual_inf_tol: 1.0,
422            acceptable_constr_viol_tol: 1.0,
423            acceptable_compl_inf_tol: 1.0,
424            acceptable_obj_change_tol: 0.1,
425            ..Default::default()
426        };
427        // First call always acceptable (no prior obj).
428        assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 10.0));
429        c.set_curr_acceptable_obj(10.0);
430        // Same f → change well under threshold → still acceptable.
431        assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 10.0));
432        // f moved by 2.0 with threshold 0.1 * max(1, |11.0|) = 1.1 →
433        // absolute change 1.0 < 1.1: acceptable.
434        assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 11.0));
435        // f moved by 5.0 — absolute change 5.0 > 1.5 = 0.1 * 15 →
436        // rejected (the stability cross-check fires).
437        assert!(!c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 15.0));
438    }
439
440    use crate::conv_check::r#trait::ConvCheck;
441
442    #[test]
443    fn set_curr_acceptable_obj_records_for_cross_check() {
444        let mut c = OptErrorConvCheck::new();
445        assert!(c.last_acceptable_obj.is_none());
446        ConvCheck::set_curr_acceptable_obj(&mut c, 4.2);
447        assert_eq!(c.last_acceptable_obj, Some(4.2));
448    }
449
450    #[test]
451    fn passes_component_tols_requires_all_under_threshold() {
452        let c = OptErrorConvCheck {
453            tol: 1e-8,
454            dual_inf_tol: 1.0,
455            constr_viol_tol: 1e-4,
456            compl_inf_tol: 1e-4,
457            ..Default::default()
458        };
459        // All under threshold → converged.
460        assert!(c.passes_component_tols(1e-9, 0.5, 1e-5, 1e-5));
461        // dual_inf above its tolerance blocks even when nlp_err is tiny.
462        assert!(!c.passes_component_tols(1e-12, 2.0, 1e-5, 1e-5));
463        // compl_inf above its tolerance blocks.
464        assert!(!c.passes_component_tols(1e-12, 0.0, 0.0, 1e-2));
465        // constr_viol above its tolerance blocks.
466        assert!(!c.passes_component_tols(1e-12, 0.0, 1e-2, 0.0));
467    }
468
469    #[test]
470    fn infeasible_stationary_requires_violation_and_flat_gradient() {
471        let c = OptErrorConvCheck {
472            constr_viol_tol: 1e-4,
473            infeas_viol_kappa: 1e2, // violation threshold = 1e-2
474            infeas_stationarity_tol: 1e-8,
475            infeas_max_streak: 5,
476            ..Default::default()
477        };
478        // Violation well above 1e-2 and the infeasibility gradient
479        // essentially zero → counts as infeasible-stationary.
480        assert!(c.is_infeasible_stationary(1e-1, 1e-9));
481        // Violation above threshold but the gradient is not flat →
482        // still making feasibility progress, does not count.
483        assert!(!c.is_infeasible_stationary(1e-1, 1e-3));
484        // Gradient flat but violation below threshold → nearly
485        // feasible, does not count.
486        assert!(!c.is_infeasible_stationary(1e-3, 1e-9));
487    }
488
489    #[test]
490    fn infeasible_stationary_disabled_by_nonpositive_knobs() {
491        let off_tol = OptErrorConvCheck {
492            infeas_stationarity_tol: 0.0,
493            infeas_max_streak: 5,
494            ..Default::default()
495        };
496        assert!(!off_tol.is_infeasible_stationary(1e9, 0.0));
497        let off_streak = OptErrorConvCheck {
498            infeas_stationarity_tol: 1e-8,
499            infeas_max_streak: 0,
500            ..Default::default()
501        };
502        assert!(!off_streak.is_infeasible_stationary(1e9, 0.0));
503    }
504
505    #[test]
506    fn infeasible_stationary_streak_fires_only_after_max_streak() {
507        let mut c = OptErrorConvCheck {
508            constr_viol_tol: 1e-4,
509            infeas_viol_kappa: 1e2, // violation threshold = 1e-2
510            infeas_stationarity_tol: 1e-8,
511            infeas_max_streak: 3,
512            ..Default::default()
513        };
514        // Infeasible-stationary iterate: violation 1e-1 > 1e-2, flat
515        // gradient. Streak accrues but does not fire until the third.
516        assert!(!c.note_infeasible_stationary(1e-1, 1e-9));
517        assert!(!c.note_infeasible_stationary(1e-1, 1e-9));
518        assert!(c.note_infeasible_stationary(1e-1, 1e-9));
519    }
520
521    #[test]
522    fn infeasible_stationary_streak_resets_on_feasibility_progress() {
523        let mut c = OptErrorConvCheck {
524            constr_viol_tol: 1e-4,
525            infeas_viol_kappa: 1e2,
526            infeas_stationarity_tol: 1e-8,
527            infeas_max_streak: 3,
528            ..Default::default()
529        };
530        assert!(!c.note_infeasible_stationary(1e-1, 1e-9));
531        assert!(!c.note_infeasible_stationary(1e-1, 1e-9));
532        // A non-stationary iterate (gradient not flat) resets the streak.
533        assert!(!c.note_infeasible_stationary(1e-1, 1e-3));
534        assert_eq!(c.infeas_streak, 0);
535        // The streak must rebuild from scratch — no carry-over credit.
536        assert!(!c.note_infeasible_stationary(1e-1, 1e-9));
537        assert!(!c.note_infeasible_stationary(1e-1, 1e-9));
538        assert!(c.note_infeasible_stationary(1e-1, 1e-9));
539    }
540
541    #[test]
542    fn infeasible_stationary_streak_never_fires_when_disabled() {
543        let mut c = OptErrorConvCheck {
544            infeas_stationarity_tol: 0.0,
545            infeas_max_streak: 5,
546            ..Default::default()
547        };
548        for _ in 0..20 {
549            assert!(!c.note_infeasible_stationary(1e9, 0.0));
550        }
551        assert_eq!(c.infeas_streak, 0);
552    }
553
554    #[test]
555    fn max_iter_exceeded() {
556        let mut c = OptErrorConvCheck {
557            max_iter: 5,
558            ..Default::default()
559        };
560        assert_eq!(
561            c.check_convergence(1.0, 5),
562            ConvergenceStatus::MaxIterExceeded
563        );
564    }
565}