1pub(crate) mod branching;
7pub(crate) mod node;
8pub(crate) mod params;
9
10use crate::solver::{check_deadline, Deadline, Solver};
11use crate::{ComparisonOp, Error, OptimizationDirection, Problem, StopReason, VarDomain, Variable};
12use core::time::Duration;
13use node::{effective_bounds, Node};
14use std::collections::BTreeMap;
15use web_time::Instant;
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum SolutionStatus {
20 Optimal,
22 Feasible,
24}
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28#[non_exhaustive]
29pub enum TerminationReason {
30 ProvenOptimal,
32 MipGap,
34 TimeLimit,
36 NodeLimit,
38}
39
40#[derive(Clone, Debug)]
43#[non_exhaustive]
44pub struct SolveOptions {
45 pub time_limit: Option<Duration>,
48 pub node_limit: Option<u64>,
53 pub mip_gap: f64,
58 pub int_tol: f64,
63 pub warm_start: Option<Vec<(Variable, f64)>>,
66 pub tolerances: Tolerances,
71}
72
73impl Default for SolveOptions {
74 fn default() -> Self {
75 Self {
76 time_limit: None,
77 node_limit: None,
78 mip_gap: 0.0,
79 int_tol: 1e-6,
80 warm_start: None,
81 tolerances: Tolerances::default(),
82 }
83 }
84}
85
86impl SolveOptions {
87 pub(crate) fn validate(&self) -> Result<(), Error> {
88 if !self.mip_gap.is_finite() || self.mip_gap < 0.0 {
89 return Err(Error::InvalidOptions(
90 "invalid SolveOptions.mip_gap: expected a finite non-negative value".to_string(),
91 ));
92 }
93 if !self.int_tol.is_finite() || !(0.0..0.5).contains(&self.int_tol) {
94 return Err(Error::InvalidOptions(
95 "invalid SolveOptions.int_tol: expected a finite value in [0, 0.5)".to_string(),
96 ));
97 }
98 if !self.tolerances.feasibility.is_finite() || self.tolerances.feasibility < 0.0 {
99 return Err(Error::InvalidOptions(
100 "invalid SolveOptions.tolerances.feasibility: expected a finite non-negative value"
101 .to_string(),
102 ));
103 }
104 if !self.tolerances.integrality_rounding.is_finite()
105 || !(0.0..0.5).contains(&self.tolerances.integrality_rounding)
106 {
107 return Err(Error::InvalidOptions(
108 "invalid SolveOptions.tolerances.integrality_rounding: expected a finite value in [0, 0.5)"
109 .to_string(),
110 ));
111 }
112 if !self.tolerances.prune_epsilon.is_finite() || self.tolerances.prune_epsilon < 0.0 {
113 return Err(Error::InvalidOptions(
114 "invalid SolveOptions.tolerances.prune_epsilon: expected a finite non-negative value"
115 .to_string(),
116 ));
117 }
118 Ok(())
119 }
120}
121
122#[derive(Clone, Debug, Default, PartialEq)]
127#[non_exhaustive]
128pub struct ResumeOptions {
129 pub time_limit: Option<Duration>,
131 pub node_limit: Option<u64>,
133 pub mip_gap: Option<f64>,
135}
136
137impl ResumeOptions {
138 pub(crate) fn validate(&self) -> Result<(), Error> {
139 if let Some(mip_gap) = self.mip_gap {
140 if !mip_gap.is_finite() || mip_gap < 0.0 {
141 return Err(Error::InvalidOptions(
142 "invalid ResumeOptions.mip_gap: expected a finite non-negative value"
143 .to_string(),
144 ));
145 }
146 }
147 Ok(())
148 }
149}
150
151#[derive(Clone, Copy, Debug)]
156#[non_exhaustive]
157pub struct Tolerances {
158 pub feasibility: f64,
167 pub integrality_rounding: f64,
172 pub prune_epsilon: f64,
181}
182
183impl Default for Tolerances {
184 fn default() -> Self {
185 Self {
186 feasibility: 1e-7,
187 integrality_rounding: 1e-5,
188 prune_epsilon: 1e-9,
189 }
190 }
191}
192
193#[derive(Clone, Copy, Debug, Default)]
195#[non_exhaustive]
196pub struct Stats {
197 pub nodes_solved: u64,
199 pub lp_iterations: u64,
201 pub elapsed: Duration,
203 pub best_bound: Option<f64>,
206 pub gap: Option<f64>,
209}
210
211#[derive(Clone, Debug)]
213pub(crate) struct Incumbent {
214 pub values: Vec<f64>,
216 pub objective: f64,
217}
218
219#[derive(Clone)]
221pub(crate) struct MipState {
222 pub solver: Solver,
223 pub root_bounds: Vec<(f64, f64)>,
225 pub applied: Vec<(usize, f64, f64)>,
227 pub open: Vec<Node>,
228 pub diving: bool,
234 pub incumbent: Option<Incumbent>,
235 pub node_seq: u64,
237 pub last_solved_id: Option<u64>,
240 pub root_solved: bool,
241 pub stats: Stats,
242 pub options: SolveOptions,
243 pub deadline: Deadline,
244 pub direction: OptimizationDirection,
246 pub pseudocosts: branching::PseudoCosts,
249 pub base: Problem,
252 pub fixed: BTreeMap<usize, f64>,
254 pub classifying_unbounded: bool,
258}
259
260impl std::fmt::Debug for MipState {
261 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262 f.debug_struct("MipState")
263 .field("open_nodes", &self.open.len())
264 .field("has_incumbent", &self.incumbent.is_some())
265 .field("stats", &self.stats)
266 .field("diving", &self.diving)
267 .field(
268 "pseudocost_observations",
269 &self.pseudocosts.observation_count(),
270 )
271 .field("classifying_unbounded", &self.classifying_unbounded)
272 .finish()
273 }
274}
275
276impl MipState {
277 pub(crate) fn current_objective(&self) -> f64 {
281 if let Some(incumbent) = &self.incumbent {
282 return incumbent.objective;
283 }
284 self.base
285 .obj_coeffs
286 .iter()
287 .enumerate()
288 .map(|(v, &coefficient)| coefficient * self.solver.get_value(v))
289 .sum()
290 }
291}
292
293#[derive(Debug)]
294pub(crate) struct MipRun {
295 pub reason: TerminationReason,
296 pub state: MipState,
297}
298
299fn build_state(problem: &Problem, options: SolveOptions) -> Result<MipState, Error> {
300 let deadline = options.time_limit.map(|d| Instant::now() + d);
301 let solver = problem.build_solver(deadline)?;
302 let root_bounds = problem
303 .var_mins
304 .iter()
305 .zip(&problem.var_maxs)
306 .map(|(&lo, &hi)| (lo, hi))
307 .collect();
308 let pseudocosts = branching::PseudoCosts::new(&problem.obj_coeffs, problem.obj_coeffs.len());
309 Ok(MipState {
310 solver,
311 root_bounds,
312 applied: Vec::new(),
313 open: Vec::new(),
314 diving: false,
315 incumbent: None,
316 node_seq: 0,
317 last_solved_id: None,
318 root_solved: false,
319 stats: Stats::default(),
320 options,
321 deadline,
322 direction: problem.direction,
323 pseudocosts,
324 base: problem.clone(),
325 fixed: BTreeMap::new(),
326 classifying_unbounded: false,
327 })
328}
329
330fn begin_unbounded_classification(state: &mut MipState) -> Result<(), Error> {
335 let base = state.base.clone();
336 let fixed = state.fixed.clone();
337 let mut feasibility = effective_problem(&base, &fixed);
338 feasibility.obj_coeffs.fill(0.0);
339
340 let deadline = state.deadline;
341 let elapsed = state.stats.elapsed;
342 let lp_iterations = state.solver.lp_iterations;
343 let mut replacement = build_state(&feasibility, state.options.clone())?;
344 replacement.deadline = deadline;
345 replacement.solver.deadline = deadline;
346 replacement.solver.lp_iterations = lp_iterations;
347 replacement.stats.elapsed = elapsed;
348 replacement.base = base;
349 replacement.fixed = fixed;
350 replacement.classifying_unbounded = true;
351 *state = replacement;
352 Ok(())
353}
354
355fn resume_or_classify(state: &mut MipState) -> Result<TerminationReason, Error> {
356 match resume_run_with_deadline(state) {
357 Err(Error::Unbounded) if !state.classifying_unbounded => {
358 begin_unbounded_classification(state)?;
359 resume_run_with_deadline(state)
360 }
361 result => result,
362 }
363}
364
365pub(crate) fn run(problem: &Problem, options: SolveOptions) -> Result<MipRun, Error> {
367 let mut state = build_state(problem, options)?;
368 let reason = resume_or_classify(&mut state)?;
369 Ok(MipRun { reason, state })
370}
371
372pub(crate) fn effective_problem(base: &Problem, fixed: &BTreeMap<usize, f64>) -> Problem {
374 let mut p = base.clone();
375 for (&v, &val) in fixed {
376 p.var_mins[v] = val;
377 p.var_maxs[v] = val;
378 }
379 p
380}
381
382fn candidate_variables_feasible(
383 values: &[f64],
384 domains: &[VarDomain],
385 tolerances: &Tolerances,
386 mut bounds: impl FnMut(usize) -> (f64, f64),
387) -> bool {
388 if values.len() != domains.len() {
389 return false;
390 }
391 values.iter().enumerate().all(|(v, &value)| {
392 let (lo, hi) = bounds(v);
393 value.is_finite()
394 && !lo.is_nan()
395 && !hi.is_nan()
396 && lo <= hi
397 && value >= lo - tolerances.feasibility
398 && value <= hi + tolerances.feasibility
399 && (!matches!(domains[v], VarDomain::Integer | VarDomain::Boolean)
400 || (value - value.round()).abs() <= tolerances.integrality_rounding)
401 })
402}
403
404pub(crate) fn incumbent_feasible(
408 base: &Problem,
409 fixed: &BTreeMap<usize, f64>,
410 values: &[f64],
411 tolerances: &Tolerances,
412) -> bool {
413 if !candidate_variables_feasible(values, &base.var_domains, tolerances, |v| {
414 fixed
415 .get(&v)
416 .map_or((base.var_mins[v], base.var_maxs[v]), |&value| {
417 (value, value)
418 })
419 }) {
420 return false;
421 }
422 for (coeffs, op, rhs) in &base.constraints {
423 let lhs: f64 = coeffs.iter().map(|(i, c)| c * values[i]).sum();
424 if !lhs.is_finite() {
425 return false;
426 }
427 let tol = tolerances.feasibility;
428 let ok = match op {
429 ComparisonOp::Eq => (lhs - rhs).abs() <= tol,
430 ComparisonOp::Le => lhs <= rhs + tol,
431 ComparisonOp::Ge => lhs >= *rhs - tol,
432 };
433 if !ok {
434 return false;
435 }
436 }
437 true
438}
439
440pub(crate) fn reedit_and_resolve(state: Box<MipState>) -> Result<MipRun, Error> {
444 let MipState {
445 base,
446 fixed,
447 incumbent,
448 mut options,
449 ..
450 } = *state;
451
452 options.warm_start = incumbent
453 .filter(|inc| incumbent_feasible(&base, &fixed, &inc.values, &options.tolerances))
454 .map(|inc| {
455 inc.values
456 .iter()
457 .enumerate()
458 .map(|(v, &val)| (Variable(v), val))
459 .collect()
460 });
461
462 let effective = effective_problem(&base, &fixed);
463 let mut run = run(&effective, options)?;
464 run.state.base = base;
467 run.state.fixed = fixed;
468 Ok(run)
469}
470
471pub(crate) fn resume_run(
473 state: &mut MipState,
474 options: ResumeOptions,
475) -> Result<TerminationReason, Error> {
476 options.validate()?;
477 state.deadline = options.time_limit.map(|d| Instant::now() + d);
478 state.options.time_limit = options.time_limit;
479 state.options.node_limit = options.node_limit;
480 state.options.mip_gap = options.mip_gap.unwrap_or(0.0);
481 resume_or_classify(state)
482}
483
484fn resume_run_with_deadline(state: &mut MipState) -> Result<TerminationReason, Error> {
485 let started = Instant::now();
486 let res = search_loop(state);
487 state.stats.elapsed += started.elapsed();
488 state.stats.lp_iterations = state.solver.lp_iterations;
489 fill_bound_stats(state);
490 res
491}
492
493fn global_bound_internal(state: &MipState) -> Option<f64> {
497 if !state.root_solved {
498 return None;
499 }
500 let open_min = state
501 .open
502 .iter()
503 .map(|n| n.lp_bound)
504 .fold(f64::INFINITY, f64::min);
505 match (&state.incumbent, state.open.is_empty()) {
506 (Some(inc), true) => Some(inc.objective), (Some(inc), false) => Some(open_min.min(inc.objective)),
508 (None, false) => Some(open_min),
509 (None, true) => None,
510 }
511}
512
513fn relative_gap(incumbent_obj: f64, bound: f64) -> f64 {
515 (incumbent_obj - bound).max(0.0) / incumbent_obj.abs().max(params::GAP_DENOM_GUARD)
516}
517
518fn to_user_space(direction: OptimizationDirection, internal: f64) -> f64 {
519 match direction {
520 OptimizationDirection::Minimize => internal,
521 OptimizationDirection::Maximize => -internal,
522 }
523}
524
525fn fill_bound_stats(state: &mut MipState) {
526 if state.classifying_unbounded {
527 state.stats.best_bound = None;
528 state.stats.gap = None;
529 return;
530 }
531 let bound = global_bound_internal(state);
532 state.stats.best_bound = bound.map(|b| to_user_space(state.direction, b));
533 state.stats.gap = match (&state.incumbent, bound) {
534 (Some(inc), Some(b)) => Some(relative_gap(inc.objective, b)),
535 _ => None,
536 };
537}
538
539fn cutoff(incumbent_obj: f64, prune_epsilon: f64) -> f64 {
541 incumbent_obj - f64::max(prune_epsilon, prune_epsilon * incumbent_obj.abs())
542}
543
544fn try_adopt_incumbent(state: &mut MipState) -> Result<bool, Error> {
548 let tolerances = &state.options.tolerances;
549 let solver = &state.solver;
550 let n = solver.num_vars;
551 let domains = &solver.orig_var_domains;
552 let mut values: Vec<f64> = (0..n).map(|v| *solver.get_value(v)).collect();
553 for (val, dom) in values.iter_mut().zip(domains.iter()) {
554 if matches!(dom, VarDomain::Integer | VarDomain::Boolean) {
555 *val = val.round();
556 }
557 }
558 if !candidate_variables_feasible(&values, domains, tolerances, |v| state.root_bounds[v])
559 || !solver.check_constraints(&values, tolerances.feasibility)
560 {
561 debug!("integral-within-tol solution rejected: rounded values infeasible");
562 return Ok(false);
563 }
564 let objective = solver.objective_of(&values);
565 if !objective.is_finite() {
566 debug!("integral-within-tol solution rejected: objective is non-finite");
567 return Ok(false);
568 }
569 let better = match &state.incumbent {
570 Some(inc) => objective < inc.objective,
571 None => true,
572 };
573 if better {
574 debug!("new incumbent, internal obj: {:.6}", objective);
575 state.incumbent = Some(Incumbent { values, objective });
576 }
577 if state.classifying_unbounded {
578 Err(Error::Unbounded)
579 } else {
580 Ok(true)
581 }
582}
583
584enum IntegralCandidate {
585 Closed,
586 Branch(usize),
587 Limit,
588}
589
590fn process_integral_candidate(
596 state: &mut MipState,
597 domains: &[VarDomain],
598 int_tol: f64,
599) -> Result<IntegralCandidate, Error> {
600 let adopted = try_adopt_incumbent(state)?;
601 if let Some(var) = branching::choose_branch_var(&state.solver, domains, 0.0, &state.pseudocosts)
602 {
603 return Ok(IntegralCandidate::Branch(var));
604 }
605 if adopted {
606 return Ok(IntegralCandidate::Closed);
607 }
608
609 debug!("exactly integral candidate failed guard; retrying from slack basis");
610 let slack = state.solver.slack_basis();
611 state
612 .solver
613 .load_basis(&slack)
614 .map_err(|e| Error::InternalError(format!("slack basis load failed: {}", e)))?;
615 match solve_node_lp(state)? {
616 NodeLp::Limit => return Ok(IntegralCandidate::Limit),
617 NodeLp::Infeasible => {
618 return Err(Error::InternalError(
619 "integral candidate became infeasible after slack-basis retry".to_string(),
620 ))
621 }
622 NodeLp::Solved => {}
623 }
624
625 if !branching::is_integral(&state.solver, domains, int_tol) {
626 return branching::choose_branch_var(&state.solver, domains, int_tol, &state.pseudocosts)
627 .map(IntegralCandidate::Branch)
628 .ok_or_else(|| {
629 Error::InternalError(
630 "slack-basis retry produced a non-integral point with no branchable variable"
631 .to_string(),
632 )
633 });
634 }
635
636 let adopted = try_adopt_incumbent(state)?;
637 if let Some(var) = branching::choose_branch_var(&state.solver, domains, 0.0, &state.pseudocosts)
638 {
639 Ok(IntegralCandidate::Branch(var))
640 } else if adopted {
641 Ok(IntegralCandidate::Closed)
642 } else {
643 Err(Error::InternalError(
644 "exactly integral solution failed feasibility validation after slack-basis retry"
645 .to_string(),
646 ))
647 }
648}
649
650fn apply_node_bounds(state: &mut MipState, node: &Node) -> bool {
653 let target = effective_bounds(&node.bound_changes);
654 if target.iter().any(|&(_, lo, hi)| lo > hi) {
655 return false;
656 }
657 for &(v, _, _) in &state.applied {
659 if target.binary_search_by_key(&v, |t| t.0).is_err() {
660 let (rlo, rhi) = state.root_bounds[v];
661 state
662 .solver
663 .set_var_bounds(v, rlo, rhi)
664 .expect("root bounds cannot cross");
665 }
666 }
667 for &(v, lo, hi) in &target {
669 state
670 .solver
671 .set_var_bounds(v, lo, hi)
672 .expect("validated bounds cannot cross");
673 }
674 state.applied = target;
675 true
676}
677
678fn branch(state: &mut MipState, parent: &Node, var: usize) {
681 let z = state.solver.cur_obj_val;
682 let val = *state.solver.get_value(var);
683 let (lo, hi) = state.solver.get_var_bounds(var);
684 let floor = {
695 let near = val.round();
696 let k = if (val - near).abs() <= state.options.int_tol {
697 near
698 } else {
699 val.floor()
700 };
701 k.clamp(lo, (hi - 1.0).max(lo))
702 };
703 let f_down = (val - floor).clamp(0.0, 1.0);
704
705 state.node_seq += 1;
706 let id = state.node_seq;
707 state.last_solved_id = Some(id);
708 let basis = state.solver.snapshot_basis();
709
710 let mut down_changes = parent.bound_changes.clone();
711 down_changes.push((var, lo, floor));
712 let mut up_changes = parent.bound_changes.clone();
713 up_changes.push((var, floor + 1.0, hi));
714
715 let down_node = Node {
716 bound_changes: down_changes,
717 basis: basis.clone(),
718 lp_bound: z,
719 depth: parent.depth + 1,
720 parent_id: id,
721 branch_var: Some(var),
722 branch_up: false,
723 branch_frac: f_down,
724 };
725 let up_node = Node {
726 bound_changes: up_changes,
727 basis,
728 lp_bound: z,
729 depth: parent.depth + 1,
730 parent_id: id,
731 branch_var: Some(var),
732 branch_up: true,
733 branch_frac: 1.0 - f_down,
734 };
735
736 let est_down = state.pseudocosts.estimate(var, false) * f_down;
739 let est_up = state.pseudocosts.estimate(var, true) * (1.0 - f_down);
740 if est_down > est_up {
741 state.open.push(down_node);
743 state.open.push(up_node);
744 } else {
745 state.open.push(up_node);
746 state.open.push(down_node);
747 }
748 state.diving = true;
750}
751
752enum NodeLp {
754 Solved,
756 Infeasible,
758 Limit,
761}
762
763fn solve_node_lp(state: &mut MipState) -> Result<NodeLp, Error> {
774 state.solver.deadline = state.deadline;
775 let err = match state.solver.reoptimize() {
776 Ok(StopReason::Finished) => return Ok(NodeLp::Solved),
777 Ok(StopReason::Limit) => return Ok(NodeLp::Limit),
778 Err(Error::Infeasible) => return Ok(NodeLp::Infeasible),
779 Err(Error::Unbounded) => {
780 return Err(Error::InternalError(
781 "bounded B&B node reported unbounded".to_string(),
782 ))
783 }
784 Err(e) => e,
786 };
787
788 debug!(
789 "node LP reoptimize failed ({}); retrying from slack basis",
790 err
791 );
792 let slack = state.solver.slack_basis();
793 state
794 .solver
795 .load_basis(&slack)
796 .map_err(|e| Error::InternalError(format!("slack basis load failed: {}", e)))?;
797 match state.solver.reoptimize() {
798 Ok(StopReason::Finished) => Ok(NodeLp::Solved),
799 Ok(StopReason::Limit) => Ok(NodeLp::Limit),
800 Err(Error::Infeasible) => Ok(NodeLp::Infeasible),
801 Err(Error::Unbounded) => Err(Error::InternalError(
802 "bounded B&B node reported unbounded".to_string(),
803 )),
804 Err(e) => Err(e),
806 }
807}
808
809fn pop_node(state: &mut MipState) -> Option<Node> {
814 if state.open.is_empty() {
815 return None;
816 }
817 if state.diving {
818 state.open.pop()
819 } else {
820 let mut best = 0;
821 for (i, n) in state.open.iter().enumerate() {
822 if n.lp_bound < state.open[best].lp_bound {
823 best = i;
824 }
825 }
826 Some(state.open.swap_remove(best))
827 }
828}
829
830fn try_warm_start(
842 state: &mut MipState,
843 hints: &[(crate::Variable, f64)],
844) -> Result<Option<TerminationReason>, Error> {
845 let domains = state.solver.orig_var_domains.clone();
846 let root_basis = state.solver.snapshot_basis();
847 let mut applied: Vec<usize> = Vec::new();
848 let mut ok = true;
849 let mut pending_error = None;
850
851 for &(var, val) in hints {
852 let v = var.idx();
853 if v >= state.solver.num_vars || !val.is_finite() {
854 ok = false;
855 break;
856 }
857 let val = if matches!(
858 domains.get(v),
859 Some(VarDomain::Integer | VarDomain::Boolean)
860 ) {
861 val.round()
862 } else {
863 val
864 };
865 let (lo, hi) = state.root_bounds[v];
866 if val < lo - params::HINT_BOUNDS_SLACK || val > hi + params::HINT_BOUNDS_SLACK {
867 ok = false;
868 break;
869 }
870 state
871 .solver
872 .set_var_bounds(v, val, val)
873 .expect("fixing to [val, val] cannot cross");
874 applied.push(v);
875 }
876
877 if ok {
878 match state.solver.reoptimize() {
879 Ok(StopReason::Finished) => {
880 if branching::is_integral(&state.solver, &domains, state.options.int_tol) {
881 match try_adopt_incumbent(state) {
886 Ok(true) => {}
887 Ok(false) => {
888 debug!("warm-start hint rejected by feasibility guard; ignored");
889 }
890 Err(error) => pending_error = Some(error),
891 }
892 } else {
893 debug!("warm-start hint LP-completed fractionally; ignored");
894 }
895 }
896 Ok(StopReason::Limit) | Err(Error::Infeasible) => {
897 debug!("warm-start hint infeasible or out of time; ignored");
898 }
899 Err(error) => pending_error = Some(error),
900 }
901 } else {
902 debug!(
903 "warm-start hint invalid (unknown variable, non-finite value, or out of bounds); ignored"
904 );
905 }
906
907 for v in applied {
910 let (lo, hi) = state.root_bounds[v];
911 state
912 .solver
913 .set_var_bounds(v, lo, hi)
914 .expect("root bounds cannot cross");
915 }
916 if state.solver.load_basis(&root_basis).is_err() {
917 let slack = state.solver.slack_basis();
918 state
919 .solver
920 .load_basis(&slack)
921 .map_err(|e| Error::InternalError(format!("slack basis load failed: {}", e)))?;
922 if state.solver.reoptimize()? == StopReason::Limit {
923 state.root_solved = false;
928 if let Some(error) = pending_error {
929 return Err(error);
930 }
931 return Ok(Some(TerminationReason::TimeLimit));
932 }
933 }
934 if let Some(error) = pending_error {
935 return Err(error);
936 }
937 Ok(None)
938}
939
940fn initialize_root(
944 state: &mut MipState,
945 domains: &[VarDomain],
946) -> Result<Option<TerminationReason>, Error> {
947 if state.root_solved {
948 return Ok(None);
949 }
950
951 if state.solver.initial_solve()? == StopReason::Limit {
952 return Ok(Some(TerminationReason::TimeLimit));
953 }
954 state.root_solved = true;
955
956 if let Some(hints) = state.options.warm_start.take() {
957 if let Some(outcome) = try_warm_start(state, &hints)? {
958 return Ok(Some(outcome));
959 }
960 }
961
962 let root = Node {
963 bound_changes: Vec::new(),
964 basis: state.solver.snapshot_basis(),
965 lp_bound: state.solver.cur_obj_val,
966 depth: 0,
967 parent_id: 0,
968 branch_var: None,
969 branch_up: false,
970 branch_frac: 1.0,
971 };
972 let int_tol = state.options.int_tol;
973 if branching::is_integral(&state.solver, domains, int_tol) {
974 match process_integral_candidate(state, domains, int_tol)? {
975 IntegralCandidate::Branch(var) => branch(state, &root, var),
976 IntegralCandidate::Closed => {
977 return Ok(Some(TerminationReason::ProvenOptimal));
978 }
979 IntegralCandidate::Limit => {
980 state.root_solved = false;
981 return Ok(Some(TerminationReason::TimeLimit));
982 }
983 }
984 } else {
985 match branching::choose_branch_var(&state.solver, domains, int_tol, &state.pseudocosts) {
986 Some(var) => branch(state, &root, var),
987 None => return Err(Error::Infeasible),
990 }
991 }
992
993 Ok(None)
994}
995
996enum NodeVisit {
997 Pruned,
999 Solved,
1001 Interrupted { node: Node, lp_solved: bool },
1004}
1005
1006fn visit_node(state: &mut MipState, node: Node, domains: &[VarDomain]) -> Result<NodeVisit, Error> {
1008 if !apply_node_bounds(state, &node) {
1009 state.diving = false;
1010 return Ok(NodeVisit::Pruned);
1011 }
1012
1013 let warm = state.last_solved_id == Some(node.parent_id);
1014 if !warm && state.solver.load_basis(&node.basis).is_err() {
1015 debug!("basis load failed; falling back to slack basis");
1016 let slack = state.solver.slack_basis();
1017 state
1018 .solver
1019 .load_basis(&slack)
1020 .map_err(|e| Error::InternalError(format!("slack basis load failed: {}", e)))?;
1021 }
1022
1023 match solve_node_lp(state)? {
1024 NodeLp::Solved => {}
1025 NodeLp::Infeasible => {
1026 state.last_solved_id = None;
1027 state.diving = false;
1028 return Ok(NodeVisit::Solved);
1029 }
1030 NodeLp::Limit => {
1031 return Ok(NodeVisit::Interrupted {
1032 node,
1033 lp_solved: false,
1034 })
1035 }
1036 }
1037
1038 let objective = state.solver.cur_obj_val;
1039 if let Some(var) = node.branch_var {
1040 state.pseudocosts.record(
1041 var,
1042 node.branch_up,
1043 (objective - node.lp_bound).max(0.0) / node.branch_frac.max(params::BRANCH_FRAC_GUARD),
1044 );
1045 }
1046 if let Some(incumbent) = &state.incumbent {
1047 if objective >= cutoff(incumbent.objective, state.options.tolerances.prune_epsilon) {
1048 state.last_solved_id = None;
1049 state.diving = false;
1050 return Ok(NodeVisit::Solved);
1051 }
1052 }
1053
1054 let int_tol = state.options.int_tol;
1055 if branching::is_integral(&state.solver, domains, int_tol) {
1056 match process_integral_candidate(state, domains, int_tol)? {
1057 IntegralCandidate::Branch(var) => branch(state, &node, var),
1058 IntegralCandidate::Closed => {
1059 state.last_solved_id = None;
1060 state.diving = false;
1061 }
1062 IntegralCandidate::Limit => {
1063 return Ok(NodeVisit::Interrupted {
1064 node,
1065 lp_solved: true,
1066 })
1067 }
1068 }
1069 return Ok(NodeVisit::Solved);
1070 }
1071
1072 match branching::choose_branch_var(&state.solver, domains, int_tol, &state.pseudocosts) {
1073 Some(var) => branch(state, &node, var),
1074 None => {
1075 state.last_solved_id = None;
1076 state.diving = false;
1077 }
1078 }
1079 Ok(NodeVisit::Solved)
1080}
1081
1082fn search_loop(state: &mut MipState) -> Result<TerminationReason, Error> {
1083 let domains = state.solver.orig_var_domains.clone();
1084 state.solver.deadline = state.deadline;
1085
1086 if let Some(outcome) = initialize_root(state, &domains)? {
1087 return Ok(outcome);
1088 }
1089
1090 let mut nodes_this_run: u64 = 0;
1091
1092 loop {
1093 if state.open.is_empty() {
1100 break;
1101 }
1102
1103 if state.options.mip_gap > 0.0 {
1107 if let (Some(inc), Some(bound)) = (&state.incumbent, global_bound_internal(state)) {
1108 if bound >= inc.objective {
1113 state.open.clear();
1114 break;
1115 }
1116 if relative_gap(inc.objective, bound) <= state.options.mip_gap {
1117 return Ok(TerminationReason::MipGap);
1118 }
1119 }
1120 }
1121
1122 if check_deadline(&state.deadline) == StopReason::Limit {
1124 return Ok(TerminationReason::TimeLimit);
1125 }
1126 let node = match pop_node(state) {
1127 Some(n) => n,
1128 None => break,
1129 };
1130
1131 if let Some(inc) = &state.incumbent {
1133 if node.lp_bound >= cutoff(inc.objective, state.options.tolerances.prune_epsilon) {
1134 state.diving = false;
1135 continue;
1136 }
1137 }
1138
1139 if let Some(nl) = state.options.node_limit {
1144 if nodes_this_run >= nl {
1145 state.open.push(node);
1146 state.diving = false;
1147 return Ok(TerminationReason::NodeLimit);
1148 }
1149 }
1150
1151 match visit_node(state, node, &domains)? {
1152 NodeVisit::Pruned => continue,
1153 NodeVisit::Solved => {
1154 state.stats.nodes_solved += 1;
1155 nodes_this_run += 1;
1156 }
1157 NodeVisit::Interrupted { node, lp_solved } => {
1158 if lp_solved {
1159 state.stats.nodes_solved += 1;
1160 }
1161 state.open.push(node);
1162 state.last_solved_id = None;
1163 state.diving = false;
1164 return Ok(TerminationReason::TimeLimit);
1165 }
1166 }
1167 }
1168
1169 if state.incumbent.is_some() {
1170 Ok(TerminationReason::ProvenOptimal)
1171 } else {
1172 Err(Error::Infeasible)
1173 }
1174}
1175
1176#[cfg(test)]
1177mod tests {
1178 use super::*;
1179 use crate::{ComparisonOp, OptimizationDirection, Problem};
1180
1181 fn int_2var_problem() -> Problem {
1182 let mut p = Problem::new(OptimizationDirection::Minimize);
1185 let a = p.add_integer_var(3.0, (0, 10));
1186 let b = p.add_integer_var(4.0, (0, 10));
1187 p.add_constraint(&[(a, 1.0), (b, 2.0)], ComparisonOp::Ge, 5.0);
1188 p.add_constraint(&[(a, 3.0), (b, 1.0)], ComparisonOp::Ge, 4.0);
1189 p
1190 }
1191
1192 fn binary_knapsack() -> Problem {
1193 let mut p = Problem::new(OptimizationDirection::Maximize);
1196 let x = p.add_binary_var(8.0);
1197 let y = p.add_binary_var(11.0);
1198 let z = p.add_binary_var(6.0);
1199 let w = p.add_binary_var(4.0);
1200 p.add_constraint(
1201 &[(x, 5.0), (y, 7.0), (z, 4.0), (w, 3.0)],
1202 ComparisonOp::Le,
1203 14.0,
1204 );
1205 p
1206 }
1207
1208 fn incumbent_obj(state: &MipState) -> f64 {
1209 state.incumbent.as_ref().unwrap().objective
1210 }
1211
1212 #[test]
1213 fn driver_finds_integer_optimum() {
1214 let run = run(&int_2var_problem(), SolveOptions::default()).unwrap();
1215 assert_eq!(run.reason, TerminationReason::ProvenOptimal);
1216 assert!((incumbent_obj(&run.state) - 11.0).abs() < 1e-6);
1218 let inc = run.state.incumbent.as_ref().unwrap();
1219 assert!((inc.values[0] - 1.0).abs() < 1e-6);
1220 assert!((inc.values[1] - 2.0).abs() < 1e-6);
1221 assert!(run.state.stats.nodes_solved > 0);
1222 }
1223
1224 #[test]
1225 fn driver_binary_knapsack_maximize() {
1226 let run = run(&binary_knapsack(), SolveOptions::default()).unwrap();
1227 assert_eq!(run.reason, TerminationReason::ProvenOptimal);
1228 assert!((incumbent_obj(&run.state) + 21.0).abs() < 1e-6);
1230 }
1231
1232 #[test]
1233 fn driver_no_integer_point_is_infeasible() {
1234 let mut p = Problem::new(OptimizationDirection::Minimize);
1236 let x = p.add_integer_var(1.0, (0, 10));
1237 p.add_constraint(&[(x, 2.0)], ComparisonOp::Eq, 1.0);
1238 assert_eq!(
1239 run(&p, SolveOptions::default()).unwrap_err(),
1240 crate::Error::Infeasible
1241 );
1242 }
1243
1244 #[test]
1245 fn driver_exact_node_exhaustion_reports_infeasible_not_interrupted() {
1246 let mut p = Problem::new(OptimizationDirection::Minimize);
1252 let x = p.add_integer_var(1.0, (0, 10));
1253 p.add_constraint(&[(x, 2.0)], ComparisonOp::Eq, 1.0);
1254 let mut options = SolveOptions::default();
1255 options.node_limit = Some(2);
1256 assert_eq!(run(&p, options).unwrap_err(), crate::Error::Infeasible);
1257 }
1258
1259 #[test]
1260 fn driver_node_limit_equal_to_exhaustion_count_reports_optimal() {
1261 assert_eq!(
1268 run(&int_2var_problem(), SolveOptions::default())
1269 .unwrap()
1270 .state
1271 .stats
1272 .nodes_solved,
1273 2,
1274 "node count must stay deterministic; update the limit below if it changes"
1275 );
1276 let mut options = SolveOptions::default();
1277 options.node_limit = Some(2);
1278 let r = run(&int_2var_problem(), options).unwrap();
1279 assert_eq!(r.reason, TerminationReason::ProvenOptimal);
1280 assert!((incumbent_obj(&r.state) - 11.0).abs() < 1e-6);
1281 }
1282
1283 #[test]
1284 fn driver_node_limit_interrupts_and_resumes_to_same_optimum() {
1285 let mut options = SolveOptions::default();
1286 options.node_limit = Some(1);
1287 let mut r = run(&int_2var_problem(), options).unwrap();
1288 let mut guard = 0;
1289 while r.reason != TerminationReason::ProvenOptimal {
1290 guard += 1;
1291 assert!(guard < 10_000, "resume loop did not terminate");
1292 r.reason = resume_run(&mut r.state, ResumeOptions::default()).unwrap();
1293 }
1294 assert!(guard >= 1, "node_limit=1 should interrupt at least once");
1295 assert!((incumbent_obj(&r.state) - 11.0).abs() < 1e-6);
1296 }
1297
1298 #[test]
1299 fn open_children_have_branch_metadata() {
1300 let mut options = SolveOptions::default();
1301 options.node_limit = Some(0);
1302 let run = run(&int_2var_problem(), options).unwrap();
1303
1304 assert_eq!(run.reason, TerminationReason::NodeLimit);
1305 assert_eq!(run.state.open.len(), 2);
1306 assert!(run.state.open.iter().all(|node| node.branch_var.is_some()));
1307 }
1308
1309 #[test]
1310 fn driver_zero_time_limit_interrupts_cleanly_then_resumes() {
1311 let mut options = SolveOptions::default();
1312 options.time_limit = Some(Duration::ZERO);
1313 let mut r = run(&binary_knapsack(), options).unwrap();
1314 assert_eq!(r.reason, TerminationReason::TimeLimit);
1315 assert!(r.state.incumbent.is_none());
1316 let resume_options = ResumeOptions {
1317 time_limit: Some(Duration::from_secs(10)),
1318 ..ResumeOptions::default()
1319 };
1320 let reason = resume_run(&mut r.state, resume_options).unwrap();
1321 assert_eq!(reason, TerminationReason::ProvenOptimal);
1322 assert!((incumbent_obj(&r.state) + 21.0).abs() < 1e-6);
1323 }
1324
1325 #[test]
1326 fn optimal_solve_reports_zero_gap_and_matching_bound() {
1327 let r = run(&int_2var_problem(), SolveOptions::default()).unwrap();
1328 assert_eq!(r.reason, TerminationReason::ProvenOptimal);
1329 assert_eq!(r.state.stats.gap, Some(0.0));
1330 assert!((r.state.stats.best_bound.unwrap() - 11.0).abs() < 1e-6);
1332 }
1333
1334 #[test]
1335 fn unsolved_root_with_incumbent_has_no_proven_bound_or_gap() {
1336 let problem = binary_knapsack();
1337 let mut state = build_state(&problem, SolveOptions::default()).unwrap();
1338 state.incumbent = Some(Incumbent {
1339 values: vec![0.0; problem.obj_coeffs.len()],
1340 objective: 0.0,
1341 });
1342 state.root_solved = false;
1343 state.open.clear();
1344
1345 fill_bound_stats(&mut state);
1346
1347 assert_eq!(state.stats.best_bound, None);
1348 assert_eq!(state.stats.gap, None);
1349 }
1350
1351 #[test]
1352 fn maximize_bound_is_in_user_space() {
1353 let r = run(&binary_knapsack(), SolveOptions::default()).unwrap();
1354 assert_eq!(r.reason, TerminationReason::ProvenOptimal);
1355 assert!((r.state.stats.best_bound.unwrap() - 21.0).abs() < 1e-6);
1357 }
1358
1359 #[test]
1360 fn mip_gap_stops_early_with_consistent_bound() {
1361 let mut options = SolveOptions::default();
1362 options.mip_gap = 0.5;
1363 let r = run(&binary_knapsack(), options).unwrap();
1364 assert_eq!(r.reason, TerminationReason::MipGap);
1365 let inc = -incumbent_obj(&r.state); let bound = r.state.stats.best_bound.unwrap();
1367 assert!(inc <= bound + 1e-9);
1369 assert!((bound - inc) / bound.abs().max(1e-10) <= 0.5 + 1e-9);
1370 }
1371
1372 #[test]
1373 fn feasible_interrupt_reports_gap() {
1374 let mut options = SolveOptions::default();
1375 options.node_limit = Some(2);
1376 let mut r = run(&binary_knapsack(), options).unwrap();
1377 let mut guard = 0;
1379 while r.reason == TerminationReason::NodeLimit && r.state.incumbent.is_none() {
1380 guard += 1;
1381 assert!(guard < 10_000);
1382 r.reason = resume_run(
1383 &mut r.state,
1384 ResumeOptions {
1385 node_limit: Some(2),
1386 ..ResumeOptions::default()
1387 },
1388 )
1389 .unwrap();
1390 }
1391 if r.reason == TerminationReason::NodeLimit {
1392 assert!(r.state.stats.gap.unwrap() >= 0.0);
1394 assert!(r.state.stats.best_bound.is_some());
1395 }
1396 }
1397
1398 #[test]
1399 fn plunge_and_jump_selection_preserves_optima() {
1400 let r = run(&int_2var_problem(), SolveOptions::default()).unwrap();
1402 assert!((incumbent_obj(&r.state) - 11.0).abs() < 1e-6);
1403
1404 let r = run(&binary_knapsack(), SolveOptions::default()).unwrap();
1405 assert!((incumbent_obj(&r.state) + 21.0).abs() < 1e-6);
1406
1407 let mut options = SolveOptions::default();
1408 options.node_limit = Some(1);
1409 let mut r = run(&binary_knapsack(), options).unwrap();
1410 let mut guard = 0;
1411 while r.reason != TerminationReason::ProvenOptimal {
1412 guard += 1;
1413 assert!(guard < 10_000);
1414 r.reason = resume_run(&mut r.state, ResumeOptions::default()).unwrap();
1415 }
1416 assert!((incumbent_obj(&r.state) + 21.0).abs() < 1e-6);
1417 }
1420
1421 #[test]
1422 fn tolerances_default_matches_documented_values() {
1423 let t = Tolerances::default();
1424 assert_eq!(
1425 t.feasibility, 1e-7,
1426 "see Tolerances::feasibility's doc default"
1427 );
1428 assert_eq!(
1429 t.integrality_rounding, 1e-5,
1430 "see Tolerances::integrality_rounding's doc default"
1431 );
1432 assert_eq!(
1433 t.prune_epsilon, 1e-9,
1434 "see Tolerances::prune_epsilon's doc default"
1435 );
1436 }
1437
1438 #[test]
1439 fn try_adopt_incumbent_respects_custom_feasibility_tolerance() {
1440 let m = 1.0e9;
1448 let mut p = Problem::new(OptimizationDirection::Minimize);
1449 let x = p.add_var(1.0, (0.0, f64::INFINITY));
1450 let b = p.add_binary_var(0.0);
1451 p.add_constraint(&[(x, 1.0), (b, -m)], ComparisonOp::Eq, 10.0);
1452
1453 let mut solved = run(&p, SolveOptions::default()).unwrap();
1454 let state = &mut solved.state;
1455
1456 state.solver.set_var_bounds(b.idx(), 5e-7, 5e-7).unwrap();
1461 assert_eq!(
1462 state.solver.reoptimize().unwrap(),
1463 crate::StopReason::Finished
1464 );
1465 assert!((*state.solver.get_value(x.idx()) - 510.0).abs() < 1e-6);
1466
1467 state.options.tolerances.feasibility = Tolerances::default().feasibility;
1470 assert!(
1471 !try_adopt_incumbent(state).unwrap(),
1472 "a 500-unit rounding-induced violation must be rejected at the default feasibility tolerance"
1473 );
1474
1475 state.options.tolerances.feasibility = 1e6;
1478 assert!(
1479 try_adopt_incumbent(state).unwrap(),
1480 "the same violation must be accepted once tolerances.feasibility is loosened past it"
1481 );
1482 }
1483
1484 #[test]
1485 fn incumbent_feasible_row_tolerance_is_absolute_not_relative_to_rhs() {
1486 let mut p = Problem::new(OptimizationDirection::Minimize);
1489 let x = p.add_var(1.0, (0.0, f64::INFINITY));
1490 p.add_constraint(&[(x, 1.0)], ComparisonOp::Le, 1000.0);
1491 let fixed = std::collections::BTreeMap::new();
1492 let tolerances = Tolerances::default();
1493
1494 assert!(incumbent_feasible(
1496 &p,
1497 &fixed,
1498 &[1000.0 + 5e-8],
1499 &tolerances
1500 ));
1501
1502 assert!(!incumbent_feasible(
1505 &p,
1506 &fixed,
1507 &[1000.0 + 5e-5],
1508 &tolerances
1509 ));
1510 }
1511
1512 #[test]
1513 fn candidate_validation_rejects_non_finite_and_malformed_values() {
1514 let mut problem = Problem::new(OptimizationDirection::Minimize);
1515 problem.add_integer_var(1.0, (0, 10));
1516 let fixed = BTreeMap::new();
1517 let tolerances = Tolerances::default();
1518
1519 assert!(!incumbent_feasible(&problem, &fixed, &[], &tolerances));
1520 assert!(!incumbent_feasible(
1521 &problem,
1522 &fixed,
1523 &[f64::NAN],
1524 &tolerances
1525 ));
1526 assert!(!incumbent_feasible(
1527 &problem,
1528 &fixed,
1529 &[f64::INFINITY],
1530 &tolerances
1531 ));
1532
1533 let mut overflowing_row = Problem::new(OptimizationDirection::Minimize);
1534 let x = overflowing_row.add_var(0.0, (0.0, f64::INFINITY));
1535 overflowing_row.add_constraint(&[(x, 1.0e308)], ComparisonOp::Le, 1.0e308);
1536 assert!(!incumbent_feasible(
1537 &overflowing_row,
1538 &fixed,
1539 &[1.0e308],
1540 &tolerances
1541 ));
1542 }
1543
1544 #[test]
1545 fn valid_candidate_completes_unbounded_classification() {
1546 let mut problem = Problem::new(OptimizationDirection::Minimize);
1547 problem.add_integer_var(1.0, (0, 10));
1548 let mut state = build_state(&problem, SolveOptions::default()).unwrap();
1549 assert_eq!(state.solver.initial_solve().unwrap(), StopReason::Finished);
1550 state.classifying_unbounded = true;
1551
1552 assert_eq!(try_adopt_incumbent(&mut state), Err(Error::Unbounded));
1553 }
1554
1555 #[test]
1556 fn warm_start_restores_bounds_before_unbounded_verdict() {
1557 let mut problem = Problem::new(OptimizationDirection::Minimize);
1558 let x = problem.add_integer_var(0.0, (0, 10));
1559 let mut state = build_state(&problem, SolveOptions::default()).unwrap();
1560 assert_eq!(state.solver.initial_solve().unwrap(), StopReason::Finished);
1561 state.classifying_unbounded = true;
1562
1563 assert_eq!(
1564 try_warm_start(&mut state, &[(x, 5.0)]),
1565 Err(Error::Unbounded)
1566 );
1567 assert_eq!(state.solver.get_var_bounds(x.idx()), (0.0, 10.0));
1568 }
1569}