pub(crate) mod branching;
pub(crate) mod node;
pub(crate) mod params;
use crate::solver::{check_deadline, Deadline, Solver};
use crate::{ComparisonOp, Error, OptimizationDirection, Problem, StopReason, VarDomain, Variable};
use core::time::Duration;
use node::{effective_bounds, Node};
use std::collections::BTreeMap;
use web_time::Instant;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
Optimal,
Feasible,
Interrupted,
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct SolveOptions {
pub time_limit: Option<Duration>,
pub node_limit: Option<u64>,
pub mip_gap: f64,
pub int_tol: f64,
pub warm_start: Option<Vec<(Variable, f64)>>,
pub tolerances: Tolerances,
}
impl Default for SolveOptions {
fn default() -> Self {
Self {
time_limit: None,
node_limit: None,
mip_gap: 0.0,
int_tol: 1e-6,
warm_start: None,
tolerances: Tolerances::default(),
}
}
}
impl SolveOptions {
pub(crate) fn validate(&self) -> Result<(), Error> {
if !self.mip_gap.is_finite() || self.mip_gap < 0.0 {
return Err(Error::InvalidOptions(
"invalid SolveOptions.mip_gap: expected a finite non-negative value".to_string(),
));
}
if !self.int_tol.is_finite() || !(0.0..0.5).contains(&self.int_tol) {
return Err(Error::InvalidOptions(
"invalid SolveOptions.int_tol: expected a finite value in [0, 0.5)".to_string(),
));
}
if !self.tolerances.feasibility.is_finite() || self.tolerances.feasibility < 0.0 {
return Err(Error::InvalidOptions(
"invalid SolveOptions.tolerances.feasibility: expected a finite non-negative value"
.to_string(),
));
}
if !self.tolerances.integrality_rounding.is_finite()
|| !(0.0..0.5).contains(&self.tolerances.integrality_rounding)
{
return Err(Error::InvalidOptions(
"invalid SolveOptions.tolerances.integrality_rounding: expected a finite value in [0, 0.5)"
.to_string(),
));
}
if !self.tolerances.prune_epsilon.is_finite() || self.tolerances.prune_epsilon < 0.0 {
return Err(Error::InvalidOptions(
"invalid SolveOptions.tolerances.prune_epsilon: expected a finite non-negative value"
.to_string(),
));
}
Ok(())
}
}
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct Tolerances {
pub feasibility: f64,
pub integrality_rounding: f64,
pub prune_epsilon: f64,
}
impl Default for Tolerances {
fn default() -> Self {
Self {
feasibility: 1e-7,
integrality_rounding: 1e-5,
prune_epsilon: 1e-9,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
#[non_exhaustive]
pub struct Stats {
pub nodes_solved: u64,
pub lp_iterations: u64,
pub elapsed: Duration,
pub best_bound: Option<f64>,
pub gap: Option<f64>,
}
#[derive(Clone, Debug)]
pub(crate) struct Incumbent {
pub values: Vec<f64>,
pub objective: f64,
}
#[derive(Clone)]
pub(crate) struct MipState {
pub solver: Solver,
pub root_bounds: Vec<(f64, f64)>,
pub applied: Vec<(usize, f64, f64)>,
pub open: Vec<Node>,
pub diving: bool,
pub incumbent: Option<Incumbent>,
pub node_seq: u64,
pub last_solved_id: Option<u64>,
pub root_solved: bool,
pub stats: Stats,
pub options: SolveOptions,
pub deadline: Deadline,
pub direction: OptimizationDirection,
pub pseudocosts: branching::PseudoCosts,
pub base: Problem,
pub fixed: BTreeMap<usize, f64>,
pub classifying_unbounded: bool,
}
impl std::fmt::Debug for MipState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MipState")
.field("open_nodes", &self.open.len())
.field("has_incumbent", &self.incumbent.is_some())
.field("stats", &self.stats)
.field("diving", &self.diving)
.field(
"pseudocost_observations",
&self.pseudocosts.observation_count(),
)
.field("classifying_unbounded", &self.classifying_unbounded)
.finish()
}
}
impl MipState {
pub(crate) fn current_objective(&self) -> f64 {
if let Some(incumbent) = &self.incumbent {
return incumbent.objective;
}
self.base
.obj_coeffs
.iter()
.enumerate()
.map(|(v, &coefficient)| coefficient * self.solver.get_value(v))
.sum()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum MipOutcome {
Optimal,
Interrupted,
}
#[derive(Debug)]
pub(crate) struct MipRun {
pub outcome: MipOutcome,
pub state: MipState,
}
pub(crate) fn status_of(outcome: MipOutcome, state: &MipState) -> Status {
match outcome {
MipOutcome::Optimal => Status::Optimal,
MipOutcome::Interrupted => {
if state.classifying_unbounded {
Status::Interrupted
} else if state.incumbent.is_some() {
Status::Feasible
} else {
Status::Interrupted
}
}
}
}
fn build_state(problem: &Problem, options: SolveOptions) -> Result<MipState, Error> {
let deadline = options.time_limit.map(|d| Instant::now() + d);
let solver = problem.build_solver(deadline)?;
let root_bounds = problem
.var_mins
.iter()
.zip(&problem.var_maxs)
.map(|(&lo, &hi)| (lo, hi))
.collect();
let pseudocosts = branching::PseudoCosts::new(&problem.obj_coeffs, problem.obj_coeffs.len());
Ok(MipState {
solver,
root_bounds,
applied: Vec::new(),
open: Vec::new(),
diving: false,
incumbent: None,
node_seq: 0,
last_solved_id: None,
root_solved: false,
stats: Stats::default(),
options,
deadline,
direction: problem.direction,
pseudocosts,
base: problem.clone(),
fixed: BTreeMap::new(),
classifying_unbounded: false,
})
}
fn begin_unbounded_classification(state: &mut MipState) -> Result<(), Error> {
let base = state.base.clone();
let fixed = state.fixed.clone();
let mut feasibility = effective_problem(&base, &fixed);
feasibility.obj_coeffs.fill(0.0);
let deadline = state.deadline;
let elapsed = state.stats.elapsed;
let lp_iterations = state.solver.lp_iterations;
let mut replacement = build_state(&feasibility, state.options.clone())?;
replacement.deadline = deadline;
replacement.solver.deadline = deadline;
replacement.solver.lp_iterations = lp_iterations;
replacement.stats.elapsed = elapsed;
replacement.base = base;
replacement.fixed = fixed;
replacement.classifying_unbounded = true;
*state = replacement;
Ok(())
}
fn resume_or_classify(state: &mut MipState) -> Result<MipOutcome, Error> {
match resume_run_with_deadline(state) {
Err(Error::Unbounded) if !state.classifying_unbounded => {
begin_unbounded_classification(state)?;
resume_run_with_deadline(state)
}
result => result,
}
}
pub(crate) fn run(problem: &Problem, options: SolveOptions) -> Result<MipRun, Error> {
let mut state = build_state(problem, options)?;
let outcome = resume_or_classify(&mut state)?;
Ok(MipRun { outcome, state })
}
pub(crate) fn effective_problem(base: &Problem, fixed: &BTreeMap<usize, f64>) -> Problem {
let mut p = base.clone();
for (&v, &val) in fixed {
p.var_mins[v] = val;
p.var_maxs[v] = val;
}
p
}
fn candidate_variables_feasible(
values: &[f64],
domains: &[VarDomain],
tolerances: &Tolerances,
mut bounds: impl FnMut(usize) -> (f64, f64),
) -> bool {
if values.len() != domains.len() {
return false;
}
values.iter().enumerate().all(|(v, &value)| {
let (lo, hi) = bounds(v);
value.is_finite()
&& !lo.is_nan()
&& !hi.is_nan()
&& lo <= hi
&& value >= lo - tolerances.feasibility
&& value <= hi + tolerances.feasibility
&& (!matches!(domains[v], VarDomain::Integer | VarDomain::Boolean)
|| (value - value.round()).abs() <= tolerances.integrality_rounding)
})
}
pub(crate) fn incumbent_feasible(
base: &Problem,
fixed: &BTreeMap<usize, f64>,
values: &[f64],
tolerances: &Tolerances,
) -> bool {
if !candidate_variables_feasible(values, &base.var_domains, tolerances, |v| {
fixed
.get(&v)
.map_or((base.var_mins[v], base.var_maxs[v]), |&value| {
(value, value)
})
}) {
return false;
}
for (coeffs, op, rhs) in &base.constraints {
let lhs: f64 = coeffs.iter().map(|(i, c)| c * values[i]).sum();
if !lhs.is_finite() {
return false;
}
let tol = tolerances.feasibility;
let ok = match op {
ComparisonOp::Eq => (lhs - rhs).abs() <= tol,
ComparisonOp::Le => lhs <= rhs + tol,
ComparisonOp::Ge => lhs >= *rhs - tol,
};
if !ok {
return false;
}
}
true
}
pub(crate) fn reedit_and_resolve(state: Box<MipState>) -> Result<MipRun, Error> {
let MipState {
base,
fixed,
incumbent,
mut options,
..
} = *state;
options.warm_start = incumbent
.filter(|inc| incumbent_feasible(&base, &fixed, &inc.values, &options.tolerances))
.map(|inc| {
inc.values
.iter()
.enumerate()
.map(|(v, &val)| (Variable(v), val))
.collect()
});
let effective = effective_problem(&base, &fixed);
let mut run = run(&effective, options)?;
run.state.base = base;
run.state.fixed = fixed;
Ok(run)
}
pub(crate) fn resume_run(
state: &mut MipState,
time_limit: Option<Duration>,
) -> Result<MipOutcome, Error> {
state.deadline = time_limit.map(|d| Instant::now() + d);
resume_or_classify(state)
}
fn resume_run_with_deadline(state: &mut MipState) -> Result<MipOutcome, Error> {
let started = Instant::now();
let res = search_loop(state);
state.stats.elapsed += started.elapsed();
state.stats.lp_iterations = state.solver.lp_iterations;
fill_bound_stats(state);
res
}
fn global_bound_internal(state: &MipState) -> Option<f64> {
let open_min = state
.open
.iter()
.map(|n| n.lp_bound)
.fold(f64::INFINITY, f64::min);
match (&state.incumbent, state.open.is_empty()) {
(Some(inc), true) => Some(inc.objective), (Some(inc), false) => Some(open_min.min(inc.objective)),
(None, false) => Some(open_min),
(None, true) => None,
}
}
fn relative_gap(incumbent_obj: f64, bound: f64) -> f64 {
(incumbent_obj - bound).max(0.0) / incumbent_obj.abs().max(params::GAP_DENOM_GUARD)
}
fn to_user_space(direction: OptimizationDirection, internal: f64) -> f64 {
match direction {
OptimizationDirection::Minimize => internal,
OptimizationDirection::Maximize => -internal,
}
}
fn fill_bound_stats(state: &mut MipState) {
if state.classifying_unbounded {
state.stats.best_bound = None;
state.stats.gap = None;
return;
}
let bound = global_bound_internal(state);
state.stats.best_bound = bound.map(|b| to_user_space(state.direction, b));
state.stats.gap = match (&state.incumbent, bound) {
(Some(inc), Some(b)) => Some(relative_gap(inc.objective, b)),
_ => None,
};
}
fn cutoff(incumbent_obj: f64, prune_epsilon: f64) -> f64 {
incumbent_obj - f64::max(prune_epsilon, prune_epsilon * incumbent_obj.abs())
}
fn try_adopt_incumbent(state: &mut MipState) -> Result<bool, Error> {
let tolerances = &state.options.tolerances;
let solver = &state.solver;
let n = solver.num_vars;
let domains = &solver.orig_var_domains;
let mut values: Vec<f64> = (0..n).map(|v| *solver.get_value(v)).collect();
for (val, dom) in values.iter_mut().zip(domains.iter()) {
if matches!(dom, VarDomain::Integer | VarDomain::Boolean) {
*val = val.round();
}
}
if !candidate_variables_feasible(&values, domains, tolerances, |v| state.root_bounds[v])
|| !solver.check_constraints(&values, tolerances.feasibility)
{
debug!("integral-within-tol solution rejected: rounded values infeasible");
return Ok(false);
}
let objective = solver.objective_of(&values);
if !objective.is_finite() {
debug!("integral-within-tol solution rejected: objective is non-finite");
return Ok(false);
}
let better = match &state.incumbent {
Some(inc) => objective < inc.objective,
None => true,
};
if better {
debug!("new incumbent, internal obj: {:.6}", objective);
state.incumbent = Some(Incumbent { values, objective });
}
if state.classifying_unbounded {
Err(Error::Unbounded)
} else {
Ok(true)
}
}
enum IntegralCandidate {
Closed,
Branch(usize),
Limit,
}
fn process_integral_candidate(
state: &mut MipState,
domains: &[VarDomain],
int_tol: f64,
) -> Result<IntegralCandidate, Error> {
let adopted = try_adopt_incumbent(state)?;
if let Some(var) = branching::choose_branch_var(&state.solver, domains, 0.0, &state.pseudocosts)
{
return Ok(IntegralCandidate::Branch(var));
}
if adopted {
return Ok(IntegralCandidate::Closed);
}
debug!("exactly integral candidate failed guard; retrying from slack basis");
let slack = state.solver.slack_basis();
state
.solver
.load_basis(&slack)
.map_err(|e| Error::InternalError(format!("slack basis load failed: {}", e)))?;
match solve_node_lp(state)? {
NodeLp::Limit => return Ok(IntegralCandidate::Limit),
NodeLp::Infeasible => {
return Err(Error::InternalError(
"integral candidate became infeasible after slack-basis retry".to_string(),
))
}
NodeLp::Solved => {}
}
if !branching::is_integral(&state.solver, domains, int_tol) {
return branching::choose_branch_var(&state.solver, domains, int_tol, &state.pseudocosts)
.map(IntegralCandidate::Branch)
.ok_or_else(|| {
Error::InternalError(
"slack-basis retry produced a non-integral point with no branchable variable"
.to_string(),
)
});
}
let adopted = try_adopt_incumbent(state)?;
if let Some(var) = branching::choose_branch_var(&state.solver, domains, 0.0, &state.pseudocosts)
{
Ok(IntegralCandidate::Branch(var))
} else if adopted {
Ok(IntegralCandidate::Closed)
} else {
Err(Error::InternalError(
"exactly integral solution failed feasibility validation after slack-basis retry"
.to_string(),
))
}
}
fn apply_node_bounds(state: &mut MipState, node: &Node) -> bool {
let target = effective_bounds(&node.bound_changes);
if target.iter().any(|&(_, lo, hi)| lo > hi) {
return false;
}
for &(v, _, _) in &state.applied {
if target.binary_search_by_key(&v, |t| t.0).is_err() {
let (rlo, rhi) = state.root_bounds[v];
state
.solver
.set_var_bounds(v, rlo, rhi)
.expect("root bounds cannot cross");
}
}
for &(v, lo, hi) in &target {
state
.solver
.set_var_bounds(v, lo, hi)
.expect("validated bounds cannot cross");
}
state.applied = target;
true
}
fn branch(state: &mut MipState, parent: &Node, var: usize) {
let z = state.solver.cur_obj_val;
let val = *state.solver.get_value(var);
let (lo, hi) = state.solver.get_var_bounds(var);
let floor = {
let near = val.round();
let k = if (val - near).abs() <= state.options.int_tol {
near
} else {
val.floor()
};
k.clamp(lo, (hi - 1.0).max(lo))
};
let f_down = (val - floor).clamp(0.0, 1.0);
state.node_seq += 1;
let id = state.node_seq;
state.last_solved_id = Some(id);
let basis = state.solver.snapshot_basis();
let mut down_changes = parent.bound_changes.clone();
down_changes.push((var, lo, floor));
let mut up_changes = parent.bound_changes.clone();
up_changes.push((var, floor + 1.0, hi));
let down_node = Node {
bound_changes: down_changes,
basis: basis.clone(),
lp_bound: z,
depth: parent.depth + 1,
parent_id: id,
branch_var: Some(var),
branch_up: false,
branch_frac: f_down,
};
let up_node = Node {
bound_changes: up_changes,
basis,
lp_bound: z,
depth: parent.depth + 1,
parent_id: id,
branch_var: Some(var),
branch_up: true,
branch_frac: 1.0 - f_down,
};
let est_down = state.pseudocosts.estimate(var, false) * f_down;
let est_up = state.pseudocosts.estimate(var, true) * (1.0 - f_down);
if est_down > est_up {
state.open.push(down_node);
state.open.push(up_node);
} else {
state.open.push(up_node);
state.open.push(down_node);
}
state.diving = true;
}
enum NodeLp {
Solved,
Infeasible,
Limit,
}
fn solve_node_lp(state: &mut MipState) -> Result<NodeLp, Error> {
state.solver.deadline = state.deadline;
let err = match state.solver.reoptimize() {
Ok(StopReason::Finished) => return Ok(NodeLp::Solved),
Ok(StopReason::Limit) => return Ok(NodeLp::Limit),
Err(Error::Infeasible) => return Ok(NodeLp::Infeasible),
Err(Error::Unbounded) => {
return Err(Error::InternalError(
"bounded B&B node reported unbounded".to_string(),
))
}
Err(e) => e,
};
debug!(
"node LP reoptimize failed ({}); retrying from slack basis",
err
);
let slack = state.solver.slack_basis();
state
.solver
.load_basis(&slack)
.map_err(|e| Error::InternalError(format!("slack basis load failed: {}", e)))?;
match state.solver.reoptimize() {
Ok(StopReason::Finished) => Ok(NodeLp::Solved),
Ok(StopReason::Limit) => Ok(NodeLp::Limit),
Err(Error::Infeasible) => Ok(NodeLp::Infeasible),
Err(Error::Unbounded) => Err(Error::InternalError(
"bounded B&B node reported unbounded".to_string(),
)),
Err(e) => Err(e),
}
}
fn pop_node(state: &mut MipState) -> Option<Node> {
if state.open.is_empty() {
return None;
}
if state.diving {
state.open.pop()
} else {
let mut best = 0;
for (i, n) in state.open.iter().enumerate() {
if n.lp_bound < state.open[best].lp_bound {
best = i;
}
}
Some(state.open.swap_remove(best))
}
}
fn try_warm_start(
state: &mut MipState,
hints: &[(crate::Variable, f64)],
) -> Result<Option<MipOutcome>, Error> {
let domains = state.solver.orig_var_domains.clone();
let root_basis = state.solver.snapshot_basis();
let mut applied: Vec<usize> = Vec::new();
let mut ok = true;
let mut pending_error = None;
for &(var, val) in hints {
let v = var.idx();
if v >= state.solver.num_vars || !val.is_finite() {
ok = false;
break;
}
let val = if matches!(
domains.get(v),
Some(VarDomain::Integer | VarDomain::Boolean)
) {
val.round()
} else {
val
};
let (lo, hi) = state.root_bounds[v];
if val < lo - params::HINT_BOUNDS_SLACK || val > hi + params::HINT_BOUNDS_SLACK {
ok = false;
break;
}
state
.solver
.set_var_bounds(v, val, val)
.expect("fixing to [val, val] cannot cross");
applied.push(v);
}
if ok {
match state.solver.reoptimize() {
Ok(StopReason::Finished) => {
if branching::is_integral(&state.solver, &domains, state.options.int_tol) {
match try_adopt_incumbent(state) {
Ok(true) => {}
Ok(false) => {
debug!("warm-start hint rejected by feasibility guard; ignored");
}
Err(error) => pending_error = Some(error),
}
} else {
debug!("warm-start hint LP-completed fractionally; ignored");
}
}
Ok(StopReason::Limit) | Err(Error::Infeasible) => {
debug!("warm-start hint infeasible or out of time; ignored");
}
Err(error) => pending_error = Some(error),
}
} else {
debug!(
"warm-start hint invalid (unknown variable, non-finite value, or out of bounds); ignored"
);
}
for v in applied {
let (lo, hi) = state.root_bounds[v];
state
.solver
.set_var_bounds(v, lo, hi)
.expect("root bounds cannot cross");
}
if state.solver.load_basis(&root_basis).is_err() {
let slack = state.solver.slack_basis();
state
.solver
.load_basis(&slack)
.map_err(|e| Error::InternalError(format!("slack basis load failed: {}", e)))?;
if state.solver.reoptimize()? == StopReason::Limit {
state.root_solved = false;
if let Some(error) = pending_error {
return Err(error);
}
return Ok(Some(MipOutcome::Interrupted));
}
}
if let Some(error) = pending_error {
return Err(error);
}
Ok(None)
}
fn initialize_root(
state: &mut MipState,
domains: &[VarDomain],
) -> Result<Option<MipOutcome>, Error> {
if state.root_solved {
return Ok(None);
}
if state.solver.initial_solve()? == StopReason::Limit {
return Ok(Some(MipOutcome::Interrupted));
}
state.root_solved = true;
if let Some(hints) = state.options.warm_start.take() {
if let Some(outcome) = try_warm_start(state, &hints)? {
return Ok(Some(outcome));
}
}
let root = Node {
bound_changes: Vec::new(),
basis: state.solver.snapshot_basis(),
lp_bound: state.solver.cur_obj_val,
depth: 0,
parent_id: 0,
branch_var: None,
branch_up: false,
branch_frac: 1.0,
};
let int_tol = state.options.int_tol;
if branching::is_integral(&state.solver, domains, int_tol) {
match process_integral_candidate(state, domains, int_tol)? {
IntegralCandidate::Branch(var) => branch(state, &root, var),
IntegralCandidate::Closed => return Ok(Some(MipOutcome::Optimal)),
IntegralCandidate::Limit => {
state.root_solved = false;
return Ok(Some(MipOutcome::Interrupted));
}
}
} else {
match branching::choose_branch_var(&state.solver, domains, int_tol, &state.pseudocosts) {
Some(var) => branch(state, &root, var),
None => return Err(Error::Infeasible),
}
}
Ok(None)
}
enum NodeVisit {
Pruned,
Solved,
Interrupted { node: Node, lp_solved: bool },
}
fn visit_node(state: &mut MipState, node: Node, domains: &[VarDomain]) -> Result<NodeVisit, Error> {
if !apply_node_bounds(state, &node) {
state.diving = false;
return Ok(NodeVisit::Pruned);
}
let warm = state.last_solved_id == Some(node.parent_id);
if !warm && state.solver.load_basis(&node.basis).is_err() {
debug!("basis load failed; falling back to slack basis");
let slack = state.solver.slack_basis();
state
.solver
.load_basis(&slack)
.map_err(|e| Error::InternalError(format!("slack basis load failed: {}", e)))?;
}
match solve_node_lp(state)? {
NodeLp::Solved => {}
NodeLp::Infeasible => {
state.last_solved_id = None;
state.diving = false;
return Ok(NodeVisit::Solved);
}
NodeLp::Limit => {
return Ok(NodeVisit::Interrupted {
node,
lp_solved: false,
})
}
}
let objective = state.solver.cur_obj_val;
if let Some(var) = node.branch_var {
state.pseudocosts.record(
var,
node.branch_up,
(objective - node.lp_bound).max(0.0) / node.branch_frac.max(params::BRANCH_FRAC_GUARD),
);
}
if let Some(incumbent) = &state.incumbent {
if objective >= cutoff(incumbent.objective, state.options.tolerances.prune_epsilon) {
state.last_solved_id = None;
state.diving = false;
return Ok(NodeVisit::Solved);
}
}
let int_tol = state.options.int_tol;
if branching::is_integral(&state.solver, domains, int_tol) {
match process_integral_candidate(state, domains, int_tol)? {
IntegralCandidate::Branch(var) => branch(state, &node, var),
IntegralCandidate::Closed => {
state.last_solved_id = None;
state.diving = false;
}
IntegralCandidate::Limit => {
return Ok(NodeVisit::Interrupted {
node,
lp_solved: true,
})
}
}
return Ok(NodeVisit::Solved);
}
match branching::choose_branch_var(&state.solver, domains, int_tol, &state.pseudocosts) {
Some(var) => branch(state, &node, var),
None => {
state.last_solved_id = None;
state.diving = false;
}
}
Ok(NodeVisit::Solved)
}
fn search_loop(state: &mut MipState) -> Result<MipOutcome, Error> {
let domains = state.solver.orig_var_domains.clone();
state.solver.deadline = state.deadline;
if let Some(outcome) = initialize_root(state, &domains)? {
return Ok(outcome);
}
let mut nodes_this_run: u64 = 0;
loop {
if state.open.is_empty() {
break;
}
if state.options.mip_gap > 0.0 {
if let (Some(inc), Some(bound)) = (&state.incumbent, global_bound_internal(state)) {
if relative_gap(inc.objective, bound) <= state.options.mip_gap {
return Ok(MipOutcome::Optimal);
}
}
}
if check_deadline(&state.deadline) == StopReason::Limit {
return Ok(MipOutcome::Interrupted);
}
let node = match pop_node(state) {
Some(n) => n,
None => break,
};
if let Some(inc) = &state.incumbent {
if node.lp_bound >= cutoff(inc.objective, state.options.tolerances.prune_epsilon) {
state.diving = false;
continue;
}
}
if let Some(nl) = state.options.node_limit {
if nodes_this_run >= nl {
state.open.push(node);
state.diving = false;
return Ok(MipOutcome::Interrupted);
}
}
match visit_node(state, node, &domains)? {
NodeVisit::Pruned => continue,
NodeVisit::Solved => {
state.stats.nodes_solved += 1;
nodes_this_run += 1;
}
NodeVisit::Interrupted { node, lp_solved } => {
if lp_solved {
state.stats.nodes_solved += 1;
}
state.open.push(node);
state.last_solved_id = None;
state.diving = false;
return Ok(MipOutcome::Interrupted);
}
}
}
if state.incumbent.is_some() {
Ok(MipOutcome::Optimal)
} else {
Err(Error::Infeasible)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ComparisonOp, OptimizationDirection, Problem};
fn int_2var_problem() -> Problem {
let mut p = Problem::new(OptimizationDirection::Minimize);
let a = p.add_integer_var(3.0, (0, 10));
let b = p.add_integer_var(4.0, (0, 10));
p.add_constraint(&[(a, 1.0), (b, 2.0)], ComparisonOp::Ge, 5.0);
p.add_constraint(&[(a, 3.0), (b, 1.0)], ComparisonOp::Ge, 4.0);
p
}
fn binary_knapsack() -> Problem {
let mut p = Problem::new(OptimizationDirection::Maximize);
let x = p.add_binary_var(8.0);
let y = p.add_binary_var(11.0);
let z = p.add_binary_var(6.0);
let w = p.add_binary_var(4.0);
p.add_constraint(
&[(x, 5.0), (y, 7.0), (z, 4.0), (w, 3.0)],
ComparisonOp::Le,
14.0,
);
p
}
fn incumbent_obj(state: &MipState) -> f64 {
state.incumbent.as_ref().unwrap().objective
}
#[test]
fn driver_finds_integer_optimum() {
let run = run(&int_2var_problem(), SolveOptions::default()).unwrap();
assert_eq!(run.outcome, MipOutcome::Optimal);
assert!((incumbent_obj(&run.state) - 11.0).abs() < 1e-6);
let inc = run.state.incumbent.as_ref().unwrap();
assert!((inc.values[0] - 1.0).abs() < 1e-6);
assert!((inc.values[1] - 2.0).abs() < 1e-6);
assert!(run.state.stats.nodes_solved > 0);
}
#[test]
fn driver_binary_knapsack_maximize() {
let run = run(&binary_knapsack(), SolveOptions::default()).unwrap();
assert_eq!(run.outcome, MipOutcome::Optimal);
assert!((incumbent_obj(&run.state) + 21.0).abs() < 1e-6);
}
#[test]
fn driver_no_integer_point_is_infeasible() {
let mut p = Problem::new(OptimizationDirection::Minimize);
let x = p.add_integer_var(1.0, (0, 10));
p.add_constraint(&[(x, 2.0)], ComparisonOp::Eq, 1.0);
assert_eq!(
run(&p, SolveOptions::default()).unwrap_err(),
crate::Error::Infeasible
);
}
#[test]
fn driver_exact_node_exhaustion_reports_infeasible_not_interrupted() {
let mut p = Problem::new(OptimizationDirection::Minimize);
let x = p.add_integer_var(1.0, (0, 10));
p.add_constraint(&[(x, 2.0)], ComparisonOp::Eq, 1.0);
let mut options = SolveOptions::default();
options.node_limit = Some(2);
assert_eq!(run(&p, options).unwrap_err(), crate::Error::Infeasible);
}
#[test]
fn driver_node_limit_equal_to_exhaustion_count_reports_optimal() {
assert_eq!(
run(&int_2var_problem(), SolveOptions::default())
.unwrap()
.state
.stats
.nodes_solved,
2,
"node count must stay deterministic; update the limit below if it changes"
);
let mut options = SolveOptions::default();
options.node_limit = Some(2);
let r = run(&int_2var_problem(), options).unwrap();
assert_eq!(r.outcome, MipOutcome::Optimal);
assert!((incumbent_obj(&r.state) - 11.0).abs() < 1e-6);
}
#[test]
fn driver_node_limit_interrupts_and_resumes_to_same_optimum() {
let mut options = SolveOptions::default();
options.node_limit = Some(1);
let mut r = run(&int_2var_problem(), options).unwrap();
let mut guard = 0;
while r.outcome == MipOutcome::Interrupted {
guard += 1;
assert!(guard < 10_000, "resume loop did not terminate");
r.outcome = resume_run(&mut r.state, None).unwrap();
}
assert!(guard >= 1, "node_limit=1 should interrupt at least once");
assert!((incumbent_obj(&r.state) - 11.0).abs() < 1e-6);
}
#[test]
fn open_children_have_branch_metadata() {
let mut options = SolveOptions::default();
options.node_limit = Some(0);
let run = run(&int_2var_problem(), options).unwrap();
assert_eq!(run.outcome, MipOutcome::Interrupted);
assert_eq!(run.state.open.len(), 2);
assert!(run.state.open.iter().all(|node| node.branch_var.is_some()));
}
#[test]
fn driver_zero_time_limit_interrupts_cleanly_then_resumes() {
let mut options = SolveOptions::default();
options.time_limit = Some(Duration::ZERO);
let mut r = run(&binary_knapsack(), options).unwrap();
assert_eq!(r.outcome, MipOutcome::Interrupted);
assert!(r.state.incumbent.is_none());
assert_eq!(status_of(r.outcome, &r.state), Status::Interrupted);
let outcome = resume_run(&mut r.state, None).unwrap();
assert_eq!(outcome, MipOutcome::Optimal);
assert!((incumbent_obj(&r.state) + 21.0).abs() < 1e-6);
}
#[test]
fn optimal_solve_reports_zero_gap_and_matching_bound() {
let r = run(&int_2var_problem(), SolveOptions::default()).unwrap();
assert_eq!(r.outcome, MipOutcome::Optimal);
assert_eq!(r.state.stats.gap, Some(0.0));
assert!((r.state.stats.best_bound.unwrap() - 11.0).abs() < 1e-6);
}
#[test]
fn maximize_bound_is_in_user_space() {
let r = run(&binary_knapsack(), SolveOptions::default()).unwrap();
assert_eq!(r.outcome, MipOutcome::Optimal);
assert!((r.state.stats.best_bound.unwrap() - 21.0).abs() < 1e-6);
}
#[test]
fn mip_gap_stops_early_with_consistent_bound() {
let mut options = SolveOptions::default();
options.mip_gap = 0.5;
let r = run(&binary_knapsack(), options).unwrap();
assert_eq!(r.outcome, MipOutcome::Optimal); let inc = -incumbent_obj(&r.state); let bound = r.state.stats.best_bound.unwrap();
assert!(inc <= bound + 1e-9);
assert!((bound - inc) / bound.abs().max(1e-10) <= 0.5 + 1e-9);
}
#[test]
fn feasible_interrupt_reports_gap() {
let mut options = SolveOptions::default();
options.node_limit = Some(2);
let mut r = run(&binary_knapsack(), options).unwrap();
let mut guard = 0;
while r.outcome == MipOutcome::Interrupted && r.state.incumbent.is_none() {
guard += 1;
assert!(guard < 10_000);
r.outcome = resume_run(&mut r.state, None).unwrap();
}
if r.outcome == MipOutcome::Interrupted {
assert!(r.state.stats.gap.unwrap() >= 0.0);
assert!(r.state.stats.best_bound.is_some());
}
}
#[test]
fn plunge_and_jump_selection_preserves_optima() {
let r = run(&int_2var_problem(), SolveOptions::default()).unwrap();
assert!((incumbent_obj(&r.state) - 11.0).abs() < 1e-6);
let r = run(&binary_knapsack(), SolveOptions::default()).unwrap();
assert!((incumbent_obj(&r.state) + 21.0).abs() < 1e-6);
let mut options = SolveOptions::default();
options.node_limit = Some(1);
let mut r = run(&binary_knapsack(), options).unwrap();
let mut guard = 0;
while r.outcome == MipOutcome::Interrupted {
guard += 1;
assert!(guard < 10_000);
r.outcome = resume_run(&mut r.state, None).unwrap();
}
assert!((incumbent_obj(&r.state) + 21.0).abs() < 1e-6);
}
#[test]
fn tolerances_default_matches_documented_values() {
let t = Tolerances::default();
assert_eq!(
t.feasibility, 1e-7,
"see Tolerances::feasibility's doc default"
);
assert_eq!(
t.integrality_rounding, 1e-5,
"see Tolerances::integrality_rounding's doc default"
);
assert_eq!(
t.prune_epsilon, 1e-9,
"see Tolerances::prune_epsilon's doc default"
);
}
#[test]
fn try_adopt_incumbent_respects_custom_feasibility_tolerance() {
let m = 1.0e9;
let mut p = Problem::new(OptimizationDirection::Minimize);
let x = p.add_var(1.0, (0.0, f64::INFINITY));
let b = p.add_binary_var(0.0);
p.add_constraint(&[(x, 1.0), (b, -m)], ComparisonOp::Eq, 10.0);
let mut solved = run(&p, SolveOptions::default()).unwrap();
let state = &mut solved.state;
state.solver.set_var_bounds(b.idx(), 5e-7, 5e-7).unwrap();
assert_eq!(
state.solver.reoptimize().unwrap(),
crate::StopReason::Finished
);
assert!((*state.solver.get_value(x.idx()) - 510.0).abs() < 1e-6);
state.options.tolerances.feasibility = Tolerances::default().feasibility;
assert!(
!try_adopt_incumbent(state).unwrap(),
"a 500-unit rounding-induced violation must be rejected at the default feasibility tolerance"
);
state.options.tolerances.feasibility = 1e6;
assert!(
try_adopt_incumbent(state).unwrap(),
"the same violation must be accepted once tolerances.feasibility is loosened past it"
);
}
#[test]
fn incumbent_feasible_row_tolerance_is_absolute_not_relative_to_rhs() {
let mut p = Problem::new(OptimizationDirection::Minimize);
let x = p.add_var(1.0, (0.0, f64::INFINITY));
p.add_constraint(&[(x, 1.0)], ComparisonOp::Le, 1000.0);
let fixed = std::collections::BTreeMap::new();
let tolerances = Tolerances::default();
assert!(incumbent_feasible(
&p,
&fixed,
&[1000.0 + 5e-8],
&tolerances
));
assert!(!incumbent_feasible(
&p,
&fixed,
&[1000.0 + 5e-5],
&tolerances
));
}
#[test]
fn candidate_validation_rejects_non_finite_and_malformed_values() {
let mut problem = Problem::new(OptimizationDirection::Minimize);
problem.add_integer_var(1.0, (0, 10));
let fixed = BTreeMap::new();
let tolerances = Tolerances::default();
assert!(!incumbent_feasible(&problem, &fixed, &[], &tolerances));
assert!(!incumbent_feasible(
&problem,
&fixed,
&[f64::NAN],
&tolerances
));
assert!(!incumbent_feasible(
&problem,
&fixed,
&[f64::INFINITY],
&tolerances
));
let mut overflowing_row = Problem::new(OptimizationDirection::Minimize);
let x = overflowing_row.add_var(0.0, (0.0, f64::INFINITY));
overflowing_row.add_constraint(&[(x, 1.0e308)], ComparisonOp::Le, 1.0e308);
assert!(!incumbent_feasible(
&overflowing_row,
&fixed,
&[1.0e308],
&tolerances
));
}
#[test]
fn valid_candidate_completes_unbounded_classification() {
let mut problem = Problem::new(OptimizationDirection::Minimize);
problem.add_integer_var(1.0, (0, 10));
let mut state = build_state(&problem, SolveOptions::default()).unwrap();
assert_eq!(state.solver.initial_solve().unwrap(), StopReason::Finished);
state.classifying_unbounded = true;
assert_eq!(try_adopt_incumbent(&mut state), Err(Error::Unbounded));
}
#[test]
fn warm_start_restores_bounds_before_unbounded_verdict() {
let mut problem = Problem::new(OptimizationDirection::Minimize);
let x = problem.add_integer_var(0.0, (0, 10));
let mut state = build_state(&problem, SolveOptions::default()).unwrap();
assert_eq!(state.solver.initial_solve().unwrap(), StopReason::Finished);
state.classifying_unbounded = true;
assert_eq!(
try_warm_start(&mut state, &[(x, 5.0)]),
Err(Error::Unbounded)
);
assert_eq!(state.solver.get_var_bounds(x.idx()), (0.0, 10.0));
}
}