use std::collections::BTreeMap;
use std::time::Instant;
use crate::options::SolverOptions;
use crate::problem::{ConstraintType, SolverResult};
use crate::qp::problem::QpProblem;
use crate::sparse::CscMatrix;
use super::bound::{all_bounds_finite, is_feasible_result};
const Q_ZERO_TOL: f64 = 1e-12;
const MCCORMICK_INEQ_PER_OFF_DIAG: usize = 4;
const MCCORMICK_INEQ_PER_DIAG: usize = 3;
pub(crate) fn mccormick_lower_bound(
problem: &QpProblem,
node_bounds: &[(f64, f64)],
base_opts: &SolverOptions,
deadline: Option<Instant>,
) -> Option<f64> {
solve_lifted_lp(
problem,
node_bounds,
base_opts,
deadline,
true,
)
}
fn solve_lifted_lp(
problem: &QpProblem,
node_bounds: &[(f64, f64)],
base_opts: &SolverOptions,
deadline: Option<Instant>,
include_envelope: bool,
) -> Option<f64> {
if !all_bounds_finite(node_bounds) {
return None;
}
let pairs = collect_bilinear_pairs(&problem.q);
if pairs.is_empty() {
return None;
}
let lifted = build_mccormick_lp(problem, node_bounds, &pairs, include_envelope)?;
let mut opts = base_opts.clone();
opts.multistart = None;
opts.global_optimization = None;
opts.warm_start = None;
opts.warm_start_qp = None;
opts.warm_start_lp = None;
opts.deadline = deadline;
let res: SolverResult = crate::qp::solve_qp_with(&lifted, &opts);
if !is_feasible_result(&res.status) {
return None;
}
Some(res.objective)
}
#[derive(Clone, Copy)]
struct BilinearTerm {
i: usize,
j: usize,
coef: f64,
}
fn collect_bilinear_pairs(q: &CscMatrix) -> Vec<BilinearTerm> {
let mut acc: BTreeMap<(usize, usize), f64> = BTreeMap::new();
for col in 0..q.ncols {
for k in q.col_ptr[col]..q.col_ptr[col + 1] {
let row = q.row_ind[k];
let v = q.values[k];
if v.abs() < Q_ZERO_TOL {
continue;
}
let key = if row <= col { (row, col) } else { (col, row) };
*acc.entry(key).or_insert(0.0) += 0.5 * v;
}
}
acc.into_iter()
.filter(|(_, c)| c.abs() >= Q_ZERO_TOL)
.map(|((i, j), coef)| BilinearTerm { i, j, coef })
.collect()
}
fn w_interval(bounds: &[(f64, f64)], i: usize, j: usize) -> (f64, f64) {
let (li, ui) = bounds[i];
if i == j {
let lo = if li <= 0.0 && ui >= 0.0 {
0.0
} else {
(li * li).min(ui * ui)
};
let hi = (li * li).max(ui * ui);
return (lo, hi);
}
let (lj, uj) = bounds[j];
let candidates = [li * lj, li * uj, ui * lj, ui * uj];
let mut lo = candidates[0];
let mut hi = candidates[0];
for &v in &candidates[1..] {
if v < lo {
lo = v;
}
if v > hi {
hi = v;
}
}
(lo, hi)
}
fn push_row_3(
rows: &mut Vec<usize>,
cols: &mut Vec<usize>,
vals: &mut Vec<f64>,
row: usize,
var_x: usize,
var_w: usize,
coef_x: f64,
coef_w: f64,
) {
if coef_x != 0.0 {
rows.push(row);
cols.push(var_x);
vals.push(coef_x);
}
rows.push(row);
cols.push(var_w);
vals.push(coef_w);
}
fn push_row_4(
rows: &mut Vec<usize>,
cols: &mut Vec<usize>,
vals: &mut Vec<f64>,
row: usize,
var_xi: usize,
var_xj: usize,
var_w: usize,
coef_xi: f64,
coef_xj: f64,
coef_w: f64,
) {
if coef_xi != 0.0 {
rows.push(row);
cols.push(var_xi);
vals.push(coef_xi);
}
if coef_xj != 0.0 {
rows.push(row);
cols.push(var_xj);
vals.push(coef_xj);
}
rows.push(row);
cols.push(var_w);
vals.push(coef_w);
}
fn build_mccormick_lp(
problem: &QpProblem,
node_bounds: &[(f64, f64)],
pairs: &[BilinearTerm],
include_envelope: bool,
) -> Option<QpProblem> {
let n = problem.num_vars;
let p = pairs.len();
let total_vars = n + p;
let q = CscMatrix::from_triplets(&[], &[], &[], total_vars, total_vars).ok()?;
let mut c = vec![0.0_f64; total_vars];
c[..n].copy_from_slice(&problem.c);
for (k, term) in pairs.iter().enumerate() {
c[n + k] = term.coef;
}
let mut bounds = Vec::with_capacity(total_vars);
bounds.extend_from_slice(node_bounds);
for term in pairs {
bounds.push(w_interval(node_bounds, term.i, term.j));
}
let extra_rows: usize = if include_envelope {
pairs
.iter()
.map(|t| {
if t.i == t.j {
MCCORMICK_INEQ_PER_DIAG
} else {
MCCORMICK_INEQ_PER_OFF_DIAG
}
})
.sum()
} else {
0
};
let total_rows = problem.num_constraints + extra_rows;
let nnz_estimate = problem.a.values.len() + extra_rows * 3;
let mut rows: Vec<usize> = Vec::with_capacity(nnz_estimate);
let mut cols: Vec<usize> = Vec::with_capacity(nnz_estimate);
let mut vals: Vec<f64> = Vec::with_capacity(nnz_estimate);
let mut b: Vec<f64> = Vec::with_capacity(total_rows);
let mut types: Vec<ConstraintType> = Vec::with_capacity(total_rows);
for col in 0..n {
for k in problem.a.col_ptr[col]..problem.a.col_ptr[col + 1] {
rows.push(problem.a.row_ind[k]);
cols.push(col);
vals.push(problem.a.values[k]);
}
}
b.extend_from_slice(&problem.b);
types.extend_from_slice(&problem.constraint_types);
let mut row = problem.num_constraints;
if !include_envelope {
debug_assert_eq!(row, total_rows);
let a = CscMatrix::from_triplets(&rows, &cols, &vals, total_rows, total_vars).ok()?;
let mut lifted = QpProblem::new(q, c, a, b, bounds, types).ok()?;
lifted.obj_offset = problem.obj_offset;
return Some(lifted);
}
for (k, term) in pairs.iter().enumerate() {
let (li, ui) = node_bounds[term.i];
let (lj, uj) = node_bounds[term.j];
let w_col = n + k;
if term.i == term.j {
push_row_3(
&mut rows,
&mut cols,
&mut vals,
row,
term.i,
w_col,
-2.0 * li,
1.0,
);
b.push(-li * li);
types.push(ConstraintType::Ge);
row += 1;
push_row_3(
&mut rows,
&mut cols,
&mut vals,
row,
term.i,
w_col,
-2.0 * ui,
1.0,
);
b.push(-ui * ui);
types.push(ConstraintType::Ge);
row += 1;
push_row_3(
&mut rows,
&mut cols,
&mut vals,
row,
term.i,
w_col,
-(li + ui),
1.0,
);
b.push(-li * ui);
types.push(ConstraintType::Le);
row += 1;
} else {
push_row_4(
&mut rows, &mut cols, &mut vals, row, term.i, term.j, w_col, -lj, -li, 1.0,
);
b.push(-li * lj);
types.push(ConstraintType::Ge);
row += 1;
push_row_4(
&mut rows, &mut cols, &mut vals, row, term.i, term.j, w_col, -uj, -ui, 1.0,
);
b.push(-ui * uj);
types.push(ConstraintType::Ge);
row += 1;
push_row_4(
&mut rows, &mut cols, &mut vals, row, term.i, term.j, w_col, -uj, -li, 1.0,
);
b.push(-li * uj);
types.push(ConstraintType::Le);
row += 1;
push_row_4(
&mut rows, &mut cols, &mut vals, row, term.i, term.j, w_col, -lj, -ui, 1.0,
);
b.push(-ui * lj);
types.push(ConstraintType::Le);
row += 1;
}
}
debug_assert_eq!(row, total_rows);
let a = CscMatrix::from_triplets(&rows, &cols, &vals, total_rows, total_vars).ok()?;
let mut lifted = QpProblem::new(q, c, a, b, bounds, types).ok()?;
lifted.obj_offset = problem.obj_offset;
Some(lifted)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::options::SolverOptions;
use crate::problem::ConstraintType;
fn diag_q(diag: &[f64]) -> CscMatrix {
let n = diag.len();
let rows: Vec<usize> = (0..n).collect();
let cols: Vec<usize> = (0..n).collect();
CscMatrix::from_triplets(&rows, &cols, diag, n, n).unwrap()
}
fn build_problem(q: CscMatrix, c: Vec<f64>, bounds: Vec<(f64, f64)>) -> QpProblem {
let n = c.len();
let a = CscMatrix::from_triplets(&[], &[], &[], 0, n).unwrap();
QpProblem::new(q, c, a, vec![], bounds, vec![]).unwrap()
}
#[test]
fn collect_bilinear_pairs_full_symmetric_aggregates_off_diag() {
let q = CscMatrix::from_triplets(&[0, 1, 0], &[0, 0, 1], &[2.0, 1.0, 1.0], 2, 2).unwrap();
let pairs = collect_bilinear_pairs(&q);
let mut got: Vec<(usize, usize, f64)> = pairs.iter().map(|t| (t.i, t.j, t.coef)).collect();
got.sort_by_key(|(i, j, _)| (*i, *j));
assert_eq!(got.len(), 2);
assert_eq!(got[0].0, 0);
assert_eq!(got[0].1, 0);
assert!((got[0].2 - 1.0).abs() < 1e-12);
assert_eq!(got[1].0, 0);
assert_eq!(got[1].1, 1);
assert!((got[1].2 - 1.0).abs() < 1e-12);
}
#[test]
fn collect_bilinear_pairs_upper_only_storage() {
let q = CscMatrix::from_triplets(&[0], &[1], &[1.0], 2, 2).unwrap();
let pairs = collect_bilinear_pairs(&q);
assert_eq!(pairs.len(), 1);
assert_eq!(pairs[0].i, 0);
assert_eq!(pairs[0].j, 1);
assert!((pairs[0].coef - 0.5).abs() < 1e-12);
}
#[test]
fn w_interval_diag_zero_crossing() {
let (lo, hi) = w_interval(&[(-2.0, 3.0)], 0, 0);
assert_eq!(lo, 0.0);
assert_eq!(hi, 9.0);
}
#[test]
fn w_interval_off_diag_sign_change() {
let (lo, hi) = w_interval(&[(-1.0, 2.0), (-3.0, 1.0)], 0, 1);
assert_eq!(lo, -6.0);
assert_eq!(hi, 3.0);
}
#[test]
fn mccormick_lb_none_for_infinite_box() {
let q = diag_q(&[-2.0]);
let p = build_problem(q, vec![0.0], vec![(f64::NEG_INFINITY, 1.0)]);
let opts = SolverOptions::default();
assert!(mccormick_lower_bound(&p, &p.bounds, &opts, None).is_none());
}
#[test]
fn mccormick_lb_none_for_zero_q() {
let q = CscMatrix::from_triplets(&[], &[], &[], 2, 2).unwrap();
let p = build_problem(q, vec![1.0, -1.0], vec![(-1.0, 1.0); 2]);
let opts = SolverOptions::default();
assert!(mccormick_lower_bound(&p, &p.bounds, &opts, None).is_none());
}
#[test]
fn mccormick_lb_recovers_concave_1d_global() {
let q = diag_q(&[-2.0]);
let p = build_problem(q, vec![0.0], vec![(-2.0, 2.0)]);
let opts = SolverOptions::default();
let lb = mccormick_lower_bound(&p, &p.bounds, &opts, None).expect("McCormick must solve");
assert!(lb.is_finite());
assert!(
(lb - (-4.0)).abs() < 1e-4,
"expected tight lb ≈ -4, got {lb}"
);
}
#[test]
fn mccormick_lb_tighter_than_alpha_bb_on_asymmetric_bilinear() {
use crate::qp::global::bound_alpha_bb::{alpha_bb_lower_bound, gershgorin_alpha};
let q = CscMatrix::from_triplets(&[0, 1], &[1, 0], &[-1.0, -1.0], 2, 2).unwrap();
let p = build_problem(q, vec![0.0, 0.0], vec![(-2.0, 1.0), (-1.0, 2.0)]);
let opts = SolverOptions::default();
let alpha = gershgorin_alpha(&p.q);
let lb_alpha = alpha_bb_lower_bound(&p, &p.bounds, alpha, &opts, None).expect("α-BB");
let lb_mc = mccormick_lower_bound(&p, &p.bounds, &opts, None).expect("McCormick");
assert!(
lb_mc <= -2.0 + 1e-4,
"McCormick lb {lb_mc} must underestimate global -2"
);
assert!(
lb_alpha <= -2.0 + 1e-4,
"α-BB lb {lb_alpha} must underestimate global -2"
);
assert!(
lb_mc > lb_alpha + 1e-3,
"McCormick should beat α-BB on pure bilinear asym box: mc={lb_mc}, alpha={lb_alpha}"
);
assert!(
lb_mc >= -2.0 - 1e-4,
"McCormick should reach tight -2, got {lb_mc}"
);
}
#[test]
fn mccormick_lb_underestimates_objective_at_corners() {
let q = CscMatrix::from_triplets(&[0, 1], &[1, 0], &[1.0, 1.0], 2, 2).unwrap();
let p = build_problem(q, vec![0.0, 0.0], vec![(-1.0, 1.0); 2]);
let opts = SolverOptions::default();
let lb = mccormick_lower_bound(&p, &p.bounds, &opts, None).expect("McCormick");
assert!(
lb <= -1.0 + 1e-6,
"lb {lb} must underestimate global min -1"
);
}
#[test]
fn mccormick_lb_with_linear_eq_constraint() {
let q = diag_q(&[-2.0, -2.0]);
let a = CscMatrix::from_triplets(&[0, 0], &[0, 1], &[1.0, 1.0], 1, 2).unwrap();
let p = QpProblem::new(
q,
vec![0.0, 0.0],
a,
vec![1.0],
vec![(0.0, 1.0); 2],
vec![ConstraintType::Eq],
)
.unwrap();
let opts = SolverOptions::default();
let lb = mccormick_lower_bound(&p, &p.bounds, &opts, None).expect("McCormick");
assert!(
lb.is_finite() && lb <= -1.0 + 1e-4,
"lb {lb} should be ≤ -1"
);
}
#[test]
fn mccormick_lb_tightens_as_box_shrinks() {
let q = diag_q(&[-2.0]);
let p = build_problem(q, vec![0.0], vec![(-2.0, 2.0)]);
let opts = SolverOptions::default();
let lb_wide = mccormick_lower_bound(&p, &[(-2.0, 2.0)], &opts, None).expect("wide");
let lb_narrow = mccormick_lower_bound(&p, &[(0.0, 1.0)], &opts, None).expect("narrow");
assert!(
lb_narrow >= lb_wide - 1e-6,
"narrow lb {lb_narrow} should not be worse than wide {lb_wide}"
);
}
#[test]
fn mccormick_lb_strictly_better_than_envelope_removed() {
let q = diag_q(&[-2.0]);
let p = build_problem(q, vec![1.0], vec![(0.0, 1.0)]);
let opts = SolverOptions::default();
let lb_full = solve_lifted_lp(&p, &p.bounds, &opts, None, true).expect("full");
let lb_noop = solve_lifted_lp(&p, &p.bounds, &opts, None, false).expect("noop");
assert!(
lb_noop.is_finite(),
"noop must remain finite, got {lb_noop}"
);
assert!(
lb_full > lb_noop + 0.5,
"McCormick envelope must add real tightness: full={lb_full}, noop={lb_noop}"
);
assert!(
lb_full <= 0.0 + 1e-4,
"full lb {lb_full} must underestimate 0"
);
assert!(
lb_noop <= 0.0 + 1e-4,
"noop lb {lb_noop} must underestimate 0"
);
}
#[test]
fn mccormick_lb_with_linear_ge_constraint() {
let q = diag_q(&[-2.0, -2.0]);
let a = CscMatrix::from_triplets(&[0, 0], &[0, 1], &[1.0, 1.0], 1, 2).unwrap();
let p = QpProblem::new(
q,
vec![0.0, 0.0],
a,
vec![1.0],
vec![(0.0, 1.0); 2],
vec![ConstraintType::Ge],
)
.unwrap();
let opts = SolverOptions::default();
let lb = mccormick_lower_bound(&p, &p.bounds, &opts, None).expect("Ge McCormick");
assert!(
lb.is_finite() && lb <= -2.0 + 1e-4,
"Ge lb {lb} must underestimate global ≥ -2"
);
}
#[test]
fn mccormick_lb_with_zero_coef_box_edges() {
let q = diag_q(&[-2.0]);
let p = build_problem(q, vec![0.0], vec![(0.0, 2.0)]);
let opts = SolverOptions::default();
let lb = mccormick_lower_bound(&p, &p.bounds, &opts, None).expect("zero-coef path");
assert!(
lb.is_finite() && (lb - (-4.0)).abs() < 1e-3,
"zero-coef edge lb {lb} must reach tight global -4 (±1e-3)"
);
}
#[test]
fn mccormick_lb_q_zero_tol_boundary() {
let half = Q_ZERO_TOL * 0.5;
let q_below = CscMatrix::from_triplets(&[0], &[0], &[half], 1, 1).unwrap();
let p_below = build_problem(q_below, vec![0.0], vec![(-1.0, 1.0)]);
let opts = SolverOptions::default();
assert!(
mccormick_lower_bound(&p_below, &p_below.bounds, &opts, None).is_none(),
"Q values below Q_ZERO_TOL must produce empty pairs → None"
);
let live = Q_ZERO_TOL * 10.0;
let q_above = CscMatrix::from_triplets(&[0], &[0], &[live], 1, 1).unwrap();
let p_above = build_problem(q_above, vec![0.0], vec![(-1.0, 1.0)]);
let lb = mccormick_lower_bound(&p_above, &p_above.bounds, &opts, None)
.expect("above-threshold Q must yield lb");
assert!(
lb.is_finite(),
"above-threshold lb must be finite, got {lb}"
);
}
#[test]
fn mccormick_lb_holds_on_n8_dense_bilinear() {
const N: usize = 8;
let mut rows = Vec::new();
let mut cols = Vec::new();
let mut vals = Vec::new();
for c in 0..N {
for r in 0..N {
rows.push(r);
cols.push(c);
vals.push(if r == c { -1.0 } else { 1.0 });
}
}
let q = CscMatrix::from_triplets(&rows, &cols, &vals, N, N).unwrap();
let a = CscMatrix::from_triplets(&[], &[], &[], 0, N).unwrap();
let p = QpProblem::new(q, vec![0.0; N], a, vec![], vec![(-1.0, 1.0); N], vec![]).unwrap();
let opts = SolverOptions::default();
let lb = mccormick_lower_bound(&p, &p.bounds, &opts, None)
.expect("n=8 dense bilinear must solve");
assert!(lb.is_finite(), "n=8 lb must be finite, got {lb}");
let qx = p.q.mat_vec_mul(&[1.0; N]).unwrap();
let xqx: f64 = [1.0; N].iter().zip(qx.iter()).map(|(a, b)| a * b).sum();
let f_corner = 0.5 * xqx;
assert!(
lb <= f_corner + 1e-5,
"n=8 lb {lb} must underestimate corner f={f_corner}"
);
}
}