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 (never adds constraint rows), so the LP never grows during the search.
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/// The outcome class of a finished or interrupted solve.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum Status {
20    /// Proven optimal (within the configured `mip_gap`, which defaults to exact).
21    Optimal,
22    /// A limit was hit; a feasible solution is available but optimality is unproven.
23    Feasible,
24    /// A limit was hit before any usable solution was found. Value accessors
25    /// ([`crate::Solution::objective`] etc.) expose the search's current
26    /// working point on such solutions — possibly fractional and infeasible,
27    /// useful for inspection only. Checking the status before treating values
28    /// as the answer is the caller's responsibility; call
29    /// [`crate::Solution::resume`] to continue the search.
30    Interrupted,
31}
32
33/// Options controlling a solve. Construct with [`SolveOptions::default`] and
34/// mutate the fields you need.
35#[derive(Clone, Debug)]
36#[non_exhaustive]
37pub struct SolveOptions {
38    /// Wall-clock budget for this call (`None` = unlimited). On expiry the search
39    /// stops cleanly and can be resumed.
40    pub time_limit: Option<Duration>,
41    /// Maximum number of branch & bound nodes to solve in this call
42    /// (`None` = unlimited). Deterministic alternative to `time_limit`; the
43    /// budget applies per call, so each `resume` gets a fresh budget.
44    /// The root relaxation does not count as a node.
45    pub node_limit: Option<u64>,
46    /// Relative MIP gap at which the search stops and reports [`Status::Optimal`].
47    /// Must be finite and non-negative. Default `0.0` (prove exact optimality).
48    pub mip_gap: f64,
49    /// Integrality tolerance: a value within this distance of an integer counts
50    /// as integral. Default `1e-6`. Loosening it does not loosen final
51    /// feasibility: a rounded candidate must still pass the absolute
52    /// `tolerances.feasibility` per-row/bound check (default `1e-7`) before it
53    /// is accepted, so a very loose `int_tol` mainly causes extra exact-fixing
54    /// branching rather than admitting an infeasible point. Must be finite and
55    /// in the half-open range `[0, 0.5)`.
56    pub int_tol: f64,
57    /// Optional (partial) starting assignment used to seed the incumbent.
58    /// Advisory: an infeasible or incomplete hint is ignored. Default `None`.
59    pub warm_start: Option<Vec<(Variable, f64)>>,
60    /// Expert-level numeric tolerances (see [`Tolerances`]). Most callers
61    /// should leave this at [`Tolerances::default`]; override an individual
62    /// field only once you understand the correctness/permissiveness
63    /// trade-off documented on it.
64    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/// Expert-level numeric tolerances for a solve (see [`SolveOptions::tolerances`]).
117///
118/// These are distinct from the rest of [`SolveOptions`] in kind: each field
119/// here trades correctness risk against permissiveness in a way that
120/// requires understanding a specific piece of solver behavior to tune
121/// safely, so they are grouped separately rather than left as top-level
122/// `SolveOptions` fields. Most callers never need to touch this and should
123/// start from [`Tolerances::default`].
124///
125/// Purely internal numeric constants that carry no user-facing meaning (e.g.
126/// denominator guards, branching heuristics) live in a separate, undocumented-
127/// to-callers internal module instead of here — this struct is reserved for
128/// numbers whose value is part of the solver's observable contract.
129#[derive(Clone, Copy, Debug)]
130#[non_exhaustive]
131pub struct Tolerances {
132    /// Absolute tolerance, in the same units as the problem's bounds and
133    /// constraint right-hand sides, used to validate a rounded-to-integer
134    /// candidate solution before it is accepted as the incumbent (the
135    /// "rounded-incumbent guard"): applied to each variable's distance
136    /// outside its bounds and to each row's distance outside its feasible
137    /// range. Also used, identically, by the post-edit warm-start
138    /// pre-filter that decides whether a previous incumbent survives a
139    /// [`crate::Solution`] edit.
140    ///
141    /// This is deliberately an ABSOLUTE tolerance, never one scaled by a
142    /// row's or bound's magnitude: a relative tolerance is blind to the
143    /// "big-M" trap, where a violation that is tiny RELATIVE to a huge row
144    /// coefficient (e.g. a slack of 5.0 against a coefficient of 1e9) is
145    /// nonetheless decisive in absolute terms — exactly the case this guard
146    /// exists to catch. See `Solver::check_constraints` for the full
147    /// rationale.
148    ///
149    /// Must be finite and non-negative. Default `1e-7`.
150    pub feasibility: f64,
151    /// Distance from the nearest integer within which an integer/boolean
152    /// variable's value is still treated as exactly that integer. Used by
153    /// the post-edit warm-start pre-filter's integrality check, mirroring
154    /// [`crate::Solution::var_value`]'s own rounding check.
155    ///
156    /// Note: [`crate::Solution::var_value`]'s internal rounding sanity
157    /// assert always uses [`Tolerances::default`]'s value for this field,
158    /// never the value configured for the solve that produced the solution.
159    /// That assert exists purely to catch a solver bug — an accepted
160    /// incumbent must already be integral-clean by the time it reaches the
161    /// user — not to reflect a caller's preference, so it intentionally does
162    /// not follow a loosened setting here.
163    ///
164    /// Must be finite and in the half-open range `[0, 0.5)`. Default `1e-5`.
165    pub integrality_rounding: f64,
166    /// Relative slack subtracted from the incumbent objective to form the
167    /// branch & bound pruning cutoff: a node whose bound is not strictly
168    /// better than `incumbent - max(prune_epsilon, prune_epsilon *
169    /// |incumbent|)` is pruned. Guards against continuing to explore or
170    /// retain nodes that could only ever match the incumbent to within
171    /// float noise.
172    ///
173    /// Must be finite and non-negative. Default `1e-9`.
174    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/// Statistics of a solve, available via [`crate::Solution::stats`].
188#[derive(Clone, Copy, Debug, Default)]
189#[non_exhaustive]
190pub struct Stats {
191    /// Branch & bound nodes whose LP was solved (0 for pure-LP problems).
192    pub nodes_solved: u64,
193    /// Total simplex pivots across the whole solve (including the root LP).
194    pub lp_iterations: u64,
195    /// Wall-clock time spent inside the solver, accumulated across resumes.
196    pub elapsed: Duration,
197    /// Best proven bound on the objective, in user space. `None` until an
198    /// incumbent or an open node exists to derive one from.
199    pub best_bound: Option<f64>,
200    /// Relative gap between incumbent and best bound. `None` until both are
201    /// known; `Some(0.0)` once optimality is proven.
202    pub gap: Option<f64>,
203}
204
205/// A feasible integer assignment, in internal (minimize) objective space.
206#[derive(Clone, Debug)]
207pub(crate) struct Incumbent {
208    /// Values of the structural variables (length = Problem var count).
209    pub values: Vec<f64>,
210    pub objective: f64,
211}
212
213/// The complete, resumable state of a branch & bound search.
214#[derive(Clone)]
215pub(crate) struct MipState {
216    pub solver: Solver,
217    /// Original bounds of the structural vars (to reset when jumping between nodes).
218    pub root_bounds: Vec<(f64, f64)>,
219    /// Bound changes currently applied to `solver` (collapsed, sorted by var).
220    pub applied: Vec<(usize, f64, f64)>,
221    pub open: Vec<Node>,
222    /// Pop policy toggle for `pop_node`: `true` while the most recently processed
223    /// node pushed children (keep plunging via LIFO pop); `false` once a dive dies
224    /// out with no children pushed, or once a node is requeued unsolved by an
225    /// interruption — the next pop then jumps to the open node with the best
226    /// (lowest) bound instead of blindly continuing the old dive.
227    pub diving: bool,
228    pub incumbent: Option<Incumbent>,
229    /// Sequence counter for branchings; children carry it as `parent_id`.
230    pub node_seq: u64,
231    /// `Some(id)` iff `solver` currently holds the optimal basis + bounds of the
232    /// branching with that id — its children can skip the basis load (warm dive).
233    pub last_solved_id: Option<u64>,
234    pub root_solved: bool,
235    pub stats: Stats,
236    pub options: SolveOptions,
237    pub deadline: Deadline,
238    /// Consumed by `fill_bound_stats` to report `best_bound`/`gap` in user space.
239    pub direction: OptimizationDirection,
240    /// Learned per-variable branching degradation estimates, updated after each
241    /// node LP solve and consulted by `branching::choose_branch_var`.
242    pub pseudocosts: branching::PseudoCosts,
243    /// Clean copy of the user's problem, including post-solve edits — never
244    /// contains branching artifacts. Post-solve edits re-solve from this.
245    pub base: Problem,
246    /// User-level fix_var overlay on `base` (var → fixed value).
247    pub fixed: BTreeMap<usize, f64>,
248    /// True while a zero-objective search classifies an unbounded LP
249    /// relaxation as either integer-feasible (the original MILP is unbounded)
250    /// or integer-infeasible.
251    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    /// Objective of the solution exposed through the public value accessors.
272    /// Unboundedness classification uses a zero-objective solver, so a working
273    /// point without an incumbent is evaluated against the original model.
274    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
345/// Replace a state whose original relaxation is unbounded with a
346/// zero-objective integer-feasibility search. For a rational MILP, one
347/// integer-feasible point plus the original relaxation ray proves
348/// unboundedness; exhausting this search proves integer infeasibility.
349fn 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
380/// Build the search state for `problem` and run it under `options`.
381pub(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
387/// `base` with the fix_var overlay applied to the variable bounds.
388pub(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
419/// Cheap feasibility check of a value vector against base + fixes: bounds,
420/// domains, and every user-scale constraint row. This is a warm-start prefilter;
421/// adoption re-validates the candidate against the active solver's scaled rows.
422pub(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
455/// After a user edit: drop the open tree, carry the incumbent as a warm-start
456/// hint when it survives the edit, and re-run the search on base + fixes.
457/// The fresh run gets the state's original options (incl. a fresh time budget).
458pub(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` cloned `effective` as its base; restore the true base/fixed split so
480    // later edits keep composing against the user's problem.
481    run.state.base = base;
482    run.state.fixed = fixed;
483    Ok(run)
484}
485
486/// Continue a paused search with a fresh time budget.
487pub(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
504/// Best proven lower bound (internal space) on the optimum: the min over open-node
505/// bounds and the incumbent. `None` while nothing is known (no nodes, no incumbent).
506/// Only valid BETWEEN nodes (a popped node's subtree is otherwise unaccounted).
507fn 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), // proof complete
515        (Some(inc), false) => Some(open_min.min(inc.objective)),
516        (None, false) => Some(open_min),
517        (None, true) => None,
518    }
519}
520
521/// Relative gap between incumbent and bound, internal space (0.0 when they meet).
522fn 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
547/// Prune threshold: a node whose lower bound is ≥ this cannot improve the incumbent.
548fn cutoff(incumbent_obj: f64, prune_epsilon: f64) -> f64 {
549    incumbent_obj - f64::max(prune_epsilon, prune_epsilon * incumbent_obj.abs())
550}
551
552/// Validate and adopt the solver's current solution using integer-rounded
553/// values. `Ok(false)` means rounding produced an invalid candidate and the
554/// caller must branch. A valid candidate completes unboundedness classification.
555fn 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
598/// Adopt a feasible rounded candidate, but close the current subtree only when
599/// the LP point itself is exactly integral. If an exactly integral point fails
600/// the independent feasibility guard, retry once from the all-slack basis:
601/// large coefficients can leave an eta-updated continuous value just outside
602/// the absolute guard even though a clean factorization recovers the vertex.
603fn 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
658/// Apply `node`'s bounds to the solver, diffing against what is currently applied.
659/// Returns false (node pruned, solver untouched) if the node's bounds cross.
660fn 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    // Reset vars that are currently changed but absent from the target.
666    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    // Apply the target bounds (validated above, cannot fail).
676    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
686/// Branch on `var` at the solver's current (just solved) optimum: push the two
687/// children carrying the parent's basis and objective bound.
688fn 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    // The split point k (children: x ≤ k and x ≥ k + 1) must be
693    // noise-robust: a raw `val.floor()` of a within-tolerance-integral value
694    // is catastrophic — floor(−8e-16) = −1 makes the up child
695    // (max(0, lo), hi) reproduce the parent VERBATIM and the search
696    // descends forever. Reachable through the rounding-rejected re-branch
697    // path (`choose_branch_var` at int_tol = 0) whenever LP noise puts an
698    // integer var a hair below an integer. Snap near-integral values to
699    // their integer first, then clamp k into [lo, hi − 1] so BOTH children
700    // strictly tighten the parent's [lo, hi] whenever hi − lo ≥ 1
701    // (integral bounds — guaranteed for branchable vars).
702    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    // Estimate-ordered dive: push the child with the LARGER estimated degradation
745    // first, so the cheaper (more promising) direction is popped/dived first.
746    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        // up is cheaper → push it last so it is dived first
750        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    // Children were pushed: keep plunging (LIFO pop) into this subtree.
757    state.diving = true;
758}
759
760/// Outcome of solving one branch & bound node's LP relaxation.
761enum NodeLp {
762    /// The solver now holds this node's optimal basis and objective.
763    Solved,
764    /// The node's LP is infeasible under its current bounds.
765    Infeasible,
766    /// A limit interrupted the solve (possibly during the slack retry); the
767    /// solver's unfinished, non-optimal state must not be used as a node result.
768    Limit,
769}
770
771/// Solve the current node's LP relaxation. Bounds and (if needed) the warm basis
772/// are already loaded into the solver by the caller.
773///
774/// Robustness valve: if the first `reoptimize` fails with an internal error class
775/// — anything that is neither [`Error::Infeasible`] nor [`Error::Unbounded`], e.g.
776/// a singular LU produced by numerical degradation during pivoting — fall back
777/// ONCE to the all-slack basis (which is documented to always load) and re-solve
778/// the node from scratch, then take that retry's outcome as final. The retry
779/// cannot loop: it is attempted at most once and its own internal error is
780/// propagated rather than retried again.
781fn 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        // Internal/singular error class: fall through to the one-shot slack retry.
793        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        // The retry also failed internally: propagate the error this time.
813        Err(e) => Err(e),
814    }
815}
816
817/// Pop policy: keep diving (DFS) while the last processed node produced children;
818/// when a dive dies out, jump to the open node with the best (lowest) bound. Ties
819/// in `lp_bound` resolve to the first (lowest-index) such node in `open` — the
820/// scan uses strict `<`, so `best` only moves for a strictly smaller bound.
821fn 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
838/// Evaluate a warm-start hint: fix hinted vars, LP-complete the rest, and if the
839/// completion is feasible and integral adopt it as the initial incumbent.
840/// Advisory by design — every failure path just drops the hint. Always restores
841/// the solver to the root optimum before returning.
842///
843/// Returns `Ok(None)` on the normal path (the caller proceeds to build the root
844/// node). Returns `Ok(Some(MipOutcome::Interrupted))` only when the restore of the
845/// root basis fails AND the deadline strikes mid-restore: rather than let the
846/// caller read an unfinished `cur_obj_val` as the root bound, it un-sets
847/// `root_solved` so a resume re-enters `initial_solve` and continues honestly from
848/// the solver's feasibility flags.
849fn 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                    // Rounded-incumbent feasibility guard: never bypass it for a hint.
890                    // If it rejects the completion, drop the hint — do not branch
891                    // below-tolerance vars here, that fallback is only for the main
892                    // search loop.
893                    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    // Restore the root state exactly: bounds back, then the optimal root basis
916    // (load_basis recomputes everything, discarding the hint solve).
917    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            // A limit during the root re-solve leaves `cur_obj_val` unsuitable
932            // as a root bound. Mark the root unsolved and return Interrupted;
933            // `initialize_root` continues `initial_solve` from the solver's
934            // feasibility flags on resume.
935            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
948/// Solve or resume the root relaxation, restore any advisory warm start, and
949/// either close the problem or seed the open tree. `None` means node processing
950/// can begin; `Some` is a completed or interrupted root outcome.
951fn 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            // Fractional integer variables fixed to a non-integer value cannot
994            // produce an integer point or a useful branch.
995            None => return Err(Error::Infeasible),
996        }
997    }
998
999    Ok(None)
1000}
1001
1002enum NodeVisit {
1003    /// The node was discarded before an LP solve, so it consumes no node budget.
1004    Pruned,
1005    /// One node LP completed, including an infeasible relaxation.
1006    Solved,
1007    /// The node must be restored to the frontier. `lp_solved` distinguishes an
1008    /// interrupted initial LP from an interrupted retry after a completed LP.
1009    Interrupted { node: Node, lp_solved: bool },
1010}
1011
1012/// Reconstruct and process one node selected by the outer search policy.
1013fn 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        // Tree exhausted → the proof is COMPLETE: fall through to the post-loop
1100        // incumbent-vs-Infeasible verdict. Checked before the gap/deadline/node
1101        // limit tests so a limit that lands on the exact iteration the tree empties
1102        // never masks a finished proof as Interrupted/Feasible. (The loop is only
1103        // entered with `root_solved` true; the bottom `pop_node → None → break`
1104        // remains a safety net for any other path that empties `open` mid-body.)
1105        if state.open.is_empty() {
1106            break;
1107        }
1108
1109        // Gap-based stop: an incumbent within `mip_gap` of the proven bound counts
1110        // as optimal. Checked first so it takes priority over limit interruptions.
1111        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        // Global limits are checked between nodes; no unfinished node result is consulted.
1120        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        // Prune with the stored parent bound before any LP work.
1129        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        // A node budget limits LP solves, not free bookkeeping. Pop and apply
1137        // the stored-bound prune first so hitting the exact solve count can
1138        // still finish a proof whose remaining nodes are already dominated by
1139        // the incumbent.
1140        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        // minimize 3a + 4b s.t. a + 2b >= 5, 3a + b >= 4; a,b integer in [0,10].
1180        // LP relaxation: a=0.6, b=2.2, obj 10.6. Integer optimum: a=1, b=2, obj 11.
1181        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        // maximize 8x + 11y + 6z + 4w s.t. 5x + 7y + 4z + 3w <= 14, binaries.
1191        // Optimum: y + z + w = 21 (weight 14).
1192        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        // Internal space == user space for Minimize.
1214        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        // Maximize is negated internally: internal optimum is -21.
1226        assert!((incumbent_obj(&run.state) + 21.0).abs() < 1e-6);
1227    }
1228
1229    #[test]
1230    fn driver_no_integer_point_is_infeasible() {
1231        // 2x == 1 with x integer in [0,10]: LP-feasible (x=0.5), integer-infeasible.
1232        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        // Same infeasible fixture (2x == 1, x int in [0,10]): the root LP is
1244        // fractional (x=0.5) and branches into x<=0 and x>=1, both LP-infeasible.
1245        // The tree is therefore exactly two nodes: node_limit=1 interrupts and
1246        // node_limit=2 exhausts it. At the exact exhaustion count, the empty-open
1247        // check precedes the node-limit check and must report Infeasible.
1248        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        // int_2var_problem is proven optimal in exactly 2 B&B nodes (deterministic:
1259        // the unlimited solve reports nodes_solved == 2; node_limit=1 -> Interrupted,
1260        // node_limit=2 -> Optimal). Setting the node limit to that exact count must
1261        // report Optimal, not Interrupted: when the tree empties on the same
1262        // iteration the limit would fire, the empty-`open` check at the loop top
1263        // wins and the finished proof is reported honestly.
1264        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        // User space == internal for Minimize.
1325        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        // Internally -21; user-facing bound must be +21.
1333        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); // optimal within the configured gap
1342        let inc = -incumbent_obj(&r.state); // user space (Maximize)
1343        let bound = r.state.stats.best_bound.unwrap();
1344        // Incumbent within 50% of the proven bound, and never better than it.
1345        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        // Resume with node budget until an incumbent exists but the search isn't done.
1355        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            // Feasible-but-unproven: a gap must be reported.
1363            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        // Same optima as plain DFS on all driver test problems, plus interrupt/resume.
1371        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        // After a best-bound jump the pop is NOT the last-pushed node at least once
1388        // on this instance; correctness above is the real assertion.
1389    }
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        // Derived from the big-M fixture `tests_general::solve_big_m` (same
1411        // m = 1e9 shape: `x - m*b == 10`, minimize x). Pin b to a value that
1412        // is integral-within-`int_tol` (5e-7, well inside the default 1e-6)
1413        // but not exactly 0; the rounded-incumbent guard then re-checks the
1414        // ROUNDED point (b -> 0) against the ORIGINAL row, which is off by
1415        // exactly m * 5e-7 = 500 — precisely the "big-M trap"
1416        // `tolerances.feasibility` exists to catch, in absolute terms.
1417        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        // Force the relaxation to a specific near-zero fractional b: pin its
1427        // bounds to [5e-7, 5e-7] and re-solve. The equality row then forces x
1428        // to exactly 10 + m*5e-7 = 510, deterministically — no dependence on
1429        // which vertex the simplex would otherwise have picked.
1430        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        // Default tolerance (1e-7): the rounded point (x=510, b=0) misses the
1438        // original `x - m*b == 10` row by 500 — must be rejected.
1439        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        // Absurdly loosened tolerance: the same 500-unit violation is now
1446        // within bounds — the guard must accept it.
1447        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        // `incumbent_feasible` uses the same absolute feasibility tolerance
1457        // as the rounded-incumbent guard, regardless of row magnitude.
1458        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        // Within the absolute tolerance (5e-8 < 1e-7): accepted.
1465        assert!(incumbent_feasible(
1466            &p,
1467            &fixed,
1468            &[1000.0 + 5e-8],
1469            &tolerances
1470        ));
1471
1472        // A `5e-5` violation exceeds the `1e-7` absolute tolerance even though
1473        // it is small relative to this row's right-hand side.
1474        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}