use crate::nl_reader::NlProblem;
use pounce_common::types::{lower_bound_present, upper_bound_present};
use pounce_convex::{Triplet, certify_psd_lower_triangle};
const PSD_TOL: f64 = 1e-9;
const SOCP_REFORM_FLOP_BUDGET: u128 = 20_000_000;
const SOCP_SOLVE_SIZE_CAP: u64 = 100_000_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProblemClass {
Lp,
ConvexQp,
ConvexQcqp,
NonconvexQp,
Nlp,
}
impl ProblemClass {
pub fn name(self) -> &'static str {
match self {
ProblemClass::Lp => "LP",
ProblemClass::ConvexQp => "convex QP",
ProblemClass::ConvexQcqp => "convex QCQP",
ProblemClass::NonconvexQp => "nonconvex QP",
ProblemClass::Nlp => "NLP",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClassReason {
NoNonlinearParts,
NonlinearPartsCancelled,
ObjectiveNotQuadratic,
ConstraintNotQuadratic { row: usize },
ObjectiveTermsDropped,
ConstraintTermsDropped { row: usize },
ObjectiveHessianIndefinite,
NonconvexQcqp { row: usize },
ConstraintSenseNonconvex { row: usize },
ConstraintHessianIndefinite { row: usize },
QcqpReformTooCostly { flops: u128 },
QcqpTooLargeToSolve { size: u64 },
ConvexQuadraticObjective,
ConvexQcqpWithinBudgets { flops: u128, size: u64 },
}
impl ClassReason {
pub fn explain(self) -> String {
match self {
ClassReason::NoNonlinearParts => {
"no nonlinear part in the objective or any row".to_string()
}
ClassReason::NonlinearPartsCancelled => {
"every nonlinear part expanded to a linear (or constant) polynomial".to_string()
}
ClassReason::ObjectiveNotQuadratic => {
"the objective's nonlinear part is not a degree-2 polynomial".to_string()
}
ClassReason::ConstraintNotQuadratic { row } => {
format!("row {row}'s nonlinear part is not a degree-2 polynomial")
}
ClassReason::ObjectiveTermsDropped => {
"the objective's quadratic form lost a term to an inexact \
floating-point fold or a flush to zero, so its coefficients are \
not the whole objective"
.to_string()
}
ClassReason::ConstraintTermsDropped { row } => format!(
"row {row}'s quadratic form lost a term to an inexact \
floating-point fold or a flush to zero, so its coefficients are \
not the whole row"
),
ClassReason::ObjectiveHessianIndefinite => {
"the objective Hessian (sense-adjusted for minimization) is not PSD, \
and every row is linear"
.to_string()
}
ClassReason::NonconvexQcqp { row } => format!(
"the objective Hessian (sense-adjusted for minimization) is not PSD \
and row {row} is quadratic, so this is a nonconvex QCQP rather than \
a nonconvex QP"
),
ClassReason::ConstraintSenseNonconvex { row } => format!(
"row {row} is a convex quadratic but its bound sense (>=, =, or \
two-sided) makes the feasible set nonconvex"
),
ClassReason::ConstraintHessianIndefinite { row } => {
format!("row {row}'s quadratic Hessian is not PSD")
}
ClassReason::QcqpReformTooCostly { flops } => format!(
"convex QCQP downgraded: cone reformulation costs {flops} flops \
(budget {SOCP_REFORM_FLOP_BUDGET})"
),
ClassReason::QcqpTooLargeToSolve { size } => format!(
"convex QCQP downgraded: conic solve size n·m = {size} \
(cap {SOCP_SOLVE_SIZE_CAP})"
),
ClassReason::ConvexQuadraticObjective => {
"convex quadratic objective, linear rows".to_string()
}
ClassReason::ConvexQcqpWithinBudgets { flops, size } => format!(
"convex QCQP inside both guards: reformulation {flops} flops \
(budget {SOCP_REFORM_FLOP_BUDGET}), conic solve size n·m = {size} \
(cap {SOCP_SOLVE_SIZE_CAP})"
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SolverChoice {
Nlp,
LpIpm,
QpIpm,
SocpIpm,
QpActiveSet,
}
impl SolverChoice {
pub fn describe(self) -> &'static str {
match self {
SolverChoice::Nlp => "NLP filter line-search interior-point (pounce-nlp)",
SolverChoice::LpIpm => "LP interior-point (pounce-convex)",
SolverChoice::QpIpm => "convex QP interior-point (pounce-convex)",
SolverChoice::SocpIpm => "convex QCQP conic interior-point (pounce-convex)",
SolverChoice::QpActiveSet => "active-set QP (pounce-qp)",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SolverSelection {
Auto,
Nlp,
LpIpm,
QpIpm,
Socp,
QpActiveSet,
}
impl SolverSelection {
pub fn parse(s: &str) -> Option<Self> {
match s {
"auto" => Some(SolverSelection::Auto),
"nlp" => Some(SolverSelection::Nlp),
"lp-ipm" => Some(SolverSelection::LpIpm),
"qp-ipm" => Some(SolverSelection::QpIpm),
"socp" => Some(SolverSelection::Socp),
"qp-active-set" => Some(SolverSelection::QpActiveSet),
_ => None,
}
}
pub const VALUES: &'static [&'static str] =
&["auto", "nlp", "lp-ipm", "qp-ipm", "socp", "qp-active-set"];
}
pub fn classify_problem(prob: &NlProblem) -> ProblemClass {
classify_problem_explained(prob).0
}
pub fn classify_problem_explained(prob: &NlProblem) -> (ProblemClass, ClassReason) {
let verdict = classify_inner(prob);
if std::env::var_os("POUNCE_DBG_CLASSIFY").is_some() {
eprintln!(
"pounce: problem class {} — {} [{}]",
verdict.0.name(),
verdict.1.explain(),
header_census(prob)
);
}
verdict
}
fn header_census(prob: &NlProblem) -> String {
let tree_rows = prob
.con_nonlinear
.iter()
.filter(|b| !b.is_trivially_zero())
.count();
let tree_obj = usize::from(!prob.obj_nonlinear.is_trivially_zero());
let Some(c) = prob.nl_counts else {
return format!("no .nl header census; trees: nl_rows={tree_rows} nl_obj={tree_obj}");
};
let flag = if c.nl_cons < tree_rows || c.nl_objs < tree_obj {
" — HEADER UNDER-STATES the trees; writer is non-conforming"
} else {
""
};
format!(
"header nlc={} nlo={} nlvc={} nlvo={} nlvb={} ({} of {} vars nonlinear); \
trees: nl_rows={tree_rows} nl_obj={tree_obj}{flag}",
c.nl_cons,
c.nl_objs,
c.nl_vars_cons,
c.nl_vars_objs,
c.nl_vars_both,
c.nonlinear_vars(),
prob.n,
)
}
fn classify_inner(prob: &NlProblem) -> (ProblemClass, ClassReason) {
let obj_nl = !prob.obj_nonlinear.is_trivially_zero();
let cons_nl = prob.con_nonlinear.iter().any(|b| !b.is_trivially_zero());
if !obj_nl && !cons_nl {
return (ProblemClass::Lp, ClassReason::NoNonlinearParts);
}
let obj_quad = match prob.obj_nonlinear.analyze_quadratic() {
Some(q) => q,
None if prob.obj_nonlinear.quad_terms_dropped() => {
return (ProblemClass::Nlp, ClassReason::ObjectiveTermsDropped);
}
None => return (ProblemClass::Nlp, ClassReason::ObjectiveNotQuadratic),
};
let mut any_quadratic_constraint = false;
let mut first_quadratic_row = 0usize;
for (row, c) in prob.con_nonlinear.iter().enumerate() {
if c.is_trivially_zero() {
continue;
}
match c.analyze_quadratic() {
Some(q) if q.is_empty() => {}
Some(_) => {
if !any_quadratic_constraint {
first_quadratic_row = row;
}
any_quadratic_constraint = true;
}
None if c.quad_terms_dropped() => {
return (
ProblemClass::Nlp,
ClassReason::ConstraintTermsDropped { row },
);
}
None => {
return (
ProblemClass::Nlp,
ClassReason::ConstraintNotQuadratic { row },
);
}
}
}
if !obj_quad.is_empty() {
let effective: QuadHessian = if prob.minimize {
obj_quad.clone()
} else {
obj_quad.iter().map(|(k, v)| (*k, -v)).collect()
};
if !hessian_is_psd(&effective, prob.n) {
if any_quadratic_constraint {
return (
ProblemClass::Nlp,
ClassReason::NonconvexQcqp {
row: first_quadratic_row,
},
);
}
return (
ProblemClass::NonconvexQp,
ClassReason::ObjectiveHessianIndefinite,
);
}
}
if any_quadratic_constraint {
let mut reform_flops: u128 = 0;
for (row, c) in prob.con_nonlinear.iter().enumerate() {
if c.is_trivially_zero() {
continue;
}
match c.analyze_quadratic() {
Some(q) if q.is_empty() => {} Some(q) => {
let lo = prob.g_l[row];
let hi = prob.g_u[row];
let lo_present = lower_bound_present(lo);
let hi_present = upper_bound_present(hi);
let vacuous = !lo_present && !hi_present;
let upper_only = hi_present && !lo_present;
if vacuous {
continue;
}
if !upper_only {
return (
ProblemClass::Nlp,
ClassReason::ConstraintSenseNonconvex { row },
);
}
if !hessian_is_psd(&q, prob.n) {
return (
ProblemClass::Nlp,
ClassReason::ConstraintHessianIndefinite { row },
);
}
reform_flops = reform_flops.saturating_add(socp_reform_flops(&q));
}
None if c.quad_terms_dropped() => {
return (
ProblemClass::Nlp,
ClassReason::ConstraintTermsDropped { row },
);
}
None => {
return (
ProblemClass::Nlp,
ClassReason::ConstraintNotQuadratic { row },
);
}
}
}
let solve_size = (prob.n as u64).saturating_mul(prob.m as u64);
let too_costly_to_reform = reform_flops > SOCP_REFORM_FLOP_BUDGET;
let too_large_to_solve = solve_size > SOCP_SOLVE_SIZE_CAP;
if std::env::var_os("POUNCE_DBG_SOCP_COST").is_some() {
eprintln!(
"pounce: QCQP conic reformulation cost {reform_flops} flops \
(budget {SOCP_REFORM_FLOP_BUDGET}), conic solve size n·m \
{solve_size} (cap {SOCP_SOLVE_SIZE_CAP}) → {}",
if too_costly_to_reform || too_large_to_solve {
"NLP"
} else {
"ConvexQcqp"
}
);
}
if too_costly_to_reform {
return (
ProblemClass::Nlp,
ClassReason::QcqpReformTooCostly {
flops: reform_flops,
},
);
}
if too_large_to_solve {
return (
ProblemClass::Nlp,
ClassReason::QcqpTooLargeToSolve { size: solve_size },
);
}
return (
ProblemClass::ConvexQcqp,
ClassReason::ConvexQcqpWithinBudgets {
flops: reform_flops,
size: solve_size,
},
);
}
if obj_quad.is_empty() {
(ProblemClass::Lp, ClassReason::NonlinearPartsCancelled)
} else {
(
ProblemClass::ConvexQp,
ClassReason::ConvexQuadraticObjective,
)
}
}
pub fn resolve_solver(
class: ProblemClass,
selection: SolverSelection,
) -> Result<SolverChoice, String> {
use ProblemClass as P;
use SolverSelection as S;
let is_lp = class == P::Lp;
let is_convex_qp = matches!(class, P::Lp | P::ConvexQp);
let is_conic = matches!(class, P::Lp | P::ConvexQp | P::ConvexQcqp);
match selection {
S::Auto => match class {
P::Lp | P::ConvexQp => Ok(SolverChoice::QpIpm),
P::ConvexQcqp => Ok(SolverChoice::SocpIpm),
_ => Ok(SolverChoice::Nlp),
},
S::Nlp => Ok(SolverChoice::Nlp),
S::LpIpm => {
if is_lp {
Ok(SolverChoice::LpIpm)
} else {
Err(mismatch_msg(class, "lp-ipm", "an LP"))
}
}
S::QpIpm => {
if is_convex_qp {
Ok(SolverChoice::QpIpm)
} else {
Err(mismatch_msg(class, "qp-ipm", "an LP or convex QP"))
}
}
S::Socp => {
if is_conic {
Ok(SolverChoice::SocpIpm)
} else {
Err(mismatch_msg(class, "socp", "a convex LP, QP, or QCQP"))
}
}
S::QpActiveSet => {
if is_convex_qp || class == P::NonconvexQp {
Ok(SolverChoice::QpActiveSet)
} else {
Err(mismatch_msg(
class,
"qp-active-set",
"an LP or a QP with linear constraints, convex or indefinite",
))
}
}
}
}
fn mismatch_msg(class: ProblemClass, forced: &str, expected: &str) -> String {
format!(
"problem class {} does not match forced solver {} (expected {})",
class.name(),
forced,
expected
)
}
pub(crate) use pounce_nl::nl_quadratic::QuadHessian;
#[cfg(test)]
use pounce_nl::nl_quadratic::analyze_quadratic;
fn hessian_active_vars(h: &QuadHessian) -> usize {
let mut active: Vec<usize> = Vec::with_capacity(2 * h.len());
for (i, j) in h.keys() {
active.push(*i);
active.push(*j);
}
active.sort_unstable();
active.dedup();
active.len()
}
fn socp_reform_flops(h: &QuadHessian) -> u128 {
let k = hessian_active_vars(h) as u128;
if h.keys().any(|(i, j)| i != j) {
k.saturating_mul(k).saturating_mul(k)
} else {
k
}
}
fn psd_band(h: &QuadHessian) -> f64 {
let h_scale = h.values().fold(0.0_f64, |a, v| a.max(v.abs()));
PSD_TOL * h_scale.min(1.0)
}
fn hessian_is_psd(h: &QuadHessian, n: usize) -> bool {
if h.is_empty() {
return true;
}
let tol = psd_band(h);
if h.keys().all(|(i, j)| i == j) {
return h.values().all(|value| *value >= -tol);
}
let lower: Vec<_> = h
.iter()
.map(|(&(i, j), &val)| Triplet::new(j, i, val))
.collect();
certify_psd_lower_triangle(n, &lower, tol, || {
Box::new(pounce_feral::FeralSolverInterface::with_config(
pounce_feral::FeralConfig::default(),
))
})
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nl_reader::NlBody;
use crate::nl_reader::{BinOp, Expr, UnaryOp, parse_nl_text};
#[test]
fn parse_selection_values() {
assert_eq!(SolverSelection::parse("auto"), Some(SolverSelection::Auto));
assert_eq!(SolverSelection::parse("nlp"), Some(SolverSelection::Nlp));
assert_eq!(
SolverSelection::parse("lp-ipm"),
Some(SolverSelection::LpIpm)
);
assert_eq!(
SolverSelection::parse("qp-ipm"),
Some(SolverSelection::QpIpm)
);
assert_eq!(
SolverSelection::parse("qp-active-set"),
Some(SolverSelection::QpActiveSet)
);
assert_eq!(SolverSelection::parse("lp-simplex"), None);
assert_eq!(SolverSelection::parse("bogus"), None);
}
#[test]
fn auto_routes_convex_qp_family_to_qp_ipm() {
assert_eq!(
resolve_solver(ProblemClass::Lp, SolverSelection::Auto),
Ok(SolverChoice::QpIpm),
"auto should route LP to the convex IPM (P=0)"
);
assert_eq!(
resolve_solver(ProblemClass::ConvexQp, SolverSelection::Auto),
Ok(SolverChoice::QpIpm),
"auto should route convex QP to the convex IPM"
);
}
#[test]
fn auto_routes_convex_qcqp_to_socp() {
assert_eq!(
resolve_solver(ProblemClass::ConvexQcqp, SolverSelection::Auto),
Ok(SolverChoice::SocpIpm),
"auto should route convex QCQP to the conic IPM"
);
}
#[test]
fn auto_routes_nonconvex_to_nlp() {
for class in [ProblemClass::NonconvexQp, ProblemClass::Nlp] {
assert_eq!(
resolve_solver(class, SolverSelection::Auto),
Ok(SolverChoice::Nlp),
"auto must resolve to Nlp for {:?}",
class
);
}
}
#[test]
fn forced_socp_accepts_convex_cone_family_only() {
for class in [
ProblemClass::Lp,
ProblemClass::ConvexQp,
ProblemClass::ConvexQcqp,
] {
assert_eq!(
resolve_solver(class, SolverSelection::Socp),
Ok(SolverChoice::SocpIpm),
"socp should accept {:?}",
class
);
}
assert!(resolve_solver(ProblemClass::NonconvexQp, SolverSelection::Socp).is_err());
assert!(resolve_solver(ProblemClass::Nlp, SolverSelection::Socp).is_err());
}
#[test]
fn forced_nlp_always_ok() {
assert_eq!(
resolve_solver(ProblemClass::ConvexQp, SolverSelection::Nlp),
Ok(SolverChoice::Nlp)
);
}
#[test]
fn forced_lp_on_nlp_errors() {
let err = resolve_solver(ProblemClass::Nlp, SolverSelection::LpIpm).unwrap_err();
assert!(err.contains("NLP"), "msg should name detected class: {err}");
assert!(
err.contains("lp-ipm"),
"msg should name forced solver: {err}"
);
}
#[test]
fn forced_lp_on_lp_ok() {
assert_eq!(
resolve_solver(ProblemClass::Lp, SolverSelection::LpIpm),
Ok(SolverChoice::LpIpm)
);
}
#[test]
fn forced_qp_active_set_accepts_indefinite_qp_but_not_a_qcqp() {
for class in [
ProblemClass::Lp,
ProblemClass::ConvexQp,
ProblemClass::NonconvexQp,
] {
assert_eq!(
resolve_solver(class, SolverSelection::QpActiveSet),
Ok(SolverChoice::QpActiveSet),
"qp-active-set should accept {class:?}"
);
}
for class in [ProblemClass::ConvexQcqp, ProblemClass::Nlp] {
let err = resolve_solver(class, SolverSelection::QpActiveSet).unwrap_err();
assert!(err.contains("qp-active-set"), "{err}");
assert!(err.contains(class.name()), "{err}");
}
}
#[test]
fn auto_still_routes_a_nonconvex_qp_to_nlp() {
assert_eq!(
resolve_solver(ProblemClass::NonconvexQp, SolverSelection::Auto),
Ok(SolverChoice::Nlp)
);
}
#[test]
fn forced_qp_accepts_lp_and_convex_qp_only() {
assert_eq!(
resolve_solver(ProblemClass::Lp, SolverSelection::QpIpm),
Ok(SolverChoice::QpIpm)
);
assert_eq!(
resolve_solver(ProblemClass::ConvexQp, SolverSelection::QpIpm),
Ok(SolverChoice::QpIpm)
);
assert!(resolve_solver(ProblemClass::NonconvexQp, SolverSelection::QpIpm).is_err());
assert!(resolve_solver(ProblemClass::Nlp, SolverSelection::QpIpm).is_err());
}
#[test]
fn poly_of_quadratic_diagonal() {
let e = Expr::Binary(
BinOp::Pow,
Box::new(Expr::Binary(
BinOp::Sub,
Box::new(Expr::Var(0)),
Box::new(Expr::Const(1.0)),
)),
Box::new(Expr::Const(2.0)),
);
let h = analyze_quadratic(&e).expect("degree-2 polynomial");
assert_eq!(h.get(&(0, 0)), Some(&2.0));
}
#[test]
fn poly_rejects_transcendental() {
let e = Expr::Unary(UnaryOp::Sin, Box::new(Expr::Var(0)));
assert!(analyze_quadratic(&e).is_none());
}
#[test]
fn poly_rejects_cubic() {
let e = Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(0)),
Box::new(Expr::Const(3.0)),
);
assert!(analyze_quadratic(&e).is_none());
}
#[test]
fn cross_term_hessian() {
let e = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
let h = analyze_quadratic(&e).expect("degree-2");
assert_eq!(h.get(&(0, 1)), Some(&1.0));
}
#[test]
fn large_quadratic_sum_lowers_without_quadratic_blowup() {
const N: usize = 5000;
let terms: Vec<Expr> = (0..N)
.map(|i| Expr::Binary(BinOp::Mul, Box::new(Expr::Var(i)), Box::new(Expr::Var(i))))
.collect();
let e = Expr::Sum(terms);
let h = analyze_quadratic(&e).expect("degree-2 sum of squares is a QP");
assert_eq!(h.len(), N, "every xᵢ² contributes one diagonal entry");
assert_eq!(h.get(&(0, 0)), Some(&2.0));
assert_eq!(h.get(&(N - 1, N - 1)), Some(&2.0));
}
#[test]
fn psd_accepts_convex_separable() {
let mut h = QuadHessian::new();
h.insert((0, 0), 2.0);
h.insert((1, 1), 4.0);
assert!(hessian_is_psd(&h, 2));
}
#[test]
fn psd_rejects_indefinite() {
let mut h = QuadHessian::new();
h.insert((0, 1), 1.0);
assert!(!hessian_is_psd(&h, 2));
}
#[test]
fn psd_accepts_psd_with_zero_eigenvalue() {
let mut h = QuadHessian::new();
h.insert((0, 0), 1.0);
h.insert((0, 1), 1.0);
h.insert((1, 1), 1.0);
assert!(hessian_is_psd(&h, 2));
}
#[test]
fn psd_rejects_small_but_real_negative_curvature() {
let mut h = QuadHessian::new();
h.insert((0, 0), 2.0);
h.insert((1, 1), -1e-3);
assert!(
!hessian_is_psd(&h, 2),
"a −1e-3 eigenvalue must read indefinite, not be rounded to PSD"
);
}
#[test]
fn psd_threshold_is_psd_tol_at_unit_scale() {
let mut just_inside = QuadHessian::new();
just_inside.insert((0, 0), 1.0);
just_inside.insert((1, 1), -1e-10); assert!(
hessian_is_psd(&just_inside, 2),
"−1e-10 against ‖H‖ = 1 is within tolerance and must round to PSD"
);
let mut just_outside = QuadHessian::new();
just_outside.insert((0, 0), 1.0);
just_outside.insert((1, 1), -1e-7); assert!(
!hessian_is_psd(&just_outside, 2),
"−1e-7 against ‖H‖ = 1 is beyond tolerance and must read indefinite"
);
}
#[test]
fn psd_verdict_is_invariant_under_a_change_of_units() {
for k in [1.0_f64, 1e2, 1e5, 1e8, 1e-4] {
let f = k.powi(-2);
let mut coupled = QuadHessian::new();
coupled.insert((0, 0), f);
coupled.insert((0, 1), 5.0 * f);
coupled.insert((1, 1), f);
assert!(
!hessian_is_psd(&coupled, 2),
"K = {k:e}: [[1,5],[5,1]] scaled by K⁻² is indefinite at every \
scale (|λ_min|/λ_max = 2/3); an absolute band hides it"
);
let mut diagonal = QuadHessian::new();
diagonal.insert((0, 0), 6.0 * f);
diagonal.insert((1, 1), -4.0 * f);
assert!(
!hessian_is_psd(&diagonal, 2),
"K = {k:e}: diag(6, −4) scaled by K⁻² is indefinite at every scale"
);
let mut convex = QuadHessian::new();
convex.insert((0, 0), 6.0 * f);
convex.insert((1, 1), 4.0 * f);
assert!(
hessian_is_psd(&convex, 2),
"K = {k:e}: diag(6, 4) scaled by K⁻² is PD at every scale and \
must keep reaching the convex engine"
);
}
}
#[test]
fn psd_band_does_not_widen_above_unit_scale() {
let mut h = QuadHessian::new();
h.insert((0, 0), 1e6);
h.insert((1, 1), -1e-7);
assert!(
!hessian_is_psd(&h, 2),
"−1e-7 must stay indefinite however large the rest of H is"
);
assert_eq!(
psd_band(&h),
PSD_TOL,
"the band is clamped at PSD_TOL for ‖H‖∞ ≥ 1"
);
}
#[test]
fn large_diagonal_hessian_is_cheap_and_psd() {
let n = 50_000;
let mut h = QuadHessian::new();
for i in 0..n {
h.insert((i, i), 2.0);
}
assert!(
hessian_is_psd(&h, n),
"diag(2,…,2) is PSD and must be settled by the O(nnz) sign path"
);
}
#[test]
fn large_coupled_convex_hessian_is_certified_psd() {
let k = 1_000;
let mut h = QuadHessian::new();
for i in 0..k {
h.insert((i, i), 2.0);
}
for i in 0..(k - 1) {
h.insert((i, i + 1), 0.1);
}
assert!(
hessian_is_psd(&h, k),
"a diagonally-dominant coupled Hessian over {k} vars must be \
certified PSD by the sparse factorization (CVXQP regression)"
);
}
#[test]
fn large_coupled_indefinite_hessian_is_rejected() {
let k = 1_000;
let mut h = QuadHessian::new();
for i in 0..k {
h.insert((i, i), 2.0);
}
for i in 0..(k - 1) {
h.insert((i, i + 1), 0.1);
}
h.insert((0, 0), -5.0);
assert!(
!hessian_is_psd(&h, k),
"a coupled Hessian with a strong negative-curvature direction \
must be rejected regardless of size"
);
}
#[test]
fn small_coupled_hessian_is_certified_psd() {
let mut h = QuadHessian::new();
h.insert((0, 0), 2.0);
h.insert((0, 1), 1.0);
h.insert((1, 1), 2.0);
assert!(hessian_is_psd(&h, 2));
}
#[test]
fn classify_pure_lp() {
let prob = NlProblem {
src: None,
cse_bodies: Vec::new(),
n: 2,
m: 1,
num_obj: 1,
minimize: true,
obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
obj_linear: vec![(0, 1.0), (1, 1.0)],
obj_constant: 0.0,
con_nonlinear: vec![NlBody::Tree(Expr::Const(0.0))],
con_linear: vec![vec![(0, 1.0), (1, 1.0)]],
x_l: vec![0.0, 0.0],
x_u: vec![f64::INFINITY, f64::INFINITY],
g_l: vec![f64::NEG_INFINITY],
g_u: vec![1.0],
x0: vec![0.0, 0.0],
lambda0: vec![0.0],
suffixes: Default::default(),
imported_funcs: Vec::new(),
ampl_options: Vec::new(),
nl_counts: None,
var_names: Vec::new(),
con_names: Vec::new(),
};
assert_eq!(classify_problem(&prob), ProblemClass::Lp);
}
#[test]
fn classify_convex_qp() {
let obj = Expr::Binary(
BinOp::Add,
Box::new(Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(0)),
Box::new(Expr::Const(2.0)),
)),
Box::new(Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(1)),
Box::new(Expr::Const(2.0)),
)),
);
let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
assert_eq!(classify_problem(&prob), ProblemClass::ConvexQp);
}
#[test]
fn a_quadratic_row_bounded_past_the_sentinel_is_not_vacuous() {
let con = Expr::Binary(
BinOp::Add,
Box::new(Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(0)),
Box::new(Expr::Const(2.0)),
)),
Box::new(Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(1)),
Box::new(Expr::Const(2.0)),
)),
);
let mut prob = qp_stub(Expr::Const(0.0), vec![con]);
prob.obj_linear = vec![(0, 1.0)];
prob.g_l = vec![5e20]; prob.g_u = vec![1e19]; assert_eq!(
classify_problem(&prob),
ProblemClass::Nlp,
"a `>=` quadratic row is reverse-convex and must route to NLP; \
treating it as a free row sent the model to the conic solver \
with the constraint silently dropped"
);
}
#[test]
fn classify_nonconvex_qp() {
let obj = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
assert_eq!(classify_problem(&prob), ProblemClass::NonconvexQp);
}
#[test]
fn classify_indefinite_objective_with_quadratic_row_is_not_a_qp() {
let obj = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
let ball = Expr::Binary(
BinOp::Add,
Box::new(Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(0)),
Box::new(Expr::Const(2.0)),
)),
Box::new(Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(1)),
Box::new(Expr::Const(2.0)),
)),
);
let mut prob = qp_stub(obj, vec![ball]);
prob.g_l = vec![f64::NEG_INFINITY];
prob.g_u = vec![1.0];
let (class, reason) = classify_problem_explained(&prob);
assert_eq!(class, ProblemClass::Nlp);
assert_eq!(reason, ClassReason::NonconvexQcqp { row: 0 });
assert!(
resolve_solver(class, SolverSelection::QpActiveSet).is_err(),
"a nonconvex QCQP must not reach the active-set QP engine"
);
}
#[test]
fn classify_nlp_from_transcendental_objective() {
let obj = Expr::Unary(UnaryOp::Exp, Box::new(Expr::Var(0)));
let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
}
#[test]
fn classify_maximize_psd_objective_is_nonconvex() {
let obj = Expr::Binary(
BinOp::Add,
Box::new(Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(0)),
Box::new(Expr::Const(2.0)),
)),
Box::new(Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(1)),
Box::new(Expr::Const(2.0)),
)),
);
let mut prob = qp_stub(obj, vec![Expr::Const(0.0)]);
prob.minimize = false;
assert_eq!(classify_problem(&prob), ProblemClass::NonconvexQp);
}
#[test]
fn classify_maximize_concave_objective_is_convex() {
let neg_sq = |v: usize| {
Expr::Unary(
UnaryOp::Neg,
Box::new(Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(v)),
Box::new(Expr::Const(2.0)),
)),
)
};
let obj = Expr::Binary(BinOp::Add, Box::new(neg_sq(0)), Box::new(neg_sq(1)));
let mut prob = qp_stub(obj, vec![Expr::Const(0.0)]);
prob.minimize = false;
assert_eq!(classify_problem(&prob), ProblemClass::ConvexQp);
}
#[test]
fn classify_convex_qcqp() {
let obj = Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(0)),
Box::new(Expr::Const(2.0)),
);
let con = Expr::Binary(
BinOp::Add,
Box::new(Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(0)),
Box::new(Expr::Const(2.0)),
)),
Box::new(Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(1)),
Box::new(Expr::Const(2.0)),
)),
);
let prob = qp_stub(obj, vec![con]);
assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
}
fn convex_qcqp_at_size(n: usize, m: usize) -> NlProblem {
let mut con_nonlinear = vec![NlBody::Tree(Expr::Const(0.0)); m];
con_nonlinear[0] = NlBody::Tree(Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(0)),
Box::new(Expr::Const(2.0)),
));
let g_l = vec![f64::NEG_INFINITY; m];
let mut g_u = vec![f64::INFINITY; m];
g_u[0] = 1.0; NlProblem {
src: None,
cse_bodies: Vec::new(),
n,
m,
num_obj: 1,
minimize: true,
obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
obj_linear: vec![(0, 1.0)],
obj_constant: 0.0,
con_nonlinear,
con_linear: vec![vec![]; m],
x_l: vec![f64::NEG_INFINITY; n],
x_u: vec![f64::INFINITY; n],
g_l,
g_u,
x0: vec![0.0; n],
lambda0: vec![0.0; m],
suffixes: Default::default(),
imported_funcs: Vec::new(),
ampl_options: Vec::new(),
nl_counts: None,
var_names: Vec::new(),
con_names: Vec::new(),
}
}
#[test]
fn small_convex_qcqp_routes_to_conic() {
let prob = convex_qcqp_at_size(100, 100); assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
}
#[test]
fn large_qcqp_cheap_to_reform_still_falls_back_on_solve_size() {
let prob = convex_qcqp_at_size(10_001, 10_001);
assert!((prob.n as u64) * (prob.m as u64) > SOCP_SOLVE_SIZE_CAP);
assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
}
#[test]
fn large_qcqp_under_the_solve_size_cap_keeps_conic() {
let prob = convex_qcqp_at_size(10_000, 9_000);
assert!((prob.n as u64) * (prob.m as u64) <= SOCP_SOLVE_SIZE_CAP);
assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
}
#[test]
fn qcqp_downgrades_say_which_guard_fired() {
let (class, reason) = classify_problem_explained(&convex_qcqp_at_size(10_001, 10_001));
assert_eq!(class, ProblemClass::Nlp);
assert!(
matches!(reason, ClassReason::QcqpTooLargeToSolve { size } if size == 10_001 * 10_001),
"expected the solve-size cap, got {reason:?}"
);
let (class, reason) = classify_problem_explained(&coupled_convex_qcqp_with_k_vars(400));
assert_eq!(class, ProblemClass::Nlp);
assert!(
matches!(reason, ClassReason::QcqpReformTooCostly { .. }),
"expected the reformulation budget, got {reason:?}"
);
let (class, reason) = classify_problem_explained(&convex_qcqp_at_size(100, 100));
assert_eq!(class, ProblemClass::ConvexQcqp);
assert!(
matches!(
reason,
ClassReason::ConvexQcqpWithinBudgets { size, .. } if size == 10_000
),
"expected a within-budget QCQP, got {reason:?}"
);
}
#[test]
fn nonconvex_sense_and_indefinite_hessian_are_distinct_reasons() {
let mut prob = convex_qcqp_at_size(10, 10);
prob.g_l[0] = 1.0;
prob.g_u[0] = f64::INFINITY;
let (class, reason) = classify_problem_explained(&prob);
assert_eq!(class, ProblemClass::Nlp);
assert_eq!(reason, ClassReason::ConstraintSenseNonconvex { row: 0 });
let mut prob = convex_qcqp_at_size(10, 10);
prob.con_nonlinear[0] = NlBody::Tree(Expr::Unary(
UnaryOp::Neg,
Box::new(Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(0)),
Box::new(Expr::Const(2.0)),
)),
));
let (class, reason) = classify_problem_explained(&prob);
assert_eq!(class, ProblemClass::Nlp);
assert_eq!(reason, ClassReason::ConstraintHessianIndefinite { row: 0 });
}
#[test]
fn lp_reasons_distinguish_absent_from_cancelled() {
let prob = qp_stub(Expr::Const(0.0), vec![Expr::Const(0.0)]);
assert_eq!(
classify_problem_explained(&prob),
(ProblemClass::Lp, ClassReason::NoNonlinearParts)
);
let expands = Expr::Binary(
BinOp::Mul,
Box::new(Expr::Const(2.0)),
Box::new(Expr::Var(0)),
);
let prob = qp_stub(expands, vec![Expr::Const(0.0)]);
assert_eq!(
classify_problem_explained(&prob),
(ProblemClass::Lp, ClassReason::NonlinearPartsCancelled)
);
let cancels = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(0)), Box::new(Expr::Var(0)));
let prob = qp_stub(cancels, vec![Expr::Const(0.0)]);
assert_eq!(
classify_problem_explained(&prob),
(ProblemClass::Lp, ClassReason::NonlinearPartsCancelled)
);
let big = 9007199254740992.0_f64; let loses = Expr::Binary(
BinOp::Sub,
Box::new(Expr::Binary(
BinOp::Add,
Box::new(Expr::Binary(
BinOp::Mul,
Box::new(Expr::Const(big)),
Box::new(Expr::Var(0)),
)),
Box::new(Expr::Var(0)),
)),
Box::new(Expr::Binary(
BinOp::Mul,
Box::new(Expr::Const(big)),
Box::new(Expr::Var(0)),
)),
);
let prob = qp_stub(loses, vec![Expr::Const(0.0)]);
assert_eq!(
classify_problem_explained(&prob),
(ProblemClass::Nlp, ClassReason::ObjectiveTermsDropped)
);
}
fn coupled_convex_qcqp_with_k_vars(k: usize) -> NlProblem {
let mut sum = Expr::Var(0);
for i in 1..k {
sum = Expr::Binary(BinOp::Add, Box::new(sum), Box::new(Expr::Var(i)));
}
let con = Expr::Binary(BinOp::Pow, Box::new(sum), Box::new(Expr::Const(2.0)));
NlProblem {
src: None,
cse_bodies: Vec::new(),
n: k,
m: 1,
num_obj: 1,
minimize: true,
obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
obj_linear: vec![(0, 1.0)],
obj_constant: 0.0,
con_nonlinear: vec![NlBody::Tree(con)],
con_linear: vec![vec![]],
x_l: vec![f64::NEG_INFINITY; k],
x_u: vec![f64::INFINITY; k],
g_l: vec![f64::NEG_INFINITY],
g_u: vec![1.0],
x0: vec![0.0; k],
lambda0: vec![0.0],
suffixes: Default::default(),
imported_funcs: Vec::new(),
ampl_options: Vec::new(),
nl_counts: None,
var_names: Vec::new(),
con_names: Vec::new(),
}
}
fn separable_convex_qcqp_with_k_vars(k: usize) -> NlProblem {
let sq = |i: usize| {
Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(i)),
Box::new(Expr::Const(2.0)),
)
};
let mut con = sq(0);
for i in 1..k {
con = Expr::Binary(BinOp::Add, Box::new(con), Box::new(sq(i)));
}
NlProblem {
src: None,
cse_bodies: Vec::new(),
n: k,
m: 1,
num_obj: 1,
minimize: true,
obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
obj_linear: vec![(0, 1.0)],
obj_constant: 0.0,
con_nonlinear: vec![NlBody::Tree(con)],
con_linear: vec![vec![]],
x_l: vec![f64::NEG_INFINITY; k],
x_u: vec![f64::INFINITY; k],
g_l: vec![f64::NEG_INFINITY],
g_u: vec![1.0],
x0: vec![0.0; k],
lambda0: vec![0.0],
suffixes: Default::default(),
imported_funcs: Vec::new(),
ampl_options: Vec::new(),
nl_counts: None,
var_names: Vec::new(),
con_names: Vec::new(),
}
}
#[test]
fn heavily_coupled_convex_qcqp_falls_back_to_nlp() {
let k = 300;
let prob = coupled_convex_qcqp_with_k_vars(k);
assert!((k as u128).pow(3) > SOCP_REFORM_FLOP_BUDGET);
assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
}
#[test]
fn lightly_coupled_convex_qcqp_keeps_conic() {
let k = 250;
let prob = coupled_convex_qcqp_with_k_vars(k);
assert!((k as u128).pow(3) <= SOCP_REFORM_FLOP_BUDGET);
assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
}
#[test]
fn wide_diagonal_convex_qcqp_keeps_conic() {
let k = 100_000;
let prob = separable_convex_qcqp_with_k_vars(k);
assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
std::mem::forget(prob);
}
#[test]
fn classify_concave_minimize_is_nonconvex() {
let obj = Expr::Unary(
UnaryOp::Neg,
Box::new(Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(0)),
Box::new(Expr::Const(2.0)),
)),
);
let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
assert_eq!(classify_problem(&prob), ProblemClass::NonconvexQp);
}
#[test]
fn classify_qcqp_with_indefinite_constraint_falls_back_to_nlp() {
let obj = Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(0)),
Box::new(Expr::Const(2.0)),
);
let con = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
let prob = qp_stub(obj, vec![con]);
assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
}
#[test]
fn classify_psd_quadratic_with_lower_bound_is_nonconvex() {
let obj = Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(0)),
Box::new(Expr::Const(2.0)),
);
let con = Expr::Binary(
BinOp::Add,
Box::new(Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(0)),
Box::new(Expr::Const(2.0)),
)),
Box::new(Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(1)),
Box::new(Expr::Const(2.0)),
)),
);
let mut prob = qp_stub(obj, vec![con]);
prob.g_l = vec![1.0];
prob.g_u = vec![f64::INFINITY];
assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
}
#[test]
fn classify_quadratic_equality_is_nonconvex() {
let obj = Expr::Const(0.0);
let con = Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(0)),
Box::new(Expr::Const(2.0)),
);
let mut prob = qp_stub(obj, vec![con]);
prob.g_l = vec![1.0];
prob.g_u = vec![1.0]; assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
}
#[test]
fn classify_cancelling_quadratic_objective_routes_on_exactness() {
let sq = |c: f64| {
let p = Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(0)),
Box::new(Expr::Const(2.0)),
);
if c == 1.0 {
p
} else {
Expr::Binary(BinOp::Mul, Box::new(Expr::Const(c)), Box::new(p))
}
};
let obj = Expr::Binary(BinOp::Sub, Box::new(sq(1.0)), Box::new(sq(1.0)));
let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
assert_eq!(
classify_problem_explained(&prob),
(ProblemClass::Lp, ClassReason::NonlinearPartsCancelled)
);
let big = 9007199254740992.0_f64; let obj = Expr::Binary(
BinOp::Sub,
Box::new(Expr::Binary(
BinOp::Add,
Box::new(sq(big)),
Box::new(sq(1.0)),
)),
Box::new(sq(big)),
);
let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
assert_eq!(
classify_problem_explained(&prob),
(ProblemClass::Nlp, ClassReason::ObjectiveTermsDropped)
);
}
#[test]
fn classify_nlp_from_transcendental_constraint() {
let obj = Expr::Binary(
BinOp::Pow,
Box::new(Expr::Var(0)),
Box::new(Expr::Const(2.0)),
);
let con = Expr::Unary(UnaryOp::Log, Box::new(Expr::Var(1)));
let prob = qp_stub(obj, vec![con]);
assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
}
fn qp_stub(obj_nonlinear: Expr, con_nonlinear: Vec<Expr>) -> NlProblem {
let obj_nonlinear = NlBody::Tree(obj_nonlinear);
let con_nonlinear: Vec<NlBody> = con_nonlinear.into_iter().map(NlBody::Tree).collect();
let m = con_nonlinear.len();
NlProblem {
src: None,
cse_bodies: Vec::new(),
n: 2,
m,
num_obj: 1,
minimize: true,
obj_nonlinear,
obj_linear: vec![],
obj_constant: 0.0,
con_nonlinear,
con_linear: vec![vec![]; m],
x_l: vec![f64::NEG_INFINITY; 2],
x_u: vec![f64::INFINITY; 2],
g_l: vec![f64::NEG_INFINITY; m],
g_u: vec![0.0; m],
x0: vec![0.0; 2],
lambda0: vec![0.0; m],
suffixes: Default::default(),
imported_funcs: Vec::new(),
ampl_options: Vec::new(),
nl_counts: None,
var_names: Vec::new(),
con_names: Vec::new(),
}
}
#[allow(dead_code)]
fn _parse(txt: &str) -> NlProblem {
parse_nl_text(txt).expect("valid .nl")
}
fn lp_with_row_constant(body: &str) -> NlProblem {
let nl = format!(
"g3 1 1 0
2 1 1 0 0
1 0 0 0 0 0
0 0
1 0 0
0 0 0 1
0 0 0 0 0
2 2
0 0
0 0 0 0 0
C0
{body}
O0 0
n0
r
1 6.0
b
0 0 3
0 0 3
k1
1
J0 2
0 1
1 1
G0 2
0 -1
1 -2
"
);
parse_nl_text(&nl).expect("valid .nl")
}
#[test]
fn a_literal_row_constant_classifies_lp_and_moves_the_bound() {
let prob = lp_with_row_constant("n3");
assert_eq!(classify_problem(&prob), ProblemClass::Lp);
assert!((prob.g_u[0] - 3.0).abs() < 1e-12, "g_u = {}", prob.g_u[0]);
assert!(
prob.con_nonlinear[0].is_trivially_zero(),
"the row body should be the identity zero: {:?}",
prob.con_nonlinear[0]
);
}
#[test]
fn a_computed_row_constant_does_not_make_an_lp_classify_nlp() {
let prob = lp_with_row_constant("o39\nn9");
assert_eq!(classify_problem(&prob), ProblemClass::Lp);
assert!((prob.g_u[0] - 3.0).abs() < 1e-12, "g_u = {}", prob.g_u[0]);
}
}