use super::{
finalize_no_incumbent, integer_mask, solve_milp, solve_milp_with_stats, solve_mip_core,
solve_miqp, solve_miqp_with_stats, MilpProblem, MiqpProblem,
};
use crate::options::{MipConfig, SolverOptions};
use crate::problem::{ConstraintType, LpProblem, SolveStatus, SolverResult};
use crate::qp::QpProblem;
use crate::sparse::CscMatrix;
const EPS: f64 = 1e-4;
fn opts() -> SolverOptions {
SolverOptions {
timeout_secs: Some(10.0),
..Default::default()
}
}
#[allow(clippy::too_many_arguments)] fn build_lp(
c: Vec<f64>,
rows: &[usize],
cols: &[usize],
vals: &[f64],
m: usize,
b: Vec<f64>,
ctypes: Vec<ConstraintType>,
bounds: Vec<(f64, f64)>,
) -> LpProblem {
let n = c.len();
let a = if m == 0 {
CscMatrix::new(0, n)
} else {
CscMatrix::from_triplets(rows, cols, vals, m, n).unwrap()
};
LpProblem::new_general(c, a, b, ctypes, bounds, None).unwrap()
}
fn milp(lp: LpProblem, integer_vars: Vec<usize>) -> MilpProblem {
MilpProblem::new(lp, integer_vars).unwrap()
}
#[test]
fn trivial_integer_root_is_optimal_without_branching() {
let lp = build_lp(
vec![1.0],
&[],
&[],
&[],
0,
vec![],
vec![],
vec![(0.0, 5.0)],
);
let (r, stats) = solve_milp_with_stats(&milp(lp, vec![0]), &opts(), &MipConfig::default());
assert_eq!(r.status, SolveStatus::Optimal);
assert!((r.objective - 0.0).abs() < EPS, "obj={}", r.objective);
assert!((r.solution[0] - 0.0).abs() < EPS);
assert_eq!(stats.nodes_processed, 1, "integral root must not branch");
}
#[test]
fn bt_detects_infeasibility_before_bb() {
let a = CscMatrix::from_triplets(&[0, 1], &[0, 0], &[1.0, 1.0], 2, 1).unwrap();
let lp = LpProblem::new_general(
vec![1.0],
a,
vec![3.7, 3.5],
vec![ConstraintType::Le, ConstraintType::Ge],
vec![(0.0, 10.0)],
None,
)
.unwrap();
let (r, stats) = solve_milp_with_stats(&milp(lp, vec![0]), &opts(), &MipConfig::default());
assert_eq!(
r.status,
SolveStatus::Infeasible,
"x≤3.7 ∧ x≥3.5 integer → empty domain"
);
assert_eq!(
stats.nodes_processed, 0,
"infeasibility detected by BT, not B&B"
);
}
#[test]
fn equality_row_integer_var_crossed_bounds_detect_infeasibility() {
let a = CscMatrix::from_triplets(&[0], &[0], &[1.0], 1, 1).unwrap();
let lp = LpProblem::new_general(
vec![1.0],
a,
vec![3.5],
vec![ConstraintType::Eq],
vec![(0.0, 10.0)],
None,
)
.unwrap();
let (r, stats) = solve_milp_with_stats(&milp(lp, vec![0]), &opts(), &MipConfig::default());
assert_eq!(
r.status,
SolveStatus::Infeasible,
"x=3.5 integer → no feasible integer → infeasible"
);
assert_eq!(
stats.nodes_processed, 0,
"BT must detect crossing before B&B"
);
}
#[test]
fn bt_resolves_root_as_integer_feasible() {
let lp = build_lp(
vec![-1.0],
&[0],
&[0],
&[2.0],
1,
vec![3.0],
vec![ConstraintType::Le],
vec![(0.0, 5.0)],
);
let (r, stats) = solve_milp_with_stats(&milp(lp, vec![0]), &opts(), &MipConfig::default());
assert_eq!(r.status, SolveStatus::Optimal);
assert!((r.objective - (-1.0)).abs() < EPS, "obj={}", r.objective);
assert!((r.solution[0] - 1.0).abs() < EPS, "x={}", r.solution[0]);
assert_eq!(
stats.nodes_processed, 1,
"BT tightens x ≤ 1 → root is integer-feasible, no branching"
);
assert_eq!(stats.incumbent_updates, 1);
}
#[test]
fn branching_fires_when_bt_insufficient() {
let lp = build_lp(
vec![-1.0, -1.0],
&[0, 0],
&[0, 1],
&[1.0, 1.0],
1,
vec![3.5],
vec![ConstraintType::Le],
vec![(0.0, 5.0), (0.0, 5.0)],
);
let (r, stats) = solve_milp_with_stats(&milp(lp, vec![0, 1]), &opts(), &MipConfig::default());
assert_eq!(r.status, SolveStatus::Optimal);
assert!((r.objective - (-3.0)).abs() < EPS, "obj={}", r.objective);
let s = r.solution[0].round() + r.solution[1].round();
assert!((s - 3.0).abs() < EPS, "x+y={}", s);
assert!(
stats.nodes_processed >= 3,
"fractional LP root causes branching, nodes={}",
stats.nodes_processed
);
assert!(
stats.pruned >= 1,
"at least one pruned/infeasible child expected"
);
assert!(stats.incumbent_updates >= 1);
}
#[test]
fn binary_knapsack_reaches_known_optimum() {
let lp = build_lp(
vec![-8.0, -11.0, -6.0, -4.0],
&[0, 0, 0, 0],
&[0, 1, 2, 3],
&[5.0, 7.0, 4.0, 3.0],
1,
vec![14.0],
vec![ConstraintType::Le],
vec![(0.0, 1.0); 4],
);
let r = solve_milp(&milp(lp, vec![0, 1, 2, 3]), &opts(), &MipConfig::default());
assert_eq!(r.status, SolveStatus::Optimal);
assert!((r.objective - (-21.0)).abs() < EPS, "obj={}", r.objective);
let sol: Vec<f64> = r.solution.iter().map(|v| v.round()).collect();
assert_eq!(sol, vec![0.0, 1.0, 1.0, 1.0], "knapsack pick");
}
#[test]
fn integer_infeasible_between_consecutive_integers() {
let lp = build_lp(
vec![1.0],
&[0, 1],
&[0, 0],
&[1.0, 1.0],
2,
vec![1.2, 1.8],
vec![ConstraintType::Ge, ConstraintType::Le],
vec![(0.0, 10.0)],
);
let r = solve_milp(&milp(lp, vec![0]), &opts(), &MipConfig::default());
assert_eq!(r.status, SolveStatus::Infeasible, "no integer in (1.2,1.8)");
}
#[test]
fn unbounded_relaxation_reports_unbounded() {
let lp = build_lp(
vec![-1.0],
&[],
&[],
&[],
0,
vec![],
vec![],
vec![(0.0, f64::INFINITY)],
);
let r = solve_milp(&milp(lp, vec![0]), &opts(), &MipConfig::default());
assert_eq!(r.status, SolveStatus::Unbounded);
}
#[test]
fn no_integer_vars_falls_back_to_lp() {
let lp = build_lp(
vec![1.0, 1.0],
&[0],
&[0],
&[1.0],
1,
vec![3.0],
vec![ConstraintType::Ge],
vec![(0.0, 5.0), (0.0, 5.0)],
);
let direct = crate::lp::solve_lp_with(&lp, &opts());
let r = solve_milp(&milp(lp, vec![]), &opts(), &MipConfig::default());
assert_eq!(r.status, SolveStatus::Optimal);
assert!((r.objective - direct.objective).abs() < EPS);
}
#[test]
fn no_integer_vars_fallback_preserves_caller_warm_start() {
use crate::options::WarmStartBasis;
use crate::problem::SolverResult;
use std::cell::Cell;
struct PureLpMock {
warm_start_received: Cell<bool>,
root_bounds: [(f64, f64); 1],
int_vars: [usize; 0],
}
impl PureLpMock {
fn new() -> Self {
Self {
warm_start_received: Cell::new(false),
root_bounds: [(0.0, 5.0)],
int_vars: [],
}
}
}
impl super::Relaxation for PureLpMock {
fn num_vars(&self) -> usize {
1
}
fn root_bounds(&self) -> &[(f64, f64)] {
&self.root_bounds
}
fn integer_vars(&self) -> &[usize] {
&self.int_vars
}
fn solve(&self, _bounds: &[(f64, f64)], opts: &SolverOptions) -> SolverResult {
self.warm_start_received.set(opts.warm_start.is_some());
SolverResult {
status: SolveStatus::Optimal,
objective: 0.0,
solution: vec![0.0],
..SolverResult::default()
}
}
}
let user_ws = WarmStartBasis {
basis: vec![0],
x_b: vec![0.0],
};
let opts_with_ws = SolverOptions {
warm_start: Some(user_ws),
..opts()
};
let mock = PureLpMock::new();
let (r, _) = super::solve_mip_with_stats(&mock, &opts_with_ws, &MipConfig::default());
assert_eq!(r.status, SolveStatus::Optimal);
assert!(
mock.warm_start_received.get(),
"LP/QP fallback must forward caller's warm_start to the solver; \
no-op (mutate before early-return) gives warm_start=None → false"
);
}
#[test]
fn two_var_general_integer_program() {
let lp = build_lp(
vec![-1.0, -1.0],
&[0, 0, 1, 2],
&[0, 1, 0, 1],
&[1.0, 1.0, 1.0, 1.0],
3,
vec![3.5, 2.5, 2.5],
vec![ConstraintType::Le; 3],
vec![(0.0, 5.0), (0.0, 5.0)],
);
let r = solve_milp(&milp(lp, vec![0, 1]), &opts(), &MipConfig::default());
assert_eq!(r.status, SolveStatus::Optimal);
assert!((r.objective - (-3.0)).abs() < EPS, "obj={}", r.objective);
let s = r.solution[0].round() + r.solution[1].round();
assert!((s - 3.0).abs() < EPS, "x+y={}", s);
}
#[test]
fn no_incumbent_with_open_region_is_not_infeasible() {
let r = finalize_no_incumbent(false, true, true, false);
assert_ne!(
r.status,
SolveStatus::Infeasible,
"open region must not be Infeasible"
);
assert_eq!(r.status, SolveStatus::MaxIterations);
}
#[test]
fn no_incumbent_fully_resolved_is_infeasible() {
let r = finalize_no_incumbent(false, false, true, false);
assert_eq!(r.status, SolveStatus::Infeasible);
}
#[test]
fn no_incumbent_deadline_is_timeout_not_infeasible() {
let r = finalize_no_incumbent(true, true, false, true);
assert_eq!(r.status, SolveStatus::Timeout);
}
#[test]
fn no_incumbent_budget_exhausted_is_maxiterations_not_infeasible() {
let r = finalize_no_incumbent(true, true, false, false);
assert_eq!(r.status, SolveStatus::MaxIterations);
}
#[allow(clippy::too_many_arguments)] fn qp_problem(
diag: &[f64],
c: Vec<f64>,
rows: &[usize],
cols: &[usize],
vals: &[f64],
m: usize,
b: Vec<f64>,
ctypes: Vec<ConstraintType>,
bounds: Vec<(f64, f64)>,
) -> QpProblem {
let n = diag.len();
let qidx: Vec<usize> = (0..n).collect();
let q = CscMatrix::from_triplets(&qidx, &qidx, diag, n, n).unwrap();
let a = if m == 0 {
CscMatrix::new(0, n)
} else {
CscMatrix::from_triplets(rows, cols, vals, m, n).unwrap()
};
QpProblem::new(q, c, a, b, bounds, ctypes).unwrap()
}
fn miqp(qp: QpProblem, integer_vars: Vec<usize>) -> MiqpProblem {
MiqpProblem::new(qp, integer_vars).unwrap()
}
#[test]
fn miqp_fractional_root_branches_to_integer_optimum() {
let qp = qp_problem(
&[2.0, 2.0],
vec![0.0, 0.0],
&[0, 0],
&[0, 1],
&[1.0, 1.0],
1,
vec![3.0],
vec![ConstraintType::Ge],
vec![(0.0, 5.0), (0.0, 5.0)],
);
let (r, stats) =
super::solve_miqp_with_stats(&miqp(qp, vec![0, 1]), &opts(), &MipConfig::default());
assert_eq!(r.status, SolveStatus::Optimal);
assert!((r.objective - 5.0).abs() < 1e-3, "obj={}", r.objective);
let s = r.solution[0].round() + r.solution[1].round();
assert!((s - 3.0).abs() < EPS, "x+y={}", s);
assert!(
stats.nodes_processed >= 2,
"branching expected, nodes={}",
stats.nodes_processed
);
}
#[test]
fn miqp_trivial_integer_root() {
let qp = qp_problem(
&[2.0],
vec![0.0],
&[],
&[],
&[],
0,
vec![],
vec![],
vec![(0.0, 5.0)],
);
let r = solve_miqp(&miqp(qp, vec![0]), &opts(), &MipConfig::default());
assert_eq!(r.status, SolveStatus::Optimal);
assert!(r.objective.abs() < 1e-3, "obj={}", r.objective);
assert!(r.solution[0].abs() < EPS, "x={}", r.solution[0]);
}
#[test]
fn miqp_infeasible_between_integers() {
let qp = qp_problem(
&[2.0],
vec![0.0],
&[0, 1],
&[0, 0],
&[1.0, 1.0],
2,
vec![1.2, 1.8],
vec![ConstraintType::Ge, ConstraintType::Le],
vec![(0.0, 10.0)],
);
let r = solve_miqp(&miqp(qp, vec![0]), &opts(), &MipConfig::default());
assert_eq!(r.status, SolveStatus::Infeasible);
}
#[test]
fn miqp_nonconvex_q_rejected() {
let qp = qp_problem(
&[2.0, -3.0],
vec![0.0, 0.0],
&[],
&[],
&[],
0,
vec![],
vec![],
vec![(0.0, 5.0); 2],
);
let r = solve_miqp(&miqp(qp, vec![0, 1]), &opts(), &MipConfig::default());
assert!(
matches!(r.status, SolveStatus::NonConvex(_)),
"got {:?}",
r.status
);
}
#[test]
fn miqp_no_integer_vars_falls_back_to_qp() {
let qp = qp_problem(
&[2.0, 2.0],
vec![0.0, 0.0],
&[0, 0],
&[0, 1],
&[1.0, 1.0],
1,
vec![3.0],
vec![ConstraintType::Ge],
vec![(0.0, 5.0), (0.0, 5.0)],
);
let direct = crate::qp::solve_qp_with(&qp.clone(), &opts());
let r = solve_miqp(&miqp(qp, vec![]), &opts(), &MipConfig::default());
assert_eq!(r.status, SolveStatus::Optimal);
assert!(
(r.objective - direct.objective).abs() < 1e-3,
"miqp {} vs qp {}",
r.objective,
direct.objective
);
}
#[test]
fn miqp_boxonly_offdiag_no_overprune_sentinel() {
let q = CscMatrix::from_triplets(&[0, 0, 1, 1], &[0, 1, 0, 1], &[2.0, 1.0, 1.0, 2.0], 2, 2)
.unwrap();
let a = CscMatrix::from_triplets(&[], &[], &[], 0, 2).unwrap();
let qp = QpProblem::new(
q,
vec![-6.0, -6.0],
a,
vec![],
vec![(0.0, 4.0), (0.0, 4.0)],
vec![],
)
.unwrap();
let r = solve_miqp(&miqp(qp, vec![0, 1]), &opts(), &MipConfig::default());
assert!(
(r.objective - (-12.0)).abs() < 1e-3,
"obj={} (expected −12 @ (2,2))",
r.objective
);
let s = (r.solution[0].round(), r.solution[1].round());
assert_eq!(s, (2.0, 2.0), "x*={:?}", r.solution);
}
#[test]
fn stats_timing_populated_for_milp_bt_then_branch() {
let lp = build_lp(
vec![-1.0, -1.0],
&[0, 0],
&[0, 1],
&[1.0, 1.0],
1,
vec![3.5],
vec![ConstraintType::Le],
vec![(0.0, 5.0), (0.0, 5.0)],
);
let (_, stats) = solve_milp_with_stats(&milp(lp, vec![0, 1]), &opts(), &MipConfig::default());
assert!(stats.nodes_processed >= 3, "branching expected");
assert!(
stats.relaxation_time_total_ms > 0.0,
"relax_total_ms must be >0 (instrumentation missing?)"
);
assert!(
stats.relaxation_time_root_ms > 0.0,
"relax_root_ms must be >0"
);
assert!(
stats.relaxation_time_desc_ms > 0.0,
"relax_desc_ms must be >0 (descendant instrumentation missing?)"
);
let sum = stats.relaxation_time_root_ms + stats.relaxation_time_desc_ms;
assert!(
(stats.relaxation_time_total_ms - sum).abs() < 1e-6,
"total={:.6} root={:.6} desc={:.6}",
stats.relaxation_time_total_ms,
stats.relaxation_time_root_ms,
stats.relaxation_time_desc_ms,
);
assert!(
stats.relaxation_time_optimal_ms > 0.0,
"optimal_ms must be >0"
);
assert!(
stats.approx_bounds_bytes_per_node > 0,
"bounds_bytes_per_node must be >0"
);
}
#[test]
fn stats_timing_infeasible_ms_from_lp_after_bt() {
let a = crate::sparse::CscMatrix::from_triplets(
&[0, 0, 1, 1],
&[0, 1, 0, 1],
&[1.0, 1.0, 1.0, 1.0],
2,
2,
)
.unwrap();
let lp = crate::problem::LpProblem::new_general(
vec![1.0, 1.0],
a,
vec![1.0, 2.0],
vec![ConstraintType::Le, ConstraintType::Ge],
vec![(0.0, 3.0), (0.0, 3.0)],
None,
)
.unwrap();
let (r, stats) = solve_milp_with_stats(&milp(lp, vec![0, 1]), &opts(), &MipConfig::default());
assert_eq!(r.status, SolveStatus::Infeasible);
assert!(
stats.relaxation_time_infeasible_ms > 0.0,
"infeasible_ms must be >0; pruned={} nodes={}",
stats.pruned,
stats.nodes_processed,
);
}
#[test]
fn stats_timing_root_only_for_trivial_integer_root() {
let lp = build_lp(
vec![1.0],
&[],
&[],
&[],
0,
vec![],
vec![],
vec![(0.0, 5.0)],
);
let (_, stats) = solve_milp_with_stats(&milp(lp, vec![0]), &opts(), &MipConfig::default());
assert_eq!(stats.nodes_processed, 1);
assert!(stats.relaxation_time_root_ms > 0.0, "root_ms must be >0");
assert_eq!(
stats.relaxation_time_desc_ms, 0.0,
"no descendants → desc_ms must be 0"
);
}
#[test]
fn stats_timing_populated_for_miqp_with_branching() {
let qp = qp_problem(
&[2.0, 2.0],
vec![0.0, 0.0],
&[0, 0],
&[0, 1],
&[1.0, 1.0],
1,
vec![3.0],
vec![ConstraintType::Ge],
vec![(0.0, 5.0), (0.0, 5.0)],
);
let (_, stats) =
super::solve_miqp_with_stats(&miqp(qp, vec![0, 1]), &opts(), &MipConfig::default());
assert!(stats.nodes_processed >= 2, "branching expected");
assert!(
stats.relaxation_time_total_ms > 0.0,
"MIQP relax_total_ms must be >0"
);
assert!(
stats.relaxation_time_root_ms > 0.0,
"MIQP relax_root_ms must be >0"
);
assert!(
stats.approx_bounds_bytes_per_node > 0,
"MIQP bounds_bytes_per_node must be >0"
);
}
#[test]
fn stats_bounds_bytes_scales_with_num_vars() {
let lp1 = build_lp(
vec![1.0],
&[],
&[],
&[],
0,
vec![],
vec![],
vec![(0.0, 5.0)],
);
let (_, s1) = solve_milp_with_stats(&milp(lp1, vec![0]), &opts(), &MipConfig::default());
let lp4 = build_lp(
vec![1.0, 1.0, 1.0, 1.0],
&[],
&[],
&[],
0,
vec![],
vec![],
vec![(0.0, 5.0); 4],
);
let (_, s4) = solve_milp_with_stats(&milp(lp4, vec![0]), &opts(), &MipConfig::default());
assert_eq!(
s4.approx_bounds_bytes_per_node,
4 * s1.approx_bounds_bytes_per_node,
"bytes must scale 4× with 4× the variables"
);
}
#[test]
fn integer_mask_marks_only_integer_vars() {
assert_eq!(integer_mask(3, &[0, 2]), vec![true, false, true]);
assert_eq!(integer_mask(2, &[]), vec![false, false]);
}
#[test]
fn invalid_options_rejected_at_milp_entry() {
use crate::options::IpmOptions;
let lp = build_lp(
vec![1.0],
&[],
&[],
&[],
0,
vec![],
vec![],
vec![(0.0, 5.0)],
);
let problem = milp(lp, vec![0]);
let cfg = MipConfig::default();
let cases: &[(&str, SolverOptions)] = &[
(
"nan primal_tol",
SolverOptions {
primal_tol: f64::NAN,
..Default::default()
},
),
(
"zero primal_tol",
SolverOptions {
primal_tol: 0.0,
..Default::default()
},
),
(
"neg dual_tol",
SolverOptions {
dual_tol: -1e-6,
..Default::default()
},
),
(
"neg timeout_secs",
SolverOptions {
timeout_secs: Some(-1.0),
..Default::default()
},
),
(
"zero threads",
SolverOptions {
threads: 0,
..Default::default()
},
),
(
"ipm eps zero",
SolverOptions {
ipm: IpmOptions {
eps: 0.0,
..Default::default()
},
..Default::default()
},
),
];
for (label, bad_opts) in cases {
let r = solve_milp(&problem, bad_opts, &cfg);
assert_eq!(
r.status,
crate::problem::SolveStatus::NumericalError,
"solve_milp with {label} must return NumericalError"
);
let (r2, _) = solve_milp_with_stats(&problem, bad_opts, &cfg);
assert_eq!(
r2.status,
crate::problem::SolveStatus::NumericalError,
"solve_milp_with_stats with {label} must return NumericalError"
);
}
}
#[test]
fn solve_miqp_rejects_indefinite_n1001() {
let n = 1001_usize;
let mut rows: Vec<usize> = (0..n).collect();
let mut cols: Vec<usize> = (0..n).collect();
let mut vals: Vec<f64> = vec![1.0; n];
rows.push(0);
cols.push(1);
vals.push(2.0);
let q = CscMatrix::from_triplets(&rows, &cols, &vals, n, n).unwrap();
let a = CscMatrix::new(0, n);
let qp = QpProblem::new_all_le(q, vec![0.0; n], a, vec![], vec![(0.0, 5.0); n]).unwrap();
let m = MiqpProblem::new(qp, vec![0]).unwrap();
let (result, _) = solve_miqp_with_stats(&m, &SolverOptions::default(), &MipConfig::default());
assert!(
matches!(result.status, SolveStatus::NonConvex(_)),
"solve_miqp_with_stats must reject indefinite Q with NonConvex, got {:?}",
result.status
);
}
#[test]
fn invalid_options_rejected_at_miqp_entry() {
let q = crate::sparse::CscMatrix::from_triplets(&[0], &[0], &[1.0], 1, 1).unwrap();
let c = vec![0.0];
let a = crate::sparse::CscMatrix::from_triplets(&[0], &[0], &[1.0], 1, 1).unwrap();
let b = vec![5.0];
let bounds = vec![(0.0, f64::INFINITY)];
let qp = QpProblem::new_all_le(q, c, a, b, bounds).unwrap();
let problem = MiqpProblem::new(qp, vec![0]).unwrap();
let cfg = MipConfig::default();
let cases: &[(&str, SolverOptions)] = &[
(
"nan primal_tol",
SolverOptions {
primal_tol: f64::NAN,
..Default::default()
},
),
(
"zero threads",
SolverOptions {
threads: 0,
..Default::default()
},
),
(
"neg timeout_secs",
SolverOptions {
timeout_secs: Some(-1.0),
..Default::default()
},
),
];
for (label, bad_opts) in cases {
let r = solve_miqp(&problem, bad_opts, &cfg);
assert_eq!(
r.status,
crate::problem::SolveStatus::NumericalError,
"solve_miqp with {label} must return NumericalError"
);
let (r2, _) = solve_miqp_with_stats(&problem, bad_opts, &cfg);
assert_eq!(
r2.status,
crate::problem::SolveStatus::NumericalError,
"solve_miqp_with_stats with {label} must return NumericalError"
);
}
}
#[test]
fn optimal_result_carries_bound_gap_cert() {
let lp = build_lp(
vec![1.0],
&[],
&[],
&[],
0,
vec![],
vec![],
vec![(0.0, 5.0)],
);
let (r, _) = solve_milp_with_stats(&milp(lp, vec![0]), &opts(), &MipConfig::default());
assert_eq!(r.status, SolveStatus::Optimal);
let cert = r
.bound_gap_cert
.as_ref()
.expect("Optimal must carry BoundGapCertificate");
assert!(
cert.gap_rel() <= cert.gap_tol() + 1e-10,
"gap_rel={} must be ≤ gap_tol={}",
cert.gap_rel(),
cert.gap_tol()
);
assert!(
(cert.incumbent_obj() - 0.0).abs() < 1e-6,
"incumbent obj must be ~0, got {}",
cert.incumbent_obj()
);
assert!(
cert.lower_bound() <= cert.incumbent_obj() + 1e-10,
"lb={} must be ≤ inc_obj={}",
cert.lower_bound(),
cert.incumbent_obj()
);
}
#[test]
fn knapsack_truncated_has_no_bound_gap_cert() {
let lp = build_lp(
vec![-8.0, -11.0],
&[0, 0],
&[0, 1],
&[5.0, 7.0],
1,
vec![10.0],
vec![ConstraintType::Le],
vec![(0.0, 1.0), (0.0, 1.0)],
);
let cfg = MipConfig {
max_nodes: 2,
..MipConfig::default()
};
let (r, _) = solve_milp_with_stats(&milp(lp, vec![0, 1]), &opts(), &cfg);
assert_ne!(
r.status,
SolveStatus::Optimal,
"must not claim Optimal with open queue"
);
assert!(
r.bound_gap_cert.is_none(),
"non-Optimal must have no BoundGapCertificate"
);
}
#[test]
fn proof_uncertain_blocks_optimal_despite_closed_gap() {
use crate::options::SolverOptions;
use crate::problem::SolverResult;
use std::cell::Cell;
struct SeqMock {
call: Cell<usize>,
root_bounds: [(f64, f64); 1],
int_vars: [usize; 1],
}
impl SeqMock {
fn new() -> Self {
Self {
call: Cell::new(0),
root_bounds: [(0.0, 2.0)],
int_vars: [0],
}
}
}
impl super::Relaxation for SeqMock {
fn num_vars(&self) -> usize {
1
}
fn root_bounds(&self) -> &[(f64, f64)] {
&self.root_bounds
}
fn integer_vars(&self) -> &[usize] {
&self.int_vars
}
fn solve(&self, _bounds: &[(f64, f64)], _opts: &SolverOptions) -> SolverResult {
let n = self.call.get();
self.call.set(n + 1);
match n {
0 => SolverResult {
status: SolveStatus::Optimal,
objective: 1.0,
solution: vec![0.5],
..SolverResult::default()
},
1 => SolverResult {
status: SolveStatus::SuboptimalSolution,
objective: 0.0,
solution: vec![0.0],
..SolverResult::default()
},
2 => SolverResult {
status: SolveStatus::Optimal,
objective: -5.0,
solution: vec![1.0],
..SolverResult::default()
},
_ => SolverResult::numerical_error(),
}
}
}
let mock = SeqMock::new();
let (r, _) = super::solve_mip_with_stats(&mock, &opts(), &MipConfig::default());
assert_ne!(
r.status,
SolveStatus::Optimal,
"proof_uncertain must block Optimal claim (within_gap is true but region unverified)"
);
assert!(
r.bound_gap_cert.is_none(),
"proof_uncertain must suppress BoundGapCertificate"
);
}
#[test]
fn warm_start_propagation_preserves_correct_objective() {
let lp = build_lp(
vec![-1.0, -2.0],
&[0, 0],
&[0, 1],
&[1.0, 1.0],
1,
vec![3.0],
vec![ConstraintType::Le],
vec![(0.0, 3.0), (0.0, 3.0)],
);
let problem = milp(lp, vec![0, 1]);
let (r, _) = solve_milp_with_stats(&problem, &opts(), &MipConfig::default());
assert_eq!(r.status, SolveStatus::Optimal);
assert!(
(r.objective + 6.0).abs() < EPS,
"expected obj=-6, got {}",
r.objective
);
}
#[test]
fn warm_start_binary_knapsack_correct() {
let lp = build_lp(
vec![-6.0, -10.0, -5.0],
&[0, 0, 0],
&[0, 1, 2],
&[3.0, 5.0, 2.0],
1,
vec![6.0],
vec![ConstraintType::Le],
vec![(0.0, 1.0), (0.0, 1.0), (0.0, 1.0)],
);
let problem = milp(lp, vec![0, 1, 2]);
let (r, _) = solve_milp_with_stats(&problem, &opts(), &MipConfig::default());
assert_eq!(
r.status,
SolveStatus::Optimal,
"expected Optimal, got {:?}",
r.status
);
assert!(
(r.objective + 11.0).abs() < EPS,
"expected obj=-11, got {}",
r.objective
);
}
#[test]
fn warm_start_infeasible_milp_still_infeasible() {
let lp = build_lp(
vec![1.0],
&[],
&[],
&[],
0,
vec![],
vec![],
vec![(1.2, 1.8)],
);
let problem = milp(lp, vec![0]);
let r = solve_milp(&problem, &opts(), &MipConfig::default());
assert_eq!(
r.status,
SolveStatus::Infeasible,
"expected Infeasible, got {:?}",
r.status
);
}
#[test]
fn warm_start_rounding_fails_child_no_timeout() {
use crate::options::{SolverOptions, WarmStartBasis};
let lp = build_lp(
vec![-5.0, -4.0],
&[0, 0, 1, 1],
&[0, 1, 0, 1],
&[6.0, 4.0, 1.0, 2.0],
2,
vec![24.0, 6.0],
vec![ConstraintType::Le, ConstraintType::Le],
vec![(0.0, 10.0), (0.0, 1.0)], );
let ws = WarmStartBasis {
basis: vec![0, 1],
x_b: vec![3.0, 1.5],
};
let opts = SolverOptions {
timeout_secs: Some(10.0),
recover_warm_start_basis: true,
warm_start: Some(ws),
..Default::default()
};
let r = crate::lp::solve_lp_with(&lp, &opts);
assert_ne!(
r.status,
crate::problem::SolveStatus::Timeout,
"child LP with warm start must not timeout, got status={:?}",
r.status
);
assert_eq!(
r.status,
crate::problem::SolveStatus::Optimal,
"child LP should be Optimal (x=3 or x=4, y=1), got status={:?}",
r.status
);
}
#[test]
fn bound_layout_changes_inf_ub_to_finite_drops_warm_start() {
use crate::options::WarmStartBasis;
use crate::problem::SolverResult;
use std::cell::{Cell, RefCell};
struct InfBoundMock {
call: Cell<usize>,
child_got_ws: RefCell<Vec<bool>>,
root_bounds: [(f64, f64); 1],
int_vars: [usize; 1],
}
impl InfBoundMock {
fn new() -> Self {
Self {
call: Cell::new(0),
child_got_ws: RefCell::new(vec![]),
root_bounds: [(0.0, f64::INFINITY)],
int_vars: [0],
}
}
}
impl super::Relaxation for InfBoundMock {
fn num_vars(&self) -> usize {
1
}
fn root_bounds(&self) -> &[(f64, f64)] {
&self.root_bounds
}
fn integer_vars(&self) -> &[usize] {
&self.int_vars
}
fn solve(&self, bounds: &[(f64, f64)], opts: &SolverOptions) -> SolverResult {
let n = self.call.get();
self.call.set(n + 1);
if n == 0 {
SolverResult {
status: SolveStatus::Optimal,
objective: 1.5,
solution: vec![1.5],
warm_start_basis: Some(WarmStartBasis {
basis: vec![0],
x_b: vec![1.5],
}),
..SolverResult::default()
}
} else {
self.child_got_ws
.borrow_mut()
.push(opts.warm_start.is_some());
let x = bounds[0].0.ceil();
SolverResult {
status: SolveStatus::Optimal,
objective: x,
solution: vec![x],
..SolverResult::default()
}
}
}
}
let mock = InfBoundMock::new();
let (r, _) = super::solve_mip_with_stats(&mock, &opts(), &MipConfig::default());
assert_eq!(r.status, SolveStatus::Optimal);
let got_ws = mock.child_got_ws.borrow();
assert!(
got_ws.iter().any(|&ws| !ws),
"down-branch (ub ∞→finite) must receive warm_start=None; \
no-op (skip layout check) gives all-true: {got_ws:?}"
);
}
#[test]
fn warm_start_propagated_to_child_nodes_sentinel() {
use crate::options::WarmStartBasis;
use crate::problem::SolverResult;
use std::cell::{Cell, RefCell};
struct PropMock {
call: Cell<usize>,
warm_starts_received: RefCell<Vec<bool>>,
root_bounds: [(f64, f64); 1],
int_vars: [usize; 1],
}
impl PropMock {
fn new() -> Self {
Self {
call: Cell::new(0),
warm_starts_received: RefCell::new(vec![]),
root_bounds: [(0.0, 3.0)],
int_vars: [0],
}
}
}
impl super::Relaxation for PropMock {
fn num_vars(&self) -> usize {
1
}
fn root_bounds(&self) -> &[(f64, f64)] {
&self.root_bounds
}
fn integer_vars(&self) -> &[usize] {
&self.int_vars
}
fn solve(&self, bounds: &[(f64, f64)], opts: &SolverOptions) -> SolverResult {
let n = self.call.get();
self.call.set(n + 1);
if n > 0 {
self.warm_starts_received
.borrow_mut()
.push(opts.warm_start.is_some());
}
if n == 0 {
SolverResult {
status: SolveStatus::Optimal,
objective: 0.5,
solution: vec![0.5],
warm_start_basis: Some(WarmStartBasis {
basis: vec![0],
x_b: vec![0.5],
}),
..Default::default()
}
} else {
let x = bounds[0].0.ceil().max(bounds[0].0);
SolverResult {
status: SolveStatus::Optimal,
objective: x,
solution: vec![x],
..Default::default()
}
}
}
}
let mock = PropMock::new();
let (r, _) = super::solve_mip_with_stats(&mock, &opts(), &MipConfig::default());
assert_eq!(r.status, SolveStatus::Optimal);
let received = mock.warm_starts_received.borrow();
assert!(
received.iter().any(|&ws| ws),
"warm_start must be propagated to at least one child node; \
no-op (child() instead of child_warm()) gives all-false: {received:?}"
);
}
fn fp_sentinel_problem() -> MilpProblem {
let lp = build_lp(
vec![-3.0, -5.0, -2.0, -4.0],
&[0, 0, 0, 0],
&[0, 1, 2, 3],
&[3.0, 5.0, 2.0, 4.0],
1,
vec![7.0],
vec![ConstraintType::Le],
vec![(0.0, 1.0); 4],
);
milp(lp, vec![0, 1, 2, 3])
}
#[test]
fn feasibility_pump_finds_initial_integer_solution() {
let problem = fp_sentinel_problem();
let (r, stats) = solve_milp_with_stats(&problem, &opts(), &MipConfig::default());
assert_eq!(r.status, SolveStatus::Optimal);
assert!(
stats.fp_incumbent_found,
"FP must find an initial integer solution; \
removing FP call gives fp_incumbent_found=false → FAIL"
);
assert!(
stats.incumbent_updates >= 1,
"incumbent_updates must reflect FP find"
);
}
#[test]
fn feasibility_pump_reduces_bb_nodes() {
let problem = fp_sentinel_problem();
let cfg = MipConfig::default();
let mask = integer_mask(problem.lp.num_vars, &problem.integer_vars);
let known_inc = SolverResult {
status: SolveStatus::Optimal,
objective: -7.0,
solution: vec![1.0, 0.0, 0.0, 1.0],
..SolverResult::default()
};
let (_, with_inc) = solve_mip_core(&problem, &opts(), &cfg, mask.clone(), Some(known_inc));
let (_, no_inc) = solve_mip_core(&problem, &opts(), &cfg, mask, None);
assert!(
with_inc.nodes_processed < no_inc.nodes_processed,
"initial incumbent must reduce B&B nodes: with={} without={}",
with_inc.nodes_processed,
no_inc.nodes_processed
);
}
#[test]
fn feasibility_pump_handles_pure_lp_pass_through() {
let lp = build_lp(
vec![1.0, 1.0],
&[0, 0],
&[0, 1],
&[1.0, 1.0],
1,
vec![3.0],
vec![ConstraintType::Le],
vec![(0.0, 5.0), (0.0, 5.0)],
);
let problem = milp(lp, vec![]); let (r, stats) = solve_milp_with_stats(&problem, &opts(), &MipConfig::default());
assert_eq!(r.status, SolveStatus::Optimal);
assert!(
!stats.fp_incumbent_found,
"pure LP must not set fp_incumbent_found; \
FP is gated by `!integer_vars.is_empty()` in solve_milp_with_stats"
);
}
const FP_TIMEOUT_BUDGET_SECS: f64 = 1.0;
const TIMEOUT_WALL_MARGIN_MULT: f64 = 5.0;
fn fp_timeout_sentinel_milp() -> MilpProblem {
let n = 500usize;
let m = 100usize;
let mut s: u64 = 0x1234_5678_9ABC_DEF0;
let mut rng = || -> f64 {
s = s
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
((s >> 33) as f64) / (u32::MAX as f64)
};
let c: Vec<f64> = (0..n).map(|_| -rng() * 10.0).collect();
let mut rows = Vec::new();
let mut cols = Vec::new();
let mut vals = Vec::new();
let mut b = Vec::new();
for i in 0..m {
let mut row_sum = 0.0f64;
for j in 0..n {
if rng() < 0.5 {
let v = 1.0 + rng() * 4.0;
rows.push(i);
cols.push(j);
vals.push(v);
row_sum += v;
}
}
b.push((row_sum * 0.4).max(1.0));
}
let a = CscMatrix::from_triplets(&rows, &cols, &vals, m, n).unwrap();
let lp = LpProblem::new_general(
c,
a,
b,
vec![ConstraintType::Le; m],
vec![(0.0, 1.0); n],
None,
)
.unwrap();
milp(lp, (0..n).collect())
}
#[test]
fn fp_timeout_does_not_exceed_user_limit() {
let problem = fp_timeout_sentinel_milp();
let opts = SolverOptions {
timeout_secs: Some(FP_TIMEOUT_BUDGET_SECS),
..Default::default()
};
let t0 = std::time::Instant::now();
let (r, _) = solve_milp_with_stats(&problem, &opts, &MipConfig::default());
let elapsed = t0.elapsed().as_secs_f64();
assert!(
matches!(
r.status,
SolveStatus::Optimal
| SolveStatus::SuboptimalSolution
| SolveStatus::Timeout
| SolveStatus::Infeasible
| SolveStatus::MaxIterations
),
"unexpected status {:?}",
r.status,
);
assert!(
elapsed < FP_TIMEOUT_BUDGET_SECS * TIMEOUT_WALL_MARGIN_MULT,
"elapsed {:.3}s exceeds budget {:.1}s × margin {:.1}; \
removing shared-deadline fix lets B&B receive a fresh window after FP → overrun",
elapsed,
FP_TIMEOUT_BUDGET_SECS,
TIMEOUT_WALL_MARGIN_MULT,
);
}
#[test]
fn fp_timeout_aborts_iteration() {
let problem = fp_timeout_sentinel_milp();
let short_timeout = 0.1f64;
let opts = SolverOptions {
timeout_secs: Some(short_timeout),
..Default::default()
};
let t0 = std::time::Instant::now();
let (r, _) = solve_milp_with_stats(&problem, &opts, &MipConfig::default());
let elapsed = t0.elapsed().as_secs_f64();
assert_eq!(
r.status,
SolveStatus::Timeout,
"FP timeout must not panic and must signal Timeout; got {:?}",
r.status,
);
assert!(
elapsed < short_timeout * TIMEOUT_WALL_MARGIN_MULT,
"elapsed {:.3}s exceeds {:.2}s × {:.1}; FP deadline not honored",
elapsed,
short_timeout,
TIMEOUT_WALL_MARGIN_MULT,
);
}
#[test]
fn miqp_bt_detects_infeasibility_before_bb() {
let qp = qp_problem(
&[2.0],
vec![0.0],
&[0, 1],
&[0, 0],
&[1.0, 1.0],
2,
vec![3.7, 3.5],
vec![ConstraintType::Le, ConstraintType::Ge],
vec![(0.0, 10.0)],
);
let (r, stats) =
super::solve_miqp_with_stats(&miqp(qp, vec![0]), &opts(), &MipConfig::default());
assert_eq!(
r.status,
SolveStatus::Infeasible,
"x²: x≤3.7 ∧ x≥3.5 integer → BT detects empty domain → Infeasible"
);
assert_eq!(
stats.nodes_processed, 0,
"MIQP BT must detect infeasibility before B&B (no-op: nodes_processed > 0)"
);
}
#[test]
fn miqp_bt_reduces_bb_nodes_below_noop() {
let qp = qp_problem(
&[2.0],
vec![-7.0],
&[0],
&[0],
&[1.0],
1,
vec![3.7],
vec![ConstraintType::Le],
vec![(0.0, 5.0)],
);
let (r, stats) =
super::solve_miqp_with_stats(&miqp(qp, vec![0]), &opts(), &MipConfig::default());
assert_eq!(
r.status,
SolveStatus::Optimal,
"x²-7x: x≤3.7 integer → feasible"
);
assert!(
(r.objective - (-12.0)).abs() < EPS,
"optimal x=3 → obj=9-21=-12, got {}",
r.objective
);
assert!(
stats.nodes_processed < 5,
"BT must reduce nodes below no-BT baseline of 5; got {}",
stats.nodes_processed
);
}