use crate::conv_check::r#trait::{ConvCheck, ConvergenceStatus};
use crate::ipopt_cq::IpoptCqHandle;
use crate::ipopt_data::IpoptDataHandle;
use pounce_common::types::{Index, Number};
pub struct OptErrorConvCheck {
pub tol: Number,
pub dual_inf_tol: Number,
pub constr_viol_tol: Number,
pub compl_inf_tol: Number,
pub acceptable_tol: Number,
pub acceptable_dual_inf_tol: Number,
pub acceptable_constr_viol_tol: Number,
pub acceptable_compl_inf_tol: Number,
pub acceptable_obj_change_tol: Number,
pub acceptable_iter: Index,
pub max_iter: Index,
pub max_cpu_time: Number,
pub max_wall_time: Number,
pub acceptable_count: Index,
pub last_acceptable_obj: Option<Number>,
pub infeas_stationarity_tol: Number,
pub infeas_viol_kappa: Number,
pub infeas_max_streak: Index,
pub infeas_streak: Index,
pub obj_scale_certificate_threshold: Number,
pub primal_noise_floor_kappa: Number,
pub acceptable_progress_kappa: Number,
pub acceptable_window: std::collections::VecDeque<(Number, Number)>,
pub acceptable_progress_refusals: Index,
pub dual_inf_scale_kappa: Number,
pub dual_floor_reported: bool,
pub veto_fired: bool,
pub acceptable_veto_fired: bool,
pub masked_acceptable_veto_fired: bool,
pub veto_extra_iters: Index,
pub rel_infeas_extra_iters: Index,
pub prev_rel_viol: Number,
}
const VETO_MAX_EXTRA_ITERS: Index = 60;
const ACCEPTABLE_PROGRESS_MAX_REFUSALS: Index = 1000;
const ACCEPTABLE_PROGRESS_WINDOW_MAX: usize = 256;
const MIN_INFEAS_VIOL_FLOOR: Number = 1e-2;
pub fn certificate_masked(
obj_scale: Number,
unscaled_err: Number,
threshold: Number,
acceptable_tol: Number,
) -> bool {
if threshold.is_nan() || threshold <= 0.0 {
return false;
}
obj_scale.abs() < threshold && unscaled_err > acceptable_tol
}
impl Default for OptErrorConvCheck {
fn default() -> Self {
Self {
tol: 1e-8,
dual_inf_tol: 1.0,
constr_viol_tol: 1e-4,
compl_inf_tol: 1e-4,
acceptable_tol: 1e-6,
acceptable_dual_inf_tol: 1e10,
acceptable_constr_viol_tol: 1e-2,
acceptable_compl_inf_tol: 1e-2,
acceptable_obj_change_tol: 1e20,
acceptable_iter: 15,
max_iter: 3000,
max_cpu_time: 1e6,
max_wall_time: 1e6,
acceptable_count: 0,
last_acceptable_obj: None,
infeas_stationarity_tol: 1e-8,
infeas_viol_kappa: 1e2,
infeas_max_streak: 5,
infeas_streak: 0,
obj_scale_certificate_threshold: 1e-4,
primal_noise_floor_kappa: 64.0,
acceptable_progress_kappa: 1e-1,
acceptable_window: std::collections::VecDeque::new(),
acceptable_progress_refusals: 0,
dual_inf_scale_kappa: 1.0,
dual_floor_reported: false,
veto_fired: false,
acceptable_veto_fired: false,
masked_acceptable_veto_fired: false,
veto_extra_iters: 0,
rel_infeas_extra_iters: 0,
prev_rel_viol: Number::NAN,
}
}
}
impl OptErrorConvCheck {
pub fn new() -> Self {
Self::default()
}
fn passes_component_tols(
&self,
overall: Number,
dual_inf: Number,
constr_viol: Number,
compl_inf: Number,
dual_scale: Number,
) -> bool {
overall <= self.tol
&& dual_inf <= self.dual_inf_bound(dual_scale)
&& constr_viol <= self.constr_viol_tol
&& compl_inf <= self.compl_inf_tol
}
fn dual_inf_bound(&self, dual_scale: Number) -> Number {
if self.dual_inf_scale_kappa.is_nan()
|| self.dual_inf_scale_kappa <= 0.0
|| !dual_scale.is_finite()
|| dual_scale <= 0.0
{
return self.dual_inf_tol;
}
self.dual_inf_tol
.max(self.dual_inf_scale_kappa * self.tol * dual_scale)
}
fn noise_floor_enabled(&self) -> bool {
self.primal_noise_floor_kappa > 0.0
}
fn strict_overall(nlp_err: Number, above_primal_noise: Number) -> Number {
if !nlp_err.is_finite() {
return nlp_err;
}
nlp_err.min(above_primal_noise)
}
fn passes_acceptable_tols(
&self,
overall: Number,
dual_inf: Number,
constr_viol: Number,
compl_inf: Number,
curr_f: Number,
) -> bool {
if !overall.is_finite() || !curr_f.is_finite() {
return false;
}
let component_ok = overall <= self.acceptable_tol
&& dual_inf <= self.acceptable_dual_inf_tol
&& constr_viol <= self.acceptable_constr_viol_tol
&& compl_inf <= self.acceptable_compl_inf_tol;
if !component_ok {
return false;
}
if self.acceptable_obj_change_tol < 1e20 {
if let Some(prev) = self.last_acceptable_obj {
let denom = curr_f.abs().max(1.0);
if (prev - curr_f).abs() >= self.acceptable_obj_change_tol * denom {
return false;
}
}
}
true
}
fn note_acceptable(
&mut self,
acceptable_now: bool,
masked: bool,
nlp_err: Number,
curr_f: Number,
) -> bool {
if !acceptable_now {
self.acceptable_count = 0;
self.acceptable_window.clear();
return false;
}
self.acceptable_count += 1;
self.push_progress_sample(nlp_err, curr_f);
if self.acceptable_count < self.acceptable_iter {
return false;
}
if masked {
self.acceptable_veto_fired = true;
self.masked_acceptable_veto_fired = true;
return false;
}
if !self.streak_has_flattened()
&& self.acceptable_progress_refusals < ACCEPTABLE_PROGRESS_MAX_REFUSALS
{
if !self.acceptable_veto_fired {
tracing::info!(
nlp_err,
obj = curr_f,
acceptable_tol = self.acceptable_tol,
window = self.acceptable_window.len(),
kappa = self.acceptable_progress_kappa,
"refusing an acceptable-level termination: the error has been inside \
the acceptable band for the whole streak but is still moving across \
it, so the streak has not flattened; continuing \
(acceptable_progress_kappa=0 disables)"
);
}
self.acceptable_progress_refusals += 1;
self.acceptable_veto_fired = true;
return false;
}
true
}
fn progress_window_len(&self) -> usize {
(self.acceptable_iter.max(1) as usize).clamp(1, ACCEPTABLE_PROGRESS_WINDOW_MAX)
}
fn push_progress_sample(&mut self, nlp_err: Number, curr_f: Number) {
let cap = self.progress_window_len();
self.acceptable_window.push_back((nlp_err, curr_f));
while self.acceptable_window.len() > cap {
self.acceptable_window.pop_front();
}
}
fn streak_has_flattened(&self) -> bool {
if self.acceptable_progress_kappa.is_nan() || self.acceptable_progress_kappa <= 0.0 {
return true;
}
if self.acceptable_window.len() < self.progress_window_len()
|| self.acceptable_window.len() < 2
{
return true;
}
let bar = self.acceptable_progress_kappa * self.acceptable_tol;
let (mut err_lo, mut err_hi) = (Number::INFINITY, Number::NEG_INFINITY);
let (mut f_lo, mut f_hi) = (Number::INFINITY, Number::NEG_INFINITY);
for &(err, f) in &self.acceptable_window {
if !err.is_finite() || !f.is_finite() {
return true;
}
err_lo = err_lo.min(err);
err_hi = err_hi.max(err);
f_lo = f_lo.min(f);
f_hi = f_hi.max(f);
}
let f_curr = self.acceptable_window.back().map_or(0.0, |&(_, f)| f);
let err_flat = err_hi - err_lo <= bar;
let obj_flat = f_hi - f_lo <= bar * f_curr.abs().max(1.0);
err_flat && obj_flat
}
fn relative_viol_threshold(&self) -> Number {
(100.0 * self.constr_viol_tol).max(MIN_INFEAS_VIOL_FLOOR)
}
fn absolute_viol_threshold(&self) -> Number {
(self.infeas_viol_kappa * self.constr_viol_tol).max(MIN_INFEAS_VIOL_FLOOR)
}
fn is_infeasible_stationary(
&self,
constr_viol: Number,
rel_viol: Number,
stationarity: Number,
) -> bool {
if self.infeas_stationarity_tol <= 0.0 || self.infeas_max_streak <= 0 {
return false;
}
(constr_viol > self.absolute_viol_threshold() || rel_viol > self.relative_viol_threshold())
&& stationarity <= self.infeas_stationarity_tol
}
fn note_infeasible_stationary(
&mut self,
constr_viol: Number,
rel_viol: Number,
stationarity: Number,
) -> bool {
let still_improving = rel_viol < 0.9 * self.prev_rel_viol;
self.prev_rel_viol = rel_viol;
let effective_rel = if still_improving { 0.0 } else { rel_viol };
if self.is_infeasible_stationary(constr_viol, effective_rel, stationarity) {
self.infeas_streak += 1;
self.infeas_streak >= self.infeas_max_streak
} else {
self.infeas_streak = 0;
false
}
}
}
impl ConvCheck for OptErrorConvCheck {
fn certificate_vetoed(&self) -> bool {
self.veto_fired
}
fn acceptable_certificate_vetoed(&self) -> bool {
self.acceptable_veto_fired
}
fn check_convergence(&mut self, nlp_err: Number, iter_count: Index) -> ConvergenceStatus {
if nlp_err <= self.tol {
return ConvergenceStatus::Converged;
}
if self.acceptable_iter > 0 && nlp_err <= self.acceptable_tol {
self.acceptable_count += 1;
if self.acceptable_count >= self.acceptable_iter {
return ConvergenceStatus::ConvergedToAcceptable;
}
} else {
self.acceptable_count = 0;
}
if iter_count >= self.max_iter {
return ConvergenceStatus::MaxIterExceeded;
}
ConvergenceStatus::Continue
}
fn check_convergence_with_state(
&mut self,
nlp_err: Number,
iter_count: Index,
data: &IpoptDataHandle,
cq: &IpoptCqHandle,
) -> ConvergenceStatus {
let cq_ref = cq.borrow();
let dual_inf = cq_ref.curr_unscaled_dual_infeasibility_max();
let constr_viol = cq_ref.curr_unscaled_primal_infeasibility_max();
let compl_inf = cq_ref.curr_unscaled_complementarity_max();
let rel_viol = cq_ref.curr_relative_primal_infeasibility_max();
let curr_f = cq_ref.curr_f();
let unscaled_err = cq_ref.curr_unscaled_nlp_error();
let primal_compl_pass =
constr_viol <= self.constr_viol_tol && compl_inf <= self.compl_inf_tol;
let dual_scale =
if primal_compl_pass && dual_inf > self.dual_inf_tol && self.dual_inf_scale_kappa > 0.0
{
cq_ref.curr_unscaled_dual_infeasibility_scale_max()
} else {
0.0
};
let components_pass = primal_compl_pass && dual_inf <= self.dual_inf_bound(dual_scale);
let strict_err = if nlp_err <= self.tol || !components_pass || !self.noise_floor_enabled() {
nlp_err
} else {
Self::strict_overall(
nlp_err,
cq_ref.curr_nlp_error_above_primal_noise(self.primal_noise_floor_kappa),
)
};
let obj_scale = cq_ref.computed_obj_scaling_factor();
drop(cq_ref);
let rel_veto = rel_viol > self.relative_viol_threshold()
&& self.rel_infeas_extra_iters < VETO_MAX_EXTRA_ITERS;
let mut rel_veto_blocked = false;
if self.veto_fired || self.masked_acceptable_veto_fired {
self.veto_extra_iters += 1;
}
let budget_spent = self.veto_extra_iters > VETO_MAX_EXTRA_ITERS;
let masked = curr_f.is_finite()
&& !budget_spent
&& certificate_masked(
obj_scale,
unscaled_err,
self.obj_scale_certificate_threshold,
self.acceptable_tol,
);
let refusing_strict = masked
&& self.passes_component_tols(strict_err, dual_inf, constr_viol, compl_inf, dual_scale);
if refusing_strict && !self.veto_fired {
self.veto_fired = true;
tracing::info!(
obj_scale,
unscaled_kkt_error = unscaled_err,
scaled_nlp_error = nlp_err,
threshold = self.obj_scale_certificate_threshold,
"refusing a termination certificate masked by an extreme objective scale; \
continuing toward the true minimum (obj_scale_certificate_threshold=0 disables)"
);
}
if !masked
&& self.passes_component_tols(strict_err, dual_inf, constr_viol, compl_inf, dual_scale)
{
if rel_veto {
rel_veto_blocked = true;
if self.rel_infeas_extra_iters == 0 {
tracing::info!(
rel_viol,
constr_viol,
threshold = self.relative_viol_threshold(),
"refusing a success certificate: a constraint row is still \
violated by more than the scale-relative threshold of its own \
magnitude; continuing (bounded by the veto budget)"
);
}
} else {
if dual_inf > self.dual_inf_tol && !self.dual_floor_reported {
self.dual_floor_reported = true;
tracing::info!(
dual_inf,
dual_scale,
dual_inf_tol = self.dual_inf_tol,
bound = self.dual_inf_bound(dual_scale),
"certifying with a dual infeasibility above dual_inf_tol: it is \
within the scale-relative floor set by the terms the Lagrangian \
gradient is built from (dual_inf_scale_kappa=0 disables)"
);
}
return ConvergenceStatus::Converged;
}
}
let mut acceptable_now = self.acceptable_iter > 0
&& self.passes_acceptable_tols(nlp_err, dual_inf, constr_viol, compl_inf, curr_f);
if acceptable_now && rel_veto {
acceptable_now = false;
rel_veto_blocked = true;
}
if rel_veto_blocked {
self.rel_infeas_extra_iters += 1;
}
if self.note_acceptable(acceptable_now, masked, nlp_err, curr_f) {
return ConvergenceStatus::ConvergedToAcceptable;
}
if iter_count >= self.max_iter {
return ConvergenceStatus::MaxIterExceeded;
}
if self.infeas_stationarity_tol > 0.0 && self.infeas_max_streak > 0 {
let stationarity = cq.borrow().curr_infeasibility_stationarity();
if self.note_infeasible_stationary(constr_viol, rel_viol, stationarity) {
if cq.borrow().infeasibility_descent_available() {
self.infeas_streak = 0;
} else {
return ConvergenceStatus::LocallyInfeasible;
}
}
}
let d = data.borrow();
if let Some(deadline) = d.deadline.as_ref() {
match deadline.exceeded() {
Some(pounce_common::timing::DeadlineKind::Cpu) => {
return ConvergenceStatus::CpuTimeExceeded;
}
Some(pounce_common::timing::DeadlineKind::Wall) => {
return ConvergenceStatus::WallTimeExceeded;
}
None => {}
}
} else {
let timing = &d.timing;
if timing.overall_alg.live_cpu_time() >= self.max_cpu_time {
return ConvergenceStatus::CpuTimeExceeded;
}
if timing.overall_alg.live_wallclock_time() >= self.max_wall_time {
return ConvergenceStatus::WallTimeExceeded;
}
}
ConvergenceStatus::Continue
}
fn current_passes_strict(
&self,
nlp_err: Number,
_data: &IpoptDataHandle,
cq: &IpoptCqHandle,
) -> bool {
let cq_ref = cq.borrow();
let dual_inf = cq_ref.curr_unscaled_dual_infeasibility_max();
let constr_viol = cq_ref.curr_unscaled_primal_infeasibility_max();
let compl_inf = cq_ref.curr_unscaled_complementarity_max();
let strict_err = if self.noise_floor_enabled() {
Self::strict_overall(
nlp_err,
cq_ref.curr_nlp_error_above_primal_noise(self.primal_noise_floor_kappa),
)
} else {
nlp_err
};
let dual_scale = if dual_inf > self.dual_inf_tol && self.dual_inf_scale_kappa > 0.0 {
cq_ref.curr_unscaled_dual_infeasibility_scale_max()
} else {
0.0
};
drop(cq_ref);
self.passes_component_tols(strict_err, dual_inf, constr_viol, compl_inf, dual_scale)
}
fn tol_or_default(&self) -> Number {
self.tol
}
fn constr_viol_tol_or_default(&self) -> Number {
self.constr_viol_tol
}
fn acceptable_constr_viol_tol_or_default(&self) -> Number {
self.acceptable_constr_viol_tol
}
fn set_tolerance(&mut self, name: &str, value: Number) -> bool {
match name {
"tol" => self.tol = value,
"dual_inf_tol" => self.dual_inf_tol = value,
"constr_viol_tol" => self.constr_viol_tol = value,
"compl_inf_tol" => self.compl_inf_tol = value,
"acceptable_tol" => self.acceptable_tol = value,
"acceptable_dual_inf_tol" => self.acceptable_dual_inf_tol = value,
"acceptable_constr_viol_tol" => self.acceptable_constr_viol_tol = value,
"acceptable_compl_inf_tol" => self.acceptable_compl_inf_tol = value,
"acceptable_obj_change_tol" => self.acceptable_obj_change_tol = value,
_ => return false,
}
true
}
fn current_is_acceptable(&self, nlp_err: Number) -> bool {
nlp_err.is_finite() && nlp_err <= self.acceptable_tol
}
fn current_is_acceptable_with_state(
&self,
nlp_err: Number,
_data: &IpoptDataHandle,
cq: &IpoptCqHandle,
) -> bool {
let cq_ref = cq.borrow();
let dual_inf = cq_ref.curr_unscaled_dual_infeasibility_max();
let constr_viol = cq_ref.curr_unscaled_primal_infeasibility_max();
let compl_inf = cq_ref.curr_unscaled_complementarity_max();
let rel_viol = cq_ref.curr_relative_primal_infeasibility_max();
let curr_f = cq_ref.curr_f();
drop(cq_ref);
if rel_viol > self.relative_viol_threshold()
&& self.rel_infeas_extra_iters < VETO_MAX_EXTRA_ITERS
{
return false;
}
self.passes_acceptable_tols(nlp_err, dual_inf, constr_viol, compl_inf, curr_f)
}
fn set_curr_acceptable_obj(&mut self, obj: Number) {
self.last_acceptable_obj = Some(obj);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converges_at_tol() {
let mut c = OptErrorConvCheck::new();
assert_eq!(c.check_convergence(1e-9, 0), ConvergenceStatus::Converged);
}
#[test]
fn relative_violation_arms_the_infeasibility_prefilter() {
let c = OptErrorConvCheck::new();
assert!(c.is_infeasible_stationary(1e-13, 0.14, 1e-9));
assert!(!c.is_infeasible_stationary(1e-13, 0.0, 1e-9));
assert!(!c.is_infeasible_stationary(1e-9, 1e-3, 1e-9));
}
#[test]
fn improving_relative_violation_resets_the_streak() {
let mut c = OptErrorConvCheck::new();
c.infeas_max_streak = 3;
assert!(!c.note_infeasible_stationary(1e-13, 0.14, 1e-9));
assert!(!c.note_infeasible_stationary(1e-13, 0.14, 1e-9));
assert!(c.note_infeasible_stationary(1e-13, 0.14, 1e-9));
let mut c = OptErrorConvCheck::new();
c.infeas_max_streak = 3;
let mut rel = 0.5;
for _ in 0..20 {
assert!(
!c.note_infeasible_stationary(1e-13, rel, 1e-9),
"a converging endgame must not be declared infeasible"
);
rel *= 0.5;
}
}
#[test]
fn relative_viol_threshold_is_floored() {
let mut c = OptErrorConvCheck::new();
assert_eq!(c.relative_viol_threshold(), 1e-2);
c.constr_viol_tol = 1e-3;
assert_eq!(c.relative_viol_threshold(), 1e-1);
c.constr_viol_tol = 1e-8;
assert_eq!(c.relative_viol_threshold(), 1e-2);
}
#[test]
fn acceptable_iter_count_threshold() {
let mut c = OptErrorConvCheck {
acceptable_iter: 3,
..Default::default()
};
assert_eq!(c.check_convergence(1e-7, 0), ConvergenceStatus::Continue);
assert_eq!(c.check_convergence(1e-7, 1), ConvergenceStatus::Continue);
assert_eq!(
c.check_convergence(1e-7, 2),
ConvergenceStatus::ConvergedToAcceptable
);
}
#[test]
fn acceptable_iter_zero_disables_acceptable_termination() {
let mut c = OptErrorConvCheck {
acceptable_iter: 0,
..Default::default()
};
for k in 0..50 {
assert_eq!(
c.check_convergence(1e-7, k),
ConvergenceStatus::Continue,
"acceptable_iter=0 must not stop at the acceptable level (iter {k})"
);
}
assert_eq!(c.check_convergence(1e-9, 51), ConvergenceStatus::Converged);
}
#[test]
fn streak_resets_when_above_acceptable() {
let mut c = OptErrorConvCheck {
acceptable_iter: 3,
..Default::default()
};
assert_eq!(c.check_convergence(1e-7, 0), ConvergenceStatus::Continue);
assert_eq!(c.check_convergence(1e-3, 1), ConvergenceStatus::Continue);
assert_eq!(c.check_convergence(1e-7, 2), ConvergenceStatus::Continue);
assert_eq!(c.check_convergence(1e-7, 3), ConvergenceStatus::Continue);
assert_eq!(
c.check_convergence(1e-7, 4),
ConvergenceStatus::ConvergedToAcceptable
);
}
#[test]
fn passes_acceptable_tols_gates_on_per_component_triplet() {
let c = OptErrorConvCheck {
acceptable_tol: 1e-6,
acceptable_dual_inf_tol: 1e-3,
acceptable_constr_viol_tol: 1e-3,
acceptable_compl_inf_tol: 1e-3,
..Default::default()
};
assert!(c.passes_acceptable_tols(1e-7, 1e-4, 1e-4, 1e-4, 0.0));
assert!(!c.passes_acceptable_tols(1e-7, 1.0, 1e-4, 1e-4, 0.0));
assert!(!c.passes_acceptable_tols(1e-5, 1e-4, 1e-4, 1e-4, 0.0));
}
#[test]
fn passes_acceptable_tols_honors_obj_change_tol() {
let mut c = OptErrorConvCheck {
acceptable_tol: 1e-6,
acceptable_dual_inf_tol: 1.0,
acceptable_constr_viol_tol: 1.0,
acceptable_compl_inf_tol: 1.0,
acceptable_obj_change_tol: 0.1,
..Default::default()
};
assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 10.0));
c.set_curr_acceptable_obj(10.0);
assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 10.0));
assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 11.0));
assert!(!c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 15.0));
}
use crate::conv_check::r#trait::ConvCheck;
#[test]
fn set_curr_acceptable_obj_records_for_cross_check() {
let mut c = OptErrorConvCheck::new();
assert!(c.last_acceptable_obj.is_none());
ConvCheck::set_curr_acceptable_obj(&mut c, 4.2);
assert_eq!(c.last_acceptable_obj, Some(4.2));
}
#[test]
fn a_non_finite_objective_disqualifies_the_veto() {
let c = OptErrorConvCheck {
tol: 1e-8,
dual_inf_tol: 1.0,
constr_viol_tol: 1e-4,
compl_inf_tol: 1e-4,
..Default::default()
};
assert!(c.passes_component_tols(1e-12, 1e-9, 0.0, 0.0, 0.0));
assert!(certificate_masked(
1e-8,
8.4e-1,
c.obj_scale_certificate_threshold,
c.acceptable_tol
));
for bad in [Number::NAN, Number::INFINITY, Number::NEG_INFINITY] {
assert!(!bad.is_finite(), "{bad} should disqualify the veto");
}
assert!((1.0_f64).is_finite());
}
#[test]
fn acceptable_streak_survives_a_masked_boundary_mid_streak() {
const ERR: Number = 1e-7;
const OBJ: Number = 1.0;
let mut c = OptErrorConvCheck {
acceptable_iter: 15,
..Default::default()
};
for i in 0..14 {
assert!(
!c.note_acceptable(true, false, ERR, OBJ),
"terminated early at {i}"
);
}
assert!(
!c.note_acceptable(true, true, ERR, OBJ),
"a masked iterate must not terminate the run"
);
assert!(
c.acceptable_veto_fired,
"the streak crossed `acceptable_iter` while masked, so a termination was \
refused here and must be recorded — otherwise the fallback has nothing to \
restore and the run returns a bare failure"
);
assert!(
c.masked_acceptable_veto_fired,
"a masked refusal must be attributed to the masked arm — it is what spends \
the masked veto's iteration budget"
);
let mut c = OptErrorConvCheck {
acceptable_iter: 15,
..Default::default()
};
for _ in 0..14 {
assert!(!c.note_acceptable(true, true, ERR, OBJ));
}
assert!(
c.note_acceptable(true, false, ERR, OBJ),
"the veto lifted with the streak already at 14; the 15th qualifying iterate \
must terminate exactly as it would without the mechanism"
);
let mut c = OptErrorConvCheck {
acceptable_iter: 3,
..Default::default()
};
assert!(!c.note_acceptable(true, false, ERR, OBJ));
assert!(!c.note_acceptable(false, true, ERR, OBJ));
assert_eq!(
c.acceptable_count, 0,
"a non-qualifying iterate resets the streak"
);
assert!(
c.acceptable_window.is_empty(),
"and clears the streak window"
);
assert!(!c.note_acceptable(true, false, ERR, OBJ));
assert!(!c.note_acceptable(true, false, ERR, OBJ));
assert!(
c.note_acceptable(true, false, ERR, OBJ),
"3 consecutive qualifying iterates terminate"
);
}
#[test]
fn a_wandering_streak_refuses_acceptable_termination() {
let kissing_tail = [3.35e-08, 8.18e-08, 1.08e-07, 4.15e-07];
let mut c = OptErrorConvCheck {
acceptable_iter: 4,
..Default::default()
};
for (i, &err) in kissing_tail.iter().enumerate() {
assert!(
!c.note_acceptable(true, false, err, 1.0000011),
"the streak must not terminate at iterate {i}: the error is still \
wandering across the acceptable band"
);
}
assert!(
c.acceptable_veto_fired,
"the refusal must be recorded, or the run has nothing to fall back to"
);
assert!(
!c.masked_acceptable_veto_fired,
"a progress refusal is not a masked one and must not spend the masked \
veto's budget"
);
assert_eq!(c.acceptable_count, 4);
for _ in 0..2 {
assert!(!c.note_acceptable(true, false, 4.15e-07, 1.0000011));
}
assert!(
c.note_acceptable(true, false, 4.15e-07, 1.0000011),
"a window of four identical iterates is settled; nothing is left to refuse"
);
}
#[test]
fn a_still_descending_objective_refuses_acceptable_termination() {
let mut c = OptErrorConvCheck {
acceptable_iter: 4,
..Default::default()
};
let objs = [8.6592e-03, 8.6588e-03, 8.6584e-03, 8.6580e-03];
for (i, &f) in objs.iter().enumerate() {
assert!(
!c.note_acceptable(true, false, 1.5e-07, f),
"the streak must not terminate at iterate {i}: the objective is still \
descending"
);
}
assert!(c.acceptable_veto_fired);
}
#[test]
fn zero_progress_kappa_restores_the_bare_count() {
let mut c = OptErrorConvCheck {
acceptable_iter: 4,
acceptable_progress_kappa: 0.0,
..Default::default()
};
let kissing_tail = [3.35e-08, 8.18e-08, 1.08e-07, 4.15e-07];
for (i, &err) in kissing_tail.iter().enumerate() {
let terminated = c.note_acceptable(true, false, err, 1.0000011);
assert_eq!(
terminated,
i == 3,
"with the progress test off, iterate {i} must behave exactly as upstream"
);
}
assert!(!c.acceptable_veto_fired);
}
#[test]
fn the_progress_refusal_budget_is_bounded() {
let mut c = OptErrorConvCheck {
acceptable_iter: 2,
..Default::default()
};
let mut terminated_at = None;
for k in 0..(ACCEPTABLE_PROGRESS_MAX_REFUSALS + 10) {
let err = if k % 2 == 0 { 1e-7 } else { 9e-7 };
if c.note_acceptable(true, false, err, 1.0) {
terminated_at = Some(k);
break;
}
}
assert_eq!(
c.acceptable_progress_refusals, ACCEPTABLE_PROGRESS_MAX_REFUSALS,
"the budget must be spent, not exceeded"
);
assert!(
terminated_at.is_some(),
"a never-settling solve must still terminate at the acceptable level once \
the budget is spent"
);
}
#[test]
fn the_flatness_window_slides_past_a_transient() {
let mut c = OptErrorConvCheck {
acceptable_iter: 3,
..Default::default()
};
assert!(!c.note_acceptable(true, false, 9e-7, 1.0));
assert!(!c.note_acceptable(true, false, 5e-7, 1.0));
assert!(!c.note_acceptable(true, false, 2e-7, 1.0));
assert!(c.acceptable_veto_fired);
assert!(!c.note_acceptable(true, false, 2e-7, 1.0));
assert!(
c.note_acceptable(true, false, 2e-7, 1.0),
"the window must slide, or an early transient blocks every later termination"
);
}
#[test]
fn a_single_iterate_streak_carries_no_progress_signal() {
let mut c = OptErrorConvCheck {
acceptable_iter: 1,
..Default::default()
};
assert!(c.note_acceptable(true, false, 4.15e-07, 1.0));
assert!(!c.acceptable_veto_fired);
}
#[test]
fn non_finite_samples_do_not_refuse() {
for bad in [Number::NAN, Number::INFINITY] {
let mut c = OptErrorConvCheck {
acceptable_iter: 2,
..Default::default()
};
assert!(!c.note_acceptable(true, false, bad, 1.0));
assert!(
c.note_acceptable(true, false, 1e-7, 1.0),
"a {bad} sample in the window must not be treated as a progress signal"
);
}
}
#[test]
fn certificate_masked_needs_both_an_extreme_scale_and_a_non_stationary_point() {
let (th, atol) = (1e-4, 1e-6);
assert!(certificate_masked(1e-8, 8.4e-1, th, atol));
assert!(!certificate_masked(4e-2, 8.4e-1, th, atol));
assert!(!certificate_masked(1.0, 1e3, th, atol));
assert!(!certificate_masked(1e-8, 1e-9, th, atol));
assert!(!certificate_masked(th, 1.0, th, atol));
assert!(!certificate_masked(1e-8, atol, th, atol));
assert!(!certificate_masked(1e-30, 1e30, 0.0, atol));
assert!(!certificate_masked(1e-30, 1e30, -1.0, atol));
}
#[test]
fn veto_blocks_both_strict_and_acceptable_termination() {
let c = OptErrorConvCheck {
tol: 1e-8,
acceptable_tol: 1e-6,
dual_inf_tol: 1.0,
constr_viol_tol: 1e-4,
compl_inf_tol: 1e-4,
..Default::default()
};
assert!(c.passes_component_tols(1e-9, 8.4e-1, 0.0, 0.0, 0.0));
assert!(certificate_masked(
1e-8,
8.4e-1,
c.obj_scale_certificate_threshold,
c.acceptable_tol
));
assert_eq!(c.obj_scale_certificate_threshold, 1e-4);
assert!(!c.veto_fired);
assert!(!ConvCheck::certificate_vetoed(&c));
}
#[test]
fn passes_component_tols_requires_all_under_threshold() {
let c = OptErrorConvCheck {
tol: 1e-8,
dual_inf_tol: 1.0,
constr_viol_tol: 1e-4,
compl_inf_tol: 1e-4,
..Default::default()
};
assert!(c.passes_component_tols(1e-9, 0.5, 1e-5, 1e-5, 0.0));
assert!(!c.passes_component_tols(1e-12, 2.0, 1e-5, 1e-5, 0.0));
assert!(!c.passes_component_tols(1e-12, 0.0, 0.0, 1e-2, 0.0));
assert!(!c.passes_component_tols(1e-12, 0.0, 1e-2, 0.0, 0.0));
}
#[test]
fn infeasible_stationary_requires_violation_and_flat_gradient() {
let c = OptErrorConvCheck {
constr_viol_tol: 1e-4,
infeas_viol_kappa: 1e2, infeas_stationarity_tol: 1e-8,
infeas_max_streak: 5,
..Default::default()
};
assert!(c.is_infeasible_stationary(1e-1, 0.0, 1e-9));
assert!(!c.is_infeasible_stationary(1e-1, 0.0, 1e-3));
assert!(!c.is_infeasible_stationary(1e-3, 0.0, 1e-9));
}
#[test]
fn tightening_constr_viol_tol_never_arms_the_absolute_arm_lower() {
let plateau_viol = 1.9430136821e-4; for &cvt in &[1e-4, 1e-5, 1.94e-6, 1e-7, 1e-9, 1e-12] {
let c = OptErrorConvCheck {
constr_viol_tol: cvt,
..Default::default()
};
let floor = c.absolute_viol_threshold();
assert_eq!(
floor, MIN_INFEAS_VIOL_FLOOR,
"constr_viol_tol={cvt} moved the absolute floor to {floor}"
);
assert!(
!c.is_infeasible_stationary(plateau_viol, 0.0, 1e-9),
"constr_viol_tol={cvt} armed the detector on the {plateau_viol} plateau"
);
}
}
#[test]
fn absolute_viol_floor_is_a_floor_not_a_cap() {
let strict = OptErrorConvCheck {
constr_viol_tol: 1e-9,
..Default::default()
};
assert_eq!(strict.absolute_viol_threshold(), 1e-2);
assert!(strict.is_infeasible_stationary(0.5, 0.0, 1e-9));
let wide = OptErrorConvCheck {
constr_viol_tol: 1e-4,
infeas_viol_kappa: 1e4, ..Default::default()
};
assert_eq!(wide.absolute_viol_threshold(), 1.0);
assert!(!wide.is_infeasible_stationary(0.5, 0.0, 1e-9));
assert!(wide.is_infeasible_stationary(2.0, 0.0, 1e-9));
let loose = OptErrorConvCheck {
constr_viol_tol: 1e-2,
..Default::default()
};
assert_eq!(loose.absolute_viol_threshold(), 1.0);
}
#[test]
fn constr_viol_tol_accessor_tracks_the_option() {
let mut c = OptErrorConvCheck {
tol: 1e-6,
constr_viol_tol: 1e-3,
..Default::default()
};
assert_eq!(c.constr_viol_tol_or_default(), 1e-3);
c.tol = 1e-10;
assert_eq!(c.constr_viol_tol_or_default(), 1e-3);
assert!(c.set_tolerance("constr_viol_tol", 1e-7));
assert_eq!(c.constr_viol_tol_or_default(), 1e-7);
}
#[test]
fn infeasible_stationary_disabled_by_nonpositive_knobs() {
let off_tol = OptErrorConvCheck {
infeas_stationarity_tol: 0.0,
infeas_max_streak: 5,
..Default::default()
};
assert!(!off_tol.is_infeasible_stationary(1e9, 0.0, 0.0));
let off_streak = OptErrorConvCheck {
infeas_stationarity_tol: 1e-8,
infeas_max_streak: 0,
..Default::default()
};
assert!(!off_streak.is_infeasible_stationary(1e9, 0.0, 0.0));
}
#[test]
fn infeasible_stationary_streak_fires_only_after_max_streak() {
let mut c = OptErrorConvCheck {
constr_viol_tol: 1e-4,
infeas_viol_kappa: 1e2, infeas_stationarity_tol: 1e-8,
infeas_max_streak: 3,
..Default::default()
};
assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9));
assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9));
assert!(c.note_infeasible_stationary(1e-1, 0.0, 1e-9));
}
#[test]
fn infeasible_stationary_streak_resets_on_feasibility_progress() {
let mut c = OptErrorConvCheck {
constr_viol_tol: 1e-4,
infeas_viol_kappa: 1e2,
infeas_stationarity_tol: 1e-8,
infeas_max_streak: 3,
..Default::default()
};
assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9));
assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9));
assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-3));
assert_eq!(c.infeas_streak, 0);
assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9));
assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9));
assert!(c.note_infeasible_stationary(1e-1, 0.0, 1e-9));
}
#[test]
fn infeasible_stationary_streak_never_fires_when_disabled() {
let mut c = OptErrorConvCheck {
infeas_stationarity_tol: 0.0,
infeas_max_streak: 5,
..Default::default()
};
for _ in 0..20 {
assert!(!c.note_infeasible_stationary(1e9, 0.0, 0.0));
}
assert_eq!(c.infeas_streak, 0);
}
#[test]
fn dual_inf_bound_forgives_a_relatively_stationary_residual_only() {
let c = OptErrorConvCheck::new();
assert_eq!(c.dual_inf_tol, 1.0);
assert_eq!(c.dual_inf_scale_kappa, 1.0);
let (orthrds2_dual_inf, orthrds2_scale) = (89.669_051_358_301_67, 1.6e12);
assert!(orthrds2_dual_inf > c.dual_inf_tol, "the reported refusal");
assert!(orthrds2_dual_inf <= c.dual_inf_bound(orthrds2_scale));
assert!(c.passes_component_tols(
5.537e-9,
orthrds2_dual_inf,
1.741e-8,
0.0,
orthrds2_scale
));
let runaway = 8.8e47;
assert!(runaway > c.dual_inf_bound(runaway));
assert!(!c.passes_component_tols(1e-12, runaway, 1.7e-10, 0.0, runaway));
assert_eq!(c.dual_inf_bound(1.0), c.dual_inf_tol);
assert_eq!(c.dual_inf_bound(0.0), c.dual_inf_tol);
assert_eq!(c.dual_inf_bound(1e-30), c.dual_inf_tol);
assert_eq!(c.dual_inf_bound(1e7), c.dual_inf_tol);
assert!(c.dual_inf_bound(1e10) > c.dual_inf_tol);
for bad in [Number::NAN, Number::INFINITY, Number::NEG_INFINITY] {
assert_eq!(c.dual_inf_bound(bad), c.dual_inf_tol, "scale {bad}");
}
}
#[test]
fn dual_inf_bound_tracks_tol_and_honours_the_opt_out() {
let mut c = OptErrorConvCheck::new();
assert_eq!(c.dual_inf_bound(1e12), 1e4);
c.tol = 1e-10;
assert_eq!(c.dual_inf_bound(1e12), 1e2);
c.tol = 1e-8;
c.dual_inf_scale_kappa = 10.0;
assert_eq!(c.dual_inf_bound(1e12), 1e5);
for off in [0.0, -1.0, Number::NAN] {
c.dual_inf_scale_kappa = off;
assert_eq!(c.dual_inf_bound(1e30), c.dual_inf_tol, "kappa {off}");
assert!(!c.passes_component_tols(1e-12, 89.7, 0.0, 0.0, 1.6e12));
}
}
#[test]
fn strict_overall_takes_the_noise_floored_aggregate() {
assert_eq!(
OptErrorConvCheck::strict_overall(1.49e-8, 9.09e-10),
9.09e-10
);
assert_eq!(OptErrorConvCheck::strict_overall(1e-9, 1e-9), 1e-9);
}
#[test]
fn strict_overall_passes_a_non_finite_error_through() {
assert!(OptErrorConvCheck::strict_overall(Number::NAN, 1e-12).is_nan());
assert_eq!(
OptErrorConvCheck::strict_overall(Number::INFINITY, 1e-12),
Number::INFINITY
);
}
#[test]
fn max_iter_exceeded() {
let mut c = OptErrorConvCheck {
max_iter: 5,
..Default::default()
};
assert_eq!(
c.check_convergence(1.0, 5),
ConvergenceStatus::MaxIterExceeded
);
}
}