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 Status {
20 Optimal,
22 Feasible,
24 Interrupted,
31}
32
33#[derive(Clone, Debug)]
36#[non_exhaustive]
37pub struct SolveOptions {
38 pub time_limit: Option<Duration>,
41 pub node_limit: Option<u64>,
46 pub mip_gap: f64,
49 pub int_tol: f64,
57 pub warm_start: Option<Vec<(Variable, f64)>>,
60 pub tolerances: Tolerances,
65}
66
67impl Default for SolveOptions {
68 fn default() -> Self {
69 Self {
70 time_limit: None,
71 node_limit: None,
72 mip_gap: 0.0,
73 int_tol: 1e-6,
74 warm_start: None,
75 tolerances: Tolerances::default(),
76 }
77 }
78}
79
80impl SolveOptions {
81 pub(crate) fn validate(&self) -> Result<(), Error> {
82 if !self.mip_gap.is_finite() || self.mip_gap < 0.0 {
83 return Err(Error::InvalidOptions(
84 "invalid SolveOptions.mip_gap: expected a finite non-negative value".to_string(),
85 ));
86 }
87 if !self.int_tol.is_finite() || !(0.0..0.5).contains(&self.int_tol) {
88 return Err(Error::InvalidOptions(
89 "invalid SolveOptions.int_tol: expected a finite value in [0, 0.5)".to_string(),
90 ));
91 }
92 if !self.tolerances.feasibility.is_finite() || self.tolerances.feasibility < 0.0 {
93 return Err(Error::InvalidOptions(
94 "invalid SolveOptions.tolerances.feasibility: expected a finite non-negative value"
95 .to_string(),
96 ));
97 }
98 if !self.tolerances.integrality_rounding.is_finite()
99 || !(0.0..0.5).contains(&self.tolerances.integrality_rounding)
100 {
101 return Err(Error::InvalidOptions(
102 "invalid SolveOptions.tolerances.integrality_rounding: expected a finite value in [0, 0.5)"
103 .to_string(),
104 ));
105 }
106 if !self.tolerances.prune_epsilon.is_finite() || self.tolerances.prune_epsilon < 0.0 {
107 return Err(Error::InvalidOptions(
108 "invalid SolveOptions.tolerances.prune_epsilon: expected a finite non-negative value"
109 .to_string(),
110 ));
111 }
112 Ok(())
113 }
114}
115
116#[derive(Clone, Copy, Debug)]
130#[non_exhaustive]
131pub struct Tolerances {
132 pub feasibility: f64,
151 pub integrality_rounding: f64,
166 pub prune_epsilon: f64,
175}
176
177impl Default for Tolerances {
178 fn default() -> Self {
179 Self {
180 feasibility: 1e-7,
181 integrality_rounding: 1e-5,
182 prune_epsilon: 1e-9,
183 }
184 }
185}
186
187#[derive(Clone, Copy, Debug, Default)]
189#[non_exhaustive]
190pub struct Stats {
191 pub nodes_solved: u64,
193 pub lp_iterations: u64,
195 pub elapsed: Duration,
197 pub best_bound: Option<f64>,
200 pub gap: Option<f64>,
203}
204
205#[derive(Clone, Debug)]
207pub(crate) struct Incumbent {
208 pub values: Vec<f64>,
210 pub objective: f64,
211}
212
213#[derive(Clone)]
215pub(crate) struct MipState {
216 pub solver: Solver,
217 pub root_bounds: Vec<(f64, f64)>,
219 pub applied: Vec<(usize, f64, f64)>,
221 pub open: Vec<Node>,
222 pub diving: bool,
228 pub incumbent: Option<Incumbent>,
229 pub node_seq: u64,
231 pub last_solved_id: Option<u64>,
234 pub root_solved: bool,
235 pub stats: Stats,
236 pub options: SolveOptions,
237 pub deadline: Deadline,
238 pub direction: OptimizationDirection,
240 pub pseudocosts: branching::PseudoCosts,
243 pub base: Problem,
246 pub fixed: BTreeMap<usize, f64>,
248 pub classifying_unbounded: bool,
252}
253
254impl std::fmt::Debug for MipState {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 f.debug_struct("MipState")
257 .field("open_nodes", &self.open.len())
258 .field("has_incumbent", &self.incumbent.is_some())
259 .field("stats", &self.stats)
260 .field("diving", &self.diving)
261 .field(
262 "pseudocost_observations",
263 &self.pseudocosts.observation_count(),
264 )
265 .field("classifying_unbounded", &self.classifying_unbounded)
266 .finish()
267 }
268}
269
270impl MipState {
271 pub(crate) fn current_objective(&self) -> f64 {
275 if let Some(incumbent) = &self.incumbent {
276 return incumbent.objective;
277 }
278 self.base
279 .obj_coeffs
280 .iter()
281 .enumerate()
282 .map(|(v, &coefficient)| coefficient * self.solver.get_value(v))
283 .sum()
284 }
285}
286
287#[derive(Clone, Copy, Debug, PartialEq, Eq)]
288pub(crate) enum MipOutcome {
289 Optimal,
290 Interrupted,
291}
292
293#[derive(Debug)]
294pub(crate) struct MipRun {
295 pub outcome: MipOutcome,
296 pub state: MipState,
297}
298
299pub(crate) fn status_of(outcome: MipOutcome, state: &MipState) -> Status {
300 match outcome {
301 MipOutcome::Optimal => Status::Optimal,
302 MipOutcome::Interrupted => {
303 if state.classifying_unbounded {
304 Status::Interrupted
305 } else if state.incumbent.is_some() {
306 Status::Feasible
307 } else {
308 Status::Interrupted
309 }
310 }
311 }
312}
313
314fn build_state(problem: &Problem, options: SolveOptions) -> Result<MipState, Error> {
315 let deadline = options.time_limit.map(|d| Instant::now() + d);
316 let solver = problem.build_solver(deadline)?;
317 let root_bounds = problem
318 .var_mins
319 .iter()
320 .zip(&problem.var_maxs)
321 .map(|(&lo, &hi)| (lo, hi))
322 .collect();
323 let pseudocosts = branching::PseudoCosts::new(&problem.obj_coeffs, problem.obj_coeffs.len());
324 Ok(MipState {
325 solver,
326 root_bounds,
327 applied: Vec::new(),
328 open: Vec::new(),
329 diving: false,
330 incumbent: None,
331 node_seq: 0,
332 last_solved_id: None,
333 root_solved: false,
334 stats: Stats::default(),
335 options,
336 deadline,
337 direction: problem.direction,
338 pseudocosts,
339 base: problem.clone(),
340 fixed: BTreeMap::new(),
341 classifying_unbounded: false,
342 })
343}
344
345fn begin_unbounded_classification(state: &mut MipState) -> Result<(), Error> {
350 let base = state.base.clone();
351 let fixed = state.fixed.clone();
352 let mut feasibility = effective_problem(&base, &fixed);
353 feasibility.obj_coeffs.fill(0.0);
354
355 let deadline = state.deadline;
356 let elapsed = state.stats.elapsed;
357 let lp_iterations = state.solver.lp_iterations;
358 let mut replacement = build_state(&feasibility, state.options.clone())?;
359 replacement.deadline = deadline;
360 replacement.solver.deadline = deadline;
361 replacement.solver.lp_iterations = lp_iterations;
362 replacement.stats.elapsed = elapsed;
363 replacement.base = base;
364 replacement.fixed = fixed;
365 replacement.classifying_unbounded = true;
366 *state = replacement;
367 Ok(())
368}
369
370fn resume_or_classify(state: &mut MipState) -> Result<MipOutcome, Error> {
371 match resume_run_with_deadline(state) {
372 Err(Error::Unbounded) if !state.classifying_unbounded => {
373 begin_unbounded_classification(state)?;
374 resume_run_with_deadline(state)
375 }
376 result => result,
377 }
378}
379
380pub(crate) fn run(problem: &Problem, options: SolveOptions) -> Result<MipRun, Error> {
382 let mut state = build_state(problem, options)?;
383 let outcome = resume_or_classify(&mut state)?;
384 Ok(MipRun { outcome, state })
385}
386
387pub(crate) fn effective_problem(base: &Problem, fixed: &BTreeMap<usize, f64>) -> Problem {
389 let mut p = base.clone();
390 for (&v, &val) in fixed {
391 p.var_mins[v] = val;
392 p.var_maxs[v] = val;
393 }
394 p
395}
396
397fn candidate_variables_feasible(
398 values: &[f64],
399 domains: &[VarDomain],
400 tolerances: &Tolerances,
401 mut bounds: impl FnMut(usize) -> (f64, f64),
402) -> bool {
403 if values.len() != domains.len() {
404 return false;
405 }
406 values.iter().enumerate().all(|(v, &value)| {
407 let (lo, hi) = bounds(v);
408 value.is_finite()
409 && !lo.is_nan()
410 && !hi.is_nan()
411 && lo <= hi
412 && value >= lo - tolerances.feasibility
413 && value <= hi + tolerances.feasibility
414 && (!matches!(domains[v], VarDomain::Integer | VarDomain::Boolean)
415 || (value - value.round()).abs() <= tolerances.integrality_rounding)
416 })
417}
418
419pub(crate) fn incumbent_feasible(
423 base: &Problem,
424 fixed: &BTreeMap<usize, f64>,
425 values: &[f64],
426 tolerances: &Tolerances,
427) -> bool {
428 if !candidate_variables_feasible(values, &base.var_domains, tolerances, |v| {
429 fixed
430 .get(&v)
431 .map_or((base.var_mins[v], base.var_maxs[v]), |&value| {
432 (value, value)
433 })
434 }) {
435 return false;
436 }
437 for (coeffs, op, rhs) in &base.constraints {
438 let lhs: f64 = coeffs.iter().map(|(i, c)| c * values[i]).sum();
439 if !lhs.is_finite() {
440 return false;
441 }
442 let tol = tolerances.feasibility;
443 let ok = match op {
444 ComparisonOp::Eq => (lhs - rhs).abs() <= tol,
445 ComparisonOp::Le => lhs <= rhs + tol,
446 ComparisonOp::Ge => lhs >= *rhs - tol,
447 };
448 if !ok {
449 return false;
450 }
451 }
452 true
453}
454
455pub(crate) fn reedit_and_resolve(state: Box<MipState>) -> Result<MipRun, Error> {
459 let MipState {
460 base,
461 fixed,
462 incumbent,
463 mut options,
464 ..
465 } = *state;
466
467 options.warm_start = incumbent
468 .filter(|inc| incumbent_feasible(&base, &fixed, &inc.values, &options.tolerances))
469 .map(|inc| {
470 inc.values
471 .iter()
472 .enumerate()
473 .map(|(v, &val)| (Variable(v), val))
474 .collect()
475 });
476
477 let effective = effective_problem(&base, &fixed);
478 let mut run = run(&effective, options)?;
479 run.state.base = base;
482 run.state.fixed = fixed;
483 Ok(run)
484}
485
486pub(crate) fn resume_run(
488 state: &mut MipState,
489 time_limit: Option<Duration>,
490) -> Result<MipOutcome, Error> {
491 state.deadline = time_limit.map(|d| Instant::now() + d);
492 resume_or_classify(state)
493}
494
495fn resume_run_with_deadline(state: &mut MipState) -> Result<MipOutcome, Error> {
496 let started = Instant::now();
497 let res = search_loop(state);
498 state.stats.elapsed += started.elapsed();
499 state.stats.lp_iterations = state.solver.lp_iterations;
500 fill_bound_stats(state);
501 res
502}
503
504fn global_bound_internal(state: &MipState) -> Option<f64> {
508 let open_min = state
509 .open
510 .iter()
511 .map(|n| n.lp_bound)
512 .fold(f64::INFINITY, f64::min);
513 match (&state.incumbent, state.open.is_empty()) {
514 (Some(inc), true) => Some(inc.objective), (Some(inc), false) => Some(open_min.min(inc.objective)),
516 (None, false) => Some(open_min),
517 (None, true) => None,
518 }
519}
520
521fn relative_gap(incumbent_obj: f64, bound: f64) -> f64 {
523 (incumbent_obj - bound).max(0.0) / incumbent_obj.abs().max(params::GAP_DENOM_GUARD)
524}
525
526fn to_user_space(direction: OptimizationDirection, internal: f64) -> f64 {
527 match direction {
528 OptimizationDirection::Minimize => internal,
529 OptimizationDirection::Maximize => -internal,
530 }
531}
532
533fn fill_bound_stats(state: &mut MipState) {
534 if state.classifying_unbounded {
535 state.stats.best_bound = None;
536 state.stats.gap = None;
537 return;
538 }
539 let bound = global_bound_internal(state);
540 state.stats.best_bound = bound.map(|b| to_user_space(state.direction, b));
541 state.stats.gap = match (&state.incumbent, bound) {
542 (Some(inc), Some(b)) => Some(relative_gap(inc.objective, b)),
543 _ => None,
544 };
545}
546
547fn cutoff(incumbent_obj: f64, prune_epsilon: f64) -> f64 {
549 incumbent_obj - f64::max(prune_epsilon, prune_epsilon * incumbent_obj.abs())
550}
551
552fn try_adopt_incumbent(state: &mut MipState) -> Result<bool, Error> {
556 let tolerances = &state.options.tolerances;
557 let solver = &state.solver;
558 let n = solver.num_vars;
559 let domains = &solver.orig_var_domains;
560 let mut values: Vec<f64> = (0..n).map(|v| *solver.get_value(v)).collect();
561 for (val, dom) in values.iter_mut().zip(domains.iter()) {
562 if matches!(dom, VarDomain::Integer | VarDomain::Boolean) {
563 *val = val.round();
564 }
565 }
566 if !candidate_variables_feasible(&values, domains, tolerances, |v| state.root_bounds[v])
567 || !solver.check_constraints(&values, tolerances.feasibility)
568 {
569 debug!("integral-within-tol solution rejected: rounded values infeasible");
570 return Ok(false);
571 }
572 let objective = solver.objective_of(&values);
573 if !objective.is_finite() {
574 debug!("integral-within-tol solution rejected: objective is non-finite");
575 return Ok(false);
576 }
577 let better = match &state.incumbent {
578 Some(inc) => objective < inc.objective,
579 None => true,
580 };
581 if better {
582 debug!("new incumbent, internal obj: {:.6}", objective);
583 state.incumbent = Some(Incumbent { values, objective });
584 }
585 if state.classifying_unbounded {
586 Err(Error::Unbounded)
587 } else {
588 Ok(true)
589 }
590}
591
592enum IntegralCandidate {
593 Closed,
594 Branch(usize),
595 Limit,
596}
597
598fn process_integral_candidate(
604 state: &mut MipState,
605 domains: &[VarDomain],
606 int_tol: f64,
607) -> Result<IntegralCandidate, Error> {
608 let adopted = try_adopt_incumbent(state)?;
609 if let Some(var) = branching::choose_branch_var(&state.solver, domains, 0.0, &state.pseudocosts)
610 {
611 return Ok(IntegralCandidate::Branch(var));
612 }
613 if adopted {
614 return Ok(IntegralCandidate::Closed);
615 }
616
617 debug!("exactly integral candidate failed guard; retrying from slack basis");
618 let slack = state.solver.slack_basis();
619 state
620 .solver
621 .load_basis(&slack)
622 .map_err(|e| Error::InternalError(format!("slack basis load failed: {}", e)))?;
623 match solve_node_lp(state)? {
624 NodeLp::Limit => return Ok(IntegralCandidate::Limit),
625 NodeLp::Infeasible => {
626 return Err(Error::InternalError(
627 "integral candidate became infeasible after slack-basis retry".to_string(),
628 ))
629 }
630 NodeLp::Solved => {}
631 }
632
633 if !branching::is_integral(&state.solver, domains, int_tol) {
634 return branching::choose_branch_var(&state.solver, domains, int_tol, &state.pseudocosts)
635 .map(IntegralCandidate::Branch)
636 .ok_or_else(|| {
637 Error::InternalError(
638 "slack-basis retry produced a non-integral point with no branchable variable"
639 .to_string(),
640 )
641 });
642 }
643
644 let adopted = try_adopt_incumbent(state)?;
645 if let Some(var) = branching::choose_branch_var(&state.solver, domains, 0.0, &state.pseudocosts)
646 {
647 Ok(IntegralCandidate::Branch(var))
648 } else if adopted {
649 Ok(IntegralCandidate::Closed)
650 } else {
651 Err(Error::InternalError(
652 "exactly integral solution failed feasibility validation after slack-basis retry"
653 .to_string(),
654 ))
655 }
656}
657
658fn apply_node_bounds(state: &mut MipState, node: &Node) -> bool {
661 let target = effective_bounds(&node.bound_changes);
662 if target.iter().any(|&(_, lo, hi)| lo > hi) {
663 return false;
664 }
665 for &(v, _, _) in &state.applied {
667 if target.binary_search_by_key(&v, |t| t.0).is_err() {
668 let (rlo, rhi) = state.root_bounds[v];
669 state
670 .solver
671 .set_var_bounds(v, rlo, rhi)
672 .expect("root bounds cannot cross");
673 }
674 }
675 for &(v, lo, hi) in &target {
677 state
678 .solver
679 .set_var_bounds(v, lo, hi)
680 .expect("validated bounds cannot cross");
681 }
682 state.applied = target;
683 true
684}
685
686fn branch(state: &mut MipState, parent: &Node, var: usize) {
689 let z = state.solver.cur_obj_val;
690 let val = *state.solver.get_value(var);
691 let (lo, hi) = state.solver.get_var_bounds(var);
692 let floor = {
703 let near = val.round();
704 let k = if (val - near).abs() <= state.options.int_tol {
705 near
706 } else {
707 val.floor()
708 };
709 k.clamp(lo, (hi - 1.0).max(lo))
710 };
711 let f_down = (val - floor).clamp(0.0, 1.0);
712
713 state.node_seq += 1;
714 let id = state.node_seq;
715 state.last_solved_id = Some(id);
716 let basis = state.solver.snapshot_basis();
717
718 let mut down_changes = parent.bound_changes.clone();
719 down_changes.push((var, lo, floor));
720 let mut up_changes = parent.bound_changes.clone();
721 up_changes.push((var, floor + 1.0, hi));
722
723 let down_node = Node {
724 bound_changes: down_changes,
725 basis: basis.clone(),
726 lp_bound: z,
727 depth: parent.depth + 1,
728 parent_id: id,
729 branch_var: Some(var),
730 branch_up: false,
731 branch_frac: f_down,
732 };
733 let up_node = Node {
734 bound_changes: up_changes,
735 basis,
736 lp_bound: z,
737 depth: parent.depth + 1,
738 parent_id: id,
739 branch_var: Some(var),
740 branch_up: true,
741 branch_frac: 1.0 - f_down,
742 };
743
744 let est_down = state.pseudocosts.estimate(var, false) * f_down;
747 let est_up = state.pseudocosts.estimate(var, true) * (1.0 - f_down);
748 if est_down > est_up {
749 state.open.push(down_node);
751 state.open.push(up_node);
752 } else {
753 state.open.push(up_node);
754 state.open.push(down_node);
755 }
756 state.diving = true;
758}
759
760enum NodeLp {
762 Solved,
764 Infeasible,
766 Limit,
769}
770
771fn solve_node_lp(state: &mut MipState) -> Result<NodeLp, Error> {
782 state.solver.deadline = state.deadline;
783 let err = match state.solver.reoptimize() {
784 Ok(StopReason::Finished) => return Ok(NodeLp::Solved),
785 Ok(StopReason::Limit) => return Ok(NodeLp::Limit),
786 Err(Error::Infeasible) => return Ok(NodeLp::Infeasible),
787 Err(Error::Unbounded) => {
788 return Err(Error::InternalError(
789 "bounded B&B node reported unbounded".to_string(),
790 ))
791 }
792 Err(e) => e,
794 };
795
796 debug!(
797 "node LP reoptimize failed ({}); retrying from slack basis",
798 err
799 );
800 let slack = state.solver.slack_basis();
801 state
802 .solver
803 .load_basis(&slack)
804 .map_err(|e| Error::InternalError(format!("slack basis load failed: {}", e)))?;
805 match state.solver.reoptimize() {
806 Ok(StopReason::Finished) => Ok(NodeLp::Solved),
807 Ok(StopReason::Limit) => Ok(NodeLp::Limit),
808 Err(Error::Infeasible) => Ok(NodeLp::Infeasible),
809 Err(Error::Unbounded) => Err(Error::InternalError(
810 "bounded B&B node reported unbounded".to_string(),
811 )),
812 Err(e) => Err(e),
814 }
815}
816
817fn pop_node(state: &mut MipState) -> Option<Node> {
822 if state.open.is_empty() {
823 return None;
824 }
825 if state.diving {
826 state.open.pop()
827 } else {
828 let mut best = 0;
829 for (i, n) in state.open.iter().enumerate() {
830 if n.lp_bound < state.open[best].lp_bound {
831 best = i;
832 }
833 }
834 Some(state.open.swap_remove(best))
835 }
836}
837
838fn try_warm_start(
850 state: &mut MipState,
851 hints: &[(crate::Variable, f64)],
852) -> Result<Option<MipOutcome>, Error> {
853 let domains = state.solver.orig_var_domains.clone();
854 let root_basis = state.solver.snapshot_basis();
855 let mut applied: Vec<usize> = Vec::new();
856 let mut ok = true;
857 let mut pending_error = None;
858
859 for &(var, val) in hints {
860 let v = var.idx();
861 if v >= state.solver.num_vars || !val.is_finite() {
862 ok = false;
863 break;
864 }
865 let val = if matches!(
866 domains.get(v),
867 Some(VarDomain::Integer | VarDomain::Boolean)
868 ) {
869 val.round()
870 } else {
871 val
872 };
873 let (lo, hi) = state.root_bounds[v];
874 if val < lo - params::HINT_BOUNDS_SLACK || val > hi + params::HINT_BOUNDS_SLACK {
875 ok = false;
876 break;
877 }
878 state
879 .solver
880 .set_var_bounds(v, val, val)
881 .expect("fixing to [val, val] cannot cross");
882 applied.push(v);
883 }
884
885 if ok {
886 match state.solver.reoptimize() {
887 Ok(StopReason::Finished) => {
888 if branching::is_integral(&state.solver, &domains, state.options.int_tol) {
889 match try_adopt_incumbent(state) {
894 Ok(true) => {}
895 Ok(false) => {
896 debug!("warm-start hint rejected by feasibility guard; ignored");
897 }
898 Err(error) => pending_error = Some(error),
899 }
900 } else {
901 debug!("warm-start hint LP-completed fractionally; ignored");
902 }
903 }
904 Ok(StopReason::Limit) | Err(Error::Infeasible) => {
905 debug!("warm-start hint infeasible or out of time; ignored");
906 }
907 Err(error) => pending_error = Some(error),
908 }
909 } else {
910 debug!(
911 "warm-start hint invalid (unknown variable, non-finite value, or out of bounds); ignored"
912 );
913 }
914
915 for v in applied {
918 let (lo, hi) = state.root_bounds[v];
919 state
920 .solver
921 .set_var_bounds(v, lo, hi)
922 .expect("root bounds cannot cross");
923 }
924 if state.solver.load_basis(&root_basis).is_err() {
925 let slack = state.solver.slack_basis();
926 state
927 .solver
928 .load_basis(&slack)
929 .map_err(|e| Error::InternalError(format!("slack basis load failed: {}", e)))?;
930 if state.solver.reoptimize()? == StopReason::Limit {
931 state.root_solved = false;
936 if let Some(error) = pending_error {
937 return Err(error);
938 }
939 return Ok(Some(MipOutcome::Interrupted));
940 }
941 }
942 if let Some(error) = pending_error {
943 return Err(error);
944 }
945 Ok(None)
946}
947
948fn initialize_root(
952 state: &mut MipState,
953 domains: &[VarDomain],
954) -> Result<Option<MipOutcome>, Error> {
955 if state.root_solved {
956 return Ok(None);
957 }
958
959 if state.solver.initial_solve()? == StopReason::Limit {
960 return Ok(Some(MipOutcome::Interrupted));
961 }
962 state.root_solved = true;
963
964 if let Some(hints) = state.options.warm_start.take() {
965 if let Some(outcome) = try_warm_start(state, &hints)? {
966 return Ok(Some(outcome));
967 }
968 }
969
970 let root = Node {
971 bound_changes: Vec::new(),
972 basis: state.solver.snapshot_basis(),
973 lp_bound: state.solver.cur_obj_val,
974 depth: 0,
975 parent_id: 0,
976 branch_var: None,
977 branch_up: false,
978 branch_frac: 1.0,
979 };
980 let int_tol = state.options.int_tol;
981 if branching::is_integral(&state.solver, domains, int_tol) {
982 match process_integral_candidate(state, domains, int_tol)? {
983 IntegralCandidate::Branch(var) => branch(state, &root, var),
984 IntegralCandidate::Closed => return Ok(Some(MipOutcome::Optimal)),
985 IntegralCandidate::Limit => {
986 state.root_solved = false;
987 return Ok(Some(MipOutcome::Interrupted));
988 }
989 }
990 } else {
991 match branching::choose_branch_var(&state.solver, domains, int_tol, &state.pseudocosts) {
992 Some(var) => branch(state, &root, var),
993 None => return Err(Error::Infeasible),
996 }
997 }
998
999 Ok(None)
1000}
1001
1002enum NodeVisit {
1003 Pruned,
1005 Solved,
1007 Interrupted { node: Node, lp_solved: bool },
1010}
1011
1012fn visit_node(state: &mut MipState, node: Node, domains: &[VarDomain]) -> Result<NodeVisit, Error> {
1014 if !apply_node_bounds(state, &node) {
1015 state.diving = false;
1016 return Ok(NodeVisit::Pruned);
1017 }
1018
1019 let warm = state.last_solved_id == Some(node.parent_id);
1020 if !warm && state.solver.load_basis(&node.basis).is_err() {
1021 debug!("basis load failed; falling back to slack basis");
1022 let slack = state.solver.slack_basis();
1023 state
1024 .solver
1025 .load_basis(&slack)
1026 .map_err(|e| Error::InternalError(format!("slack basis load failed: {}", e)))?;
1027 }
1028
1029 match solve_node_lp(state)? {
1030 NodeLp::Solved => {}
1031 NodeLp::Infeasible => {
1032 state.last_solved_id = None;
1033 state.diving = false;
1034 return Ok(NodeVisit::Solved);
1035 }
1036 NodeLp::Limit => {
1037 return Ok(NodeVisit::Interrupted {
1038 node,
1039 lp_solved: false,
1040 })
1041 }
1042 }
1043
1044 let objective = state.solver.cur_obj_val;
1045 if let Some(var) = node.branch_var {
1046 state.pseudocosts.record(
1047 var,
1048 node.branch_up,
1049 (objective - node.lp_bound).max(0.0) / node.branch_frac.max(params::BRANCH_FRAC_GUARD),
1050 );
1051 }
1052 if let Some(incumbent) = &state.incumbent {
1053 if objective >= cutoff(incumbent.objective, state.options.tolerances.prune_epsilon) {
1054 state.last_solved_id = None;
1055 state.diving = false;
1056 return Ok(NodeVisit::Solved);
1057 }
1058 }
1059
1060 let int_tol = state.options.int_tol;
1061 if branching::is_integral(&state.solver, domains, int_tol) {
1062 match process_integral_candidate(state, domains, int_tol)? {
1063 IntegralCandidate::Branch(var) => branch(state, &node, var),
1064 IntegralCandidate::Closed => {
1065 state.last_solved_id = None;
1066 state.diving = false;
1067 }
1068 IntegralCandidate::Limit => {
1069 return Ok(NodeVisit::Interrupted {
1070 node,
1071 lp_solved: true,
1072 })
1073 }
1074 }
1075 return Ok(NodeVisit::Solved);
1076 }
1077
1078 match branching::choose_branch_var(&state.solver, domains, int_tol, &state.pseudocosts) {
1079 Some(var) => branch(state, &node, var),
1080 None => {
1081 state.last_solved_id = None;
1082 state.diving = false;
1083 }
1084 }
1085 Ok(NodeVisit::Solved)
1086}
1087
1088fn search_loop(state: &mut MipState) -> Result<MipOutcome, Error> {
1089 let domains = state.solver.orig_var_domains.clone();
1090 state.solver.deadline = state.deadline;
1091
1092 if let Some(outcome) = initialize_root(state, &domains)? {
1093 return Ok(outcome);
1094 }
1095
1096 let mut nodes_this_run: u64 = 0;
1097
1098 loop {
1099 if state.open.is_empty() {
1106 break;
1107 }
1108
1109 if state.options.mip_gap > 0.0 {
1112 if let (Some(inc), Some(bound)) = (&state.incumbent, global_bound_internal(state)) {
1113 if relative_gap(inc.objective, bound) <= state.options.mip_gap {
1114 return Ok(MipOutcome::Optimal);
1115 }
1116 }
1117 }
1118
1119 if check_deadline(&state.deadline) == StopReason::Limit {
1121 return Ok(MipOutcome::Interrupted);
1122 }
1123 let node = match pop_node(state) {
1124 Some(n) => n,
1125 None => break,
1126 };
1127
1128 if let Some(inc) = &state.incumbent {
1130 if node.lp_bound >= cutoff(inc.objective, state.options.tolerances.prune_epsilon) {
1131 state.diving = false;
1132 continue;
1133 }
1134 }
1135
1136 if let Some(nl) = state.options.node_limit {
1141 if nodes_this_run >= nl {
1142 state.open.push(node);
1143 state.diving = false;
1144 return Ok(MipOutcome::Interrupted);
1145 }
1146 }
1147
1148 match visit_node(state, node, &domains)? {
1149 NodeVisit::Pruned => continue,
1150 NodeVisit::Solved => {
1151 state.stats.nodes_solved += 1;
1152 nodes_this_run += 1;
1153 }
1154 NodeVisit::Interrupted { node, lp_solved } => {
1155 if lp_solved {
1156 state.stats.nodes_solved += 1;
1157 }
1158 state.open.push(node);
1159 state.last_solved_id = None;
1160 state.diving = false;
1161 return Ok(MipOutcome::Interrupted);
1162 }
1163 }
1164 }
1165
1166 if state.incumbent.is_some() {
1167 Ok(MipOutcome::Optimal)
1168 } else {
1169 Err(Error::Infeasible)
1170 }
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175 use super::*;
1176 use crate::{ComparisonOp, OptimizationDirection, Problem};
1177
1178 fn int_2var_problem() -> Problem {
1179 let mut p = Problem::new(OptimizationDirection::Minimize);
1182 let a = p.add_integer_var(3.0, (0, 10));
1183 let b = p.add_integer_var(4.0, (0, 10));
1184 p.add_constraint(&[(a, 1.0), (b, 2.0)], ComparisonOp::Ge, 5.0);
1185 p.add_constraint(&[(a, 3.0), (b, 1.0)], ComparisonOp::Ge, 4.0);
1186 p
1187 }
1188
1189 fn binary_knapsack() -> Problem {
1190 let mut p = Problem::new(OptimizationDirection::Maximize);
1193 let x = p.add_binary_var(8.0);
1194 let y = p.add_binary_var(11.0);
1195 let z = p.add_binary_var(6.0);
1196 let w = p.add_binary_var(4.0);
1197 p.add_constraint(
1198 &[(x, 5.0), (y, 7.0), (z, 4.0), (w, 3.0)],
1199 ComparisonOp::Le,
1200 14.0,
1201 );
1202 p
1203 }
1204
1205 fn incumbent_obj(state: &MipState) -> f64 {
1206 state.incumbent.as_ref().unwrap().objective
1207 }
1208
1209 #[test]
1210 fn driver_finds_integer_optimum() {
1211 let run = run(&int_2var_problem(), SolveOptions::default()).unwrap();
1212 assert_eq!(run.outcome, MipOutcome::Optimal);
1213 assert!((incumbent_obj(&run.state) - 11.0).abs() < 1e-6);
1215 let inc = run.state.incumbent.as_ref().unwrap();
1216 assert!((inc.values[0] - 1.0).abs() < 1e-6);
1217 assert!((inc.values[1] - 2.0).abs() < 1e-6);
1218 assert!(run.state.stats.nodes_solved > 0);
1219 }
1220
1221 #[test]
1222 fn driver_binary_knapsack_maximize() {
1223 let run = run(&binary_knapsack(), SolveOptions::default()).unwrap();
1224 assert_eq!(run.outcome, MipOutcome::Optimal);
1225 assert!((incumbent_obj(&run.state) + 21.0).abs() < 1e-6);
1227 }
1228
1229 #[test]
1230 fn driver_no_integer_point_is_infeasible() {
1231 let mut p = Problem::new(OptimizationDirection::Minimize);
1233 let x = p.add_integer_var(1.0, (0, 10));
1234 p.add_constraint(&[(x, 2.0)], ComparisonOp::Eq, 1.0);
1235 assert_eq!(
1236 run(&p, SolveOptions::default()).unwrap_err(),
1237 crate::Error::Infeasible
1238 );
1239 }
1240
1241 #[test]
1242 fn driver_exact_node_exhaustion_reports_infeasible_not_interrupted() {
1243 let mut p = Problem::new(OptimizationDirection::Minimize);
1249 let x = p.add_integer_var(1.0, (0, 10));
1250 p.add_constraint(&[(x, 2.0)], ComparisonOp::Eq, 1.0);
1251 let mut options = SolveOptions::default();
1252 options.node_limit = Some(2);
1253 assert_eq!(run(&p, options).unwrap_err(), crate::Error::Infeasible);
1254 }
1255
1256 #[test]
1257 fn driver_node_limit_equal_to_exhaustion_count_reports_optimal() {
1258 assert_eq!(
1265 run(&int_2var_problem(), SolveOptions::default())
1266 .unwrap()
1267 .state
1268 .stats
1269 .nodes_solved,
1270 2,
1271 "node count must stay deterministic; update the limit below if it changes"
1272 );
1273 let mut options = SolveOptions::default();
1274 options.node_limit = Some(2);
1275 let r = run(&int_2var_problem(), options).unwrap();
1276 assert_eq!(r.outcome, MipOutcome::Optimal);
1277 assert!((incumbent_obj(&r.state) - 11.0).abs() < 1e-6);
1278 }
1279
1280 #[test]
1281 fn driver_node_limit_interrupts_and_resumes_to_same_optimum() {
1282 let mut options = SolveOptions::default();
1283 options.node_limit = Some(1);
1284 let mut r = run(&int_2var_problem(), options).unwrap();
1285 let mut guard = 0;
1286 while r.outcome == MipOutcome::Interrupted {
1287 guard += 1;
1288 assert!(guard < 10_000, "resume loop did not terminate");
1289 r.outcome = resume_run(&mut r.state, None).unwrap();
1290 }
1291 assert!(guard >= 1, "node_limit=1 should interrupt at least once");
1292 assert!((incumbent_obj(&r.state) - 11.0).abs() < 1e-6);
1293 }
1294
1295 #[test]
1296 fn open_children_have_branch_metadata() {
1297 let mut options = SolveOptions::default();
1298 options.node_limit = Some(0);
1299 let run = run(&int_2var_problem(), options).unwrap();
1300
1301 assert_eq!(run.outcome, MipOutcome::Interrupted);
1302 assert_eq!(run.state.open.len(), 2);
1303 assert!(run.state.open.iter().all(|node| node.branch_var.is_some()));
1304 }
1305
1306 #[test]
1307 fn driver_zero_time_limit_interrupts_cleanly_then_resumes() {
1308 let mut options = SolveOptions::default();
1309 options.time_limit = Some(Duration::ZERO);
1310 let mut r = run(&binary_knapsack(), options).unwrap();
1311 assert_eq!(r.outcome, MipOutcome::Interrupted);
1312 assert!(r.state.incumbent.is_none());
1313 assert_eq!(status_of(r.outcome, &r.state), Status::Interrupted);
1314 let outcome = resume_run(&mut r.state, None).unwrap();
1315 assert_eq!(outcome, MipOutcome::Optimal);
1316 assert!((incumbent_obj(&r.state) + 21.0).abs() < 1e-6);
1317 }
1318
1319 #[test]
1320 fn optimal_solve_reports_zero_gap_and_matching_bound() {
1321 let r = run(&int_2var_problem(), SolveOptions::default()).unwrap();
1322 assert_eq!(r.outcome, MipOutcome::Optimal);
1323 assert_eq!(r.state.stats.gap, Some(0.0));
1324 assert!((r.state.stats.best_bound.unwrap() - 11.0).abs() < 1e-6);
1326 }
1327
1328 #[test]
1329 fn maximize_bound_is_in_user_space() {
1330 let r = run(&binary_knapsack(), SolveOptions::default()).unwrap();
1331 assert_eq!(r.outcome, MipOutcome::Optimal);
1332 assert!((r.state.stats.best_bound.unwrap() - 21.0).abs() < 1e-6);
1334 }
1335
1336 #[test]
1337 fn mip_gap_stops_early_with_consistent_bound() {
1338 let mut options = SolveOptions::default();
1339 options.mip_gap = 0.5;
1340 let r = run(&binary_knapsack(), options).unwrap();
1341 assert_eq!(r.outcome, MipOutcome::Optimal); let inc = -incumbent_obj(&r.state); let bound = r.state.stats.best_bound.unwrap();
1344 assert!(inc <= bound + 1e-9);
1346 assert!((bound - inc) / bound.abs().max(1e-10) <= 0.5 + 1e-9);
1347 }
1348
1349 #[test]
1350 fn feasible_interrupt_reports_gap() {
1351 let mut options = SolveOptions::default();
1352 options.node_limit = Some(2);
1353 let mut r = run(&binary_knapsack(), options).unwrap();
1354 let mut guard = 0;
1356 while r.outcome == MipOutcome::Interrupted && r.state.incumbent.is_none() {
1357 guard += 1;
1358 assert!(guard < 10_000);
1359 r.outcome = resume_run(&mut r.state, None).unwrap();
1360 }
1361 if r.outcome == MipOutcome::Interrupted {
1362 assert!(r.state.stats.gap.unwrap() >= 0.0);
1364 assert!(r.state.stats.best_bound.is_some());
1365 }
1366 }
1367
1368 #[test]
1369 fn plunge_and_jump_selection_preserves_optima() {
1370 let r = run(&int_2var_problem(), SolveOptions::default()).unwrap();
1372 assert!((incumbent_obj(&r.state) - 11.0).abs() < 1e-6);
1373
1374 let r = run(&binary_knapsack(), SolveOptions::default()).unwrap();
1375 assert!((incumbent_obj(&r.state) + 21.0).abs() < 1e-6);
1376
1377 let mut options = SolveOptions::default();
1378 options.node_limit = Some(1);
1379 let mut r = run(&binary_knapsack(), options).unwrap();
1380 let mut guard = 0;
1381 while r.outcome == MipOutcome::Interrupted {
1382 guard += 1;
1383 assert!(guard < 10_000);
1384 r.outcome = resume_run(&mut r.state, None).unwrap();
1385 }
1386 assert!((incumbent_obj(&r.state) + 21.0).abs() < 1e-6);
1387 }
1390
1391 #[test]
1392 fn tolerances_default_matches_documented_values() {
1393 let t = Tolerances::default();
1394 assert_eq!(
1395 t.feasibility, 1e-7,
1396 "see Tolerances::feasibility's doc default"
1397 );
1398 assert_eq!(
1399 t.integrality_rounding, 1e-5,
1400 "see Tolerances::integrality_rounding's doc default"
1401 );
1402 assert_eq!(
1403 t.prune_epsilon, 1e-9,
1404 "see Tolerances::prune_epsilon's doc default"
1405 );
1406 }
1407
1408 #[test]
1409 fn try_adopt_incumbent_respects_custom_feasibility_tolerance() {
1410 let m = 1.0e9;
1418 let mut p = Problem::new(OptimizationDirection::Minimize);
1419 let x = p.add_var(1.0, (0.0, f64::INFINITY));
1420 let b = p.add_binary_var(0.0);
1421 p.add_constraint(&[(x, 1.0), (b, -m)], ComparisonOp::Eq, 10.0);
1422
1423 let mut solved = run(&p, SolveOptions::default()).unwrap();
1424 let state = &mut solved.state;
1425
1426 state.solver.set_var_bounds(b.idx(), 5e-7, 5e-7).unwrap();
1431 assert_eq!(
1432 state.solver.reoptimize().unwrap(),
1433 crate::StopReason::Finished
1434 );
1435 assert!((*state.solver.get_value(x.idx()) - 510.0).abs() < 1e-6);
1436
1437 state.options.tolerances.feasibility = Tolerances::default().feasibility;
1440 assert!(
1441 !try_adopt_incumbent(state).unwrap(),
1442 "a 500-unit rounding-induced violation must be rejected at the default feasibility tolerance"
1443 );
1444
1445 state.options.tolerances.feasibility = 1e6;
1448 assert!(
1449 try_adopt_incumbent(state).unwrap(),
1450 "the same violation must be accepted once tolerances.feasibility is loosened past it"
1451 );
1452 }
1453
1454 #[test]
1455 fn incumbent_feasible_row_tolerance_is_absolute_not_relative_to_rhs() {
1456 let mut p = Problem::new(OptimizationDirection::Minimize);
1459 let x = p.add_var(1.0, (0.0, f64::INFINITY));
1460 p.add_constraint(&[(x, 1.0)], ComparisonOp::Le, 1000.0);
1461 let fixed = std::collections::BTreeMap::new();
1462 let tolerances = Tolerances::default();
1463
1464 assert!(incumbent_feasible(
1466 &p,
1467 &fixed,
1468 &[1000.0 + 5e-8],
1469 &tolerances
1470 ));
1471
1472 assert!(!incumbent_feasible(
1475 &p,
1476 &fixed,
1477 &[1000.0 + 5e-5],
1478 &tolerances
1479 ));
1480 }
1481
1482 #[test]
1483 fn candidate_validation_rejects_non_finite_and_malformed_values() {
1484 let mut problem = Problem::new(OptimizationDirection::Minimize);
1485 problem.add_integer_var(1.0, (0, 10));
1486 let fixed = BTreeMap::new();
1487 let tolerances = Tolerances::default();
1488
1489 assert!(!incumbent_feasible(&problem, &fixed, &[], &tolerances));
1490 assert!(!incumbent_feasible(
1491 &problem,
1492 &fixed,
1493 &[f64::NAN],
1494 &tolerances
1495 ));
1496 assert!(!incumbent_feasible(
1497 &problem,
1498 &fixed,
1499 &[f64::INFINITY],
1500 &tolerances
1501 ));
1502
1503 let mut overflowing_row = Problem::new(OptimizationDirection::Minimize);
1504 let x = overflowing_row.add_var(0.0, (0.0, f64::INFINITY));
1505 overflowing_row.add_constraint(&[(x, 1.0e308)], ComparisonOp::Le, 1.0e308);
1506 assert!(!incumbent_feasible(
1507 &overflowing_row,
1508 &fixed,
1509 &[1.0e308],
1510 &tolerances
1511 ));
1512 }
1513
1514 #[test]
1515 fn valid_candidate_completes_unbounded_classification() {
1516 let mut problem = Problem::new(OptimizationDirection::Minimize);
1517 problem.add_integer_var(1.0, (0, 10));
1518 let mut state = build_state(&problem, SolveOptions::default()).unwrap();
1519 assert_eq!(state.solver.initial_solve().unwrap(), StopReason::Finished);
1520 state.classifying_unbounded = true;
1521
1522 assert_eq!(try_adopt_incumbent(&mut state), Err(Error::Unbounded));
1523 }
1524
1525 #[test]
1526 fn warm_start_restores_bounds_before_unbounded_verdict() {
1527 let mut problem = Problem::new(OptimizationDirection::Minimize);
1528 let x = problem.add_integer_var(0.0, (0, 10));
1529 let mut state = build_state(&problem, SolveOptions::default()).unwrap();
1530 assert_eq!(state.solver.initial_solve().unwrap(), StopReason::Finished);
1531 state.classifying_unbounded = true;
1532
1533 assert_eq!(
1534 try_warm_start(&mut state, &[(x, 5.0)]),
1535 Err(Error::Unbounded)
1536 );
1537 assert_eq!(state.solver.get_var_bounds(x.idx()), (0.0, 10.0));
1538 }
1539}