use faer::Mat;
use pounce_common::types::{Index, Number};
use pounce_nlp::mpcc::ComplementarityPair;
use pounce_nlp::return_codes::ApplicationReturnStatus;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MpccStage {
Exact,
Continuation,
Penalty,
Branch,
}
impl MpccStage {
pub fn label(self) -> &'static str {
match self {
Self::Exact => "exact product",
Self::Continuation => "Scholtes continuation",
Self::Penalty => "l1 penalty",
Self::Branch => "branch NLP",
}
}
}
pub const STAGES: [MpccStage; 3] = [
MpccStage::Continuation,
MpccStage::Exact,
MpccStage::Penalty,
];
pub const SCHOLTES_SCHEDULE: [Number; 9] = [1e0, 1e-1, 1e-2, 1e-3, 1e-4, 1e-5, 1e-6, 1e-7, 1e-8];
pub fn warm_start_assignments(final_mu: Number) -> Vec<String> {
let mu = if final_mu.is_finite() && final_mu > 0.0 {
final_mu.clamp(1e-9, 1e-1)
} else {
1e-6
};
let p = 1e-9;
vec![
"warm_start_init_point yes\n".to_string(),
format!("mu_init {mu:e}\n"),
format!("warm_start_bound_push {p:e}\n"),
format!("warm_start_bound_frac {p:e}\n"),
format!("warm_start_slack_bound_push {p:e}\n"),
format!("warm_start_slack_bound_frac {p:e}\n"),
format!("warm_start_mult_bound_push {p:e}\n"),
]
}
pub const EXACT_STAGE_TAU: Number = 1e-6;
pub const EXACT_BUDGET_FACTOR: usize = 3;
pub const EXACT_BUDGET_FLOOR: usize = 200;
pub const FINISH_BUDGET_FLOOR: usize = 500;
pub fn exact_stage_budget(answered: bool, continuation_iterations: usize) -> Option<usize> {
answered.then(|| {
EXACT_BUDGET_FACTOR
.saturating_mul(continuation_iterations)
.max(EXACT_BUDGET_FLOOR)
})
}
pub fn finish_budget(schedule_iterations: usize) -> usize {
schedule_iterations.max(FINISH_BUDGET_FLOOR)
}
pub const SAME_ANSWER_REL_TOL: Number = 1e-4;
pub const CORNER_SLACK_FACTOR: Number = 10.0;
pub fn relaxation_slack(products: &[Number], product_lambda: &[Number]) -> Number {
products
.iter()
.zip(product_lambda)
.map(|(&p, &l)| {
if p.is_finite() && l.is_finite() {
l.abs() * p.max(0.0)
} else {
Number::INFINITY
}
})
.sum()
}
#[allow(clippy::too_many_arguments)]
pub fn complementarity_gap(
x: &[Number],
z_l: &[Number],
z_u: &[Number],
x_l: &[Number],
x_u: &[Number],
g: &[Number],
lambda: &[Number],
g_l: &[Number],
g_u: &[Number],
) -> Number {
let n = x.len();
let m = g.len();
if [z_l.len(), z_u.len(), x_l.len(), x_u.len()] != [n; 4]
|| [lambda.len(), g_l.len(), g_u.len()] != [m; 3]
{
return Number::INFINITY;
}
let term = |mult: Number, v: Number, lo: Number, hi: Number| -> Number {
if !mult.is_finite() || !v.is_finite() {
return Number::INFINITY;
}
let d = [lo, hi]
.into_iter()
.filter(|b| b.is_finite())
.map(|b| (v - b).abs())
.fold(Number::INFINITY, Number::min);
if d.is_finite() { mult.abs() * d } else { 0.0 }
};
let bounds: Number = (0..n)
.map(|i| {
let lo = term(z_l[i], x[i], x_l[i], Number::INFINITY);
let hi = term(z_u[i], x[i], Number::NEG_INFINITY, x_u[i]);
lo + hi
})
.sum();
let rows: Number = (0..m).map(|j| term(lambda[j], g[j], g_l[j], g_u[j])).sum();
bounds + rows
}
pub fn same_answer_allowance(f_a: Number, f_b: Number, slack: Number, gap: Number) -> Number {
SAME_ANSWER_REL_TOL * f_a.abs().max(f_b.abs())
+ CORNER_SLACK_FACTOR * slack.max(0.0)
+ gap.max(0.0)
}
pub fn finish_drifted(f_finish: Number, f_relaxed: Number, relaxed_slack: Number) -> bool {
if !f_relaxed.is_finite() || !relaxed_slack.is_finite() {
return false;
}
!f_finish.is_finite()
|| f_finish > f_relaxed + same_answer_allowance(f_relaxed, f_relaxed, relaxed_slack, 0.0)
}
pub fn answers_agree(f_a: Number, f_b: Number, slack: Number, gap: Number) -> bool {
f_a.is_finite()
&& f_b.is_finite()
&& slack.is_finite()
&& gap.is_finite()
&& (f_a - f_b).abs() <= same_answer_allowance(f_a, f_b, slack, gap)
}
pub const RUNG_RETRY_FLOOR: usize = 200;
pub fn budgeted_max_iter(budget: usize, caller_max_iter: Index) -> Index {
let b = Index::try_from(budget).unwrap_or(Index::MAX);
b.min(caller_max_iter.max(0))
}
pub const TOUCHED_OPTIONS: [&str; 13] = [
"max_iter",
"mu_strategy_fallback",
"warm_start_init_point",
"mu_init",
"warm_start_bound_push",
"warm_start_bound_frac",
"warm_start_slack_bound_push",
"warm_start_slack_bound_frac",
"warm_start_mult_bound_push",
"l1_exact_penalty_barrier",
"bound_relax_factor",
"honor_original_bounds",
"perturb_slack_max_rows",
];
pub const SAFE_DEFAULTS: [(&str, &str); 3] = [
("bound_relax_factor", "0"),
("honor_original_bounds", "yes"),
("perturb_slack_max_rows", "0"),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reported {
Answer(MpccStage),
WeakFallback(MpccStage),
FeasibleUncertified(MpccStage),
Verdict,
}
pub fn describe_outcome(
reported: Reported,
n_solves: usize,
total_iterations: usize,
max_abs_product: Number,
certificate: Option<&Certificate>,
) -> String {
let solves = format!(
"{n_solves} lowered solve{}, {total_iterations} iterations in all",
if n_solves == 1 { "" } else { "s" }
);
let head = match reported {
Reported::Answer(stage) => format!(
"pounce: complementarity: answered by the {} formulation ({solves}, violation = \
{max_abs_product:.1e})",
stage.label(),
),
Reported::WeakFallback(stage) => format!(
"pounce: complementarity: no formulation produced a strongly stationary answer \
({solves}); reporting the {} formulation's weakly stationary point (violation = \
{max_abs_product:.1e})",
stage.label(),
),
Reported::FeasibleUncertified(stage) => format!(
"pounce: complementarity: no formulation produced a stationary answer ({solves}); \
reporting the {} formulation's feasible point (violation = {max_abs_product:.1e})",
stage.label(),
),
Reported::Verdict => format!(
"pounce: complementarity: no formulation produced an answer ({solves}); reporting \
the exact-product solve's verdict"
),
};
let tail = match certificate {
None => "; stationarity not evaluated".to_string(),
Some(c) => {
let class = match c.class {
StationarityClass::S => "S (strongly stationary)",
StationarityClass::W => {
"W only (a biactive pair has a wrong-sign multiplier: a branch may still descend)"
}
StationarityClass::None if !c.feasible => {
return format!(
"{head}; {} pair{}, {} biactive; stationarity: none (the point violates a \
row or bound by {:.1e}, beyond constr_viol_tol).",
c.n_pairs,
if c.n_pairs == 1 { "" } else { "s" },
c.n_biactive,
c.primal_violation
);
}
StationarityClass::None => "none (not a stationary point of the model)",
StationarityClass::NotSearched => {
"not determined (the model or its multiplier set is too large to certify)"
}
};
format!(
"; {} pair{}, {} biactive; stationarity: {class}.",
c.n_pairs,
if c.n_pairs == 1 { "" } else { "s" },
c.n_biactive
)
}
};
head + &tail
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Strategy {
Both,
Exact,
Continuation,
}
impl Strategy {
pub fn parse(s: &str) -> Option<Strategy> {
match s {
"both" => Some(Strategy::Both),
"exact" => Some(Strategy::Exact),
"continuation" => Some(Strategy::Continuation),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Strategy::Both => "both",
Strategy::Exact => "exact",
Strategy::Continuation => "continuation",
}
}
pub fn runs(self, stage: MpccStage) -> bool {
!matches!(
(self, stage),
(Strategy::Exact, MpccStage::Continuation) | (Strategy::Continuation, MpccStage::Exact)
)
}
}
pub fn converged(status: ApplicationReturnStatus) -> bool {
matches!(
status,
ApplicationReturnStatus::SolveSucceeded
| ApplicationReturnStatus::SolvedToAcceptableLevel
| ApplicationReturnStatus::FeasiblePointFound
)
}
pub fn compared(stage: MpccStage) -> bool {
matches!(stage, MpccStage::Continuation | MpccStage::Exact)
}
pub fn answers(class: StationarityClass) -> bool {
matches!(class, StationarityClass::S | StationarityClass::NotSearched)
}
pub fn kept_if_nothing_better(class: StationarityClass) -> bool {
class == StationarityClass::W
}
pub fn accepts(
status: ApplicationReturnStatus,
max_abs_product: Number,
constr_viol_tol: Number,
) -> bool {
converged(status) && max_abs_product.is_finite() && max_abs_product <= constr_viol_tol
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StationarityClass {
S,
W,
None,
NotSearched,
}
#[derive(Debug, Clone)]
pub struct Certificate {
pub class: StationarityClass,
pub n_pairs: usize,
pub n_biactive: usize,
pub n_neither: usize,
pub multipliers_unique: bool,
pub residual: Number,
pub resid_tol: Number,
pub tight: bool,
pub primal_violation: Number,
pub feasible: bool,
pub worst_biactive_sign: Number,
}
pub struct CertificateInput<'a> {
pub x: &'a [Number],
pub grad_f: &'a [Number],
pub g: &'a [Number],
pub jac: &'a [(usize, usize, Number)],
pub x_l: &'a [Number],
pub x_u: &'a [Number],
pub g_l: &'a [Number],
pub g_u: &'a [Number],
pub pairs: &'a [ComplementarityPair],
pub solver_tol: Number,
pub feas_tol: Number,
}
pub const MAX_SEARCHED_COLUMNS: usize = 600;
pub const MAX_DENSE_ENTRIES: usize = 4_000_000;
pub const DENSE_ENTRIES: usize = 250_000;
const INF: Number = 1e19;
#[derive(Clone, Copy, PartialEq)]
enum Sign {
Free,
NonNeg,
Biactive,
}
pub fn certify(inp: &CertificateInput<'_>) -> Certificate {
certify_routed(inp, None)
}
struct PairActivity {
mixed: Vec<bool>,
g_side: Vec<(Number, Number)>,
h_side: Vec<(Number, Number)>,
hv: Vec<Number>,
g_act: Vec<bool>,
h_act: Vec<bool>,
}
fn pair_activity(inp: &CertificateInput<'_>, corner: Number) -> PairActivity {
let side = |lo: Number, hi: Number| -> (Number, Number) {
if lo > -INF { (1.0, lo) } else { (-1.0, hi) }
};
let mixed: Vec<bool> = inp
.pairs
.iter()
.map(|p| {
pounce_nlp::mpcc::is_mixed(
inp.x_l[p.var],
inp.x_u[p.var],
inp.g_l[p.row],
inp.g_u[p.row],
)
})
.collect();
let g_side: Vec<(Number, Number)> = inp
.pairs
.iter()
.zip(&mixed)
.map(|(p, &mx)| {
let (lo, hi) = (inp.x_l[p.var], inp.x_u[p.var]);
if mx {
if inp.x[p.var] - lo <= hi - inp.x[p.var] {
(1.0, lo)
} else {
(-1.0, hi)
}
} else if lo > -INF && hi < INF {
if inp.g_l[p.row] > -INF {
(1.0, lo)
} else {
(-1.0, hi)
}
} else {
side(lo, hi)
}
})
.collect();
let h_side: Vec<(Number, Number)> = inp
.pairs
.iter()
.zip(&mixed)
.zip(&g_side)
.map(|((p, &mx), &(sg, _))| {
if mx {
(sg, 0.0)
} else {
side(inp.g_l[p.row], inp.g_u[p.row])
}
})
.collect();
let gv: Vec<Number> = inp
.pairs
.iter()
.zip(&g_side)
.map(|(p, &(s, b))| s * (inp.x[p.var] - b))
.collect();
let hv: Vec<Number> = inp
.pairs
.iter()
.zip(&h_side)
.map(|(p, &(s, c))| s * (inp.g[p.row] - c))
.collect();
let gs = gv.iter().fold(1.0_f64, |a, v| a.max(v.abs()));
let hs = hv.iter().fold(1.0_f64, |a, v| a.max(v.abs()));
let g_act: Vec<bool> = gv.iter().map(|v| v.abs() <= corner * gs).collect();
let h_act: Vec<bool> = hv.iter().map(|v| v.abs() <= corner * hs).collect();
PairActivity {
mixed,
g_side,
h_side,
hv,
g_act,
h_act,
}
}
fn certify_routed(inp: &CertificateInput<'_>, force_sparse: Option<bool>) -> Certificate {
let n = inp.x.len();
let m = inp.g.len();
let corner = inp.solver_tol.max(0.0).sqrt();
let row_tol = corner;
let PairActivity {
mixed,
g_side,
h_side,
hv,
g_act,
h_act,
} = pair_activity(inp, corner);
let n_biactive = g_act.iter().zip(&h_act).filter(|(a, b)| **a && **b).count();
let n_neither = g_act
.iter()
.zip(&h_act)
.filter(|(a, b)| !**a && !**b)
.count();
let viol = |v: Number, lo: Number, hi: Number| {
let below = if lo > -INF { lo - v } else { 0.0 };
let above = if hi < INF { v - hi } else { 0.0 };
below.max(above).max(0.0)
};
let mixed_sign = (0..inp.pairs.len())
.filter(|&k| mixed[k] && g_act[k])
.map(|k| (-hv[k]).max(0.0));
let primal_violation = (0..m)
.map(|i| viol(inp.g[i], inp.g_l[i], inp.g_u[i]))
.chain((0..n).map(|j| viol(inp.x[j], inp.x_l[j], inp.x_u[j])))
.chain(mixed_sign)
.fold(0.0, Number::max);
let feasible = primal_violation <= inp.feas_tol;
let mut pair_row = vec![None; m];
let mut pair_var = vec![None; n];
for (k, p) in inp.pairs.iter().enumerate() {
pair_row[p.row] = Some(k);
pair_var[p.var] = Some(k);
}
let mut rows_of: Vec<Vec<(usize, Number)>> = vec![Vec::new(); m];
for &(r, c, v) in inp.jac {
rows_of[r].push((c, v));
}
let mut cols: Vec<(Vec<(usize, Number)>, Sign)> = Vec::new();
for i in 0..m {
if let Some(k) = pair_row[i] {
let sh = h_side[k].0;
let grad_h: Vec<(usize, Number)> =
rows_of[i].iter().map(|&(c, v)| (c, sh * v)).collect();
match (g_act[k], h_act[k]) {
(_, false) => {}
(true, true) => cols.push((grad_h, Sign::Biactive)),
(false, true) => cols.push((grad_h, Sign::Free)),
}
continue;
}
let (lo, hi) = (inp.g_l[i], inp.g_u[i]);
let at_lo = lo > -INF && inp.g[i] - lo <= row_tol;
let at_hi = hi < INF && hi - inp.g[i] <= row_tol;
let neg: Vec<(usize, Number)> = rows_of[i].iter().map(|&(c, v)| (c, -v)).collect();
match (at_lo, at_hi) {
(true, true) => cols.push((rows_of[i].clone(), Sign::Free)),
(true, false) => cols.push((rows_of[i].clone(), Sign::NonNeg)),
(false, true) => cols.push((neg, Sign::NonNeg)),
(false, false) => {}
}
}
for (j, pv) in pair_var.iter().enumerate() {
if let Some(k) = *pv {
let sg = g_side[k].0;
match (g_act[k], h_act[k]) {
(false, _) => {}
(true, true) => cols.push((vec![(j, sg)], Sign::Biactive)),
(true, false) => cols.push((vec![(j, sg)], Sign::Free)),
}
if !mixed[k] {
let (lo, hi) = (inp.x_l[j], inp.x_u[j]);
let other_active = if sg > 0.0 {
hi < INF && hi - inp.x[j] <= row_tol
} else {
lo > -INF && inp.x[j] - lo <= row_tol
};
if other_active {
cols.push((vec![(j, -sg)], Sign::NonNeg));
}
}
continue;
}
let (lo, hi) = (inp.x_l[j], inp.x_u[j]);
let at_lo = lo > -INF && inp.x[j] - lo <= row_tol;
let at_hi = hi < INF && hi - inp.x[j] <= row_tol;
match (at_lo, at_hi) {
(true, true) => cols.push((vec![(j, 1.0)], Sign::Free)),
(true, false) => cols.push((vec![(j, 1.0)], Sign::NonNeg)),
(false, true) => cols.push((vec![(j, -1.0)], Sign::NonNeg)),
(false, false) => {}
}
}
let b = inp.grad_f.to_vec();
let k = cols.len();
let col_max = cols
.iter()
.flat_map(|(ents, _)| ents)
.fold(0.0_f64, |m, &(_, v)| m.max(v.abs()));
let dual_scale = 1.0_f64
.max(b.iter().fold(0.0_f64, |m, v| m.max(v.abs())))
.max(col_max);
let resid_tol = inp.solver_tol.max(1e-6) * dual_scale;
let sign_tol = corner * dual_scale;
let accept_tol = resid_tol + sign_tol;
let signs: Vec<Sign> = cols.iter().map(|(_, s)| *s).collect();
let mut cert = Certificate {
class: StationarityClass::None,
n_pairs: inp.pairs.len(),
n_biactive,
n_neither,
multipliers_unique: true,
residual: Number::INFINITY,
resid_tol: accept_tol,
tight: false,
primal_violation,
feasible,
worst_biactive_sign: 0.0,
};
if n_neither > 0 || !feasible {
return cert;
}
let classify_unique = |cert: &mut Certificate, theta: &[Number]| {
let ordinary_ok = signs
.iter()
.zip(theta)
.all(|(s, t)| *s != Sign::NonNeg || *t >= -sign_tol);
if !ordinary_ok {
return;
}
let worst = signs
.iter()
.zip(theta)
.filter(|(s, _)| **s == Sign::Biactive)
.fold(0.0_f64, |w, (_, t)| w.min(*t));
cert.worst_biactive_sign = worst;
cert.class = if worst >= -sign_tol {
StationarityClass::S
} else {
StationarityClass::W
};
};
let sparse = force_sparse.unwrap_or(n.saturating_mul(k) > DENSE_ENTRIES);
if sparse {
let entries: Vec<Vec<(usize, Number)>> = cols.iter().map(|(e, _)| e.clone()).collect();
match sparse_least_squares(n, &entries, &b) {
LeastSquares::Solved(theta) => {
let mut r = b.clone();
for (ents, t) in entries.iter().zip(&theta) {
for &(row, v) in ents {
r[row] -= v * t;
}
}
cert.residual = r.iter().fold(0.0, |m: Number, v| m.max(v.abs()));
cert.tight = cert.residual <= resid_tol;
if cert.residual <= accept_tol {
classify_unique(&mut cert, &theta);
}
return cert;
}
LeastSquares::RankDeficient if n.saturating_mul(k) <= MAX_DENSE_ENTRIES => {
}
LeastSquares::RankDeficient | LeastSquares::Failed => {
cert.multipliers_unique = false;
cert.residual = Number::NAN;
cert.class = StationarityClass::NotSearched;
cert.tight = true; return cert;
}
}
}
let mut a = Mat::<Number>::from_fn(n, k, |_, _| 0.0);
for (c, (ents, _)) in cols.iter().enumerate() {
for &(r, v) in ents {
a[(r, c)] += v;
}
}
let (theta, rank) = lstsq(&a, &b);
let r_free = resid_inf(&a, &theta, &b);
cert.residual = r_free;
cert.tight = r_free <= resid_tol;
if r_free > accept_tol {
return cert; }
if rank == k {
classify_unique(&mut cert, &theta);
return cert;
}
cert.multipliers_unique = false;
let constrained = signs.iter().filter(|s| **s != Sign::Free).count();
if constrained > MAX_SEARCHED_COLUMNS {
cert.class = StationarityClass::NotSearched;
cert.tight = true; return cert;
}
for (class, biactive_signed) in [(StationarityClass::S, true), (StationarityClass::W, false)] {
let nonneg: Vec<bool> = signs
.iter()
.map(|s| *s == Sign::NonNeg || (biactive_signed && *s == Sign::Biactive))
.collect();
let r = signed_lstsq_residual(&a, &b, &nonneg);
if r <= accept_tol {
cert.class = class;
cert.residual = r;
cert.tight = r_free <= resid_tol;
return cert;
}
}
cert
}
#[derive(Debug, Clone, PartialEq)]
enum LeastSquares {
Solved(Vec<Number>),
RankDeficient,
Failed,
}
fn sparse_least_squares(n: usize, cols: &[Vec<(usize, Number)>], b: &[Number]) -> LeastSquares {
use pounce_linsol::{ESymSolverStatus, SparseSymLinearSolverInterface};
let k = cols.len();
if b.len() != n || cols.iter().flatten().any(|&(r, _)| r >= n) {
return LeastSquares::Failed;
}
if k == 0 {
return LeastSquares::Solved(Vec::new());
}
if k > n {
return LeastSquares::RankDeficient;
}
let a_max = cols
.iter()
.flatten()
.fold(0.0_f64, |m, &(_, v)| m.max(v.abs()));
let s = a_max.max(1.0);
let mut trip: Vec<(Index, Index, Number)> = (1..=n as Index).map(|i| (i, i, s)).collect();
for (j, c) in cols.iter().enumerate() {
let mut c = c.clone();
c.sort_by_key(|&(r, _)| r);
for (r, v) in c {
let (row, col) = ((n + j + 1) as Index, (r + 1) as Index);
match trip.last_mut() {
Some(last) if last.0 == row && last.1 == col => last.2 += v,
_ => trip.push((row, col, v)),
}
}
}
let irn: Vec<Index> = trip.iter().map(|t| t.0).collect();
let jcn: Vec<Index> = trip.iter().map(|t| t.1).collect();
let mut solver = pounce_feral::FeralSolverInterface::new();
let dim = (n + k) as Index;
if solver.initialize_structure(dim, irn.len() as Index, &irn, &jcn) != ESymSolverStatus::Success
{
return LeastSquares::Failed;
}
for (slot, t) in solver.values_array_mut().iter_mut().zip(&trip) {
*slot = t.2;
}
let mut rhs0 = vec![0.0; n + k];
rhs0[..n].copy_from_slice(b);
let mut z = rhs0.clone();
match solver.multi_solve(true, &irn, &jcn, 1, &mut z, true, k as Index) {
ESymSolverStatus::Success => {}
ESymSolverStatus::Singular | ESymSolverStatus::WrongInertia => {
return LeastSquares::RankDeficient;
}
_ => return LeastSquares::Failed,
}
for _ in 0..REFINEMENT_STEPS {
let mut corr = rhs0.clone();
for &(r, c, v) in &trip {
let (r, c) = ((r - 1) as usize, (c - 1) as usize);
corr[r] -= v * z[c];
if r != c {
corr[c] -= v * z[r];
}
}
if solver.multi_solve(false, &irn, &jcn, 1, &mut corr, false, 0)
!= ESymSolverStatus::Success
{
return LeastSquares::Failed;
}
z.iter_mut().zip(&corr).for_each(|(zi, ci)| *zi += ci);
}
if z.iter().all(|v| v.is_finite()) {
LeastSquares::Solved(z[n..].to_vec())
} else {
LeastSquares::Failed
}
}
const REFINEMENT_STEPS: usize = 2;
fn lstsq(a: &Mat<Number>, b: &[Number]) -> (Vec<Number>, usize) {
let (n, k) = (a.nrows(), a.ncols());
if k == 0 {
return (Vec::new(), 0);
}
let Ok(svd) = a.thin_svd() else {
return (vec![0.0; k], 0);
};
let s: Vec<Number> = svd.S().column_vector().iter().copied().collect();
let smax = s.first().copied().unwrap_or(0.0);
let tol = smax * (n.max(k) as Number) * Number::EPSILON;
let u = svd.U();
let v = svd.V();
let mut theta = vec![0.0; k];
let mut rank = 0;
for (i, &si) in s.iter().enumerate() {
if si <= tol {
continue;
}
rank += 1;
let coef = (0..n).map(|r| u[(r, i)] * b[r]).sum::<Number>() / si;
for (c, t) in theta.iter_mut().enumerate() {
*t += coef * v[(c, i)];
}
}
(theta, rank)
}
fn resid_inf(a: &Mat<Number>, theta: &[Number], b: &[Number]) -> Number {
(0..a.nrows())
.map(|r| ((0..a.ncols()).map(|c| a[(r, c)] * theta[c]).sum::<Number>() - b[r]).abs())
.fold(0.0, Number::max)
}
fn signed_lstsq_residual(a: &Mat<Number>, b: &[Number], nonneg: &[bool]) -> Number {
let n = a.nrows();
let free: Vec<usize> = (0..a.ncols()).filter(|&c| !nonneg[c]).collect();
let cons: Vec<usize> = (0..a.ncols()).filter(|&c| nonneg[c]).collect();
let basis: Vec<Vec<Number>> = if free.is_empty() {
Vec::new()
} else {
let af = Mat::<Number>::from_fn(n, free.len(), |r, c| a[(r, free[c])]);
match af.thin_svd() {
Ok(svd) => {
let s: Vec<Number> = svd.S().column_vector().iter().copied().collect();
let smax = s.first().copied().unwrap_or(0.0);
let tol = smax * (n.max(free.len()) as Number) * Number::EPSILON;
let u = svd.U();
(0..s.len())
.filter(|&i| s[i] > tol)
.map(|i| (0..n).map(|r| u[(r, i)]).collect())
.collect()
}
Err(_) => return Number::INFINITY,
}
};
let project = |v: &mut Vec<Number>| {
for q in &basis {
let d: Number = q.iter().zip(v.iter()).map(|(x, y)| x * y).sum();
for (vi, qi) in v.iter_mut().zip(q) {
*vi -= d * qi;
}
}
};
let mut pb = b.to_vec();
project(&mut pb);
let pa: Vec<Vec<Number>> = cons
.iter()
.map(|&c| {
let mut v: Vec<Number> = (0..n).map(|r| a[(r, c)]).collect();
project(&mut v);
v
})
.collect();
let x = nnls(&pa, &pb, n);
(0..n)
.map(|r| {
(pb[r]
- pa.iter()
.zip(&x)
.map(|(col, xi)| col[r] * xi)
.sum::<Number>())
.abs()
})
.fold(0.0, Number::max)
}
fn nnls(cols: &[Vec<Number>], b: &[Number], n: usize) -> Vec<Number> {
let k = cols.len();
let mut x = vec![0.0; k];
if k == 0 {
return x;
}
let mut passive = vec![false; k];
let resid = |x: &[Number]| -> Vec<Number> {
(0..n)
.map(|r| b[r] - cols.iter().zip(x).map(|(c, xi)| c[r] * xi).sum::<Number>())
.collect()
};
let grad = |res: &[Number]| -> Vec<Number> {
cols.iter()
.map(|c| c.iter().zip(res).map(|(a, r)| a * r).sum())
.collect()
};
let scale = cols
.iter()
.flatten()
.fold(0.0_f64, |m, v| m.max(v.abs()))
.max(1.0)
* b.iter().fold(0.0_f64, |m, v| m.max(v.abs())).max(1.0);
let wtol = 1e-12 * scale * (n.max(k) as Number);
let mut blocked = vec![false; k];
for _outer in 0..3 * k + 10 {
let w = grad(&resid(&x));
let Some((t, wt)) = (0..k)
.filter(|&j| !passive[j] && !blocked[j])
.map(|j| (j, w[j]))
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
else {
break;
};
if wt <= wtol {
break;
}
passive[t] = true;
for _inner in 0..3 * k + 10 {
let p: Vec<usize> = (0..k).filter(|&j| passive[j]).collect();
let ap = Mat::<Number>::from_fn(n, p.len(), |r, c| cols[p[c]][r]);
let (z, _) = lstsq(&ap, b);
if z.iter().all(|&v| v > 0.0) {
for (i, &j) in p.iter().enumerate() {
x[j] = z[i];
}
break;
}
let mut alpha = 1.0_f64;
for (i, &j) in p.iter().enumerate() {
if z[i] <= 0.0 {
let d = x[j] - z[i];
if d > 0.0 {
alpha = alpha.min(x[j] / d);
}
}
}
for (i, &j) in p.iter().enumerate() {
x[j] += alpha * (z[i] - x[j]);
if x[j] <= 1e-15 * scale {
x[j] = 0.0;
passive[j] = false;
}
}
}
if passive[t] {
blocked.fill(false);
} else {
blocked[t] = true;
}
}
x
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HeldSide {
G,
H,
}
#[derive(Debug, Clone, PartialEq)]
pub enum BVerdict {
B,
NotB {
held: Vec<HeldSide>,
decrease: Number,
},
NotChecked(&'static str),
}
#[derive(Debug, Clone, PartialEq)]
pub struct BCheck {
pub verdict: BVerdict,
pub n_biactive: usize,
pub branches_solved: usize,
pub enumerated: bool,
}
pub const MAX_ENUMERATED_BIACTIVE: usize = 10;
pub fn check_b_stationarity(inp: &CertificateInput<'_>) -> BCheck {
let corner = inp.solver_tol.max(0.0).sqrt();
let act = pair_activity(inp, corner);
let np = inp.pairs.len();
let biactive: Vec<usize> = (0..np).filter(|&k| act.g_act[k] && act.h_act[k]).collect();
let mut out = BCheck {
verdict: BVerdict::NotChecked(""),
n_biactive: biactive.len(),
branches_solved: 0,
enumerated: false,
};
if (0..np).any(|k| !act.g_act[k] && !act.h_act[k]) {
out.verdict = BVerdict::NotChecked("a pair is not complementary at the point");
return out;
}
let scale = inp.grad_f.iter().fold(1.0_f64, |a, v| a.max(v.abs()));
let band = corner * scale;
let held_of = |choice: &dyn Fn(usize) -> Option<HeldSide>| -> Vec<Option<HeldSide>> {
(0..np)
.map(|k| match (act.g_act[k], act.h_act[k]) {
(true, false) => Some(HeldSide::G),
(false, true) => Some(HeldSide::H),
_ => choice(k),
})
.collect()
};
let full = |h: &[Option<HeldSide>]| -> Vec<HeldSide> {
h.iter().map(|s| s.unwrap_or(HeldSide::G)).collect()
};
if biactive.len() <= MAX_ENUMERATED_BIACTIVE {
out.enumerated = true;
let mut best: Option<(Number, Vec<HeldSide>)> = None;
for mask in 0..(1usize << biactive.len()) {
let held = held_of(&|k| {
let bit = biactive.iter().position(|&b| b == k).unwrap();
Some(if mask >> bit & 1 == 0 {
HeldSide::G
} else {
HeldSide::H
})
});
let Some((v, _)) = branch_lp(inp, &act, &held) else {
out.verdict = BVerdict::NotChecked("a branch LP did not solve");
return out;
};
out.branches_solved += 1;
if best.as_ref().is_none_or(|(b, _)| v < *b) {
best = Some((v, full(&held)));
}
}
out.verdict = match best {
Some((v, held)) if v < -band => BVerdict::NotB { held, decrease: v },
_ => BVerdict::B,
};
return out;
}
let relaxed = held_of(&|_| None);
let Some((_, d)) = branch_lp(inp, &act, &relaxed) else {
out.verdict = BVerdict::NotChecked("the relaxed LP did not solve");
return out;
};
out.branches_solved += 1;
let dg = |k: usize| act.g_side[k].0 * d[inp.pairs[k].var];
let dh = |k: usize| {
let i = inp.pairs[k].row;
act.h_side[k].0
* inp
.jac
.iter()
.filter(|&&(r, _, _)| r == i)
.map(|&(_, c, v)| v * d[c])
.sum::<Number>()
};
let held = held_of(&|k| {
Some(if dg(k) <= dh(k) {
HeldSide::G
} else {
HeldSide::H
})
});
let Some((v, _)) = branch_lp(inp, &act, &held) else {
out.verdict = BVerdict::NotChecked("a branch LP did not solve");
return out;
};
out.branches_solved += 1;
out.verdict = if v < -band {
BVerdict::NotB {
held: full(&held),
decrease: v,
}
} else {
BVerdict::NotChecked(
"too many biactive pairs to enumerate; the heuristic branch does not descend",
)
};
out
}
fn branch_lp(
inp: &CertificateInput<'_>,
act: &PairActivity,
held: &[Option<HeldSide>],
) -> Option<(Number, Vec<Number>)> {
use pounce_convex::{QpOptions, QpProblem, QpStatus, Triplet, solve_qp_ipm};
let n = inp.x.len();
let m = inp.g.len();
let row_tol = inp.solver_tol.max(0.0).sqrt();
let mut rows_of: Vec<Vec<(usize, Number)>> = vec![Vec::new(); m];
for &(r, c, v) in inp.jac {
rows_of[r].push((c, v));
}
let (mut lb, mut ub) = (vec![-1.0; n], vec![1.0; n]);
let (mut a, mut b, mut g, mut h) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
let eq = |coef: &[(usize, Number)], s: Number, a: &mut Vec<Triplet>, b: &mut Vec<Number>| {
let r = b.len();
a.extend(coef.iter().map(|&(c, v)| Triplet {
row: r,
col: c,
val: s * v,
}));
b.push(0.0);
};
let geq = |coef: &[(usize, Number)], s: Number, g: &mut Vec<Triplet>, h: &mut Vec<Number>| {
let r = h.len();
g.extend(coef.iter().map(|&(c, v)| Triplet {
row: r,
col: c,
val: -s * v,
}));
h.push(0.0);
};
let mut pair_row = vec![None; m];
let mut pair_var = vec![None; n];
for (k, p) in inp.pairs.iter().enumerate() {
pair_row[p.row] = Some(k);
pair_var[p.var] = Some(k);
}
for i in (0..m).filter(|&i| pair_row[i].is_none()) {
let (lo, hi) = (inp.g_l[i], inp.g_u[i]);
let at_lo = lo > -INF && inp.g[i] - lo <= row_tol;
let at_hi = hi < INF && hi - inp.g[i] <= row_tol;
match (at_lo, at_hi) {
(true, true) => eq(&rows_of[i], 1.0, &mut a, &mut b),
(true, false) => geq(&rows_of[i], 1.0, &mut g, &mut h),
(false, true) => geq(&rows_of[i], -1.0, &mut g, &mut h),
(false, false) => {}
}
}
for j in 0..n {
let (lo, hi) = (inp.x_l[j], inp.x_u[j]);
let (mut at_lo, mut at_hi) = (
lo > -INF && inp.x[j] - lo <= row_tol,
hi < INF && hi - inp.x[j] <= row_tol,
);
if let Some(k) = pair_var[j] {
if act.mixed[k] {
continue; }
if act.g_side[k].0 > 0.0 {
at_lo = false;
} else {
at_hi = false;
}
}
if at_lo {
lb[j] = 0.0;
}
if at_hi {
ub[j] = 0.0;
}
}
for (k, p) in inp.pairs.iter().enumerate() {
let (sg, sh) = (act.g_side[k].0, act.h_side[k].0);
let gcoef = [(p.var, 1.0)];
let hcoef = &rows_of[p.row];
match held[k] {
Some(HeldSide::G) => {
lb[p.var] = 0.0;
ub[p.var] = 0.0;
if act.h_act[k] {
geq(hcoef, sh, &mut g, &mut h);
}
}
Some(HeldSide::H) => {
eq(hcoef, 1.0, &mut a, &mut b);
if act.g_act[k] {
geq(&gcoef, sg, &mut g, &mut h);
}
}
None => {
geq(&gcoef, sg, &mut g, &mut h);
geq(hcoef, sh, &mut g, &mut h);
}
}
}
let prob = QpProblem {
n,
p_lower: Vec::new(),
c: inp.grad_f.to_vec(),
a,
b,
g,
h,
lb,
ub,
};
let opts = QpOptions::default();
let sol = solve_qp_ipm(&prob, &opts, || {
Box::new(pounce_feral::FeralSolverInterface::new())
as Box<dyn pounce_linsol::SparseSymLinearSolverInterface>
});
matches!(sol.status, QpStatus::Optimal).then_some((sol.obj, sol.x))
}
pub fn branch_bounds(
inp: &CertificateInput<'_>,
held: &[HeldSide],
) -> pounce_nlp::mpcc::BranchBounds {
let corner = inp.solver_tol.max(0.0).sqrt();
let act = pair_activity(inp, corner);
let mut out = pounce_nlp::mpcc::BranchBounds::default();
for (k, p) in inp.pairs.iter().enumerate() {
let ((sg, bnd), (_, c)) = (act.g_side[k], act.h_side[k]);
match held[k] {
HeldSide::G => {
out.vars.push((p.var, bnd, bnd));
if act.mixed[k] {
out.rows.push(if sg > 0.0 {
(p.row, 0.0, 2.0 * INF)
} else {
(p.row, -2.0 * INF, 0.0)
});
}
}
HeldSide::H => out.rows.push((p.row, c, c)),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nnls_on_near_dependent_columns_terminates_at_the_fit() {
let a = vec![1.0, 2.0, -1.0];
let a2: Vec<Number> = a.iter().map(|v| v * (1.0 + 1e-13)).collect();
let neg: Vec<Number> = a.iter().map(|v| -v).collect();
let b = vec![2.0, 4.0, -2.0];
let x = nnls(&[a.clone(), a2.clone(), neg], &b, 3);
assert!(x.iter().all(|&v| v >= 0.0));
let r: Number = (0..3)
.map(|i| (b[i] - x[0] * a[i] - x[1] * a2[i] + x[2] * a[i]).powi(2))
.sum();
assert!(r.sqrt() < 1e-9, "residual {}", r.sqrt());
}
#[test]
fn a_converged_lowering_with_a_large_product_is_not_an_answer() {
let ok = ApplicationReturnStatus::SolveSucceeded;
assert!(accepts(ok, 1e-9, 1e-4));
assert!(!accepts(ok, 1e-3, 1e-4));
assert!(!accepts(ok, Number::NAN, 1e-4));
assert!(!accepts(
ApplicationReturnStatus::InfeasibleProblemDetected,
0.0,
1e-4
));
assert!(accepts(
ApplicationReturnStatus::FeasiblePointFound,
0.0,
1e-4
));
}
#[test]
fn warm_mu_is_clamped_and_never_zero() {
let mu = |v| {
warm_start_assignments(v)
.into_iter()
.find(|a| a.starts_with("mu_init"))
.unwrap()
};
assert_eq!(mu(1e-12), "mu_init 1e-9\n");
assert_eq!(mu(3.0), "mu_init 1e-1\n");
assert_eq!(mu(0.0), "mu_init 1e-6\n");
assert_eq!(mu(Number::NAN), "mu_init 1e-6\n");
}
#[test]
fn the_exact_stage_is_budgeted_only_after_an_answer() {
assert_eq!(exact_stage_budget(false, 0), None);
assert_eq!(exact_stage_budget(false, 5000), None);
assert_eq!(exact_stage_budget(true, 0), Some(EXACT_BUDGET_FLOOR));
assert_eq!(exact_stage_budget(true, 10), Some(EXACT_BUDGET_FLOOR));
assert_eq!(exact_stage_budget(true, 189), Some(3 * 189));
assert_eq!(exact_stage_budget(true, usize::MAX), Some(usize::MAX));
}
fn scholtes4_relaxed(c: Number, tau: Number) -> (Number, Number) {
let lambda = c / tau.sqrt();
(-2.0 * c * tau.sqrt(), relaxation_slack(&[tau], &[lambda]))
}
#[test]
fn relaxation_slack_is_the_first_order_value_of_the_products() {
assert_eq!(relaxation_slack(&[], &[]), 0.0);
assert!((relaxation_slack(&[1e-8, 2e-8], &[1e4, -3.0]) - (1e-4 + 6e-8)).abs() < 1e-18);
assert_eq!(relaxation_slack(&[-0.5], &[7.0]), 0.0);
assert!(relaxation_slack(&[1e-8], &[Number::NAN]).is_infinite());
assert!(relaxation_slack(&[Number::INFINITY], &[1.0]).is_infinite());
}
#[test]
fn a_finish_that_drifts_above_its_relaxed_start_is_flagged() {
let slack = relaxation_slack(&[5e-9; 4], &[30.0; 4]);
assert!(finish_drifted(0.783, 0.65304, slack));
assert!(!finish_drifted(0.65304 + 1e-6, 0.65304, slack));
assert!(!finish_drifted(0.6529, 0.65304, slack));
let (f_s4, s_s4) = scholtes4_relaxed(1.0, 1e-8);
assert!(!finish_drifted(3e-11, f_s4, s_s4));
assert!(!finish_drifted(
2e-9,
-9.999818e-5,
relaxation_slack(&[1e-8], &[1e4])
));
assert!(finish_drifted(Number::NAN, 1.0, 1e-8));
assert!(!finish_drifted(1.0, Number::INFINITY, 1e-8));
assert!(!finish_drifted(5.0, 1.0, Number::INFINITY));
}
#[test]
fn the_drift_verdict_does_not_change_with_the_objectives_scale() {
for c in [1e-6, 1e-3, 0.1, 1.0, 10.0, 1e3, 1e6] {
let (fr, slack) = scholtes4_relaxed(c, 1e-8);
assert!(!finish_drifted(c * 3e-11, fr, slack), "scholtes4 x{c}");
assert!(!finish_drifted(0.0, fr, slack), "scholtes4 x{c}");
assert!(finish_drifted(-1e3 * fr, fr, slack), "scholtes4 x{c}");
let pack = relaxation_slack(&[5e-9; 4], &[30.0 * c; 4]);
assert!(finish_drifted(0.783 * c, 0.65304 * c, pack), "pack x{c}");
assert!(!finish_drifted(0.6529 * c, 0.65304 * c, pack), "pack x{c}");
}
assert!(!finish_drifted(
3e-10,
-2e-3,
relaxation_slack(&[1e-8], &[1e5])
));
}
#[test]
fn answers_agree_within_the_relative_tolerance() {
assert!(answers_agree(0.65304, 0.65310, 0.0, 0.0));
assert!(!answers_agree(953.23, 964.12, 0.0, 0.0));
assert!(answers_agree(1e-9, -5e-5, 2.5e-5, 0.0));
assert!(!answers_agree(f64::NAN, 0.0, 0.0, 0.0));
assert!(!answers_agree(0.0, 0.0, f64::INFINITY, 0.0));
assert!(answers_agree(0.0, 0.0, 0.0, 0.0));
assert!(!answers_agree(0.0, 0.0, 0.0, f64::INFINITY));
}
#[test]
fn two_answers_at_zero_agree_within_their_gaps() {
for c in [1e-6, 1.0, 1e6] {
assert!(
answers_agree(c * 1e-11, c * 5e-20, 0.0, c * 3.5e-11),
"x{c}"
);
assert!(!answers_agree(c * 1e-11, c * 5e-20, 0.0, 0.0), "x{c}");
assert!(
!answers_agree(c * 1e-9, c * 5e-20, 0.0, c * 3.5e-11),
"x{c}"
);
}
}
#[test]
fn the_complementarity_gap_sums_multiplier_times_distance() {
let inf = Number::INFINITY;
let gap = complementarity_gap(
&[1e-9, 2.0, 5.0],
&[0.01, 0.0, 0.0],
&[0.0, -4.0, 0.0],
&[0.0, -inf, -inf],
&[inf, 3.0, inf],
&[0.5, 1.0, 7.0],
&[-1e-8, 30.0, 0.0],
&[0.0, 1.0, -inf],
&[inf, 1.0, inf],
);
assert!((gap - (1e-11 + 4.0 + 0.5e-8)).abs() < 1e-15, "{gap:e}");
let scaled = complementarity_gap(
&[1e-9],
&[1e3 * 0.01],
&[0.0],
&[0.0],
&[inf],
&[],
&[],
&[],
&[],
);
assert!((scaled - 1e3 * 1e-11).abs() < 1e-20, "{scaled:e}");
assert!(
complementarity_gap(&[1.0], &[], &[0.0], &[0.0], &[inf], &[], &[], &[], &[])
.is_infinite()
);
assert!(
complementarity_gap(
&[1.0],
&[f64::NAN],
&[0.0],
&[0.0],
&[inf],
&[],
&[],
&[],
&[]
)
.is_infinite()
);
assert_eq!(
complementarity_gap(&[], &[], &[], &[], &[], &[], &[], &[], &[]),
0.0
);
}
#[test]
fn answers_agree_does_not_change_with_the_objectives_scale() {
for c in [1e-6, 1e-3, 0.1, 1.0, 10.0, 1e3, 1e6] {
assert!(answers_agree(c * 1e-9, -c * 5e-5, c * 2.5e-5, 0.0), "x{c}");
assert!(!answers_agree(c * 1e-9, -c * 5e-5, 0.0, 0.0), "x{c}");
assert!(answers_agree(c * 0.65304, c * 0.65310, 0.0, 0.0), "x{c}");
assert!(!answers_agree(c * 953.23, c * 964.12, 0.0, 0.0), "x{c}");
let (fr, slack) = scholtes4_relaxed(c, 1e-8);
assert!(answers_agree(c * 3e-11, fr, slack, 0.0), "scholtes4 x{c}");
}
}
#[test]
fn the_finish_budget_has_a_floor_and_follows_the_schedule() {
assert_eq!(finish_budget(0), FINISH_BUDGET_FLOOR);
assert_eq!(finish_budget(721), 721);
}
#[test]
fn a_budget_never_exceeds_the_callers_max_iter() {
assert_eq!(budgeted_max_iter(567, 3000), 567);
assert_eq!(budgeted_max_iter(567, 100), 100);
assert_eq!(budgeted_max_iter(usize::MAX, 3000), 3000);
assert_eq!(budgeted_max_iter(10, -1), 0);
}
#[test]
fn every_assignment_is_restored() {
for a in warm_start_assignments(1e-5) {
let name = a.split_whitespace().next().unwrap();
assert!(
TOUCHED_OPTIONS.contains(&name),
"{name} is written but not restored"
);
}
}
const NI: Number = -1e20;
const PI: Number = 1e20;
#[derive(Clone)]
struct Model {
x_l: Vec<Number>,
x_u: Vec<Number>,
g_l: Vec<Number>,
g_u: Vec<Number>,
pairs: Vec<ComplementarityPair>,
jac: Vec<(usize, usize, Number)>,
}
fn run(m: &Model, x: &[Number], grad_f: &[Number], g: &[Number]) -> Certificate {
let inp = CertificateInput {
x,
grad_f,
g,
jac: &m.jac,
x_l: &m.x_l,
x_u: &m.x_u,
g_l: &m.g_l,
g_u: &m.g_u,
pairs: &m.pairs,
solver_tol: 1e-8,
feas_tol: 1e-4,
};
let dense = certify_routed(&inp, Some(false));
let sparse = certify_routed(&inp, Some(true));
assert_eq!(
(dense.class, dense.multipliers_unique),
(sparse.class, sparse.multipliers_unique),
"dense {dense:?}\nsparse {sparse:?}"
);
if dense.multipliers_unique && dense.residual.is_finite() {
assert!(
(dense.residual - sparse.residual).abs() <= 1e-6 * dense.resid_tol,
"dense {dense:?}\nsparse {sparse:?}"
);
}
dense
}
#[test]
fn a_mixed_pair_is_certified_from_the_bound_it_is_at() {
let m = Model {
x_l: vec![0.0, NI],
x_u: vec![1.0, PI],
g_l: vec![NI],
g_u: vec![PI],
pairs: vec![ComplementarityPair { row: 0, var: 0 }],
jac: vec![(0, 0, -1.0), (0, 1, 1.0)],
};
let g = |x: &[Number]| vec![x[1] - x[0]];
let x = [0.0, 2.0];
let c = run(&m, &x, &[-1.0, 0.0], &g(&x));
assert_eq!(c.class, StationarityClass::S, "{c:?}");
assert!(c.feasible, "{c:?}");
let x = [1.0, 0.0];
let c = run(&m, &x, &[-2.0, 0.0], &g(&x));
assert_eq!(c.class, StationarityClass::S, "{c:?}");
let x = [0.5, 0.5];
let c = run(&m, &x, &[-1.0, 1.0], &g(&x));
assert_eq!(c.class, StationarityClass::S, "{c:?}");
let x = [0.0, -1.0];
let c = run(&m, &x, &[0.0, 0.0], &g(&x));
assert_eq!(c.class, StationarityClass::None, "{c:?}");
assert!(
!c.feasible && (c.primal_violation - 1.0).abs() < 1e-12,
"{c:?}"
);
let x = [0.5, 1.0];
let c = run(&m, &x, &[0.0, 0.0], &g(&x));
assert_eq!(
(c.class, c.n_neither),
(StationarityClass::None, 1),
"{c:?}"
);
}
#[test]
fn a_pair_variables_ordinary_second_bound_carries_a_multiplier() {
let m = Model {
x_l: vec![0.0, NI],
x_u: vec![1.0, PI],
g_l: vec![0.0],
g_u: vec![PI],
pairs: vec![ComplementarityPair { row: 0, var: 0 }],
jac: vec![(0, 1, 1.0)],
};
let x = [1.0, 0.0];
let c = run(&m, &x, &[-2.0, -2.0], &[x[1]]);
assert_eq!(c.class, StationarityClass::S, "{c:?}");
let x = [0.0, 1.0];
let c = run(&m, &x, &[-4.0, 0.0], &[x[1]]);
assert_eq!(c.class, StationarityClass::S, "{c:?}");
}
fn input<'a>(
m: &'a Model,
x: &'a [Number],
grad_f: &'a [Number],
g: &'a [Number],
) -> CertificateInput<'a> {
CertificateInput {
x,
grad_f,
g,
jac: &m.jac,
x_l: &m.x_l,
x_u: &m.x_u,
g_l: &m.g_l,
g_u: &m.g_u,
pairs: &m.pairs,
solver_tol: 1e-8,
feas_tol: 1e-4,
}
}
#[test]
fn ralph1s_origin_is_b_stationary() {
let m = Model {
x_l: vec![0.0, 0.0],
x_u: vec![PI, PI],
g_l: vec![0.0],
g_u: vec![PI],
pairs: vec![ComplementarityPair { row: 0, var: 1 }],
jac: vec![(0, 0, -1.0), (0, 1, 1.0)],
};
let (x, gf, g) = ([0.0, 0.0], [2.0, -1.0], [0.0]);
assert_eq!(run(&m, &x, &gf, &g).class, StationarityClass::W);
let b = check_b_stationarity(&input(&m, &x, &gf, &g));
assert_eq!(b.verdict, BVerdict::B, "{b:?}");
assert_eq!(
(b.n_biactive, b.branches_solved, b.enumerated),
(1, 2, true)
);
}
#[test]
fn a_biactive_corner_with_descent_is_not_b_stationary() {
let m = Model {
x_l: vec![0.0, NI],
x_u: vec![PI, PI],
g_l: vec![0.0],
g_u: vec![PI],
pairs: vec![ComplementarityPair { row: 0, var: 0 }],
jac: vec![(0, 1, 1.0)],
};
let (x, gf, g) = ([0.0, 0.0], [-2.0, -2.0], [0.0]);
let inp = input(&m, &x, &gf, &g);
let b = check_b_stationarity(&inp);
let BVerdict::NotB { held, decrease } = &b.verdict else {
panic!("{b:?}")
};
assert!((decrease + 2.0).abs() < 1e-6, "{b:?}");
let bb = branch_bounds(&inp, held);
match held[0] {
HeldSide::G => assert_eq!(bb.vars, vec![(0, 0.0, 0.0)], "{bb:?}"),
HeldSide::H => assert_eq!(bb.rows, vec![(0, 0.0, 0.0)], "{bb:?}"),
}
let (x, gf, g) = ([0.0, 1.0], [-2.0, 0.0], [1.0]);
assert_eq!(
check_b_stationarity(&input(&m, &x, &gf, &g)).verdict,
BVerdict::B
);
}
#[test]
fn past_the_enumeration_cap_the_heuristic_finds_a_descent() {
let k = MAX_ENUMERATED_BIACTIVE + 1;
let n = 2 * k;
let m = Model {
x_l: (0..n).map(|j| if j < k { 0.0 } else { NI }).collect(),
x_u: vec![PI; n],
g_l: vec![0.0; k],
g_u: vec![PI; k],
pairs: (0..k)
.map(|i| ComplementarityPair { row: i, var: i })
.collect(),
jac: (0..k).map(|i| (i, k + i, 1.0)).collect(),
};
let (x, gf, g) = (vec![0.0; n], vec![-1.0; n], vec![0.0; k]);
let b = check_b_stationarity(&input(&m, &x, &gf, &g));
assert!(!b.enumerated && b.branches_solved == 2, "{b:?}");
assert!(matches!(b.verdict, BVerdict::NotB { .. }), "{b:?}");
}
#[test]
fn a_point_where_a_pair_is_not_complementary_is_not_checked() {
let m = Model {
x_l: vec![0.0, NI],
x_u: vec![PI, PI],
g_l: vec![0.0],
g_u: vec![PI],
pairs: vec![ComplementarityPair { row: 0, var: 0 }],
jac: vec![(0, 1, 1.0)],
};
let (x, gf, g) = ([1.0, 1.0], [0.0, 0.0], [1.0]);
let b = check_b_stationarity(&input(&m, &x, &gf, &g));
assert!(matches!(b.verdict, BVerdict::NotChecked(_)), "{b:?}");
}
#[allow(clippy::type_complexity)]
fn complementarity() -> (Model, impl Fn(&[Number]) -> (Vec<Number>, Vec<Number>)) {
let mut jac = vec![
(0, 1, 0.5),
(0, 2, 1.0),
(0, 3, -0.5),
(0, 4, 1.0),
(1, 0, 3.0),
(1, 1, -1.0),
(1, 5, -1.0),
(2, 0, -1.0),
(2, 1, 0.5),
(2, 6, -1.0),
(3, 0, -1.0),
(3, 1, -1.0),
(3, 7, -1.0),
];
for k in 0..3 {
jac.push((4 + k, 5 + k, 1.0));
}
let m = Model {
x_l: vec![0.0, 0.0, 0.0, 0.0, 0.0, NI, NI, NI],
x_u: vec![PI; 8],
g_l: vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
g_u: vec![0.0, 0.0, 0.0, 0.0, PI, PI, PI],
pairs: (0..3)
.map(|k| ComplementarityPair {
row: 4 + k,
var: 2 + k,
})
.collect(),
jac,
};
let eval = |x: &[Number]| {
let grad = vec![
2.0 * (x[0] - 5.0),
4.0 * (2.0 * x[1] + 1.0),
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
];
let g = vec![
2.0 * (x[1] - 1.0) - 1.5 * x[1] + x[2] - 0.5 * x[3] + x[4],
3.0 * x[0] - x[1] - 3.0 - x[5],
-x[0] + 0.5 * x[1] + 4.0 - x[6],
-x[0] - x[1] + 7.0 - x[7],
x[5],
x[6],
x[7],
];
(grad, g)
};
(m, eval)
}
#[test]
fn a_large_system_with_unique_multipliers_is_certified_sparsely() {
let n = 2001;
let m = Model {
x_l: vec![0.0; n],
x_u: vec![PI; n],
g_l: vec![],
g_u: vec![],
pairs: vec![],
jac: vec![],
};
assert!(n * n > MAX_DENSE_ENTRIES);
let mut grad = vec![1.0; n];
let cert = |grad: &[Number]| {
certify(&CertificateInput {
x: &vec![0.0; n],
grad_f: grad,
g: &[],
jac: &m.jac,
x_l: &m.x_l,
x_u: &m.x_u,
g_l: &m.g_l,
g_u: &m.g_u,
pairs: &m.pairs,
solver_tol: 1e-8,
feas_tol: 1e-4,
})
};
let c = cert(&grad);
assert_eq!(
(c.class, c.multipliers_unique),
(StationarityClass::S, true),
"{c:?}"
);
grad[7] = -1.0;
let c = cert(&grad);
assert_eq!(c.class, StationarityClass::None, "{c:?}");
}
#[test]
fn a_badly_scaled_column_gets_the_same_verdict_on_both_paths() {
for eps in [1e-3, 1e-5, 1e-7, 1e-9, 1e-12, 1e-14, 0.0] {
let m = Model {
x_l: vec![0.0, -2e19],
x_u: vec![2e19, 2e19],
g_l: vec![0.0],
g_u: vec![2e19],
pairs: vec![],
jac: vec![(0, 0, 1.0), (0, 1, eps)],
};
let c = run(&m, &[0.0, 0.0], &[2.0, eps], &[0.0]);
assert_eq!(c.class, StationarityClass::S, "{eps:e}: {c:?}");
assert_eq!(c.multipliers_unique, eps != 0.0, "{eps:e}: {c:?}");
let cols = vec![vec![(0, 1.0)], vec![(0, 1.0), (1, eps)]];
match sparse_least_squares(2, &cols, &[2.0, eps]) {
LeastSquares::Solved(theta) => {
assert!(eps != 0.0, "{theta:?}");
assert!(
theta.iter().all(|t| (t - 1.0).abs() < 1e-12),
"{eps:e}: {theta:?}"
);
}
other => assert!(
eps == 0.0 && other == LeastSquares::RankDeficient,
"{eps:e}: {other:?}"
),
}
}
}
#[test]
fn the_residual_band_is_the_same_on_the_unique_path() {
let m = Model {
x_l: vec![-2e19],
x_u: vec![2e19],
g_l: vec![],
g_u: vec![],
pairs: vec![],
jac: vec![],
};
for (r, class, tight) in [
(5e-7, StationarityClass::S, true),
(2e-5, StationarityClass::S, false),
(9e-5, StationarityClass::S, false),
(2e-4, StationarityClass::None, false),
] {
let c = run(&m, &[0.3], &[r], &[]);
assert_eq!((c.class, c.tight), (class, tight), "{r:e}: {c:?}");
assert!((c.resid_tol - (1e-6 + 1e-4)).abs() < 1e-15, "{c:?}");
}
}
#[test]
fn a_point_violating_a_row_beyond_constr_viol_tol_is_not_certified() {
let m = Model {
x_l: vec![-2e19],
x_u: vec![2e19],
g_l: vec![1.0],
g_u: vec![2e19],
pairs: vec![],
jac: vec![(0, 0, 1.0)],
};
let x = 1.0 - 5e-4;
let c = run(&m, &[x], &[1.0], &[x]);
assert_eq!(
(c.class, c.feasible),
(StationarityClass::None, false),
"{c:?}"
);
assert!((c.primal_violation - 5e-4).abs() < 1e-12, "{c:?}");
let x = 1.0 - 5e-5;
let c = run(&m, &[x], &[1.0], &[x]);
assert_eq!((c.class, c.feasible), (StationarityClass::S, true), "{c:?}");
let m = Model {
x_l: vec![0.0],
g_l: vec![],
g_u: vec![],
jac: vec![],
..m
};
let c = run(&m, &[-2e-3], &[1.0], &[]);
assert_eq!(
(c.class, c.feasible),
(StationarityClass::None, false),
"{c:?}"
);
}
#[test]
fn a_large_rank_deficient_system_is_not_searched() {
let n = 2001;
let c = certify(&CertificateInput {
x: &vec![0.0; n],
grad_f: &vec![1.0; n],
g: &[0.0],
jac: &[(0, 0, 1.0)],
x_l: &vec![0.0; n],
x_u: &vec![PI; n],
g_l: &[0.0],
g_u: &[2e19],
pairs: &[],
solver_tol: 1e-8,
feas_tol: 1e-4,
});
assert_eq!(
(c.class, c.multipliers_unique),
(StationarityClass::NotSearched, false),
"{c:?}"
);
assert!(n * (n + 1) > MAX_DENSE_ENTRIES);
}
#[test]
fn the_set_search_band_is_resid_tol_plus_the_sign_band() {
let m = Model {
x_l: vec![NI],
x_u: vec![PI],
g_l: vec![0.0, 0.0],
g_u: vec![PI, PI],
pairs: vec![],
jac: vec![(0, 0, 1.0), (1, 0, 1.0)],
};
let inside = run(&m, &[0.0], &[-5e-5], &[0.0, 0.0]);
assert!(!inside.multipliers_unique, "{inside:?}");
assert_eq!(inside.class, StationarityClass::S, "{inside:?}");
let outside = run(&m, &[0.0], &[-2e-4], &[0.0, 0.0]);
assert!(!outside.multipliers_unique, "{outside:?}");
assert_eq!(outside.class, StationarityClass::None, "{outside:?}");
}
#[test]
fn certify_matches_the_python_classifier_on_complementarity() {
let (m, eval) = complementarity();
for (x, want, nb) in [
(
[1.0, 0.0, 2.0, 0.0, 0.0, 0.0, 3.0, 6.0],
StationarityClass::S,
0,
), (
[5.0, 2.0, 0.0, 0.0, 1.0, 10.0, 0.0, 0.0],
StationarityClass::S,
1,
), (
[3.0, 4.0, 0.0, 0.0, 0.0, 2.0, 3.0, 0.0],
StationarityClass::W,
1,
), ] {
let (grad, g) = eval(&x);
assert!(
g[..4].iter().all(|v| v.abs() < 1e-12),
"{x:?} is not feasible: {g:?}"
);
let c = run(&m, &x, &grad, &g);
assert_eq!((c.class, c.n_biactive), (want, nb), "{x:?}: {c:?}");
}
}
#[test]
fn certify_reads_kkt_on_a_plain_nlp() {
let m = Model {
x_l: vec![NI; 2],
x_u: vec![PI; 2],
g_l: vec![1.0],
g_u: vec![1.0],
pairs: vec![],
jac: vec![],
};
let at = |x: [Number; 2]| {
let mut mm = Model {
jac: vec![(0, 0, 2.0 * x[0]), (0, 1, 2.0 * x[1])],
..m.clone()
};
mm.jac.retain(|e| e.2 != 0.0);
let grad = [4.0 * x[0] - 1.0, 4.0 * x[1]];
run(&mm, &x, &grad, &[x[0] * x[0] + x[1] * x[1]]).class
};
assert_eq!(at([1.0, 0.0]), StationarityClass::S);
assert_eq!(at([0.0, 1.0]), StationarityClass::None); }
#[test]
fn certify_ralph1_ctrap_and_qpec_small() {
let m = Model {
x_l: vec![0.0, 0.0],
x_u: vec![PI; 2],
g_l: vec![0.0],
g_u: vec![PI],
pairs: vec![ComplementarityPair { row: 0, var: 1 }],
jac: vec![(0, 0, -1.0), (0, 1, 1.0)],
};
let c = run(&m, &[0.0, 0.0], &[2.0, -1.0], &[0.0]);
assert_eq!((c.class, c.n_biactive), (StationarityClass::W, 1), "{c:?}");
let mirrored = Model {
x_l: vec![NI, NI],
x_u: vec![0.0, 0.0],
g_l: vec![NI],
g_u: vec![0.0],
pairs: vec![ComplementarityPair { row: 0, var: 1 }],
jac: vec![(0, 0, -1.0), (0, 1, 1.0)],
};
let c = run(&mirrored, &[0.0, 0.0], &[-2.0, 1.0], &[0.0]);
assert_eq!((c.class, c.n_biactive), (StationarityClass::W, 1), "{c:?}");
let m = Model {
x_l: vec![0.0, NI],
x_u: vec![PI; 2],
g_l: vec![0.0],
g_u: vec![PI],
pairs: vec![ComplementarityPair { row: 0, var: 0 }],
jac: vec![(0, 1, 1.0)],
};
let c = run(&m, &[0.0, 0.0], &[-1.0, -1.0], &[0.0]);
assert_eq!(c.class, StationarityClass::W, "{c:?}");
let c = run(&m, &[0.5, 0.0], &[0.0, -1.0], &[0.0]);
assert_eq!((c.class, c.n_biactive), (StationarityClass::S, 0), "{c:?}");
let m = Model {
x_l: vec![0.0, 0.0, 0.0],
x_u: vec![2.0, PI, PI],
g_l: vec![0.0, 0.0],
g_u: vec![PI, PI],
pairs: vec![
ComplementarityPair { row: 0, var: 1 },
ComplementarityPair { row: 1, var: 2 },
],
jac: vec![(0, 0, -1.0), (0, 1, 2.0), (1, 0, 1.0), (1, 2, 2.0)],
};
let c = run(&m, &[1.0, 1.0, 0.0], &[0.0, 0.0, 0.0], &[0.0, 0.0]);
assert_eq!((c.class, c.n_biactive), (StationarityClass::S, 1), "{c:?}");
let m = Model {
x_l: vec![NI, NI],
x_u: vec![0.0, PI],
g_l: vec![NI],
g_u: vec![0.0],
pairs: vec![ComplementarityPair { row: 0, var: 0 }],
jac: vec![(0, 1, 1.0)],
};
let c = run(&m, &[-0.5, 0.0], &[0.0, 1.0], &[0.0]);
assert_eq!((c.class, c.n_biactive), (StationarityClass::S, 0), "{c:?}");
let c = run(&m, &[0.0, 0.0], &[1.0, 1.0], &[0.0]);
assert_eq!(c.class, StationarityClass::W, "{c:?}");
}
#[test]
fn certify_searches_the_multiplier_set_when_it_is_not_a_point() {
let m = Model {
x_l: vec![0.0, NI],
x_u: vec![PI; 2],
g_l: vec![0.0, 0.0],
g_u: vec![PI, 0.0],
pairs: vec![ComplementarityPair { row: 0, var: 0 }],
jac: vec![(0, 1, 1.0), (1, 1, 1.0)],
};
let c = run(&m, &[0.0, 0.0], &[1.0, -0.1], &[0.0, 0.0]);
assert!(!c.multipliers_unique, "{c:?}");
assert_eq!((c.class, c.n_biactive), (StationarityClass::S, 1), "{c:?}");
let a = Mat::<Number>::from_fn(2, 3, |r, col| match (r, col) {
(0, 0) => 1.0,
(1, 1) | (1, 2) => 1.0,
_ => 0.0,
});
let (theta, _) = lstsq(&a, &[1.0, -0.1]);
assert!(theta[1] < 0.0, "{theta:?}");
}
#[test]
fn certify_refuses_a_point_where_a_pair_is_not_complementary() {
let m = Model {
x_l: vec![0.0, NI],
x_u: vec![PI; 2],
g_l: vec![0.0],
g_u: vec![PI],
pairs: vec![ComplementarityPair { row: 0, var: 0 }],
jac: vec![(0, 1, 1.0)],
};
let c = run(&m, &[0.5, 0.5], &[0.0, 0.0], &[0.5]);
assert_eq!(
(c.class, c.n_neither),
(StationarityClass::None, 1),
"{c:?}"
);
}
}