pub(crate) mod branch;
pub(crate) mod node;
mod problem;
pub(crate) mod queue;
pub use problem::{MilpProblem, MipProblemError, MiqpProblem};
use crate::options::{MipConfig, SolverOptions};
use crate::problem::{SolveStatus, SolverResult};
use crate::problem::certificate::BoundGapCertificate;
use crate::qp::global::pruning::{should_prune, within_gap};
use std::time::{Duration, Instant};
use branch::{
branch_bounds, is_integer_feasible, select_branching_variable, split_integer_box,
widest_splittable_integer,
};
use node::MipNode;
use queue::NodeQueue;
pub(crate) trait Relaxation {
fn num_vars(&self) -> usize;
fn root_bounds(&self) -> &[(f64, f64)];
fn integer_vars(&self) -> &[usize];
fn solve(&self, bounds: &[(f64, f64)], opts: &SolverOptions) -> SolverResult;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct MipStats {
pub nodes_processed: usize,
pub max_depth_seen: usize,
pub pruned: usize,
pub incumbent_updates: usize,
pub relaxation_time_total_ms: f64,
pub relaxation_time_root_ms: f64,
pub relaxation_time_desc_ms: f64,
pub relaxation_time_optimal_ms: f64,
pub relaxation_time_infeasible_ms: f64,
pub lp_presolve_us_total: u64,
pub lp_solve_us_total: u64,
pub lp_postsolve_us_total: u64,
pub approx_bounds_bytes_per_node: usize,
}
pub fn solve_milp(problem: &MilpProblem, options: &SolverOptions, cfg: &MipConfig) -> SolverResult {
solve_milp_with_stats(problem, options, cfg).0
}
pub fn solve_milp_with_stats(
problem: &MilpProblem,
options: &SolverOptions,
cfg: &MipConfig,
) -> (SolverResult, MipStats) {
if options.validate().is_err() {
return (SolverResult::numerical_error(), MipStats::default());
}
solve_mip_with_stats(problem, options, cfg)
}
pub fn solve_miqp(problem: &MiqpProblem, options: &SolverOptions, cfg: &MipConfig) -> SolverResult {
solve_miqp_with_stats(problem, options, cfg).0
}
pub fn solve_miqp_with_stats(
problem: &MiqpProblem,
options: &SolverOptions,
cfg: &MipConfig,
) -> (SolverResult, MipStats) {
if options.validate().is_err() {
return (SolverResult::numerical_error(), MipStats::default());
}
if !problem.is_convex() {
return (nonconvex_result(), MipStats::default());
}
solve_mip_with_stats(problem, options, cfg)
}
fn solve_mip_with_stats<R: Relaxation>(
problem: &R,
options: &SolverOptions,
cfg: &MipConfig,
) -> (SolverResult, MipStats) {
let mut stats = MipStats::default();
let mask = integer_mask(problem.num_vars(), problem.integer_vars());
stats.approx_bounds_bytes_per_node = problem.num_vars() * 2 * std::mem::size_of::<f64>();
let deadline = options.deadline.or_else(|| {
options
.timeout_secs
.map(|s| Instant::now() + Duration::from_secs_f64(s))
});
let mut shared = options.clone();
shared.deadline = deadline;
shared.timeout_secs = None;
shared.multistart = None;
shared.global_optimization = None;
if problem.integer_vars().is_empty() {
return (problem.solve(problem.root_bounds(), &shared), stats);
}
let mut state = MipState::new();
let mut q = NodeQueue::new();
q.push(MipNode::root(problem.root_bounds().to_vec(), f64::NEG_INFINITY));
let mut open_lb = f64::INFINITY; let mut had_open = false; let mut proof_uncertain = false; let mut deadline_stop = false;
let mut maxnodes_stop = false;
let mut unbounded = false;
let mut root_solved = false;
while let Some(node) = q.pop() {
if deadline_hit(&deadline) {
open_lb = open_lb.min(node.lower_bound);
had_open = true;
deadline_stop = true;
break;
}
if stats.nodes_processed >= cfg.max_nodes {
open_lb = open_lb.min(node.lower_bound);
had_open = true;
maxnodes_stop = true;
break;
}
if let Some(inc) = state.incumbent_obj {
if should_prune(node.lower_bound, Some(inc), cfg.gap_tol) {
stats.pruned += 1;
continue;
}
}
let t0 = Instant::now();
let res = problem.solve(&node.var_bounds, &shared);
let elapsed_ms = t0.elapsed().as_secs_f64() * 1000.0;
stats.nodes_processed += 1;
stats.max_depth_seen = stats.max_depth_seen.max(node.depth);
stats.relaxation_time_total_ms += elapsed_ms;
if !root_solved {
stats.relaxation_time_root_ms = elapsed_ms;
root_solved = true;
} else {
stats.relaxation_time_desc_ms += elapsed_ms;
}
match res.status {
SolveStatus::Optimal => stats.relaxation_time_optimal_ms += elapsed_ms,
SolveStatus::Infeasible => stats.relaxation_time_infeasible_ms += elapsed_ms,
_ => {}
}
if let Some(tb) = res.timing_breakdown {
stats.lp_presolve_us_total += tb.presolve_us;
stats.lp_solve_us_total += tb.solve_us;
stats.lp_postsolve_us_total += tb.postsolve_us;
}
match res.status {
SolveStatus::Infeasible => {
stats.pruned += 1;
continue;
}
SolveStatus::Unbounded => {
unbounded = true;
break;
}
SolveStatus::Timeout => {
open_lb = open_lb.min(node.lower_bound);
had_open = true;
deadline_stop = true;
break;
}
_ => {}
}
let trusted = matches!(res.status, SolveStatus::Optimal) && !res.solution.is_empty();
if trusted {
let node_lb = node.lower_bound.max(res.objective);
if let Some(inc) = state.incumbent_obj {
if should_prune(node_lb, Some(inc), cfg.gap_tol) {
stats.pruned += 1;
continue;
}
}
if is_integer_feasible(&res.solution, &mask, cfg.integer_feas_tol) {
if state.consider(&res) {
stats.incumbent_updates += 1;
}
continue; }
if node.depth + 1 > cfg.max_depth {
open_lb = open_lb.min(node_lb);
had_open = true;
continue;
}
let jb = select_branching_variable(
&res.solution,
&mask,
cfg.integer_feas_tol,
cfg.branching,
)
.expect("a non-integer-feasible Optimal relaxation has a fractional integer var");
let (down, up) = branch_bounds(&node.var_bounds, jb, res.solution[jb]);
q.push(node.child(down, node_lb));
q.push(node.child(up, node_lb));
} else {
match widest_splittable_integer(&node.var_bounds, &mask) {
Some(_) if node.depth + 1 > cfg.max_depth => {
open_lb = open_lb.min(node.lower_bound);
had_open = true;
proof_uncertain = true;
}
Some(jb) => {
let (down, up) = split_integer_box(&node.var_bounds, jb);
q.push(node.child(down, node.lower_bound));
q.push(node.child(up, node.lower_bound));
}
None => {
open_lb = open_lb.min(node.lower_bound);
had_open = true;
proof_uncertain = true;
}
}
}
}
if unbounded {
return (SolverResult::unbounded(), stats);
}
let remaining_lb = match q.best_lower_bound() {
Some(b) => open_lb.min(b),
None => open_lb,
};
let interrupted = deadline_stop || maxnodes_stop;
match state.incumbent.take() {
Some(mut inc) => {
let inc_obj = state.incumbent_obj.expect("incumbent objective set");
let proven = !proof_uncertain && within_gap(inc_obj, remaining_lb, cfg.gap_tol);
inc.solution = round_integers(inc.solution, problem.integer_vars());
inc.status = if proven {
let effective_lb = remaining_lb.min(inc_obj);
let scale = 1.0_f64.max(inc_obj.abs());
let gap_rel = (inc_obj - effective_lb) / scale;
inc.bound_gap_cert = Some(BoundGapCertificate::new(
inc_obj,
effective_lb,
gap_rel,
cfg.gap_tol,
));
SolveStatus::Optimal
} else if deadline_stop {
SolveStatus::Timeout
} else {
SolveStatus::SuboptimalSolution
};
(inc, stats)
}
None => (
finalize_no_incumbent(interrupted, had_open, q.is_empty(), deadline_stop),
stats,
),
}
}
fn finalize_no_incumbent(
interrupted: bool,
had_open: bool,
queue_empty: bool,
deadline_stop: bool,
) -> SolverResult {
let fully_resolved = !interrupted && !had_open && queue_empty;
if fully_resolved {
SolverResult::infeasible()
} else if deadline_stop {
no_solution_result(SolveStatus::Timeout)
} else {
no_solution_result(SolveStatus::MaxIterations)
}
}
fn integer_mask(num_vars: usize, integer_vars: &[usize]) -> Vec<bool> {
let mut mask = vec![false; num_vars];
for &j in integer_vars {
if j < num_vars {
mask[j] = true;
}
}
mask
}
fn nonconvex_result() -> SolverResult {
SolverResult {
status: SolveStatus::NonConvex(
"convex MIQP only: Q is not positive semidefinite".to_string(),
),
objective: f64::INFINITY,
solution: vec![],
..Default::default()
}
}
fn deadline_hit(deadline: &Option<Instant>) -> bool {
deadline.is_some_and(|d| Instant::now() >= d)
}
fn round_integers(mut sol: Vec<f64>, integer_vars: &[usize]) -> Vec<f64> {
for &j in integer_vars {
if j < sol.len() {
sol[j] = sol[j].round();
}
}
sol
}
fn no_solution_result(status: SolveStatus) -> SolverResult {
SolverResult {
status,
objective: f64::INFINITY,
solution: vec![],
..Default::default()
}
}
struct MipState {
incumbent: Option<SolverResult>,
incumbent_obj: Option<f64>,
}
impl MipState {
fn new() -> Self {
Self { incumbent: None, incumbent_obj: None }
}
fn consider(&mut self, res: &SolverResult) -> bool {
let better = match self.incumbent_obj {
None => true,
Some(o) => res.objective < o,
};
if better {
self.incumbent_obj = Some(res.objective);
self.incumbent = Some(res.clone());
}
better
}
}
#[cfg(test)]
mod tests;