pub(crate) mod bound;
pub(crate) mod bound_alpha_bb;
pub(crate) mod bound_mccormick;
pub(crate) mod branch;
pub(crate) mod node;
pub(crate) mod pruning;
pub(crate) mod tree;
use crate::options::{GlobalOptimizationConfig, QpWarmStart, SolverOptions};
use crate::problem::certificate::BoundGapCertificate;
use crate::problem::{SolveStatus, SolverResult};
use crate::qp::certificate::prove_optimal;
use crate::qp::ipm_solver::core::compute_duality_gap_rel;
use crate::qp::ipm_solver::kkt::{
bound_violation as kkt_bound_violation, complementarity_residual_rel as kkt_comp_residual,
kkt_residual_rel, primal_residual_rel as kkt_primal_residual,
};
use crate::qp::ipm_solver::outcome::ProblemView;
use crate::qp::kkt_resid::dual_sign_violation as kkt_dual_sign_violation;
use crate::qp::problem::QpProblem;
use std::time::{Duration, Instant};
use bound::{interval_quadratic_bounds, is_feasible_result, solve_local_upper_bound};
use bound_alpha_bb::{alpha_bb_lower_bound, gershgorin_alpha};
use bound_mccormick::mccormick_lower_bound;
use branch::{select_branching_variable, split_node};
use node::BBNode;
use pruning::{should_prune, within_gap};
use tree::BBTree;
const POLISH_KKT_ACCEPT_FACTOR: f64 = 100.0;
const POLISH_KKT_ABS_CAP: f64 = 1e-3;
const POLISH_TIMEOUT_SECS: f64 = 5.0;
#[derive(Debug, Clone, Copy, Default)]
pub struct GlobalStats {
pub nodes_processed: usize,
pub max_depth_seen: usize,
pub pruned: usize,
}
pub fn solve_qp_global(
problem: &QpProblem,
options: &SolverOptions,
cfg: &GlobalOptimizationConfig,
) -> SolverResult {
solve_qp_global_with_stats(problem, options, cfg).0
}
pub fn solve_qp_global_with_stats(
problem: &QpProblem,
options: &SolverOptions,
cfg: &GlobalOptimizationConfig,
) -> (SolverResult, GlobalStats) {
if options.validate().is_err() {
return (SolverResult::numerical_error(), GlobalStats::default());
}
let deadline = options.deadline.or_else(|| {
options
.timeout_secs
.map(|s| Instant::now() + Duration::from_secs_f64(s))
});
let mut shared_opts = options.clone();
shared_opts.deadline = deadline;
shared_opts.timeout_secs = None;
shared_opts.multistart = None;
shared_opts.global_optimization = None;
let root_bounds = problem.bounds.clone();
let mut stats = GlobalStats::default();
let root_solve = solve_local_upper_bound(problem, &root_bounds, &shared_opts, None);
if !is_feasible_result(&root_solve.status) {
return (root_solve, stats);
}
let alpha = if cfg.use_alpha_bb {
gershgorin_alpha(&problem.q)
} else {
0.0
};
let q_indefinite = is_q_indefinite(problem);
let root_lb = compute_node_lower_bound(
problem,
&root_bounds,
alpha,
&shared_opts,
deadline,
cfg.use_alpha_bb,
cfg.use_mccormick,
);
let mut state = SearchState::new(root_solve);
stats.nodes_processed = 1;
let user_eps = shared_opts.ipm_eps();
if within_gap(state.incumbent_obj, root_lb, cfg.gap_tol) {
state.polish_incumbent_duals(problem, &shared_opts, cfg.gap_tol, q_indefinite);
return (
state.finalize_proven(problem, root_lb, q_indefinite, cfg.gap_tol, user_eps),
stats,
);
}
let mut tree = BBTree::new();
let root_node = BBNode::root(root_bounds, root_lb);
let root_x = state.incumbent_sol.clone();
match select_branching_variable(&root_node, &root_x) {
None => {
if within_gap(state.incumbent_obj, root_lb, cfg.gap_tol) {
state.polish_incumbent_duals(problem, &shared_opts, cfg.gap_tol, q_indefinite);
return (
state.finalize_proven(problem, root_lb, q_indefinite, cfg.gap_tol, user_eps),
stats,
);
}
return (
state.finalize_unproven(root_lb, stats.nodes_processed, 0, cfg, q_indefinite),
stats,
);
}
Some(j) => {
let warm = state.build_warm();
let (l, r) = split_node(&root_node, j, root_x[j], warm);
tree.push(l);
tree.push(r);
}
}
let mut max_depth_breached = false;
let mut depth_discard_lb: f64 = f64::INFINITY;
while let Some(node) = tree.pop() {
if deadline_hit(&deadline) {
break;
}
if stats.nodes_processed >= cfg.max_nodes {
break;
}
if should_prune(node.lower_bound, Some(state.incumbent_obj), cfg.gap_tol) {
stats.pruned += 1;
continue;
}
let local_lb = compute_node_lower_bound(
problem,
&node.var_bounds,
alpha,
&shared_opts,
deadline,
cfg.use_alpha_bb,
cfg.use_mccormick,
);
let node_lb = local_lb.max(node.lower_bound);
if should_prune(node_lb, Some(state.incumbent_obj), cfg.gap_tol) {
stats.pruned += 1;
continue;
}
stats.nodes_processed += 1;
if node.depth > stats.max_depth_seen {
stats.max_depth_seen = node.depth;
}
let res =
solve_local_upper_bound(problem, &node.var_bounds, &shared_opts, node.warm.as_ref());
if !is_feasible_result(&res.status) {
continue;
}
let improved = res.objective < state.incumbent_obj;
if improved {
state.update_incumbent(&res);
}
if node.depth + 1 > cfg.max_depth {
max_depth_breached = true;
depth_discard_lb = depth_discard_lb.min(node_lb);
continue;
}
if let Some(j) = select_branching_variable(&node, &res.solution) {
let warm = build_warm_from(&res);
let (left, right) = split_node(&node, j, res.solution[j], warm);
tree.push(left);
tree.push(right);
}
}
state.polish_incumbent_duals(problem, &shared_opts, cfg.gap_tol, q_indefinite);
let halted_early = !tree.is_empty()
|| max_depth_breached
|| deadline_hit(&deadline)
|| stats.nodes_processed >= cfg.max_nodes;
let result = if halted_early {
let remaining_lb = tree
.best_lower_bound()
.unwrap_or(f64::INFINITY)
.min(depth_discard_lb);
let proven = within_gap(state.incumbent_obj, remaining_lb, cfg.gap_tol);
let inc_obj = state.incumbent_obj;
if proven {
let lb_for_proof = remaining_lb.min(inc_obj);
state.finalize_proven(problem, lb_for_proof, q_indefinite, cfg.gap_tol, user_eps)
} else {
state.finalize_unproven(
remaining_lb,
stats.nodes_processed,
stats.max_depth_seen,
cfg,
q_indefinite,
)
}
} else {
let inc_obj = state.incumbent_obj;
state.finalize_proven(problem, inc_obj, q_indefinite, cfg.gap_tol, user_eps)
};
(result, stats)
}
fn is_q_indefinite(problem: &QpProblem) -> bool {
gershgorin_alpha(&problem.q) > 0.0
}
fn deadline_hit(deadline: &Option<Instant>) -> bool {
deadline.is_some_and(|d| Instant::now() >= d)
}
fn compute_node_lower_bound(
problem: &QpProblem,
bounds: &[(f64, f64)],
alpha: f64,
base_opts: &SolverOptions,
deadline: Option<Instant>,
use_alpha_bb: bool,
use_mccormick: bool,
) -> f64 {
let (interval_lb, _) = interval_quadratic_bounds(problem, bounds);
let mut lb = interval_lb;
if use_alpha_bb {
if let Some(ab_lb) = alpha_bb_lower_bound(problem, bounds, alpha, base_opts, deadline) {
lb = lb.max(ab_lb);
}
}
if use_mccormick {
if let Some(mc_lb) = mccormick_lower_bound(problem, bounds, base_opts, deadline) {
lb = lb.max(mc_lb);
}
}
lb
}
fn build_warm_from(res: &SolverResult) -> Option<QpWarmStart> {
if res.solution.is_empty() {
return None;
}
Some(QpWarmStart {
x: res.solution.clone(),
y: res.dual_solution.clone(),
mu: res
.final_residuals
.map(|(_, _, g)| g)
.unwrap_or(1e-6)
.max(1e-10),
})
}
fn is_polish_acceptable(
status: &SolveStatus,
polished_obj: f64,
incumbent_obj: f64,
gap_tol: f64,
) -> bool {
let converged = matches!(status, SolveStatus::Optimal | SolveStatus::LocallyOptimal);
if !converged || !polished_obj.is_finite() {
return false;
}
let scale = 1.0_f64.max(incumbent_obj.abs());
polished_obj <= incumbent_obj + gap_tol * scale
}
fn structural_empty_col_mask(problem: &QpProblem) -> Vec<bool> {
let n = problem.num_vars;
let a_ncols = problem.a.col_ptr.len().saturating_sub(1);
let q_ncols = problem.q.col_ptr.len().saturating_sub(1);
(0..n)
.map(|j| {
let a_empty = j >= a_ncols || problem.a.col_ptr[j + 1] == problem.a.col_ptr[j];
let q_empty = j >= q_ncols || problem.q.col_ptr[j + 1] == problem.q.col_ptr[j];
a_empty && q_empty
})
.collect()
}
fn is_polish_suboptimal_acceptable(
polished: &SolverResult,
problem: &QpProblem,
incumbent_obj: f64,
gap_tol: f64,
user_eps: f64,
) -> bool {
if !matches!(polished.status, SolveStatus::SuboptimalSolution) {
return false;
}
if !polished.objective.is_finite() {
return false;
}
let scale = 1.0_f64.max(incumbent_obj.abs());
if polished.objective > incumbent_obj + gap_tol * scale {
return false;
}
let n_lb = problem
.bounds
.iter()
.filter(|&&(lb, _)| lb.is_finite())
.count();
let n_ub = problem
.bounds
.iter()
.filter(|&&(_, ub)| ub.is_finite())
.count();
if polished.solution.len() != problem.num_vars
|| polished.dual_solution.len() != problem.num_constraints
|| polished.bound_duals.len() != n_lb + n_ub
{
return false;
}
let kkt_tol = (user_eps * POLISH_KKT_ACCEPT_FACTOR).min(POLISH_KKT_ABS_CAP);
let eliminated_cols = structural_empty_col_mask(problem);
let view = ProblemView {
q: &problem.q,
a: &problem.a,
c: &problem.c,
b: &problem.b,
bounds: &problem.bounds,
constraint_types: &problem.constraint_types,
eliminated_cols: &eliminated_cols,
};
let kkt = kkt_residual_rel(
&view,
&polished.solution,
&polished.dual_solution,
&polished.bound_duals,
);
let pf = kkt_primal_residual(&view, &polished.solution);
let bv = kkt_bound_violation(&problem.bounds, &polished.solution);
let comp = kkt_comp_residual(
&view,
&polished.solution,
&polished.dual_solution,
&polished.bound_duals,
);
let dsign = kkt_dual_sign_violation(
&problem.constraint_types,
&polished.dual_solution,
&problem.bounds,
&polished.bound_duals,
);
kkt <= kkt_tol && pf <= kkt_tol && bv <= kkt_tol && comp <= kkt_tol && dsign <= kkt_tol
}
fn is_polish_kkt_recovery(
polished: &SolverResult,
problem: &QpProblem,
incumbent_obj: f64,
gap_tol: f64,
user_eps: f64,
) -> bool {
if !matches!(polished.status, SolveStatus::Optimal | SolveStatus::LocallyOptimal) {
return false;
}
if !polished.objective.is_finite() {
return false;
}
let scale = 1.0_f64.max(incumbent_obj.abs());
if polished.objective > incumbent_obj + gap_tol * scale {
return false;
}
let n_lb = problem
.bounds
.iter()
.filter(|&&(lb, _)| lb.is_finite())
.count();
let n_ub = problem
.bounds
.iter()
.filter(|&&(_, ub)| ub.is_finite())
.count();
if polished.solution.len() != problem.num_vars
|| polished.dual_solution.len() != problem.num_constraints
|| polished.bound_duals.len() != n_lb + n_ub
{
return false;
}
let kkt_tol = (user_eps * POLISH_KKT_ACCEPT_FACTOR).min(POLISH_KKT_ABS_CAP);
let eliminated_cols = structural_empty_col_mask(problem);
let view = ProblemView {
q: &problem.q,
a: &problem.a,
c: &problem.c,
b: &problem.b,
bounds: &problem.bounds,
constraint_types: &problem.constraint_types,
eliminated_cols: &eliminated_cols,
};
let kkt = kkt_residual_rel(
&view,
&polished.solution,
&polished.dual_solution,
&polished.bound_duals,
);
let pf = kkt_primal_residual(&view, &polished.solution);
let bv = kkt_bound_violation(&problem.bounds, &polished.solution);
let comp = kkt_comp_residual(
&view,
&polished.solution,
&polished.dual_solution,
&polished.bound_duals,
);
let dsign = kkt_dual_sign_violation(
&problem.constraint_types,
&polished.dual_solution,
&problem.bounds,
&polished.bound_duals,
);
kkt <= kkt_tol && pf <= kkt_tol && bv <= kkt_tol && comp <= kkt_tol && dsign <= kkt_tol
}
struct SearchState {
incumbent_result: SolverResult,
incumbent_obj: f64,
incumbent_sol: Vec<f64>,
incumbent_updated: bool,
}
impl SearchState {
fn new(root: SolverResult) -> Self {
let obj = root.objective;
let sol = root.solution.clone();
Self {
incumbent_result: root,
incumbent_obj: obj,
incumbent_sol: sol,
incumbent_updated: false,
}
}
fn build_warm(&self) -> Option<QpWarmStart> {
build_warm_from(&self.incumbent_result)
}
fn update_incumbent(&mut self, res: &SolverResult) {
self.incumbent_obj = res.objective;
self.incumbent_sol = res.solution.clone();
self.incumbent_result = res.clone();
self.incumbent_updated = true;
}
fn polish_incumbent_duals(
&mut self,
problem: &QpProblem,
base_opts: &SolverOptions,
gap_tol: f64,
relax_for_nonconvex: bool,
) {
if !self.incumbent_updated
&& matches!(
self.incumbent_result.status,
SolveStatus::Optimal | SolveStatus::LocallyOptimal
)
{
return;
}
let Some(warm) = build_warm_from(&self.incumbent_result) else {
return;
};
if warm.x.len() != problem.num_vars {
return;
}
let mut opts = base_opts.clone();
opts.warm_start_qp = Some(warm);
opts.multistart = None;
opts.global_optimization = None;
let now = Instant::now();
let polish_deadline = match base_opts.deadline {
Some(d) if d > now => d,
_ => now + Duration::from_secs_f64(POLISH_TIMEOUT_SECS),
};
opts.deadline = Some(polish_deadline);
opts.timeout_secs = None;
let user_eps = base_opts.ipm_eps();
let polished = crate::qp::solve_qp_with(problem, &opts);
if is_polish_acceptable(&polished.status, polished.objective, self.incumbent_obj, gap_tol)
|| is_polish_suboptimal_acceptable(
&polished,
problem,
self.incumbent_obj,
gap_tol,
user_eps,
)
|| (relax_for_nonconvex
&& is_polish_kkt_recovery(
&polished,
problem,
self.incumbent_obj,
gap_tol,
user_eps,
))
{
self.update_incumbent(&polished);
}
}
fn finalize_proven(
mut self,
problem: &QpProblem,
lower_bound: f64,
q_indefinite: bool,
gap_tol: f64,
user_eps: f64,
) -> SolverResult {
let eliminated_cols = structural_empty_col_mask(problem);
let view = ProblemView {
q: &problem.q,
a: &problem.a,
c: &problem.c,
b: &problem.b,
bounds: &problem.bounds,
constraint_types: &problem.constraint_types,
eliminated_cols: &eliminated_cols,
};
let duality_gap_rel = self
.incumbent_result
.duality_gap_rel
.unwrap_or_else(|| compute_duality_gap_rel(problem, &self.incumbent_result));
let cert_result = {
let x = &self.incumbent_result.solution;
let y = &self.incumbent_result.dual_solution;
let z = &self.incumbent_result.bound_duals;
prove_optimal(&view, x, y, z, duality_gap_rel, user_eps)
};
match cert_result {
Ok(opt_cert) => {
let scale = 1.0_f64.max(self.incumbent_obj.abs());
let gap_rel = (self.incumbent_obj - lower_bound) / scale;
self.incumbent_result.bound_gap_cert = Some(BoundGapCertificate::new(
self.incumbent_obj,
lower_bound,
gap_rel,
gap_tol,
));
self.incumbent_result.opt_cert = Some(opt_cert);
self.incumbent_result.status = if q_indefinite {
SolveStatus::NonconvexGlobal
} else {
SolveStatus::Optimal
};
log::debug!(
"QP global proven: status={} obj={:.6e} lb={:.6e} gap_rel={:.3e}",
self.incumbent_result.status,
self.incumbent_obj,
lower_bound,
gap_rel
);
}
Err(not_proven) => {
self.incumbent_result.status = if q_indefinite {
SolveStatus::NonconvexLocal
} else {
SolveStatus::LocallyOptimal
};
log::debug!(
"QP global gap-closed but KKT failed ({:?}): demoted to {}",
not_proven.failing_conditions,
self.incumbent_result.status,
);
}
}
self.incumbent_result
}
fn finalize_unproven(
mut self,
lower_bound: f64,
nodes: usize,
depth: usize,
cfg: &GlobalOptimizationConfig,
q_indefinite: bool,
) -> SolverResult {
self.incumbent_result.status = if q_indefinite {
SolveStatus::NonconvexLocal
} else {
SolveStatus::LocallyOptimal
};
let gap = self.incumbent_obj - lower_bound;
log::debug!(
"QP global unproven: status={} obj={:.6e} lb={:.6e} gap={:.3e} nodes={} depth={} tol={:.0e}",
self.incumbent_result.status, self.incumbent_obj, lower_bound, gap, nodes, depth, cfg.gap_tol
);
self.incumbent_result
}
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)]
mod tests {
use super::*;
use crate::sparse::CscMatrix;
use crate::test_kkt::assert_solver_invariants_qp;
fn diag_concave_1d(bnd: f64) -> QpProblem {
let q = CscMatrix::from_triplets(&[0], &[0], &[-2.0], 1, 1).unwrap();
let a = CscMatrix::from_triplets(&[], &[], &[], 0, 1).unwrap();
QpProblem::new_all_le(q, vec![0.0], a, vec![], vec![(-bnd, bnd)]).unwrap()
}
fn opts(timeout: f64) -> SolverOptions {
let mut o = SolverOptions::default();
o.timeout_secs = Some(timeout);
o
}
#[test]
fn solve_qp_global_finds_corner_minimum_concave_1d() {
let p = diag_concave_1d(2.0);
let cfg = GlobalOptimizationConfig::default();
let r = solve_qp_global(&p, &opts(5.0), &cfg);
assert!(
matches!(
r.status,
SolveStatus::Optimal
| SolveStatus::LocallyOptimal
| SolveStatus::NonconvexGlobal
| SolveStatus::NonconvexLocal
),
"expected Optimal/Locally/NonconvexGlobal/NonconvexLocal, got {:?}",
r.status
);
assert!(
r.objective < -3.99,
"expected global ≈ -4, got obj={:.4}",
r.objective
);
}
#[test]
fn solve_qp_global_cold_vs_global_separation() {
let p = diag_concave_1d(2.0);
let cold = crate::qp::solve_qp_with(&p, &opts(5.0));
let global = solve_qp_global(&p, &opts(5.0), &GlobalOptimizationConfig::default());
assert!(
global.objective <= cold.objective + 1e-6,
"global ({}) should be ≤ cold ({})",
global.objective,
cold.objective
);
assert!(
global.objective < -3.99,
"global should reach corner, got {}",
global.objective
);
}
fn diag_convex_1d(bnd: f64) -> QpProblem {
let q = CscMatrix::from_triplets(&[0], &[0], &[2.0], 1, 1).unwrap();
let a = CscMatrix::from_triplets(&[], &[], &[], 0, 1).unwrap();
QpProblem::new_all_le(q, vec![0.0], a, vec![], vec![(-bnd, bnd)]).unwrap()
}
#[test]
fn convex_q_yields_optimal_not_nonconvex_global() {
let p = diag_convex_1d(3.0);
let r = solve_qp_global(&p, &opts(2.0), &GlobalOptimizationConfig::default());
assert!(
matches!(r.status, SolveStatus::Optimal),
"convex Q must yield Optimal, got {:?}",
r.status
);
assert_solver_invariants_qp(&r, &p);
}
#[test]
fn indefinite_q_proven_yields_nonconvex_global() {
let p = diag_concave_1d(2.0);
let r = solve_qp_global(&p, &opts(5.0), &GlobalOptimizationConfig::default());
assert!(
matches!(r.status, SolveStatus::NonconvexGlobal),
"indefinite Q + proven must yield NonconvexGlobal, got {:?}",
r.status
);
}
#[test]
fn indefinite_q_unproven_yields_nonconvex_local() {
let q = CscMatrix::from_triplets(&[0, 1], &[0, 1], &[-2.0, -2.0], 2, 2).unwrap();
let a = CscMatrix::from_triplets(&[], &[], &[], 0, 2).unwrap();
let p = QpProblem::new_all_le(q, vec![0.0, 0.0], a, vec![], vec![(-1.0, 1.0), (-1.0, 1.0)])
.unwrap();
let cfg = GlobalOptimizationConfig {
gap_tol: 1e-12,
max_depth: 1,
max_nodes: 1,
..GlobalOptimizationConfig::default()
};
let r = solve_qp_global(&p, &opts(5.0), &cfg);
assert!(
matches!(r.status, SolveStatus::NonconvexLocal),
"indefinite Q + unproven must yield NonconvexLocal, got {:?}",
r.status
);
}
#[test]
fn is_q_indefinite_distinguishes_psd_and_indefinite() {
let psd = diag_convex_1d(1.0);
let indef = diag_concave_1d(1.0);
assert!(!is_q_indefinite(&psd), "x² should be PSD");
assert!(is_q_indefinite(&indef), "-x² should be indefinite");
}
#[test]
fn qp_global_proven_convex_has_bound_gap_cert() {
let p = diag_convex_1d(3.0);
let r = solve_qp_global(&p, &opts(2.0), &GlobalOptimizationConfig::default());
assert!(matches!(r.status, SolveStatus::Optimal));
let cert = r
.bound_gap_cert
.as_ref()
.expect("proven QP global (Optimal) must carry BoundGapCertificate");
assert!(
cert.gap_rel() <= cert.gap_tol() + 1e-10,
"gap_rel={:.3e} must be ≤ gap_tol={:.3e}",
cert.gap_rel(),
cert.gap_tol()
);
}
#[test]
fn qp_global_proven_nonconvex_has_bound_gap_cert() {
let p = diag_concave_1d(2.0);
let r = solve_qp_global(&p, &opts(5.0), &GlobalOptimizationConfig::default());
assert!(matches!(r.status, SolveStatus::NonconvexGlobal));
let cert = r
.bound_gap_cert
.as_ref()
.expect("proven QP global (NonconvexGlobal) must carry BoundGapCertificate");
assert!(cert.gap_rel() <= cert.gap_tol() + 1e-10);
}
#[test]
fn qp_global_unproven_has_no_bound_gap_cert() {
let q = CscMatrix::from_triplets(&[0, 1], &[0, 1], &[-2.0, -2.0], 2, 2).unwrap();
let a = CscMatrix::from_triplets(&[], &[], &[], 0, 2).unwrap();
let p = QpProblem::new_all_le(q, vec![0.0, 0.0], a, vec![], vec![(-1.0, 1.0), (-1.0, 1.0)])
.unwrap();
let cfg = GlobalOptimizationConfig {
gap_tol: 1e-12,
max_depth: 1,
max_nodes: 1,
..GlobalOptimizationConfig::default()
};
let r = solve_qp_global(&p, &opts(5.0), &cfg);
assert!(
matches!(
r.status,
SolveStatus::NonconvexLocal | SolveStatus::LocallyOptimal
),
"expected unproven status, got {:?}",
r.status
);
assert!(
r.bound_gap_cert.is_none(),
"unproven must have no BoundGapCertificate"
);
}
#[test]
fn depth_exceeded_lb_folds_into_remaining_lb_blocks_false_cert() {
let q = CscMatrix::from_triplets(&[0, 1], &[0, 1], &[-2.0, -2.0], 2, 2).unwrap();
let a = CscMatrix::from_triplets(&[], &[], &[], 0, 2).unwrap();
let p = QpProblem::new_all_le(q, vec![0.0, 0.0], a, vec![], vec![(-1.0, 1.0), (-1.0, 1.0)])
.unwrap();
let cfg = GlobalOptimizationConfig {
gap_tol: 1e-12,
max_depth: 1,
max_nodes: 10_000,
use_alpha_bb: false,
use_mccormick: false,
..GlobalOptimizationConfig::default()
};
let r = solve_qp_global(&p, &opts(10.0), &cfg);
assert!(
matches!(r.status, SolveStatus::NonconvexLocal),
"depth-exceeded lb must block false proven: expected NonconvexLocal, got {:?}",
r.status
);
assert!(
r.bound_gap_cert.is_none(),
"depth-exceeded unproven must have no BoundGapCertificate"
);
}
#[test]
fn branched_incumbent_duals_reconciled_to_original_box() {
use crate::problem::ConstraintType;
use crate::qp::ipm_solver::kkt::complementarity_residual_rel;
use crate::qp::ipm_solver::outcome::ProblemView;
use crate::test_kkt::EPS_KKT;
let q = CscMatrix::from_triplets(&[0, 1, 2], &[0, 1, 2], &[1.0, -1.0, -1.0], 3, 3).unwrap();
let a = CscMatrix::from_triplets(&[0, 0], &[0, 1], &[-1.0, 0.6], 3, 3).unwrap();
let p = QpProblem::new(
q,
vec![0.0, 0.0, 0.0],
a,
vec![-0.5, 0.5, 1.0],
vec![(-0.5, 0.5); 3],
vec![ConstraintType::Le; 3],
)
.unwrap();
let cfg = GlobalOptimizationConfig::default();
let r = solve_qp_global(&p, &opts(8.0), &cfg);
assert!(
(r.objective - (-0.23)).abs() < 1e-2,
"expected global ≈ -0.23, got obj={:.4} status={:?}",
r.objective,
r.status
);
let view = ProblemView::from_problem(&p);
let comp =
complementarity_residual_rel(&view, &r.solution, &r.dual_solution, &r.bound_duals);
assert!(
comp < EPS_KKT,
"branched-incumbent duals must satisfy original-box complementarity: comp={:.3e} > {:.3e} (status={:?})",
comp,
EPS_KKT,
r.status
);
}
#[test]
fn polish_acceptance_rejects_unconverged_status() {
assert!(is_polish_acceptable(&SolveStatus::Optimal, 0.0, 0.0, 1e-6));
assert!(is_polish_acceptable(
&SolveStatus::LocallyOptimal,
0.0,
0.0,
1e-6
));
assert!(!is_polish_acceptable(
&SolveStatus::MaxIterations,
0.0,
0.0,
1e-6
));
assert!(!is_polish_acceptable(
&SolveStatus::SuboptimalSolution,
0.0,
0.0,
1e-6
));
assert!(!is_polish_acceptable(
&SolveStatus::Infeasible,
0.0,
0.0,
1e-6
));
assert!(!is_polish_acceptable(
&SolveStatus::NumericalError,
0.0,
0.0,
1e-6
));
assert!(!is_polish_acceptable(&SolveStatus::Timeout, 0.0, 0.0, 1e-6));
}
#[test]
fn polish_acceptance_rejects_worse_obj() {
let gap_tol = 1e-4_f64;
let inc = -1.0_f64;
let scale = 1.0_f64.max(inc.abs());
let tol = gap_tol * scale;
assert!(is_polish_acceptable(
&SolveStatus::Optimal,
inc,
inc,
gap_tol
));
assert!(is_polish_acceptable(
&SolveStatus::Optimal,
inc - 0.5,
inc,
gap_tol
));
assert!(is_polish_acceptable(
&SolveStatus::Optimal,
inc + tol * 0.5,
inc,
gap_tol
));
assert!(!is_polish_acceptable(
&SolveStatus::Optimal,
inc + tol + 1e-10,
inc,
gap_tol
));
assert!(!is_polish_acceptable(
&SolveStatus::Optimal,
0.0,
inc,
gap_tol
));
assert!(!is_polish_acceptable(
&SolveStatus::Optimal,
1.0,
inc,
gap_tol
));
let inc = 0.0_f64;
let scale = 1.0_f64.max(inc.abs());
let tol = gap_tol * scale;
assert!(is_polish_acceptable(
&SolveStatus::Optimal,
0.0,
inc,
gap_tol
));
assert!(is_polish_acceptable(
&SolveStatus::Optimal,
-0.5,
inc,
gap_tol
));
assert!(!is_polish_acceptable(
&SolveStatus::Optimal,
tol + 1e-10,
inc,
gap_tol
));
let inc = 100.0_f64;
let scale = 1.0_f64.max(inc.abs());
let tol = gap_tol * scale; assert!(is_polish_acceptable(
&SolveStatus::Optimal,
inc + tol * 0.5,
inc,
gap_tol
));
assert!(!is_polish_acceptable(
&SolveStatus::Optimal,
inc + tol + 1e-10,
inc,
gap_tol
));
}
#[test]
fn invalid_options_rejected_at_global_entry() {
let p = diag_concave_1d(2.0);
let cfg = GlobalOptimizationConfig::default();
let cases: &[(&str, SolverOptions)] = &[
(
"neg timeout_secs",
SolverOptions {
timeout_secs: Some(-1.0),
..Default::default()
},
),
(
"inf timeout_secs",
SolverOptions {
timeout_secs: Some(f64::INFINITY),
..Default::default()
},
),
(
"nan primal_tol",
SolverOptions {
primal_tol: f64::NAN,
..Default::default()
},
),
(
"zero threads",
SolverOptions {
threads: 0,
..Default::default()
},
),
];
for (label, opts) in cases {
let result = solve_qp_global(&p, opts, &cfg);
assert_eq!(
result.status,
SolveStatus::NumericalError,
"solve_qp_global with {label} must return NumericalError (not panic)"
);
}
}
#[test]
fn is_polish_suboptimal_acceptable_rejects_wrong_sign_duals() {
use crate::problem::ConstraintType;
let q = CscMatrix::from_triplets(&[], &[], &[], 1, 1).unwrap();
let a = CscMatrix::from_triplets(&[], &[], &[], 1, 1).unwrap();
let problem = QpProblem::new(
q,
vec![0.0],
a,
vec![0.0],
vec![(f64::NEG_INFINITY, f64::INFINITY)],
vec![ConstraintType::Le],
)
.unwrap();
let polished = SolverResult {
status: SolveStatus::SuboptimalSolution,
objective: 0.0,
solution: vec![0.0],
dual_solution: vec![-0.5],
bound_duals: vec![],
..SolverResult::default()
};
assert!(
!is_polish_suboptimal_acceptable(&polished, &problem, 0.0, 0.1, 1e-6),
"wrong-sign dual (y = -0.5 for Le constraint) must be rejected by dual_sign gate",
);
}
#[test]
fn is_polish_suboptimal_acceptable_rejects_mismatched_dimensions() {
use crate::problem::ConstraintType;
let q = CscMatrix::from_triplets(&[], &[], &[], 2, 2).unwrap();
let a = CscMatrix::from_triplets(&[], &[], &[], 1, 2).unwrap();
let problem = QpProblem::new(
q,
vec![0.0, 0.0],
a,
vec![0.0],
vec![(f64::NEG_INFINITY, f64::INFINITY); 2],
vec![ConstraintType::Le],
)
.unwrap();
let polished_short_sol = SolverResult {
status: SolveStatus::SuboptimalSolution,
objective: 0.0,
solution: vec![0.0], dual_solution: vec![0.0],
bound_duals: vec![],
..SolverResult::default()
};
assert!(
!is_polish_suboptimal_acceptable(&polished_short_sol, &problem, 0.0, 0.1, 1e-6),
"mismatched solution dimension must be rejected",
);
let polished_short_dual = SolverResult {
status: SolveStatus::SuboptimalSolution,
objective: 0.0,
solution: vec![0.0, 0.0],
dual_solution: vec![], bound_duals: vec![],
..SolverResult::default()
};
assert!(
!is_polish_suboptimal_acceptable(&polished_short_dual, &problem, 0.0, 0.1, 1e-6),
"mismatched dual_solution dimension must be rejected",
);
}
#[test]
fn is_polish_suboptimal_acceptable_rejects_mismatched_bound_duals() {
use crate::problem::ConstraintType;
let q = CscMatrix::from_triplets(&[], &[], &[], 1, 1).unwrap();
let a = CscMatrix::from_triplets(&[], &[], &[], 0, 1).unwrap();
let problem = QpProblem::new(
q,
vec![0.0],
a,
vec![],
vec![(0.0_f64, f64::INFINITY)],
vec![ConstraintType::Le; 0],
)
.unwrap();
let polished_wrong_bd = SolverResult {
status: SolveStatus::SuboptimalSolution,
objective: 0.0,
solution: vec![0.0],
dual_solution: vec![],
bound_duals: vec![0.0, 0.0], ..SolverResult::default()
};
assert!(
!is_polish_suboptimal_acceptable(&polished_wrong_bd, &problem, 0.0, 0.1, 1e-6),
"mismatched bound_duals length must be rejected by dimension guard",
);
}
#[test]
fn is_polish_kkt_recovery_five_axis_gates() {
use crate::problem::ConstraintType;
let q = CscMatrix::from_triplets(&[], &[], &[], 1, 1).unwrap();
let a = CscMatrix::from_triplets(&[], &[], &[], 1, 1).unwrap();
let problem = QpProblem::new(
q,
vec![0.0],
a,
vec![0.0],
vec![(f64::NEG_INFINITY, f64::INFINITY)],
vec![ConstraintType::Le],
)
.unwrap();
let incumbent_obj = 0.0_f64;
let gap_tol = 0.1_f64;
let user_eps = 1e-6_f64;
let valid = SolverResult {
status: SolveStatus::Optimal,
objective: 0.0,
solution: vec![0.0],
dual_solution: vec![0.0],
bound_duals: vec![],
..SolverResult::default()
};
assert!(
is_polish_kkt_recovery(&valid, &problem, incumbent_obj, gap_tol, user_eps),
"axis 4 (accept): all gates pass must return true",
);
for bad_status in [
SolveStatus::SuboptimalSolution,
SolveStatus::MaxIterations,
SolveStatus::Timeout,
SolveStatus::NumericalError,
SolveStatus::Infeasible,
] {
let polished = SolverResult {
status: bad_status.clone(),
..valid.clone()
};
assert!(
!is_polish_kkt_recovery(&polished, &problem, incumbent_obj, gap_tol, user_eps),
"axis 1 (status): {:?} must be rejected",
bad_status,
);
}
let polished_short_sol = SolverResult {
solution: vec![],
..valid.clone()
};
assert!(
!is_polish_kkt_recovery(
&polished_short_sol,
&problem,
incumbent_obj,
gap_tol,
user_eps
),
"axis 2 (dim): wrong solution.len must be rejected",
);
let polished_short_dual = SolverResult {
dual_solution: vec![],
..valid.clone()
};
assert!(
!is_polish_kkt_recovery(
&polished_short_dual,
&problem,
incumbent_obj,
gap_tol,
user_eps
),
"axis 2 (dim): wrong dual_solution.len must be rejected",
);
let polished_wrong_sign = SolverResult {
dual_solution: vec![-0.5],
..valid.clone()
};
assert!(
!is_polish_kkt_recovery(
&polished_wrong_sign,
&problem,
incumbent_obj,
gap_tol,
user_eps
),
"axis 3 (KKT): wrong-sign dual must be rejected",
);
let polished_worse = SolverResult {
objective: 1.0,
..valid.clone()
};
assert!(
!is_polish_kkt_recovery(
&polished_worse,
&problem,
incumbent_obj,
gap_tol,
user_eps
),
"axis 5 (obj guard): polished obj {} > incumbent + gap_tol*scale = {} must be rejected (P1-A)",
polished_worse.objective,
incumbent_obj + gap_tol * 1.0_f64.max(incumbent_obj.abs()),
);
let polished_within_tol = SolverResult {
objective: -9.5,
..valid.clone()
};
assert!(
is_polish_kkt_recovery(&polished_within_tol, &problem, -10.0, gap_tol, user_eps),
"axis 5: improvement within scaled tol must be accepted",
);
let polished_outside_tol = SolverResult {
objective: -8.0,
..valid.clone()
};
assert!(
!is_polish_kkt_recovery(&polished_outside_tol, &problem, -10.0, gap_tol, user_eps),
"axis 5: obj outside scaled tol (-8 > -10 + 0.1*10 = -9) must be rejected",
);
}
#[test]
fn finalize_proven_dual_gate_table() {
let q_conv = CscMatrix::from_triplets(&[0], &[0], &[2.0_f64], 1, 1).unwrap();
let a_empty = CscMatrix::from_triplets(&[], &[], &[], 0, 1).unwrap();
let p_convex = QpProblem::new_all_le(
q_conv,
vec![0.0_f64],
a_empty.clone(),
vec![],
vec![(-1.0_f64, 1.0_f64)],
)
.unwrap();
let q_indef = CscMatrix::from_triplets(&[0], &[0], &[-2.0_f64], 1, 1).unwrap();
let p_indef = QpProblem::new_all_le(
q_indef,
vec![0.0_f64],
a_empty,
vec![],
vec![(-1.0_f64, 1.0_f64)],
)
.unwrap();
let user_eps = 1e-6_f64;
let gap_tol = 1e-6_f64;
let good_conv = SolverResult {
status: SolveStatus::Optimal,
objective: 0.0,
solution: vec![0.0_f64],
dual_solution: vec![],
bound_duals: vec![0.0_f64, 0.0_f64], duality_gap_rel: Some(0.0),
..Default::default()
};
let r =
SearchState::new(good_conv).finalize_proven(&p_convex, 0.0, false, gap_tol, user_eps);
assert_eq!(
r.status,
SolveStatus::Optimal,
"convex-good-dual must be Optimal"
);
assert!(
r.bound_gap_cert.is_some(),
"Optimal must carry bound_gap_cert"
);
assert!(r.opt_cert.is_some(), "Optimal must carry opt_cert");
let bad_conv = SolverResult {
status: SolveStatus::SuboptimalSolution,
objective: 0.0,
solution: vec![0.0_f64],
dual_solution: vec![],
bound_duals: vec![100.0_f64, -100.0_f64], duality_gap_rel: Some(0.5),
..Default::default()
};
let r =
SearchState::new(bad_conv).finalize_proven(&p_convex, 0.0, false, gap_tol, user_eps);
assert_eq!(
r.status,
SolveStatus::LocallyOptimal,
"convex-bad-dual must be demoted to LocallyOptimal"
);
assert!(
r.bound_gap_cert.is_none(),
"demoted must have no bound_gap_cert"
);
assert!(r.opt_cert.is_none(), "demoted must have no opt_cert");
let good_indef = SolverResult {
status: SolveStatus::Optimal,
objective: -1.0,
solution: vec![1.0_f64],
dual_solution: vec![],
bound_duals: vec![0.0_f64, 2.0_f64], duality_gap_rel: Some(0.0),
..Default::default()
};
let r =
SearchState::new(good_indef).finalize_proven(&p_indef, -1.0, true, gap_tol, user_eps);
assert_eq!(
r.status,
SolveStatus::NonconvexGlobal,
"indefinite-good-dual must be NonconvexGlobal"
);
assert!(
r.bound_gap_cert.is_some(),
"NonconvexGlobal must carry bound_gap_cert"
);
assert!(r.opt_cert.is_some(), "NonconvexGlobal must carry opt_cert");
let bad_indef = SolverResult {
status: SolveStatus::SuboptimalSolution,
objective: -1.0,
solution: vec![1.0_f64],
dual_solution: vec![],
bound_duals: vec![50.0_f64, 50.0_f64], duality_gap_rel: Some(0.5),
..Default::default()
};
let r =
SearchState::new(bad_indef).finalize_proven(&p_indef, -1.0, true, gap_tol, user_eps);
assert_eq!(
r.status,
SolveStatus::NonconvexLocal,
"indefinite-bad-dual must be demoted to NonconvexLocal"
);
assert!(
r.bound_gap_cert.is_none(),
"demoted must have no bound_gap_cert"
);
assert!(r.opt_cert.is_none(), "demoted must have no opt_cert");
}
#[test]
fn finalize_proven_empty_col_not_false_demoted() {
let q = CscMatrix::from_triplets(&[0], &[0], &[-2.0_f64], 2, 2).unwrap();
let a = CscMatrix::from_triplets(&[], &[], &[], 0, 2).unwrap();
let problem = QpProblem::new_all_le(
q,
vec![0.0_f64, 1.0_f64], a,
vec![],
vec![(-1.0_f64, 1.0_f64), (0.0_f64, 1.0_f64)],
)
.unwrap();
let incumbent = SolverResult {
status: SolveStatus::Optimal,
objective: -1.0,
solution: vec![1.0_f64, 0.0_f64],
dual_solution: vec![],
bound_duals: vec![0.0_f64, 0.0_f64, 2.0_f64, 0.0_f64],
duality_gap_rel: Some(0.0),
..Default::default()
};
let user_eps = 1e-6_f64;
let gap_tol = 1e-6_f64;
let r =
SearchState::new(incumbent).finalize_proven(&problem, -1.0, true, gap_tol, user_eps);
assert_eq!(
r.status,
SolveStatus::NonconvexGlobal,
"EmptyCol incumbent must not be false-demoted: expected NonconvexGlobal, got {:?}. \
Sentinel: structural_empty_col_mask returning vec![false; n] causes this FAIL \
because kkt for x₁ (c=1,z=0) gives 1.0 ≫ eps.",
r.status,
);
assert!(r.opt_cert.is_some(), "NonconvexGlobal must carry opt_cert");
}
#[test]
fn proptest_seed_a46bde58_kkt_regression() {
use crate::problem::ConstraintType;
let rows = vec![0usize, 1, 2, 0, 1, 2, 0, 1, 2];
let cols = vec![0usize, 0, 0, 1, 1, 1, 2, 2, 2];
let vals = vec![
0.16000000000000003_f64,
-0.03915063848637796,
0.3192145885469365,
-0.03915063848637796,
0.460208173753392,
-0.17436676978450188,
0.3192145885469365,
-0.17436676978450188,
1.0576743304356357,
];
let q = CscMatrix::from_triplets(&rows, &cols, &vals, 3, 3).unwrap();
let c = vec![
0.653536572287863_f64,
-0.010836684577960307,
-1.445105979349165,
];
let a_rows = vec![1usize];
let a_cols = vec![0usize];
let a_vals = vec![0.3965134170122774_f64];
let a = CscMatrix::from_triplets(&a_rows, &a_cols, &a_vals, 2, 3).unwrap();
let b = vec![2.477268994387253_f64, -0.7050675637248502];
let bounds = vec![
(-0.5_f64, 0.5),
(-2.519765539465491, 2.519765539465491),
(-0.6799197663837497, 0.6799197663837497),
];
let cts = vec![ConstraintType::Le, ConstraintType::Ge];
let problem = QpProblem::new(q, c, a, b, bounds, cts).unwrap();
let mut o = SolverOptions::default();
o.timeout_secs = Some(15.0);
let cfg = GlobalOptimizationConfig::default();
let res = solve_qp_global(&problem, &o, &cfg);
assert!(
matches!(res.status, SolveStatus::NonconvexLocal | SolveStatus::NonconvexGlobal),
"expected NonconvexLocal/NonconvexGlobal, got {:?}",
res.status
);
let elim = structural_empty_col_mask(&problem);
let view = ProblemView {
q: &problem.q,
a: &problem.a,
c: &problem.c,
b: &problem.b,
bounds: &problem.bounds,
constraint_types: &problem.constraint_types,
eliminated_cols: &elim,
};
let comp = kkt_comp_residual(
&view,
&res.solution,
&res.dual_solution,
&res.bound_duals,
);
let stat = kkt_residual_rel(&view, &res.solution, &res.dual_solution, &res.bound_duals);
let pf = kkt_primal_residual(&view, &res.solution);
assert!(
comp < 1e-3,
"a46bde58: complementarity={:.3e} >= 1e-3 (status={:?} stat={:.3e} pf={:.3e})",
comp,
res.status,
stat,
pf,
);
}
}