Skip to main content

microlp/mip/
mod.rs

1//! Branch & bound driver for mixed-integer problems.
2//!
3//! Owns exactly one [`Solver`] per search. Branching changes variable bounds in
4//! place.
5
6pub(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/// Whether a usable solution is proven optimal.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum SolutionStatus {
20    /// The solver completed the optimality proof.
21    Optimal,
22    /// A valid incumbent is available, but exact optimality was not proven.
23    Feasible,
24}
25
26/// Why a solve or resume call returned.
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28#[non_exhaustive]
29pub enum TerminationReason {
30    /// The LP or branch-and-bound optimality proof completed.
31    ProvenOptimal,
32    /// The configured relative MIP gap was reached before exact proof completed.
33    MipGap,
34    /// The wall-clock budget for this call was exhausted.
35    TimeLimit,
36    /// The branch-and-bound node budget for this call was exhausted.
37    NodeLimit,
38}
39
40/// Options controlling a solve. Construct with [`SolveOptions::default`] and
41/// mutate the fields you need.
42#[derive(Clone, Debug)]
43#[non_exhaustive]
44pub struct SolveOptions {
45    /// Wall-clock budget for this call (`None` = unlimited). On expiry the search
46    /// and can be resumed.
47    pub time_limit: Option<Duration>,
48    /// Maximum number of branch & bound nodes to solve in this call
49    /// (`None` = unlimited). Deterministic alternative to `time_limit`; the
50    /// budget applies per call, so [`crate::SolveOutcome::resume`] reapplies it
51    /// as a fresh budget. The root relaxation does not count as a node.
52    pub node_limit: Option<u64>,
53    /// Relative MIP gap at which the search may stop with a feasible incumbent.
54    /// Such a stop reports [`SolutionStatus::Feasible`] and
55    /// [`TerminationReason::MipGap`]. Must be finite and non-negative. Default
56    /// `0.0` (prove exact optimality).
57    pub mip_gap: f64,
58    /// Integrality tolerance: a value within this distance of an integer counts
59    /// as integral. Default `1e-6`. A very loose `int_tol` mainly causes extra
60    /// exact-fixing branching rather than admitting an infeasible point.
61    /// Must be finite and in the half-open range `[0, 0.5)`.
62    pub int_tol: f64,
63    /// Optional (partial) starting assignment used to seed the incumbent.
64    /// An infeasible or incomplete hint is ignored. Default `None`.
65    pub warm_start: Option<Vec<(Variable, f64)>>,
66    /// Edit the tolerances used by the solver, most callers
67    /// should leave this at [`Tolerances::default`]; override an individual
68    /// field only once you understand the correctness/permissiveness
69    /// trade-off documented on it.
70    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/// Overrides for the solver settings used for a subsequent search/resume call.
123///
124/// These fields override the ones defined in the previous call to
125/// [`Problem::solve`] or [`Problem::resume`].
126#[derive(Clone, Debug, Default, PartialEq)]
127#[non_exhaustive]
128pub struct ResumeOptions {
129    /// New wall-clock budget (`None` = unlimited).
130    pub time_limit: Option<Duration>,
131    /// New branch-and-bound node budget (`None` = unlimited).
132    pub node_limit: Option<u64>,
133    /// New relative MIP gap (`None` = no MIP gap / exact optimality `0.0`).
134    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/// Numeric tolerances for a solve (see [`SolveOptions::tolerances`]).
152///
153/// This options override the solver's tolerances when solving the problem.
154/// Only edit those if you are sure of the impact of changing those values.
155#[derive(Clone, Copy, Debug)]
156#[non_exhaustive]
157pub struct Tolerances {
158    /// Uused to validate a rounded-to-integer
159    /// candidate solution before it is accepted as the incumbent.
160    /// Applied to each variable's distance
161    /// outside its bounds and to each row's distance outside its feasible
162    /// range. Also used, identically, by the post-edit warm-start
163    /// pre-filter that decides whether a previous incumbent survives a
164    /// [`crate::Solution`] edit.
165    /// Must be finite and non-negative. Default `1e-7`.
166    pub feasibility: f64,
167    /// Distance from the nearest integer within which an integer/boolean
168    /// variable's value is still treated as exactly that integer. Used by
169    /// the post-edit warm-start pre-filter's integrality check.
170    /// Must be finite and in the half-open range `[0, 0.5)`. Default `1e-5`.
171    pub integrality_rounding: f64,
172    /// Relative slack subtracted from the incumbent objective to form the
173    /// branch & bound pruning cutoff: a node whose bound is not strictly
174    /// better than `incumbent - max(prune_epsilon, prune_epsilon *
175    /// |incumbent|)` is pruned. Guards against continuing to explore or
176    /// retain nodes that could only ever match the incumbent to within
177    /// float noise.
178    ///
179    /// Must be finite and non-negative. Default `1e-9`.
180    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/// Statistics of a solve, available via [`crate::Solution::stats`].
194#[derive(Clone, Copy, Debug, Default)]
195#[non_exhaustive]
196pub struct Stats {
197    /// Branch & bound nodes whose LP was solved (0 for pure-LP problems).
198    pub nodes_solved: u64,
199    /// Total simplex pivots across the whole solve (including the root LP).
200    pub lp_iterations: u64,
201    /// Wall-clock time spent inside the solver, accumulated across resumes.
202    pub elapsed: Duration,
203    /// Best proven bound on the objective, in user space. `None` until an
204    /// incumbent or an open node exists to derive one from.
205    pub best_bound: Option<f64>,
206    /// Relative gap between incumbent and best bound. `None` until both are
207    /// known; `Some(0.0)` once optimality is proven.
208    pub gap: Option<f64>,
209}
210
211/// A feasible integer assignment, in internal (minimize) objective space.
212#[derive(Clone, Debug)]
213pub(crate) struct Incumbent {
214    /// Values of the structural variables (length = Problem var count).
215    pub values: Vec<f64>,
216    pub objective: f64,
217}
218
219/// The complete, resumable state of a branch & bound search.
220#[derive(Clone)]
221pub(crate) struct MipState {
222    pub solver: Solver,
223    /// Original bounds of the structural vars (to reset when jumping between nodes).
224    pub root_bounds: Vec<(f64, f64)>,
225    /// Bound changes currently applied to `solver` (collapsed, sorted by var).
226    pub applied: Vec<(usize, f64, f64)>,
227    pub open: Vec<Node>,
228    /// Pop policy toggle for `pop_node`: `true` while the most recently processed
229    /// node pushed children (keep plunging via LIFO pop); `false` once a dive dies
230    /// out with no children pushed, or once a node is requeued unsolved by an
231    /// interruption — the next pop then jumps to the open node with the best
232    /// (lowest) bound instead of blindly continuing the old dive.
233    pub diving: bool,
234    pub incumbent: Option<Incumbent>,
235    /// Sequence counter for branchings; children carry it as `parent_id`.
236    pub node_seq: u64,
237    /// `Some(id)` iff `solver` currently holds the optimal basis + bounds of the
238    /// branching with that id — its children can skip the basis load (warm dive).
239    pub last_solved_id: Option<u64>,
240    pub root_solved: bool,
241    pub stats: Stats,
242    pub options: SolveOptions,
243    pub deadline: Deadline,
244    /// Consumed by `fill_bound_stats` to report `best_bound`/`gap` in user space.
245    pub direction: OptimizationDirection,
246    /// Learned per-variable branching degradation estimates, updated after each
247    /// node LP solve and consulted by `branching::choose_branch_var`.
248    pub pseudocosts: branching::PseudoCosts,
249    /// Clean copy of the user's problem, including post-solve edits — never
250    /// contains branching artifacts. Post-solve edits re-solve from this.
251    pub base: Problem,
252    /// User-level fix_var overlay on `base` (var → fixed value).
253    pub fixed: BTreeMap<usize, f64>,
254    /// True while a zero-objective search classifies an unbounded LP
255    /// relaxation as either integer-feasible (the original MILP is unbounded)
256    /// or integer-infeasible.
257    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    /// Objective of the solution exposed through the public value accessors.
278    /// Unboundedness classification uses a zero-objective solver, so a working
279    /// point without an incumbent is evaluated against the original model.
280    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
330/// Replace a state whose original relaxation is unbounded with a
331/// zero-objective integer-feasibility search. For a rational MILP, one
332/// integer-feasible point plus the original relaxation ray proves
333/// unboundedness; exhausting this search proves integer infeasibility.
334fn 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
365/// Build the search state for `problem` and run it under `options`.
366pub(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
372/// `base` with the fix_var overlay applied to the variable bounds.
373pub(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
404/// Cheap feasibility check of a value vector against base + fixes: bounds,
405/// domains, and every user-scale constraint row. This is a warm-start prefilter;
406/// adoption re-validates the candidate against the active solver's scaled rows.
407pub(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
440/// After a user edit: drop the open tree, carry the incumbent as a warm-start
441/// hint when it survives the edit, and re-run the search on base + fixes.
442/// The fresh run gets the state's original options (incl. a fresh time budget).
443pub(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` cloned `effective` as its base; restore the true base/fixed split so
465    // later edits keep composing against the user's problem.
466    run.state.base = base;
467    run.state.fixed = fixed;
468    Ok(run)
469}
470
471/// Continue a paused search with fresh per-call budgets and options.
472pub(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
493/// Best proven lower bound (internal space) on the optimum: the min over open-node
494/// bounds and the incumbent. `None` while nothing is known (no nodes, no incumbent).
495/// Only valid BETWEEN nodes (a popped node's subtree is otherwise unaccounted).
496fn 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), // proof complete
507        (Some(inc), false) => Some(open_min.min(inc.objective)),
508        (None, false) => Some(open_min),
509        (None, true) => None,
510    }
511}
512
513/// Relative gap between incumbent and bound, internal space (0.0 when they meet).
514fn 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
539/// Prune threshold: a node whose lower bound is ≥ this cannot improve the incumbent.
540fn cutoff(incumbent_obj: f64, prune_epsilon: f64) -> f64 {
541    incumbent_obj - f64::max(prune_epsilon, prune_epsilon * incumbent_obj.abs())
542}
543
544/// Validate and adopt the solver's current solution using integer-rounded
545/// values. `Ok(false)` means rounding produced an invalid candidate and the
546/// caller must branch. A valid candidate completes unboundedness classification.
547fn 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
590/// Adopt a feasible rounded candidate, but close the current subtree only when
591/// the LP point itself is exactly integral. If an exactly integral point fails
592/// the independent feasibility guard, retry once from the all-slack basis:
593/// large coefficients can leave an eta-updated continuous value just outside
594/// the absolute guard even though a clean factorization recovers the vertex.
595fn 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
650/// Apply `node`'s bounds to the solver, diffing against what is currently applied.
651/// Returns false (node pruned, solver untouched) if the node's bounds cross.
652fn 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    // Reset vars that are currently changed but absent from the target.
658    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    // Apply the target bounds (validated above, cannot fail).
668    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
678/// Branch on `var` at the solver's current (just solved) optimum: push the two
679/// children carrying the parent's basis and objective bound.
680fn 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    // The split point k (children: x ≤ k and x ≥ k + 1) must be
685    // noise-robust: a raw `val.floor()` of a within-tolerance-integral value
686    // is catastrophic — floor(−8e-16) = −1 makes the up child
687    // (max(0, lo), hi) reproduce the parent VERBATIM and the search
688    // descends forever. Reachable through the rounding-rejected re-branch
689    // path (`choose_branch_var` at int_tol = 0) whenever LP noise puts an
690    // integer var a hair below an integer. Snap near-integral values to
691    // their integer first, then clamp k into [lo, hi − 1] so BOTH children
692    // strictly tighten the parent's [lo, hi] whenever hi − lo ≥ 1
693    // (integral bounds — guaranteed for branchable vars).
694    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    // Estimate-ordered dive: push the child with the LARGER estimated degradation
737    // first, so the cheaper (more promising) direction is popped/dived first.
738    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        // up is cheaper → push it last so it is dived first
742        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    // Children were pushed: keep plunging (LIFO pop) into this subtree.
749    state.diving = true;
750}
751
752/// Outcome of solving one branch & bound node's LP relaxation.
753enum NodeLp {
754    /// The solver now holds this node's optimal basis and objective.
755    Solved,
756    /// The node's LP is infeasible under its current bounds.
757    Infeasible,
758    /// A limit interrupted the solve (possibly during the slack retry); the
759    /// solver's unfinished, non-optimal state must not be used as a node result.
760    Limit,
761}
762
763/// Solve the current node's LP relaxation. Bounds and (if needed) the warm basis
764/// are already loaded into the solver by the caller.
765///
766/// Robustness valve: if the first `reoptimize` fails with an internal error class
767/// — anything that is neither [`Error::Infeasible`] nor [`Error::Unbounded`], e.g.
768/// a singular LU produced by numerical degradation during pivoting — fall back
769/// ONCE to the all-slack basis (which is documented to always load) and re-solve
770/// the node from scratch, then take that retry's outcome as final. The retry
771/// cannot loop: it is attempted at most once and its own internal error is
772/// propagated rather than retried again.
773fn 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        // Internal/singular error class: fall through to the one-shot slack retry.
785        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        // The retry also failed internally: propagate the error this time.
805        Err(e) => Err(e),
806    }
807}
808
809/// Pop policy: keep diving (DFS) while the last processed node produced children;
810/// when a dive dies out, jump to the open node with the best (lowest) bound. Ties
811/// in `lp_bound` resolve to the first (lowest-index) such node in `open` — the
812/// scan uses strict `<`, so `best` only moves for a strictly smaller bound.
813fn 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
830/// Evaluate a warm-start hint: fix hinted vars, LP-complete the rest, and if the
831/// completion is feasible and integral adopt it as the initial incumbent.
832/// Advisory by design — every failure path just drops the hint. Always restores
833/// the solver to the root optimum before returning.
834///
835/// Returns `Ok(None)` on the normal path (the caller proceeds to build the root
836/// node). Returns `Ok(Some(TerminationReason::TimeLimit))` only when the restore
837/// of the root basis fails AND the deadline strikes mid-restore: rather than let the
838/// caller read an unfinished `cur_obj_val` as the root bound, it un-sets
839/// `root_solved` so a resume re-enters `initial_solve` and continues honestly from
840/// the solver's feasibility flags.
841fn 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                    // Rounded-incumbent feasibility guard: never bypass it for a hint.
882                    // If it rejects the completion, drop the hint — do not branch
883                    // below-tolerance vars here, that fallback is only for the main
884                    // search loop.
885                    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    // Restore the root state exactly: bounds back, then the optimal root basis
908    // (load_basis recomputes everything, discarding the hint solve).
909    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            // A limit during the root re-solve leaves `cur_obj_val` unsuitable
924            // as a root bound. Mark the root unsolved and return Interrupted;
925            // `initialize_root` continues `initial_solve` from the solver's
926            // feasibility flags on resume.
927            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
940/// Solve or resume the root relaxation, restore any advisory warm start, and
941/// either close the problem or seed the open tree. `None` means node processing
942/// can begin; `Some` is a completed or interrupted root outcome.
943fn 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            // Fractional integer variables fixed to a non-integer value cannot
988            // produce an integer point or a useful branch.
989            None => return Err(Error::Infeasible),
990        }
991    }
992
993    Ok(None)
994}
995
996enum NodeVisit {
997    /// The node was discarded before an LP solve, so it consumes no node budget.
998    Pruned,
999    /// One node LP completed, including an infeasible relaxation.
1000    Solved,
1001    /// The node must be restored to the frontier. `lp_solved` distinguishes an
1002    /// interrupted initial LP from an interrupted retry after a completed LP.
1003    Interrupted { node: Node, lp_solved: bool },
1004}
1005
1006/// Reconstruct and process one node selected by the outer search policy.
1007fn 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        // Tree exhausted → the proof is COMPLETE: fall through to the post-loop
1094        // incumbent-vs-Infeasible verdict. Checked before the gap/deadline/node
1095        // limit tests so a limit that lands on the exact iteration the tree empties
1096        // never masks a finished proof as Interrupted/Feasible. (The loop is only
1097        // entered with `root_solved` true; the bottom `pop_node → None → break`
1098        // remains a safety net for any other path that empties `open` mid-body.)
1099        if state.open.is_empty() {
1100            break;
1101        }
1102
1103        // Proof-quality stops: exact incumbent/bound equality wins; otherwise
1104        // an incumbent within `mip_gap` is a valid feasible result. Checked
1105        // first so proof quality takes priority over limit interruptions.
1106        if state.options.mip_gap > 0.0 {
1107            if let (Some(inc), Some(bound)) = (&state.incumbent, global_bound_internal(state)) {
1108                // The open tree may still contain nodes whose stored bounds
1109                // equal the incumbent. Equality of the incumbent and global
1110                // bound is nevertheless a complete proof, so it must not be
1111                // weakened to a gap-satisfied feasible result.
1112                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        // Global limits are checked between nodes; no unfinished node result is consulted.
1123        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        // Prune with the stored parent bound before any LP work.
1132        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        // A node budget limits LP solves, not free bookkeeping. Pop and apply
1140        // the stored-bound prune first so hitting the exact solve count can
1141        // still finish a proof whose remaining nodes are already dominated by
1142        // the incumbent.
1143        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        // minimize 3a + 4b s.t. a + 2b >= 5, 3a + b >= 4; a,b integer in [0,10].
1183        // LP relaxation: a=0.6, b=2.2, obj 10.6. Integer optimum: a=1, b=2, obj 11.
1184        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        // maximize 8x + 11y + 6z + 4w s.t. 5x + 7y + 4z + 3w <= 14, binaries.
1194        // Optimum: y + z + w = 21 (weight 14).
1195        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        // Internal space == user space for Minimize.
1217        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        // Maximize is negated internally: internal optimum is -21.
1229        assert!((incumbent_obj(&run.state) + 21.0).abs() < 1e-6);
1230    }
1231
1232    #[test]
1233    fn driver_no_integer_point_is_infeasible() {
1234        // 2x == 1 with x integer in [0,10]: LP-feasible (x=0.5), integer-infeasible.
1235        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        // Same infeasible fixture (2x == 1, x int in [0,10]): the root LP is
1247        // fractional (x=0.5) and branches into x<=0 and x>=1, both LP-infeasible.
1248        // The tree is therefore exactly two nodes: node_limit=1 interrupts and
1249        // node_limit=2 exhausts it. At the exact exhaustion count, the empty-open
1250        // check precedes the node-limit check and must report Infeasible.
1251        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        // int_2var_problem is proven optimal in exactly 2 B&B nodes (deterministic:
1262        // the unlimited solve reports nodes_solved == 2; node_limit=1 -> Interrupted,
1263        // node_limit=2 -> Optimal). Setting the node limit to that exact count must
1264        // report Optimal, not Interrupted: when the tree empties on the same
1265        // iteration the limit would fire, the empty-`open` check at the loop top
1266        // wins and the finished proof is reported honestly.
1267        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        // User space == internal for Minimize.
1331        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        // Internally -21; user-facing bound must be +21.
1356        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); // user space (Maximize)
1366        let bound = r.state.stats.best_bound.unwrap();
1367        // Incumbent within 50% of the proven bound, and never better than it.
1368        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        // Resume with node budget until an incumbent exists but the search isn't done.
1378        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            // Feasible-but-unproven: a gap must be reported.
1393            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        // Same optima as plain DFS on all driver test problems, plus interrupt/resume.
1401        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        // After a best-bound jump the pop is NOT the last-pushed node at least once
1418        // on this instance; correctness above is the real assertion.
1419    }
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        // Derived from the big-M fixture `tests_general::solve_big_m` (same
1441        // m = 1e9 shape: `x - m*b == 10`, minimize x). Pin b to a value that
1442        // is integral-within-`int_tol` (5e-7, well inside the default 1e-6)
1443        // but not exactly 0; the rounded-incumbent guard then re-checks the
1444        // ROUNDED point (b -> 0) against the ORIGINAL row, which is off by
1445        // exactly m * 5e-7 = 500 — precisely the "big-M trap"
1446        // `tolerances.feasibility` exists to catch, in absolute terms.
1447        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        // Force the relaxation to a specific near-zero fractional b: pin its
1457        // bounds to [5e-7, 5e-7] and re-solve. The equality row then forces x
1458        // to exactly 10 + m*5e-7 = 510, deterministically — no dependence on
1459        // which vertex the simplex would otherwise have picked.
1460        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        // Default tolerance (1e-7): the rounded point (x=510, b=0) misses the
1468        // original `x - m*b == 10` row by 500 — must be rejected.
1469        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        // Absurdly loosened tolerance: the same 500-unit violation is now
1476        // within bounds — the guard must accept it.
1477        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        // `incumbent_feasible` uses the same absolute feasibility tolerance
1487        // as the rounded-incumbent guard, regardless of row magnitude.
1488        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        // Within the absolute tolerance (5e-8 < 1e-7): accepted.
1495        assert!(incumbent_feasible(
1496            &p,
1497            &fixed,
1498            &[1000.0 + 5e-8],
1499            &tolerances
1500        ));
1501
1502        // A `5e-5` violation exceeds the `1e-7` absolute tolerance even though
1503        // it is small relative to this row's right-hand side.
1504        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}