#[cfg(test)]
mod tests_mip_api {
use crate::*;
use core::time::Duration;
fn int_2var_problem() -> (Problem, Variable, Variable) {
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, a, b)
}
#[test]
fn milp_solve_reports_optimal_and_rounds_values() {
let (p, a, b) = int_2var_problem();
let sol = p.solve().unwrap();
assert_eq!(sol.status(), Status::Optimal);
assert!((sol.objective() - 11.0).abs() < 1e-6);
assert_eq!(sol.var_value(a), 1.0);
assert_eq!(sol.var_value(b), 2.0);
assert!(sol.stats().nodes_solved > 0);
assert!(sol.stats().lp_iterations > 0);
}
#[test]
fn milp_maximize_sign_is_correct() {
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,
);
let sol = p.solve().unwrap();
assert_eq!(sol.status(), Status::Optimal);
assert!((sol.objective() - 21.0).abs() < 1e-6);
assert_eq!(sol.var_value(x), 0.0);
assert_eq!(sol.var_value(y), 1.0);
}
#[test]
fn zero_time_limit_is_interrupted_then_resume_finishes() {
let (mut p, _, _) = int_2var_problem();
p.set_time_limit(Duration::ZERO);
let sol = p.solve().unwrap();
assert_eq!(sol.status(), Status::Interrupted);
assert!(sol.gap().is_none());
let sol = sol.resume(None).unwrap();
assert_eq!(sol.status(), Status::Optimal);
assert!((sol.objective() - 11.0).abs() < 1e-6);
}
#[test]
fn interrupted_reads_expose_current_state_without_panicking() {
let (mut p, x, y) = int_2var_problem();
p.set_time_limit(Duration::ZERO);
let sol = p.solve().unwrap();
assert_eq!(sol.status(), Status::Interrupted);
assert!(sol.objective().is_finite());
assert!(sol.var_value_raw(x).is_finite());
assert!(sol.var_value(y).is_finite());
assert!(sol[x].is_finite());
assert_eq!(sol.iter().count(), 2);
assert!(sol.iter().all(|(_, v)| v.is_finite()));
let sol = sol.resume(None).unwrap();
assert_eq!(sol.status(), Status::Optimal);
assert!((sol.objective() - 11.0).abs() < 1e-6);
}
#[test]
fn node_limit_interrupts_deterministically_and_resumes() {
let (p, _, _) = int_2var_problem();
let mut options = SolveOptions::default();
options.node_limit = Some(1);
let mut sol = p.solve_with(options).unwrap();
let mut resumes = 0;
while sol.status() != Status::Optimal {
resumes += 1;
assert!(resumes < 10_000);
sol = sol.resume(None).unwrap();
}
assert!((sol.objective() - 11.0).abs() < 1e-6);
}
#[test]
fn invalid_integrality_tolerances_are_rejected() {
for int_tol in [-1.0, f64::NAN, 0.5, f64::INFINITY] {
let mut p = Problem::new(OptimizationDirection::Minimize);
p.add_integer_var(1.0, (0, 1));
let mut options = SolveOptions::default();
options.int_tol = int_tol;
options.node_limit = Some(1);
let err = p.solve_with(options).unwrap_err();
assert!(
matches!(&err, Error::InvalidOptions(message) if message.contains("int_tol")),
"unexpected error for int_tol={int_tol}: {err}"
);
}
}
#[test]
fn invalid_gap_and_expert_tolerances_are_rejected() {
fn assert_invalid(field: &str, options: SolveOptions) {
let mut p = Problem::new(OptimizationDirection::Minimize);
p.add_integer_var(1.0, (0, 1));
let err = p.solve_with(options).unwrap_err();
assert!(
matches!(&err, Error::InvalidOptions(message) if message.contains(field)),
"unexpected error for {field}: {err}"
);
}
for value in [-1.0, f64::NAN, f64::INFINITY] {
let mut options = SolveOptions::default();
options.mip_gap = value;
assert_invalid("mip_gap", options);
}
for value in [-1.0, f64::NAN, f64::INFINITY] {
let mut options = SolveOptions::default();
options.tolerances.feasibility = value;
assert_invalid("tolerances.feasibility", options);
}
for value in [-1.0, f64::NAN, 0.5, f64::INFINITY] {
let mut options = SolveOptions::default();
options.tolerances.integrality_rounding = value;
assert_invalid("tolerances.integrality_rounding", options);
}
for value in [-1.0, f64::NAN, f64::INFINITY] {
let mut options = SolveOptions::default();
options.tolerances.prune_epsilon = value;
assert_invalid("tolerances.prune_epsilon", options);
}
}
#[test]
fn rounded_root_candidate_does_not_bypass_optimality_proof() {
let mut p = Problem::new(OptimizationDirection::Minimize);
let b = p.add_binary_var(-4_000_000.0);
let y = p.add_binary_var(-1.0);
let z = p.add_binary_var(-2.0);
p.add_constraint(&[(b, 1.0)], ComparisonOp::Le, 5e-7);
p.add_constraint(&[(b, 2_000_000.0), (z, 1.0)], ComparisonOp::Le, 1.0);
p.add_constraint(&[(y, 1.0), (z, 1.0)], ComparisonOp::Le, 1.0);
let sol = p.solve().unwrap();
assert_eq!(sol.status(), Status::Optimal);
assert!((sol.objective() - -2.0).abs() < 1e-9);
assert_eq!(sol.var_value(b), 0.0);
assert_eq!(sol.var_value(y), 0.0);
assert_eq!(sol.var_value(z), 1.0);
}
#[test]
fn rounded_node_candidate_does_not_bypass_its_subtree_proof() {
let mut p = Problem::new(OptimizationDirection::Minimize);
let a = p.add_binary_var(0.0);
let b = p.add_binary_var(-4_000_000.0);
let y = p.add_binary_var(-1.0);
let z = p.add_binary_var(-2.0);
p.add_constraint(&[(a, 1.0)], ComparisonOp::Ge, 0.5);
p.add_constraint(&[(b, 1.0)], ComparisonOp::Le, 5e-7);
p.add_constraint(&[(b, 2_000_000.0), (z, 1.0)], ComparisonOp::Le, 1.0);
p.add_constraint(&[(y, 1.0), (z, 1.0)], ComparisonOp::Le, 1.0);
let sol = p.solve().unwrap();
assert_eq!(sol.status(), Status::Optimal);
assert!((sol.objective() - -2.0).abs() < 1e-9);
assert_eq!(sol.var_value(a), 1.0);
assert_eq!(sol.var_value(b), 0.0);
assert_eq!(sol.var_value(y), 0.0);
assert_eq!(sol.var_value(z), 1.0);
}
#[test]
fn milp_infeasible_is_an_error() {
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!(p.solve().unwrap_err(), Error::Infeasible);
}
#[test]
fn unbounded_relaxation_does_not_mask_integer_infeasibility() {
let mut p = Problem::new(OptimizationDirection::Minimize);
let x = p.add_integer_var(0.0, (0, 1));
let _y = p.add_var(-1.0, (0.0, f64::INFINITY));
p.add_constraint(&[(x, 1.0)], ComparisonOp::Eq, 0.5);
assert_eq!(p.solve().unwrap_err(), Error::Infeasible);
}
#[test]
fn unbounded_relaxation_classification_resumes_after_root_interrupt() {
let mut p = Problem::new(OptimizationDirection::Minimize);
let x = p.add_integer_var(0.0, (0, 1));
let _y = p.add_var(-1.0, (0.0, f64::INFINITY));
p.add_constraint(&[(x, 1.0)], ComparisonOp::Eq, 0.5);
let mut options = SolveOptions::default();
options.time_limit = Some(Duration::ZERO);
let interrupted = p.solve_with(options).unwrap();
assert_eq!(interrupted.status(), Status::Interrupted);
assert_eq!(interrupted.resume(None).unwrap_err(), Error::Infeasible);
}
#[test]
fn interrupted_unbounded_classification_uses_original_objective() {
let mut p = Problem::new(OptimizationDirection::Minimize);
let x = p.add_integer_var(7.0, (0, 1));
let y = p.add_var(-1.0, (0.0, f64::INFINITY));
p.add_constraint(&[(x, 1.0)], ComparisonOp::Eq, 0.5);
let mut options = SolveOptions::default();
options.node_limit = Some(0);
let sol = p.solve_with(options).unwrap();
assert_eq!(sol.status(), Status::Interrupted);
assert!((sol.var_value_raw(x) - 0.5).abs() < 1e-9);
assert!(sol.var_value_raw(y).abs() < 1e-9);
let from_values = 7.0 * sol.var_value_raw(x) - sol.var_value_raw(y);
assert!((from_values - 3.5).abs() < 1e-9);
assert!((sol.objective() - from_values).abs() < 1e-9);
assert_eq!(sol.stats().nodes_solved, 0);
}
#[test]
fn lp_path_still_solves_and_edits_incrementally() {
let mut p = Problem::new(OptimizationDirection::Maximize);
let x = p.add_var(1.0, (0.0, 4.0));
let y = p.add_var(2.0, (0.0, 3.0));
p.add_constraint(&[(x, 1.0), (y, 1.0)], ComparisonOp::Le, 5.0);
let sol = p.solve().unwrap();
assert_eq!(sol.status(), Status::Optimal);
assert!((sol.objective() - 8.0).abs() < 1e-6);
let sol = sol
.add_constraint(&[(x, 1.0)], ComparisonOp::Le, 1.0)
.unwrap();
assert!((sol.objective() - 7.0).abs() < 1e-6);
assert!((sol[x] - 1.0).abs() < 1e-6);
}
#[test]
fn optimal_lp_stats_report_bound_and_zero_gap() {
let mut p = Problem::new(OptimizationDirection::Maximize);
let _x = p.add_var(2.0, (0.0, 3.0));
let sol = p.solve().unwrap();
let stats = sol.stats();
assert_eq!(stats.best_bound, Some(6.0));
assert_eq!(stats.gap, Some(0.0));
}
#[test]
fn warm_start_with_optimal_hint_is_accepted() {
let (p, a, b) = int_2var_problem();
let mut options = SolveOptions::default();
options.warm_start = Some(vec![(a, 1.0), (b, 2.0)]);
let sol = p.solve_with(options).unwrap();
assert_eq!(sol.status(), Status::Optimal);
assert!((sol.objective() - 11.0).abs() < 1e-6);
}
#[test]
fn warm_start_with_infeasible_hint_is_ignored() {
let (p, a, b) = int_2var_problem();
let mut options = SolveOptions::default();
options.warm_start = Some(vec![(a, 0.0), (b, 0.0)]);
let sol = p.solve_with(options).unwrap();
assert_eq!(sol.status(), Status::Optimal);
assert!((sol.objective() - 11.0).abs() < 1e-6);
}
#[test]
fn warm_start_out_of_bounds_hint_is_ignored() {
let (p, a, b) = int_2var_problem();
let mut options = SolveOptions::default();
options.warm_start = Some(vec![(a, 99.0), (b, 2.0)]); let sol = p.solve_with(options).unwrap();
assert_eq!(sol.status(), Status::Optimal);
assert!((sol.objective() - 11.0).abs() < 1e-6);
}
#[test]
fn warm_start_non_finite_hint_is_ignored() {
for invalid in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let (p, a, _) = int_2var_problem();
let mut options = SolveOptions::default();
options.warm_start = Some(vec![(a, invalid)]);
let sol = p.solve_with(options).unwrap();
assert_eq!(sol.status(), Status::Optimal);
assert!((sol.objective() - 11.0).abs() < 1e-6);
}
}
#[test]
fn warm_start_nan_for_nonbasic_variable_is_ignored() {
let mut problem = Problem::new(OptimizationDirection::Minimize);
let x = problem.add_integer_var(1.0, (0, 10));
let mut options = SolveOptions::default();
options.warm_start = Some(vec![(x, f64::NAN)]);
let solution = problem.solve_with(options).unwrap();
assert_eq!(solution.status(), Status::Optimal);
assert_eq!(solution.var_value(x), 0.0);
assert_eq!(solution.objective(), 0.0);
}
#[test]
fn warm_start_partial_hint_completes_via_lp() {
let (p, a, _) = int_2var_problem();
let mut options = SolveOptions::default();
options.warm_start = Some(vec![(a, 1.0)]);
let sol = p.solve_with(options).unwrap();
assert_eq!(sol.status(), Status::Optimal);
assert!((sol.objective() - 11.0).abs() < 1e-6);
}
#[test]
fn warm_start_liveness_hint_seeds_incumbent_before_any_node() {
let (p, a, b) = int_2var_problem();
let mut options = SolveOptions::default();
options.node_limit = Some(0);
let cold = p.solve_with(options.clone()).unwrap();
assert_eq!(cold.status(), Status::Interrupted);
let mut options = SolveOptions::default();
options.node_limit = Some(0);
options.warm_start = Some(vec![(a, 1.0), (b, 2.0)]);
let hinted = p.solve_with(options).unwrap();
assert_eq!(hinted.status(), Status::Feasible);
assert!((hinted.objective() - 11.0).abs() < 1e-6);
assert_eq!(hinted.var_value(a), 1.0);
assert_eq!(hinted.var_value(b), 2.0);
assert_eq!(hinted.stats().nodes_solved, 0);
}
#[test]
fn warm_start_prunes_immediately_when_hint_is_optimal() {
let (p, a, b) = int_2var_problem();
let mut options = SolveOptions::default();
options.warm_start = Some(vec![(a, 1.0), (b, 2.0)]);
let with_hint = p.solve_with(options).unwrap();
let without = p.solve().unwrap();
assert!(with_hint.stats().nodes_solved <= without.stats().nodes_solved);
assert_eq!(with_hint.objective(), without.objective());
}
#[test]
fn milp_add_constraint_resolves_on_base_problem() {
let (p, a, b) = int_2var_problem();
let sol = p.solve().unwrap();
assert!((sol.objective() - 11.0).abs() < 1e-6);
let sol = sol
.add_constraint(&[(a, 1.0), (b, 1.0)], ComparisonOp::Ge, 4.0)
.unwrap();
assert_eq!(sol.status(), Status::Optimal);
assert!((sol.objective() - 13.0).abs() < 1e-6);
assert_eq!(sol.var_value(a), 3.0);
assert_eq!(sol.var_value(b), 1.0);
let (mut p2, a2, b2) = (int_2var_problem().0, Variable(0), Variable(1));
p2.add_constraint(&[(a2, 1.0), (b2, 1.0)], ComparisonOp::Ge, 4.0);
let fresh = p2.solve().unwrap();
assert!((fresh.objective() - sol.objective()).abs() < 1e-6);
}
#[test]
fn milp_fix_and_unfix_var_roundtrip() {
let (p, a, b) = int_2var_problem();
let sol = p.solve().unwrap();
let sol = sol.fix_var(a, 3.0).unwrap();
assert_eq!(sol.status(), Status::Optimal);
assert!((sol.objective() - 13.0).abs() < 1e-6);
assert_eq!(sol.var_value(a), 3.0);
assert_eq!(sol.var_value(b), 1.0);
let (sol, was_fixed) = sol.unfix_var(a).unwrap();
assert!(was_fixed);
assert!((sol.objective() - 11.0).abs() < 1e-6);
let (sol, was_fixed) = sol.unfix_var(b).unwrap();
assert!(!was_fixed);
assert!((sol.objective() - 11.0).abs() < 1e-6);
}
#[test]
fn milp_fix_var_outside_bounds_is_infeasible_error() {
let (p, a, _) = int_2var_problem();
let sol = p.solve().unwrap();
assert!(matches!(sol.fix_var(a, 99.0), Err(Error::Infeasible)));
}
#[test]
fn milp_fix_var_non_finite_is_infeasible_error() {
for invalid in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let (p, a, _) = int_2var_problem();
let sol = p.solve().unwrap();
assert!(
matches!(sol.fix_var(a, invalid), Err(Error::Infeasible)),
"non-finite fix {invalid} was not rejected"
);
}
}
#[test]
fn milp_edit_after_pause_completes_correctly() {
let (p, a, b) = int_2var_problem();
let mut options = SolveOptions::default();
options.node_limit = Some(1); let sol = p.solve_with(options).unwrap();
let sol = sol
.add_constraint(&[(a, 1.0), (b, 1.0)], ComparisonOp::Ge, 4.0)
.unwrap();
let sol = if sol.status() == Status::Optimal {
sol
} else {
sol.resume(None).unwrap()
};
assert!((sol.objective() - 13.0).abs() < 1e-6);
}
#[test]
fn milp_infeasible_edit_is_an_error() {
let (p, a, _) = int_2var_problem();
let sol = p.solve().unwrap();
assert!(matches!(
sol.add_constraint(&[(a, 1.0)], ComparisonOp::Le, -1.0),
Err(Error::Infeasible)
));
}
#[test]
fn lp_stats_report_nonzero_elapsed_time() {
let mut p = Problem::new(OptimizationDirection::Minimize);
let x = p.add_var(1.0, (0.0, 10.0));
p.add_constraint([(x, 1.0)], ComparisonOp::Ge, 3.0);
let sol = p.solve().unwrap();
let initial_elapsed = sol.stats().elapsed;
assert!(initial_elapsed > Duration::ZERO);
let edited = sol
.add_constraint([(x, 1.0)], ComparisonOp::Ge, 4.0)
.unwrap();
assert!(edited.stats().elapsed >= initial_elapsed);
}
#[test]
fn lp_edit_gets_a_fresh_time_budget() {
let mut p = Problem::new(OptimizationDirection::Minimize);
let x = p.add_var(1.0, (0.0, 10.0));
p.add_constraint([(x, 1.0)], ComparisonOp::Ge, 3.0);
let mut options = SolveOptions::default();
options.time_limit = Some(Duration::from_millis(50));
let sol = p.solve_with(options).unwrap();
assert_eq!(sol.status(), Status::Optimal);
std::thread::sleep(Duration::from_millis(75));
let edited = sol
.add_constraint([(x, 1.0)], ComparisonOp::Ge, 4.0)
.unwrap();
assert_eq!(edited.status(), Status::Optimal);
assert_eq!(edited.objective(), 4.0);
}
#[test]
fn lp_unfix_reports_an_interrupted_reoptimization() {
let mut p = Problem::new(OptimizationDirection::Minimize);
let x = p.add_var(-1.0, (0.0, 10.0));
let mut fixed = p.solve().unwrap().fix_var(x, 0.0).unwrap();
match &mut fixed.kind {
SolutionKind::Lp(solver) => {
solver.operation_time_limit = Some(Duration::ZERO);
}
SolutionKind::Mip(_) => unreachable!(),
}
let (unfixed, was_fixed) = fixed.unfix_var(x).unwrap();
assert!(was_fixed);
assert_eq!(unfixed.status(), Status::Interrupted);
}
#[test]
fn interrupted_lp_edit_resumes_to_the_edited_models_optimum() {
let build = || {
let mut p = Problem::new(OptimizationDirection::Minimize);
let x = p.add_var(1.0, (0.0, 10.0));
let y = p.add_var(2.0, (0.0, 10.0));
let z = p.add_var(3.0, (0.0, 10.0));
p.add_constraint([(x, 1.0), (y, 1.0), (z, 1.0)], ComparisonOp::Ge, 6.0);
(p, x, y, z)
};
let (base, x, y, z) = build();
let mut sol = base.solve().unwrap();
assert_eq!(sol.status(), Status::Optimal);
let base_obj = sol.objective();
assert!((base_obj - 6.0).abs() < 1e-6);
match &mut sol.kind {
SolutionKind::Lp(solver) => solver.operation_time_limit = Some(Duration::ZERO),
SolutionKind::Mip(_) => unreachable!("a continuous model stays pure-LP"),
}
let interrupted = sol
.add_constraint([(x, 1.0)], ComparisonOp::Le, 2.0)
.unwrap();
assert_eq!(interrupted.status(), Status::Interrupted);
assert!((interrupted.objective() - base_obj).abs() < 1e-6);
let resumed = interrupted.resume(None).unwrap();
assert_eq!(resumed.status(), Status::Optimal);
let (mut edited, ..) = build();
edited.add_constraint([(x, 1.0)], ComparisonOp::Le, 2.0);
let fresh = edited.solve().unwrap();
assert_eq!(fresh.status(), Status::Optimal);
assert!((resumed.objective() - fresh.objective()).abs() < 1e-6);
assert!((resumed.objective() - 10.0).abs() < 1e-6);
assert!(resumed.objective() > base_obj + 0.5);
assert!(resumed.var_value(x) <= 2.0 + 1e-6);
for var in [x, y, z] {
assert!((resumed.var_value(var) - fresh.var_value(var)).abs() < 1e-6);
}
}
}