use crate::nl_reader::NlProblem;
use crate::qp_extract::{ConSocpMap, recover_socp_duals};
use pounce_common::types::Index;
use pounce_convex::{ConeSpec, QpProblem, QpSolution, QpStatus};
use pounce_linsol::{Factorization, SparseSymLinearSolverInterface};
use std::collections::BTreeMap;
const ACTIVE_REL: f64 = 1e-6;
const PRIMAL_ACTIVE_REL: f64 = 1e-6;
const REFINE_STEPS: usize = 2;
const MAX_DROPS: usize = 8;
const MAX_NEWTON: usize = 3;
type SpVec = Vec<(usize, f64)>;
pub type BackendFactory = dyn Fn() -> Box<dyn SparseSymLinearSolverInterface>;
struct Quad {
con_idx: usize,
row0: usize,
dim: usize,
kappa: f64,
}
struct View<'a> {
qp: &'a QpProblem,
a_rows: Vec<SpVec>,
g_rows: Vec<SpVec>,
nonneg: Vec<bool>,
quads: Vec<Quad>,
}
#[derive(Clone, Debug)]
struct Mults {
y: Vec<f64>,
z: Vec<f64>,
quad: Vec<f64>,
z_lb: Vec<f64>,
z_ub: Vec<f64>,
}
impl Mults {
fn scale(&self) -> f64 {
[&self.y, &self.z, &self.quad, &self.z_lb, &self.z_ub]
.iter()
.flat_map(|v| v.iter())
.fold(1.0_f64, |m, v| m.max(v.abs()))
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct QcqpResiduals {
pub primal_infeasibility: f64,
pub bound_violation: f64,
pub dual_infeasibility: f64,
pub complementarity: f64,
compl_scaled: f64,
}
impl QcqpResiduals {
pub fn kkt_error(&self) -> f64 {
self.primal_infeasibility
.max(self.dual_infeasibility)
.max(self.complementarity)
}
fn merit(&self, dual_scale: f64) -> f64 {
self.primal_infeasibility
.max(self.dual_infeasibility / dual_scale)
.max(self.compl_scaled)
}
}
#[derive(Clone, Debug)]
pub struct QcqpPolish {
pub x: Vec<f64>,
pub obj: f64,
y: Vec<f64>,
z: Vec<f64>,
quad_by_con: Vec<f64>,
pub z_lb: Vec<f64>,
pub z_ub: Vec<f64>,
pub residuals: QcqpResiduals,
pub newton_steps: usize,
pub dual_scale: f64,
}
impl QcqpPolish {
pub fn constraint_duals(&self, prob: &NlProblem, con_map: &[ConSocpMap]) -> Vec<f64> {
let sign = if prob.minimize { 1.0 } else { -1.0 };
let mut lambda = recover_socp_duals(prob, con_map, &self.y, &self.z);
for (i, m) in con_map.iter().enumerate() {
if matches!(m, ConSocpMap::Quad { .. }) {
lambda[i] = sign * self.quad_by_con[i];
}
}
lambda
}
pub fn misses_tol(&self, tol: f64) -> bool {
let r = &self.residuals;
r.primal_infeasibility > tol
|| r.dual_infeasibility / self.dual_scale > tol
|| r.compl_scaled > tol
}
}
fn row_dot(row: &[(usize, f64)], x: &[f64]) -> f64 {
row.iter().map(|&(j, v)| v * x[j]).sum()
}
impl<'a> View<'a> {
fn new(qp: &'a QpProblem, cones: &[ConeSpec], con_map: &[ConSocpMap]) -> Option<Self> {
let m_ineq = qp.m_ineq();
let mut a_rows: Vec<SpVec> = vec![Vec::new(); qp.m_eq()];
for t in &qp.a {
a_rows[t.row].push((t.col, t.val));
}
let mut g_rows: Vec<SpVec> = vec![Vec::new(); m_ineq];
for t in &qp.g {
g_rows[t.row].push((t.col, t.val));
}
let mut nonneg = vec![false; m_ineq];
let mut soc_dim_at: BTreeMap<usize, usize> = BTreeMap::new();
let mut off = 0usize;
for spec in cones {
let dim = spec.dim();
if off + dim > m_ineq {
return None;
}
match spec {
ConeSpec::Nonneg(_) => nonneg[off..off + dim].iter_mut().for_each(|b| *b = true),
_ => {
soc_dim_at.insert(off, dim);
}
}
off += dim;
}
nonneg[off..].iter_mut().for_each(|b| *b = true);
let mut quads = Vec::new();
for (con_idx, m) in con_map.iter().enumerate() {
if let ConSocpMap::Quad {
z_row0,
z_row1,
kappa,
} = *m
{
let dim = *soc_dim_at.get(&z_row0)?;
if dim < 2 || z_row1 != z_row0 + 1 || !(kappa > 0.0) {
return None;
}
quads.push(Quad {
con_idx,
row0: z_row0,
dim,
kappa,
});
}
}
Some(View {
qp,
a_rows,
g_rows,
nonneg,
quads,
})
}
fn has_lb(&self, j: usize) -> bool {
self.qp.lb_of(j) > -1e19
}
fn has_ub(&self, j: usize) -> bool {
self.qp.ub_of(j) < 1e19
}
fn slack(&self, r: usize, x: &[f64]) -> f64 {
self.qp.h[r] - row_dot(&self.g_rows[r], x)
}
fn quad_value(&self, q: &Quad, x: &[f64]) -> f64 {
let s0 = self.slack(q.row0, x);
let s1 = self.slack(q.row0 + 1, x);
let sq: f64 = (q.row0 + 2..q.row0 + q.dim)
.map(|r| self.slack(r, x).powi(2))
.sum();
0.25 * sq - 0.5 * q.kappa * (s0 + s1)
}
fn quad_grad(&self, q: &Quad, x: &[f64]) -> SpVec {
let mut grad: BTreeMap<usize, f64> = BTreeMap::new();
for &(j, v) in &self.g_rows[q.row0] {
*grad.entry(j).or_insert(0.0) += q.kappa * v;
}
for r in q.row0 + 2..q.row0 + q.dim {
let s = self.slack(r, x);
for &(j, v) in &self.g_rows[r] {
*grad.entry(j).or_insert(0.0) -= 0.5 * s * v;
}
}
grad.into_iter().collect()
}
fn add_quad_hessian(&self, q: &Quad, weight: f64, h: &mut BTreeMap<(usize, usize), f64>) {
for r in q.row0 + 2..q.row0 + q.dim {
let row = &self.g_rows[r];
for &(i, a) in row {
for &(j, b) in row {
if i >= j {
*h.entry((i, j)).or_insert(0.0) += 0.5 * weight * a * b;
}
}
}
}
}
fn objective_gradient(&self, x: &[f64]) -> Vec<f64> {
let mut g = vec![0.0; self.qp.n];
self.qp.p_mul(x, &mut g);
for (gi, ci) in g.iter_mut().zip(&self.qp.c) {
*gi += ci;
}
g
}
fn objective(&self, x: &[f64]) -> f64 {
let mut px = vec![0.0; self.qp.n];
self.qp.p_mul(x, &mut px);
x.iter()
.zip(&px)
.zip(&self.qp.c)
.map(|((xi, pi), ci)| 0.5 * xi * pi + ci * xi)
.sum()
}
fn residuals(&self, x: &[f64], m: &Mults) -> QcqpResiduals {
let qp = self.qp;
let n = qp.n;
let mut r = self.objective_gradient(x);
let mut primal = 0.0_f64;
let mut compl = 0.0_f64;
let mut compl_scaled = 0.0_f64;
let mut note = |mu: f64, g: f64| {
compl = compl.max((mu * g).abs());
compl_scaled = compl_scaled.max((mu * g).abs() / (1.0 + mu.abs()));
};
for (i, row) in self.a_rows.iter().enumerate() {
primal = primal.max((row_dot(row, x) - qp.b[i]).abs());
for &(j, v) in row {
r[j] += m.y[i] * v;
}
}
for (i, row) in self.g_rows.iter().enumerate() {
if !self.nonneg[i] {
continue;
}
let s = self.slack(i, x);
primal = primal.max((-s).max(0.0));
note(m.z[i], s);
for &(j, v) in row {
r[j] += m.z[i] * v;
}
}
for (k, q) in self.quads.iter().enumerate() {
let g = self.quad_value(q, x);
primal = primal.max(g.max(0.0));
note(m.quad[k], g);
for (j, v) in self.quad_grad(q, x) {
r[j] += m.quad[k] * v;
}
}
let mut bound = 0.0_f64;
for j in 0..n {
r[j] += -m.z_lb[j] + m.z_ub[j];
if self.has_lb(j) {
let s = x[j] - qp.lb_of(j);
bound = bound.max((-s).max(0.0));
note(m.z_lb[j], s);
}
if self.has_ub(j) {
let s = qp.ub_of(j) - x[j];
bound = bound.max((-s).max(0.0));
note(m.z_ub[j], s);
}
}
QcqpResiduals {
primal_infeasibility: primal.max(bound),
bound_violation: bound,
dual_infeasibility: r.iter().fold(0.0_f64, |a, v| a.max(v.abs())),
complementarity: compl,
compl_scaled,
}
}
}
#[derive(Clone, Copy)]
enum Slot {
Y(usize),
Z(usize),
Quad(usize),
Lb(usize),
Ub(usize),
}
impl Slot {
fn get(self, m: &Mults) -> f64 {
match self {
Slot::Y(i) => m.y[i],
Slot::Z(i) => m.z[i],
Slot::Quad(i) => m.quad[i],
Slot::Lb(j) => m.z_lb[j],
Slot::Ub(j) => m.z_ub[j],
}
}
fn set(self, m: &mut Mults, v: f64) {
match self {
Slot::Y(i) => m.y[i] = v,
Slot::Z(i) => m.z[i] = v,
Slot::Quad(i) => m.quad[i] = v,
Slot::Lb(j) => m.z_lb[j] = v,
Slot::Ub(j) => m.z_ub[j] = v,
}
}
fn free(self) -> bool {
matches!(self, Slot::Y(_))
}
}
struct Col {
slot: Slot,
grad: SpVec,
value: f64,
mag: f64,
}
fn columns(view: &View, x: &[f64]) -> Vec<Col> {
let qp = view.qp;
let abs_dot = |row: &[(usize, f64)]| row.iter().map(|&(j, v)| (v * x[j]).abs()).sum::<f64>();
let mut cols = Vec::new();
for (i, row) in view.a_rows.iter().enumerate() {
cols.push(Col {
slot: Slot::Y(i),
grad: row.clone(),
value: row_dot(row, x) - qp.b[i],
mag: qp.b[i].abs() + abs_dot(row),
});
}
for (i, row) in view.g_rows.iter().enumerate() {
if view.nonneg[i] {
cols.push(Col {
slot: Slot::Z(i),
grad: row.clone(),
value: -view.slack(i, x),
mag: qp.h[i].abs() + abs_dot(row),
});
}
}
for (k, q) in view.quads.iter().enumerate() {
let s0 = view.slack(q.row0, x);
let s1 = view.slack(q.row0 + 1, x);
let sq: f64 = (q.row0 + 2..q.row0 + q.dim)
.map(|r| view.slack(r, x).powi(2))
.sum();
cols.push(Col {
slot: Slot::Quad(k),
grad: view.quad_grad(q, x),
value: view.quad_value(q, x),
mag: 0.25 * sq + 0.5 * q.kappa * (s0.abs() + s1.abs()),
});
}
#[allow(clippy::needless_range_loop)] for j in 0..qp.n {
if view.has_lb(j) {
cols.push(Col {
slot: Slot::Lb(j),
grad: vec![(j, -1.0)],
value: qp.lb_of(j) - x[j],
mag: qp.lb_of(j).abs() + x[j].abs(),
});
}
if view.has_ub(j) {
cols.push(Col {
slot: Slot::Ub(j),
grad: vec![(j, 1.0)],
value: x[j] - qp.ub_of(j),
mag: qp.ub_of(j).abs() + x[j].abs(),
});
}
}
cols
}
fn is_active(c: &Col, m: &Mults, scale: f64) -> bool {
c.slot.free()
|| c.slot.get(m) > ACTIVE_REL * scale
|| c.value >= -PRIMAL_ACTIVE_REL * (1.0 + c.mag)
}
fn refit(view: &View, x: &[f64], m: &Mults) -> Option<Mults> {
let n = view.qp.n;
let cols = columns(view, x);
let before = view.residuals(x, m).dual_infeasibility;
let mut r = view.objective_gradient(x);
for c in &cols {
let v = c.slot.get(m);
for &(j, g) in &c.grad {
r[j] += v * g;
}
}
let scale = m.scale();
let d: Vec<f64> = cols
.iter()
.map(|c| {
if c.slot.free() {
scale.sqrt()
} else if is_active(c, m, scale) {
c.slot.get(m).max(ACTIVE_REL * scale).sqrt()
} else {
0.0
}
})
.collect();
let delta = cgls_min_norm(&cols, &d, &r, n);
let mut out = m.clone();
for (c, dv) in cols.iter().zip(&delta) {
let v = c.slot.get(m) + dv;
c.slot
.set(&mut out, if c.slot.free() { v } else { v.max(0.0) });
}
(view.residuals(x, &out).dual_infeasibility < before).then_some(out)
}
fn cgls_min_norm(cols: &[Col], d: &[f64], r: &[f64], n: usize) -> Vec<f64> {
let k = cols.len();
let apply = |u: &[f64]| -> Vec<f64> {
let mut out = vec![0.0; n];
for (i, c) in cols.iter().enumerate() {
let s = d[i] * u[i];
if s != 0.0 {
for &(j, g) in &c.grad {
out[j] += s * g;
}
}
}
out
};
let apply_t = |v: &[f64]| -> Vec<f64> {
cols.iter()
.zip(d)
.map(|(c, &di)| di * row_dot(&c.grad, v))
.collect()
};
let dot = |a: &[f64], b: &[f64]| a.iter().zip(b).map(|(x, y)| x * y).sum::<f64>();
let mut u = vec![0.0; k];
let mut res: Vec<f64> = r.iter().map(|v| -v).collect();
let mut s = apply_t(&res);
let mut p = s.clone();
let mut gamma = dot(&s, &s);
let gamma0 = gamma;
for _ in 0..(2 * k).clamp(10, 500) {
if !(gamma > 1e-30 * gamma0) || !gamma.is_finite() {
break;
}
let q = apply(&p);
let qq = dot(&q, &q);
if !(qq > 0.0) {
break;
}
let alpha = gamma / qq;
for (ui, pi) in u.iter_mut().zip(&p) {
*ui += alpha * pi;
}
for (ri, qi) in res.iter_mut().zip(&q) {
*ri -= alpha * qi;
}
s = apply_t(&res);
let gamma_new = dot(&s, &s);
let beta = gamma_new / gamma;
gamma = gamma_new;
for (pi, si) in p.iter_mut().zip(&s) {
*pi = si + beta * *pi;
}
}
u.iter().zip(d).map(|(ui, di)| ui * di).collect()
}
fn newton_step(
view: &View,
x: &[f64],
m: &Mults,
backend: &BackendFactory,
) -> Option<(Vec<f64>, Mults)> {
let scale = m.scale();
let mut active: Vec<Col> = columns(view, x)
.into_iter()
.filter(|c| is_active(c, m, scale))
.collect();
for _ in 0..=MAX_DROPS {
let (x_new, mu) = solve_active_kkt(view, x, m, &active, backend)?;
let worst = active
.iter()
.zip(&mu)
.enumerate()
.filter(|(_, (c, v))| !c.slot.free() && **v < -ACTIVE_REL * scale)
.min_by(|a, b| a.1.1.total_cmp(b.1.1))
.map(|(i, _)| i);
if let Some(i) = worst {
active.remove(i);
continue;
}
let n = view.qp.n;
let mut out = Mults {
y: vec![0.0; m.y.len()],
z: vec![0.0; m.z.len()],
quad: vec![0.0; m.quad.len()],
z_lb: vec![0.0; n],
z_ub: vec![0.0; n],
};
for (c, &v) in active.iter().zip(&mu) {
c.slot
.set(&mut out, if c.slot.free() { v } else { v.max(0.0) });
}
return Some((x_new, out));
}
None
}
fn solve_active_kkt(
view: &View,
x: &[f64],
m: &Mults,
active: &[Col],
backend: &BackendFactory,
) -> Option<(Vec<f64>, Vec<f64>)> {
let qp = view.qp;
let n = qp.n;
let dim = n + active.len();
let mut h: BTreeMap<(usize, usize), f64> = BTreeMap::new();
for t in &qp.p_lower {
let (i, j) = if t.row >= t.col {
(t.row, t.col)
} else {
(t.col, t.row)
};
*h.entry((i, j)).or_insert(0.0) += t.val;
}
for c in active {
if let Slot::Quad(k) = c.slot {
view.add_quad_hessian(&view.quads[k], m.quad[k], &mut h);
}
}
let diag_mag = (0..n).fold(1.0_f64, |a, j| {
a.max(h.get(&(j, j)).copied().unwrap_or(0.0).abs())
});
let delta_w = 1e-12 * diag_mag;
for j in 0..n {
*h.entry((j, j)).or_insert(0.0) += delta_w;
}
let delta_c = 1e-12;
for (i, c) in active.iter().enumerate() {
for &(j, g) in &c.grad {
*h.entry((n + i, j)).or_insert(0.0) += g;
}
h.insert((n + i, n + i), -delta_c);
}
let entries: Vec<((usize, usize), f64)> = h.into_iter().collect();
let airn: Vec<Index> = entries
.iter()
.map(|&((i, _), _)| (i + 1) as Index)
.collect();
let ajcn: Vec<Index> = entries
.iter()
.map(|&((_, j), _)| (j + 1) as Index)
.collect();
let vals: Vec<f64> = entries.iter().map(|&(_, v)| v).collect();
let mut grad_l = view.objective_gradient(x);
for c in active {
let mu = c.slot.get(m);
for &(j, g) in &c.grad {
grad_l[j] += mu * g;
}
}
let mut b = Vec::with_capacity(dim);
b.extend(grad_l.iter().map(|v| -v));
b.extend(active.iter().map(|c| -c.value));
let mut fact = Factorization::new(dim as Index, airn, ajcn, vals, backend()).ok()?;
let mut rhs = b.clone();
fact.solve(&mut rhs, 1).ok()?;
let residual = |sol: &[f64]| -> Vec<f64> {
let mut r = b.clone();
for &((i, j), v) in &entries {
r[i] -= v * sol[j];
if i != j {
r[j] -= v * sol[i];
}
}
r
};
let inf = |v: &[f64]| v.iter().fold(0.0_f64, |a, x| a.max(x.abs()));
let mut r = residual(&rhs);
for _ in 0..REFINE_STEPS {
let mut d = r.clone();
if fact.solve(&mut d, 1).is_err() {
break;
}
let trial: Vec<f64> = rhs.iter().zip(&d).map(|(s, d)| s + d).collect();
let r_trial = residual(&trial);
if !(inf(&r_trial) < inf(&r)) {
break;
}
rhs = trial;
r = r_trial;
}
if !rhs.iter().all(|v| v.is_finite()) {
return None;
}
let x_new: Vec<f64> = x.iter().zip(&rhs[..n]).map(|(a, b)| a + b).collect();
let mu: Vec<f64> = active
.iter()
.zip(&rhs[n..])
.map(|(c, d)| c.slot.get(m) + d)
.collect();
Some((x_new, mu))
}
fn accept_step(old: &QcqpResiduals, new: &QcqpResiduals, tol: f64, dual_scale: f64) -> bool {
new.primal_infeasibility <= old.primal_infeasibility.max(tol)
&& new.merit(dual_scale) < old.merit(dual_scale)
}
pub fn polish_qcqp(
qp: &QpProblem,
cones: &[ConeSpec],
con_map: &[ConSocpMap],
sol: &QpSolution,
tol: f64,
backend: &BackendFactory,
) -> Option<QcqpPolish> {
let n = qp.n;
let optimal = matches!(sol.status, QpStatus::Optimal | QpStatus::OptimalInaccurate);
if !optimal
|| sol.x.len() != n
|| sol.y.len() != qp.m_eq()
|| sol.z.len() != qp.m_ineq()
|| !sol.x.iter().all(|v| v.is_finite())
{
return None;
}
let view = View::new(qp, cones, con_map)?;
let read =
|v: &[f64]| -> Vec<f64> { (0..n).map(|i| v.get(i).copied().unwrap_or(0.0)).collect() };
let mut m = Mults {
y: sol.y.clone(),
z: sol.z.clone(),
quad: view
.quads
.iter()
.map(|q| (sol.z[q.row0] + sol.z[q.row0 + 1]) / q.kappa)
.collect(),
z_lb: read(&sol.z_lb),
z_ub: read(&sol.z_ub),
};
let mut x = sol.x.clone();
if let Some(fit) = refit(&view, &x, &m) {
m = fit;
}
let mut res = view.residuals(&x, &m);
let mut newton_steps = 0;
let merit_scale = view
.objective_gradient(&x)
.iter()
.fold(1.0_f64, |a, v| a.max(v.abs()));
for _ in 0..MAX_NEWTON {
if !(res.merit(merit_scale) > 0.0) {
break;
}
let Some((x_new, m_new)) = newton_step(&view, &x, &m, backend) else {
break;
};
let res_new = view.residuals(&x_new, &m_new);
if accept_step(&res, &res_new, tol, merit_scale) {
x = x_new;
m = m_new;
res = res_new;
newton_steps += 1;
} else {
break;
}
}
if newton_steps > 0 {
if let Some(fit) = refit(&view, &x, &m) {
let res_fit = view.residuals(&x, &fit);
if res_fit.merit(merit_scale) <= res.merit(merit_scale) {
m = fit;
res = res_fit;
}
}
}
let obj = sol.obj + (view.objective(&x) - view.objective(&sol.x));
let dual_scale = view
.objective_gradient(&x)
.iter()
.fold(1.0_f64, |a, v| a.max(v.abs()));
let mut quad_by_con = vec![0.0; con_map.len()];
for (k, q) in view.quads.iter().enumerate() {
quad_by_con[q.con_idx] = m.quad[k];
}
Some(QcqpPolish {
x,
obj,
y: m.y,
z: m.z,
quad_by_con,
z_lb: m.z_lb,
z_ub: m.z_ub,
residuals: res,
newton_steps,
dual_scale,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nl_reader::{BinOp, Expr, NlBody};
use crate::qp_extract::{BoundRelax, extract_socp_with_map};
use pounce_convex::{QpOptions, solve_socp_ipm};
use pounce_feral::FeralSolverInterface;
type Row = (Vec<(usize, f64)>, f64, f64);
fn backend() -> Box<dyn SparseSymLinearSolverInterface> {
Box::new(FeralSolverInterface::new())
}
fn sq(e: Expr) -> Expr {
Expr::Binary(BinOp::Pow, Box::new(e), Box::new(Expr::Const(2.0)))
}
fn add(a: Expr, b: Expr) -> Expr {
Expr::Binary(BinOp::Add, Box::new(a), Box::new(b))
}
fn model(minimize: bool, p: [f64; 2], rows: &[Row]) -> NlProblem {
let shift = |v: usize, c: f64| {
Expr::Binary(BinOp::Sub, Box::new(Expr::Var(v)), Box::new(Expr::Const(c)))
};
let mut obj = add(sq(shift(0, p[0])), sq(shift(1, p[1])));
if !minimize {
obj = Expr::Binary(BinOp::Mul, Box::new(Expr::Const(-1.0)), Box::new(obj));
}
let mut con_nonlinear = vec![NlBody::Tree(add(sq(Expr::Var(0)), sq(Expr::Var(1))))];
let mut con_linear = vec![vec![]];
let mut g_l = vec![-2e19];
let mut g_u = vec![1.0];
for (coefs, lo, hi) in rows {
con_nonlinear.push(NlBody::Tree(Expr::Const(0.0)));
con_linear.push(coefs.clone());
g_l.push(*lo);
g_u.push(*hi);
}
let m = g_l.len();
NlProblem {
src: None,
cse_bodies: Vec::new(),
complementarity: Vec::new(),
n: 2,
m,
num_obj: 1,
minimize,
obj_nonlinear: NlBody::Tree(obj),
obj_linear: vec![],
obj_constant: 0.0,
con_nonlinear,
con_linear,
x_l: vec![-2e19, -2e19],
x_u: vec![2e19, 2e19],
g_l,
g_u,
x0: vec![0.0, 0.0],
lambda0: vec![0.0; m],
suffixes: Default::default(),
imported_funcs: Vec::new(),
ampl_options: Vec::new(),
nl_counts: None,
n_discrete: None,
var_names: Vec::new(),
con_names: Vec::new(),
}
}
fn grad_l(sign: f64, p: [f64; 2], x: &[f64], lambda: &[f64], rows: &[Row]) -> f64 {
let mut r = [2.0 * (x[0] - p[0]), 2.0 * (x[1] - p[1])];
for (j, rj) in r.iter_mut().enumerate() {
*rj += sign * lambda[0] * 2.0 * x[j];
for (k, (coefs, _, _)) in rows.iter().enumerate() {
for &(v, c) in coefs {
if v == j {
*rj += sign * lambda[1 + k] * c;
}
}
}
}
r.iter().fold(0.0_f64, |m, v| m.max(v.abs()))
}
fn line() -> Vec<Row> {
vec![(vec![(0, 1.0), (1, 1.0)], 1.0, 1.0)]
}
#[test]
fn issue_1114_equality_row_multipliers_are_stationary() {
let (p, rows) = ([2.0, 0.0], line());
let prob = model(true, p, &rows);
let (qp, con_map, _, cones) =
extract_socp_with_map(&prob, BoundRelax::NONE).expect("extract");
let sol = solve_socp_ipm(&qp, &cones, &QpOptions::default(), backend);
assert_eq!(sol.status, QpStatus::Optimal);
let raw = recover_socp_duals(&prob, &con_map, &sol.y, &sol.z);
let raw_r = grad_l(1.0, p, &sol.x, &raw, &rows);
let pol = polish_qcqp(&qp, &cones, &con_map, &sol, 1e-8, &backend).expect("optimal");
let lambda = pol.constraint_duals(&prob, &con_map);
assert!(
(pol.x[0] - 1.0).abs() < 1e-12 && pol.x[1].abs() < 1e-12,
"x={:?}",
pol.x
);
assert!((lambda[0] - 1.0).abs() < 1e-10, "ball μ = {}", lambda[0]);
assert!(lambda[1].abs() < 1e-10, "line ν = {}", lambda[1]);
let r = grad_l(1.0, p, &pol.x, &lambda, &rows);
assert!(
r < 1e-12,
"|∇L|∞ of the returned pair = {r:.2e} (unpolished {raw_r:.2e})"
);
assert!(
(pol.residuals.dual_infeasibility - r).abs() < 1e-13,
"reported {:.2e} vs independent {r:.2e}",
pol.residuals.dual_infeasibility
);
assert!(pol.residuals.primal_infeasibility < 1e-12);
}
#[test]
fn issue_1114_maximize_sense_round_trips() {
let (p, rows) = ([2.0, 0.0], line());
let prob = model(false, p, &rows);
let (qp, con_map, _, cones) =
extract_socp_with_map(&prob, BoundRelax::NONE).expect("extract");
let sol = solve_socp_ipm(&qp, &cones, &QpOptions::default(), backend);
assert_eq!(sol.status, QpStatus::Optimal);
let pol = polish_qcqp(&qp, &cones, &con_map, &sol, 1e-8, &backend).expect("optimal");
let lambda = pol.constraint_duals(&prob, &con_map);
assert!((lambda[0] + 1.0).abs() < 1e-10, "ball = {}", lambda[0]);
assert!(lambda[1].abs() < 1e-10, "line = {}", lambda[1]);
let r = grad_l(-1.0, p, &pol.x, &lambda, &rows);
assert!(r < 1e-12, "|∇L|∞ = {r:.2e}");
}
#[test]
fn issue_1114_newton_moves_an_inaccurate_point() {
let p = [2.0, 0.0];
let rows = vec![(vec![(1, 1.0)], -5.0, 2e19)];
let prob = model(true, p, &rows);
let (qp, con_map, _, cones) =
extract_socp_with_map(&prob, BoundRelax::NONE).expect("extract");
let mut sol = solve_socp_ipm(&qp, &cones, &QpOptions::default(), backend);
assert_eq!(sol.status, QpStatus::Optimal);
let delta: f64 = 1e-5;
sol.x = vec![(1.0 - delta * delta).sqrt(), delta];
let ConSocpMap::Ineq {
lower: Some(row), ..
} = con_map[1]
else {
panic!("expected a lower-bounded linear row: {:?}", con_map[1]);
};
sol.z[row] = 1e-12;
let raw = recover_socp_duals(&prob, &con_map, &sol.y, &sol.z);
assert!(
grad_l(1.0, p, &sol.x, &raw, &rows) > 1e-5,
"fixture must start off-stationary"
);
let pol = polish_qcqp(&qp, &cones, &con_map, &sol, 1e-8, &backend).expect("optimal");
assert!(pol.newton_steps >= 1, "no Newton step was taken");
assert!(
(pol.x[0] - 1.0).abs() < 1e-12 && pol.x[1].abs() < 1e-12,
"x={:?}",
pol.x
);
let lambda = pol.constraint_duals(&prob, &con_map);
assert!((lambda[0] - 1.0).abs() < 1e-10, "ball μ = {}", lambda[0]);
assert_eq!(lambda[1], 0.0, "inactive row multiplier");
assert!(pol.residuals.kkt_error() < 1e-12, "{:?}", pol.residuals);
}
#[test]
fn issue_1114_refit_freezes_an_inactive_row() {
let p = [2.0, 0.0];
let rows = vec![(vec![(1, 1.0)], -5.0, 2e19)];
let prob = model(true, p, &rows);
let (qp, con_map, _, cones) =
extract_socp_with_map(&prob, BoundRelax::NONE).expect("extract");
let view = View::new(&qp, &cones, &con_map).expect("layout");
let delta: f64 = 1e-5;
let x = vec![(1.0 - delta * delta).sqrt(), delta];
let row = qp.m_ineq() - 4 - 1; assert!(view.nonneg[row]);
let mut m = Mults {
y: vec![],
z: vec![0.0; qp.m_ineq()],
quad: vec![1.0],
z_lb: vec![0.0; 2],
z_ub: vec![0.0; 2],
};
m.z[row] = 1e-12;
let fit = refit(&view, &x, &m).unwrap_or(m);
assert_eq!(fit.z[row], 1e-12, "inactive row multiplier moved");
assert!(view.residuals(&x, &fit).dual_infeasibility > 1e-5);
}
#[test]
fn issue_1114_optimal_inaccurate_is_polished() {
let (p, rows) = ([2.0, 0.0], line());
let prob = model(true, p, &rows);
let (qp, con_map, _, cones) =
extract_socp_with_map(&prob, BoundRelax::NONE).expect("extract");
let mut sol = solve_socp_ipm(&qp, &cones, &QpOptions::default(), backend);
sol.status = QpStatus::OptimalInaccurate;
let pol = polish_qcqp(&qp, &cones, &con_map, &sol, 1e-8, &backend).expect("polished");
let lambda = pol.constraint_duals(&prob, &con_map);
assert!((lambda[0] - 1.0).abs() < 1e-10 && lambda[1].abs() < 1e-10);
}
#[test]
fn issue_1114_primal_active_nonbinding_row_is_dropped() {
let t = 9e-7;
let p = [2.0, t];
let rows = vec![(vec![(1, 1.0)], 0.0, 2e19)];
let prob = model(true, p, &rows);
let (qp, con_map, _, cones) =
extract_socp_with_map(&prob, BoundRelax::NONE).expect("extract");
let mut sol = solve_socp_ipm(&qp, &cones, &QpOptions::default(), backend);
assert_eq!(sol.status, QpStatus::Optimal);
let norm = (4.0 + t * t).sqrt();
let x_star = [2.0 / norm, t / norm];
sol.x = vec![x_star[0], x_star[1] + 2e-7];
let pol = polish_qcqp(&qp, &cones, &con_map, &sol, 1e-8, &backend).expect("optimal");
assert!(pol.newton_steps >= 1, "no Newton step was taken");
assert!(
(pol.x[1] - x_star[1]).abs() < 1e-13,
"x1 = {:e}, expected {:e}",
pol.x[1],
x_star[1]
);
let lambda = pol.constraint_duals(&prob, &con_map);
assert_eq!(lambda[1], 0.0, "non-binding row multiplier");
assert!(
(lambda[0] - (norm - 1.0)).abs() < 1e-9,
"ball μ = {}",
lambda[0]
);
}
#[test]
fn issue_1114_layout_mismatch_skips_the_polish() {
let prob = model(true, [2.0, 0.0], &line());
let (qp, mut con_map, _, cones) =
extract_socp_with_map(&prob, BoundRelax::NONE).expect("extract");
let sol = solve_socp_ipm(&qp, &cones, &QpOptions::default(), backend);
assert!(polish_qcqp(&qp, &cones, &con_map, &sol, 1e-8, &backend).is_some());
let ConSocpMap::Quad { z_row0, kappa, .. } = con_map[0] else {
panic!("expected the ball as a cone: {:?}", con_map[0]);
};
con_map[0] = ConSocpMap::Quad {
z_row0: z_row0 + 1,
z_row1: z_row0 + 2,
kappa,
};
assert!(polish_qcqp(&qp, &cones, &con_map, &sol, 1e-8, &backend).is_none());
assert!(
polish_qcqp(
&qp,
&cones[..cones.len() - 1],
&con_map,
&sol,
1e-8,
&backend
)
.is_none()
);
}
#[test]
fn issue_1114_accept_step_rule() {
let r = |primal: f64, dual: f64, compl_scaled: f64| QcqpResiduals {
primal_infeasibility: primal,
bound_violation: 0.0,
dual_infeasibility: dual,
complementarity: compl_scaled,
compl_scaled,
};
let old = r(1e-14, 1e-6, 0.0);
assert!(accept_step(&old, &r(1e-14, 1e-12, 0.0), 1e-8, 1.0));
assert!(!accept_step(&old, &r(1e-7, 1e-12, 0.0), 1e-8, 1.0));
assert!(accept_step(&old, &r(1e-9, 1e-12, 0.0), 1e-8, 1.0));
assert!(!accept_step(&old, &r(1e-14, 1e-6, 0.0), 1e-8, 1.0));
let inexact = r(3e-14, 7e-11, 2e-11);
let exact = r(0.0, 3e-8, 1e-15);
assert!(accept_step(&inexact, &exact, 1e-8, 2e8));
assert!(!accept_step(&inexact, &exact, 1e-8, 1.0));
}
#[test]
fn issue_1114_non_optimal_status_is_not_polished() {
let prob = model(true, [2.0, 0.0], &line());
let (qp, con_map, _, cones) =
extract_socp_with_map(&prob, BoundRelax::NONE).expect("extract");
let mut sol = solve_socp_ipm(&qp, &cones, &QpOptions::default(), backend);
sol.status = QpStatus::PrimalInfeasible;
assert!(polish_qcqp(&qp, &cones, &con_map, &sol, 1e-8, &backend).is_none());
}
}