use pounce_common::types::{NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF, Number};
use pounce_linsol::SparseSymLinearSolverInterface;
use pounce_qp::{
BoundStatus, ConsStatus, ParametricActiveSetSolver, QpOptions, QpSolver, QpStatus, WorkingSet,
};
use crate::sqp::iterates::SqpIterates;
use crate::sqp::line_search::l1_merit_line_search;
use crate::sqp::options::{SqpHessianSource, SqpOptions};
use crate::sqp::problem::SqpProblemSpec;
use crate::sqp::qp_assembly::SqpQpData;
use crate::sqp::result::{SqpResult, SqpStatus};
use crate::sqp::sqp_alg::{SqpAlgorithm, check_kkt};
use crate::sqp::warm_start::classify_working_set;
const IDENTIFIED_PRIMAL_TOL: Number = 1e-9;
const REGRESS_SLACK: Number = 1e-12;
const OBJ_REL_SLACK: Number = 1e-6;
#[derive(Debug, Clone)]
pub struct CrossoverOptions {
pub enabled: bool,
pub mult_tol: Number,
pub primal_tol: Number,
pub max_iter: u32,
}
impl Default for CrossoverOptions {
fn default() -> Self {
Self {
enabled: false,
mult_tol: 1e-8,
primal_tol: 1e-6,
max_iter: 30,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CrossoverPhase {
EqpStep,
ActiveSet,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CrossoverDecline {
NothingToIdentify,
QpFailed,
LineSearchFailed,
ActiveSetNotConverged,
Regressed,
}
#[derive(Debug, Clone)]
pub struct CrossoverReport {
pub phase: Option<CrossoverPhase>,
pub declined: Option<CrossoverDecline>,
pub n_iter: u32,
pub n_qp_solves: u32,
pub active_bounds: usize,
pub active_constraints: usize,
pub estimated_active: usize,
pub kkt_before: Number,
pub kkt_after: Number,
pub compl_after: Number,
}
impl CrossoverReport {
fn declined(reason: CrossoverDecline) -> Self {
Self {
phase: None,
declined: Some(reason),
n_iter: 0,
n_qp_solves: 0,
active_bounds: 0,
active_constraints: 0,
estimated_active: 0,
kkt_before: Number::NAN,
kkt_after: Number::NAN,
compl_after: Number::NAN,
}
}
pub fn accepted(&self) -> bool {
self.phase.is_some()
}
}
#[derive(Debug, Clone)]
pub struct CrossoverSeed {
pub x: Vec<Number>,
pub lambda_g: Vec<Number>,
pub lambda_x: Vec<Number>,
}
pub fn accepts(
before: (Number, Number, Number),
after: (Number, Number, Number),
sqp_opts: &SqpOptions,
) -> bool {
let (stat_b, viol_b, obj_b) = before;
let (stat_a, viol_a, obj_a) = after;
if !(stat_a.is_finite() && viol_a.is_finite() && obj_a.is_finite()) {
return false;
}
let stat_tol = sqp_opts.tol.min(sqp_opts.dual_inf_tol);
if stat_a > stat_b.max(stat_tol) + REGRESS_SLACK {
return false;
}
if viol_a > viol_b.max(sqp_opts.constr_viol_tol) + REGRESS_SLACK {
return false;
}
let obj_slack = OBJ_REL_SLACK * obj_b.abs().max(1.0);
obj_a <= obj_b + obj_slack
}
fn complementarity_at(
x: &[Number],
c_vals: &[Number],
lambda_x: &[Number],
lambda_g: &[Number],
xl: &[Number],
xu: &[Number],
bl_c: &[Number],
bu_c: &[Number],
) -> Number {
let mut worst = 0.0_f64;
let mut take = |slack: Number, mult: Number| {
worst = worst.max((slack.max(0.0) * mult).abs());
};
for i in 0..x.len() {
if xl[i] > NLP_LOWER_BOUND_INF {
take(x[i] - xl[i], lambda_x[i].max(0.0));
}
if xu[i] < NLP_UPPER_BOUND_INF {
take(xu[i] - x[i], (-lambda_x[i]).max(0.0));
}
}
for i in 0..c_vals.len() {
if bl_c[i] > NLP_LOWER_BOUND_INF {
take(c_vals[i] - bl_c[i], (-lambda_g[i]).max(0.0));
}
if bu_c[i] < NLP_UPPER_BOUND_INF {
take(bu_c[i] - c_vals[i], lambda_g[i].max(0.0));
}
}
worst
}
fn count_active(w: &WorkingSet) -> (usize, usize) {
let bounds = w
.bounds
.iter()
.filter(|b| !matches!(b, BoundStatus::Inactive))
.count();
let rows = w
.constraints
.iter()
.filter(|c| !matches!(c, ConsStatus::Inactive))
.count();
(bounds, rows)
}
pub fn run<N, B, S>(
nlp: &mut N,
seed: &CrossoverSeed,
opts: &CrossoverOptions,
sqp_opts: &SqpOptions,
qp_opts: &QpOptions,
mut make_backend: B,
mut make_sqp: S,
) -> (CrossoverReport, Option<SqpResult>)
where
N: SqpProblemSpec,
B: FnMut() -> Box<dyn SparseSymLinearSolverInterface>,
S: FnMut(SqpOptions) -> Option<SqpAlgorithm>,
{
let n = nlp.n();
let m = nlp.m();
let (xl, xu) = nlp.variable_bounds();
let (bl_c, bu_c) = nlp.constraint_bounds();
let any_bound = xl
.iter()
.any(|&v| v > NLP_LOWER_BOUND_INF)
.then_some(true)
.or_else(|| xu.iter().any(|&v| v < NLP_UPPER_BOUND_INF).then_some(true))
.unwrap_or(false);
if m == 0 && !any_bound {
return (
CrossoverReport::declined(CrossoverDecline::NothingToIdentify),
None,
);
}
let f_curr = nlp.eval_f(&seed.x);
let c_vals = nlp.eval_c(&seed.x);
let grad_f = nlp.eval_grad_f(&seed.x);
let jac_c = nlp.eval_jac_c(&seed.x);
let mut iter = SqpIterates {
x: seed.x.clone(),
lambda_g: seed.lambda_g.clone(),
lambda_x: seed.lambda_x.clone(),
working: None,
};
let kkt_before = check_kkt(
n, m, &iter, &grad_f, &c_vals, &bl_c, &bu_c, &xl, &xu, &jac_c,
);
let before = (kkt_before.stationarity, kkt_before.constr_viol, f_curr);
let m_eq = m_eq_count(&bl_c, &bu_c);
let working = classify_working_set(
&seed.lambda_x,
&seed.lambda_g,
m_eq,
&seed.x,
&xl,
&xu,
&c_vals,
&bl_c,
&bu_c,
opts.mult_tol,
opts.primal_tol,
);
let (est_bounds, est_rows) = count_active(&working);
let estimated_active = est_bounds + est_rows;
let mult_inf = seed
.lambda_g
.iter()
.chain(seed.lambda_x.iter())
.map(|v| v.abs())
.fold(0.0_f64, f64::max);
let nu0 = (mult_inf + sqp_opts.l1_penalty_safety)
.max(sqp_opts.l1_penalty)
.min(sqp_opts.l1_penalty_max);
let mut n_qp_solves = 0_u32;
let hessian_inertia = match sqp_opts.hessian {
SqpHessianSource::Exact => pounce_qp::HessianInertia::Indefinite,
_ => pounce_qp::HessianInertia::Psd,
};
let hess_lag = nlp.eval_hess_lag(&seed.x, &seed.lambda_g);
let qp_data = SqpQpData::build(
&seed.x,
&grad_f,
&c_vals,
&bl_c,
&bu_c,
&xl,
&xu,
jac_c.clone(),
hess_lag,
hessian_inertia,
);
let qp = qp_data.as_qp();
let mut qp_solver = ParametricActiveSetSolver::new(make_backend());
let eqp = qp_solver.solve_with_working_set(&qp, &working, qp_opts);
n_qp_solves += 1;
let mut step3_failure = CrossoverDecline::QpFailed;
if let Ok(sol) = eqp
&& sol.status == QpStatus::Optimal
{
let ls = l1_merit_line_search(
nlp,
&seed.x,
&sol.x,
&sol.lambda_g,
&grad_f,
f_curr,
&c_vals,
&bl_c,
&bu_c,
&xl,
&xu,
nu0,
sqp_opts,
None,
);
if ls.success {
let mut cand = SqpIterates {
x: ls.x_new.clone(),
lambda_g: seed.lambda_g.clone(),
lambda_x: seed.lambda_x.clone(),
working: Some(sol.working.clone()),
};
for (l, &lq) in cand.lambda_g.iter_mut().zip(sol.lambda_g.iter()) {
*l = (1.0 - ls.alpha) * *l + ls.alpha * lq;
}
for (l, &lq) in cand.lambda_x.iter_mut().zip(sol.lambda_x.iter()) {
*l = (1.0 - ls.alpha) * *l + ls.alpha * lq;
}
let grad_new = nlp.eval_grad_f(&cand.x);
let jac_new = nlp.eval_jac_c(&cand.x);
let kkt_after = check_kkt(
n, m, &cand, &grad_new, &ls.c_new, &bl_c, &bu_c, &xl, &xu, &jac_new,
);
let after = (kkt_after.stationarity, kkt_after.constr_viol, ls.f_new);
let stat_tol = sqp_opts.tol.min(sqp_opts.dual_inf_tol);
let within_tol = kkt_after.stationarity <= stat_tol
&& kkt_after.constr_viol <= sqp_opts.constr_viol_tol;
if within_tol && accepts(before, after, sqp_opts) {
let identified = identify_at(nlp, &cand.x, m_eq, &xl, &xu, &bl_c, &bu_c);
let (active_bounds, active_constraints) = count_active(&identified);
let compl_after = complementarity_at(
&cand.x,
&ls.c_new,
&cand.lambda_x,
&cand.lambda_g,
&xl,
&xu,
&bl_c,
&bu_c,
);
return (
CrossoverReport {
phase: Some(CrossoverPhase::EqpStep),
declined: None,
n_iter: 1,
n_qp_solves,
active_bounds,
active_constraints,
estimated_active,
kkt_before: kkt_before.stationarity.max(kkt_before.constr_viol),
kkt_after: kkt_after.stationarity.max(kkt_after.constr_viol),
compl_after,
},
Some(SqpResult {
x: cand.x,
lambda_g: cand.lambda_g,
lambda_x: cand.lambda_x,
obj: ls.f_new,
status: SqpStatus::Optimal,
n_iter: 1,
n_qp_solves,
n_qp_working_set_changes: sol.stats.n_working_set_changes,
final_stationarity: kkt_after.stationarity,
final_constr_viol: kkt_after.constr_viol,
working_set: Some(identified),
}),
);
}
step3_failure = CrossoverDecline::Regressed;
} else {
step3_failure = CrossoverDecline::LineSearchFailed;
}
}
if opts.max_iter == 0 {
return (CrossoverReport::declined(step3_failure), None);
}
let step4_opts = SqpOptions {
max_iter: opts.max_iter,
l1_penalty: nu0,
..sqp_opts.clone()
};
let Some(mut sqp) = make_sqp(step4_opts) else {
return (CrossoverReport::declined(step3_failure), None);
};
iter.working = Some(working);
let res = match sqp.optimize_with_warm_start(nlp, Some(iter)) {
Ok(r) => r,
Err(e) => {
tracing::debug!(target: "pounce::crossover", "crossover step 4 failed: {e:?}");
return (CrossoverReport::declined(step3_failure), None);
}
};
n_qp_solves += res.n_qp_solves;
if res.status != SqpStatus::Optimal {
return (
CrossoverReport::declined(CrossoverDecline::ActiveSetNotConverged),
None,
);
}
let after = (res.final_stationarity, res.final_constr_viol, res.obj);
if !accepts(before, after, sqp_opts) {
return (CrossoverReport::declined(CrossoverDecline::Regressed), None);
}
let identified = identify_at(nlp, &res.x, m_eq, &xl, &xu, &bl_c, &bu_c);
let (active_bounds, active_constraints) = count_active(&identified);
let compl_after = {
let c_final = nlp.eval_c(&res.x);
complementarity_at(
&res.x,
&c_final,
&res.lambda_x,
&res.lambda_g,
&xl,
&xu,
&bl_c,
&bu_c,
)
};
let mut res = res;
res.working_set = Some(identified);
let report = CrossoverReport {
phase: Some(CrossoverPhase::ActiveSet),
declined: None,
n_iter: res.n_iter,
n_qp_solves,
active_bounds,
active_constraints,
estimated_active,
kkt_before: kkt_before.stationarity.max(kkt_before.constr_viol),
kkt_after: res.final_stationarity.max(res.final_constr_viol),
compl_after,
};
(report, Some(res))
}
#[allow(clippy::too_many_arguments)]
fn identify_at<N: SqpProblemSpec>(
nlp: &mut N,
x: &[Number],
m_eq: usize,
xl: &[Number],
xu: &[Number],
bl_c: &[Number],
bu_c: &[Number],
) -> WorkingSet {
let c_vals = nlp.eval_c(x);
let tight = |v: Number, bound: Number| -> bool {
(v - bound).abs() <= IDENTIFIED_PRIMAL_TOL * bound.abs().max(1.0)
};
let mut bounds = Vec::with_capacity(xl.len());
for i in 0..xl.len() {
let lo_fin = xl[i] > NLP_LOWER_BOUND_INF;
let up_fin = xu[i] < NLP_UPPER_BOUND_INF;
let at_lo = lo_fin && tight(x[i], xl[i]);
let at_up = up_fin && tight(x[i], xu[i]);
bounds.push(if at_lo && at_up {
BoundStatus::Fixed
} else if at_lo {
BoundStatus::AtLower
} else if at_up {
BoundStatus::AtUpper
} else {
BoundStatus::Inactive
});
}
let mut constraints = Vec::with_capacity(bl_c.len());
for i in 0..bl_c.len() {
if i < m_eq {
constraints.push(ConsStatus::Equality);
continue;
}
let lo_fin = bl_c[i] > NLP_LOWER_BOUND_INF;
let up_fin = bu_c[i] < NLP_UPPER_BOUND_INF;
let g = c_vals.get(i).copied().unwrap_or(0.0);
let at_lo = lo_fin && tight(g, bl_c[i]);
let at_up = up_fin && tight(g, bu_c[i]);
constraints.push(if at_lo && at_up {
ConsStatus::Equality
} else if at_lo {
ConsStatus::AtLower
} else if at_up {
ConsStatus::AtUpper
} else {
ConsStatus::Inactive
});
}
WorkingSet {
bounds,
constraints,
}
}
fn m_eq_count(bl_c: &[Number], bu_c: &[Number]) -> usize {
bl_c.iter()
.zip(bu_c.iter())
.take_while(|(lo, hi)| lo == hi)
.count()
}
#[cfg(test)]
mod tests {
use super::*;
fn opts() -> SqpOptions {
SqpOptions {
tol: 1e-8,
dual_inf_tol: 1e-4,
constr_viol_tol: 1e-6,
..SqpOptions::default()
}
}
#[test]
fn m_eq_count_takes_the_leading_equality_block_only() {
let bl = [0.0, 0.0, -1.0, 2.0];
let bu = [0.0, 0.0, 1.0, 2.0];
assert_eq!(m_eq_count(&bl, &bu), 2);
}
#[test]
fn accepts_lets_a_residual_move_inside_its_own_tolerance() {
let o = opts();
assert!(accepts((1e-12, 1e-12, 1.0), (1e-10, 1e-10, 1.0), &o));
}
#[test]
fn accepts_refuses_a_residual_that_crosses_its_tolerance() {
let o = opts();
assert!(!accepts((1e-12, 1e-12, 1.0), (1e-3, 1e-12, 1.0), &o));
assert!(!accepts((1e-12, 1e-12, 1.0), (1e-12, 1e-4, 1.0), &o));
}
#[test]
fn accepts_refuses_an_objective_that_walked_away() {
let o = opts();
assert!(!accepts((1e-12, 1e-12, 1.0), (1e-12, 1e-12, 1.5), &o));
assert!(accepts((1e-12, 1e-12, 1.0), (1e-12, 1e-12, 0.5), &o));
}
#[test]
fn accepts_refuses_non_finite_residuals() {
let o = opts();
assert!(!accepts((1e-12, 1e-12, 1.0), (Number::NAN, 1e-12, 1.0), &o));
assert!(!accepts(
(1e-12, 1e-12, 1.0),
(1e-12, 1e-12, Number::INFINITY),
&o
));
}
#[test]
fn count_active_splits_bounds_and_rows() {
let w = WorkingSet {
bounds: vec![
BoundStatus::AtLower,
BoundStatus::Inactive,
BoundStatus::Fixed,
],
constraints: vec![
ConsStatus::Equality,
ConsStatus::Inactive,
ConsStatus::AtUpper,
],
};
assert_eq!(count_active(&w), (2, 2));
}
#[test]
fn declined_report_is_not_accepted() {
let r = CrossoverReport::declined(CrossoverDecline::NothingToIdentify);
assert!(!r.accepted());
assert_eq!(r.declined, Some(CrossoverDecline::NothingToIdentify));
}
}