Skip to main content

otspot_core/mip/
mod.rs

1//! Mixed-integer programming (MILP / MIQP) via branch-and-bound.
2//!
3//! # Approach
4//! Each B&B node solves the **continuous relaxation** (LP for MILP, convex QP for
5//! MIQP) over the node's variable bounds. Integer branching tightens one integer
6//! variable's bounds (`x_j <= floor(v)` / `x_j >= ceil(v)`) — the same node
7//! mechanism the spatial QP B&B uses for box splitting, so relaxations are solved
8//! by swapping the bounds vector. The relaxation objective is a valid lower bound;
9//! integer-feasible relaxation solutions are upper-bound incumbents. Fathoming
10//! reuses the spatial B&B's relative-gap pruning (`qp::global::pruning`).
11//!
12//! # Reuse vs. duplication
13//! - `pruning::{should_prune, within_gap}` is shared directly (pure `f64` logic).
14//! - The best-bound priority queue (`queue::NodeQueue`) is an intentional small
15//!   duplicate of `qp::global::tree::BBTree`.
16//! - The driver loop mirrors the proven spatial-B&B structure but the per-node
17//!   semantics differ (relaxation-objective lower bound vs. interval bound;
18//!   integer-feasible incumbents vs. any feasible point).
19//!
20//! MILP (LP relaxation) and convex MIQP (QP relaxation) share one generic driver
21//! (`solve_mip_with_stats`); only the relaxation solver differs, abstracted by
22//! the `Relaxation` trait. Non-convex MIQP (non-PSD `Q`) is out of scope and
23//! reported as [`SolveStatus::NonConvex`].
24
25pub(crate) mod branch;
26pub(crate) mod node;
27mod problem;
28pub(crate) mod queue;
29
30pub use problem::{MilpProblem, MipProblemError, MiqpProblem};
31
32use crate::options::{MipConfig, SolverOptions};
33use crate::problem::{SolveStatus, SolverResult};
34use crate::problem::certificate::BoundGapCertificate;
35use crate::qp::global::pruning::{should_prune, within_gap};
36use std::time::{Duration, Instant};
37
38use branch::{
39    branch_bounds, is_integer_feasible, select_branching_variable, split_integer_box,
40    widest_splittable_integer,
41};
42use node::MipNode;
43use queue::NodeQueue;
44
45/// A continuous relaxation the MIP branch-and-bound driver can solve over
46/// arbitrary variable bounds. MILP uses an LP relaxation; convex MIQP uses a
47/// QP one. Branching tightens the bounds, so the same driver works for both —
48/// only the relaxation solver differs.
49pub(crate) trait Relaxation {
50    fn num_vars(&self) -> usize;
51    fn root_bounds(&self) -> &[(f64, f64)];
52    fn integer_vars(&self) -> &[usize];
53    /// Solve the relaxation with `bounds` substituted for the original bounds.
54    /// `opts` already has multistart / global_optimization stripped and the
55    /// deadline fixed by the driver.
56    fn solve(&self, bounds: &[(f64, f64)], opts: &SolverOptions) -> SolverResult;
57}
58
59/// Search statistics returned by [`solve_milp_with_stats`] / [`solve_miqp_with_stats`].
60///
61/// Counters and timings instrument the branch-and-bound driver without changing
62/// its behaviour.  The timing fields help separate *exploration explosion* (many
63/// nodes) from *per-node cost* (slow relaxation solves).
64#[derive(Debug, Clone, Copy, Default)]
65pub struct MipStats {
66    /// Relaxation solves performed (root included).
67    pub nodes_processed: usize,
68    /// Maximum branching depth reached.
69    pub max_depth_seen: usize,
70    /// Nodes discarded by bound/infeasibility before branching.
71    pub pruned: usize,
72    /// Number of incumbent improvements (including the first one found).
73    pub incumbent_updates: usize,
74
75    // --- relaxation solve wall-clock timing (milliseconds) ---
76
77    /// Total wall time spent inside relaxation solves across all nodes (ms).
78    pub relaxation_time_total_ms: f64,
79    /// Wall time for the root node relaxation solve (ms).
80    pub relaxation_time_root_ms: f64,
81    /// Cumulative wall time for all descendant (non-root) relaxation solves (ms).
82    pub relaxation_time_desc_ms: f64,
83    /// Cumulative time in solves that returned `Optimal` (ms).
84    pub relaxation_time_optimal_ms: f64,
85    /// Cumulative time in solves that returned `Infeasible` (ms).
86    pub relaxation_time_infeasible_ms: f64,
87
88    // --- LP sub-phase timing extracted from SolverResult::timing_breakdown ---
89    // Populated only when the LP relaxation path goes through presolve *and*
90    // presolve actually reduces the problem (`was_reduced = true`).  Zero for
91    // MIQP (QP solver does not yet provide a breakdown) and for MILP nodes
92    // where presolve does not reduce (e.g. tight-bound knapsack-style nodes).
93
94    /// Cumulative LP presolve microseconds across all nodes.
95    pub lp_presolve_us_total: u64,
96    /// Cumulative LP solve (simplex) microseconds across all nodes.
97    pub lp_solve_us_total: u64,
98    /// Cumulative LP postsolve microseconds across all nodes.
99    pub lp_postsolve_us_total: u64,
100
101    // --- memory estimate ---
102
103    /// Approximate bytes per node for the bounds clone: `n_vars × 2 × size_of::<f64>()`.
104    /// Gives a rough idea of per-node memory traffic regardless of node count.
105    pub approx_bounds_bytes_per_node: usize,
106}
107
108/// Solve a MILP to (relative) ε-optimality via branch-and-bound.
109pub fn solve_milp(problem: &MilpProblem, options: &SolverOptions, cfg: &MipConfig) -> SolverResult {
110    solve_milp_with_stats(problem, options, cfg).0
111}
112
113/// Like [`solve_milp`] but also returns search statistics (test sentinel hook).
114///
115/// Returns `(NumericalError, default stats)` immediately if `options` fails
116/// validation (invalid tolerance, zero threads, etc.).
117pub fn solve_milp_with_stats(
118    problem: &MilpProblem,
119    options: &SolverOptions,
120    cfg: &MipConfig,
121) -> (SolverResult, MipStats) {
122    if options.validate().is_err() {
123        return (SolverResult::numerical_error(), MipStats::default());
124    }
125    solve_mip_with_stats(problem, options, cfg)
126}
127
128/// Solve a **convex** MIQP to (relative) ε-optimality via branch-and-bound.
129///
130/// Each node solves a convex QP relaxation (IP-PMM). A non-PSD `Q` (non-convex
131/// MIQP) is out of scope: the QP relaxation would not be a valid lower bound, so
132/// the solver returns [`SolveStatus::NonConvex`] rather than a silently wrong
133/// answer. Use `solve_qp_global` for non-convex continuous QP.
134pub fn solve_miqp(problem: &MiqpProblem, options: &SolverOptions, cfg: &MipConfig) -> SolverResult {
135    solve_miqp_with_stats(problem, options, cfg).0
136}
137
138/// Like [`solve_miqp`] but also returns search statistics (test sentinel hook).
139///
140/// Returns `(NumericalError, default stats)` immediately if `options` fails
141/// validation (invalid tolerance, zero threads, etc.).
142pub fn solve_miqp_with_stats(
143    problem: &MiqpProblem,
144    options: &SolverOptions,
145    cfg: &MipConfig,
146) -> (SolverResult, MipStats) {
147    if options.validate().is_err() {
148        return (SolverResult::numerical_error(), MipStats::default());
149    }
150    if !problem.is_convex() {
151        return (nonconvex_result(), MipStats::default());
152    }
153    solve_mip_with_stats(problem, options, cfg)
154}
155
156/// Generic branch-and-bound driver shared by MILP (LP relaxation) and convex
157/// MIQP (QP relaxation). The only difference between the two is the relaxation
158/// solver, abstracted by [`Relaxation`].
159fn solve_mip_with_stats<R: Relaxation>(
160    problem: &R,
161    options: &SolverOptions,
162    cfg: &MipConfig,
163) -> (SolverResult, MipStats) {
164    let mut stats = MipStats::default();
165    let mask = integer_mask(problem.num_vars(), problem.integer_vars());
166
167    // Approximate per-node bounds clone cost: one (f64, f64) per variable.
168    stats.approx_bounds_bytes_per_node = problem.num_vars() * 2 * std::mem::size_of::<f64>();
169
170    // deadline: prefer an explicit deadline, else derive from timeout_secs.
171    let deadline = options.deadline.or_else(|| {
172        options
173            .timeout_secs
174            .map(|s| Instant::now() + Duration::from_secs_f64(s))
175    });
176    let mut shared = options.clone();
177    shared.deadline = deadline;
178    shared.timeout_secs = None;
179    shared.multistart = None;
180    shared.global_optimization = None;
181
182    // Degenerate: no integer variables → the relaxation is the answer (LP/QP fallback).
183    if problem.integer_vars().is_empty() {
184        return (problem.solve(problem.root_bounds(), &shared), stats);
185    }
186
187    let mut state = MipState::new();
188    let mut q = NodeQueue::new();
189    // The root carries no valid lower bound yet (−∞): a bound is adopted only from an
190    // Optimal relaxation. The loop solves the root uniformly with every other node, so
191    // Infeasible / Unbounded / stalling roots are all handled in one place.
192    q.push(MipNode::root(problem.root_bounds().to_vec(), f64::NEG_INFINITY));
193
194    let mut open_lb = f64::INFINITY; // smallest valid bound over unexplored regions
195    let mut had_open = false; // any region left unexplored?
196    let mut proof_uncertain = false; // an unexplored region stems from a non-Optimal relaxation
197    let mut deadline_stop = false;
198    let mut maxnodes_stop = false;
199    let mut unbounded = false;
200    let mut root_solved = false; // first relaxation solve distinguishes root timing
201
202    while let Some(node) = q.pop() {
203        if deadline_hit(&deadline) {
204            open_lb = open_lb.min(node.lower_bound);
205            had_open = true;
206            deadline_stop = true;
207            break;
208        }
209        if stats.nodes_processed >= cfg.max_nodes {
210            open_lb = open_lb.min(node.lower_bound);
211            had_open = true;
212            maxnodes_stop = true;
213            break;
214        }
215
216        // Fathom by the inherited (parent) bound before spending a relaxation solve.
217        if let Some(inc) = state.incumbent_obj {
218            if should_prune(node.lower_bound, Some(inc), cfg.gap_tol) {
219                stats.pruned += 1;
220                continue;
221            }
222        }
223
224        let t0 = Instant::now();
225        let res = problem.solve(&node.var_bounds, &shared);
226        let elapsed_ms = t0.elapsed().as_secs_f64() * 1000.0;
227
228        stats.nodes_processed += 1;
229        stats.max_depth_seen = stats.max_depth_seen.max(node.depth);
230
231        // Accumulate timing.
232        stats.relaxation_time_total_ms += elapsed_ms;
233        if !root_solved {
234            stats.relaxation_time_root_ms = elapsed_ms;
235            root_solved = true;
236        } else {
237            stats.relaxation_time_desc_ms += elapsed_ms;
238        }
239        match res.status {
240            SolveStatus::Optimal => stats.relaxation_time_optimal_ms += elapsed_ms,
241            SolveStatus::Infeasible => stats.relaxation_time_infeasible_ms += elapsed_ms,
242            _ => {}
243        }
244        if let Some(tb) = res.timing_breakdown {
245            stats.lp_presolve_us_total += tb.presolve_us;
246            stats.lp_solve_us_total += tb.solve_us;
247            stats.lp_postsolve_us_total += tb.postsolve_us;
248        }
249
250        match res.status {
251            SolveStatus::Infeasible => {
252                stats.pruned += 1;
253                continue;
254            }
255            SolveStatus::Unbounded => {
256                unbounded = true;
257                break;
258            }
259            SolveStatus::Timeout => {
260                open_lb = open_lb.min(node.lower_bound);
261                had_open = true;
262                deadline_stop = true;
263                break;
264            }
265            _ => {}
266        }
267
268        // Trust ONLY an exactly-Optimal relaxation (this includes the fixed-point
269        // evaluator, which returns Optimal) as a lower bound. A SuboptimalSolution
270        // primal objective is an UPPER bound on the relaxation optimum, NOT a lower
271        // bound — using it to fathom would over-prune and could drop the true optimum
272        // (the box-only off-diagonal silent-wrong).
273        let trusted = matches!(res.status, SolveStatus::Optimal) && !res.solution.is_empty();
274
275        if trusted {
276            // Optimal relaxation objective is a valid lower bound for this region.
277            let node_lb = node.lower_bound.max(res.objective);
278
279            if let Some(inc) = state.incumbent_obj {
280                if should_prune(node_lb, Some(inc), cfg.gap_tol) {
281                    stats.pruned += 1;
282                    continue;
283                }
284            }
285
286            // Integer-feasible Optimal relaxation → incumbent (feasible point, exact UB).
287            if is_integer_feasible(&res.solution, &mask, cfg.integer_feas_tol) {
288                if state.consider(&res) {
289                    stats.incumbent_updates += 1;
290                }
291                continue; // integer-feasible leaf — nothing to branch on
292            }
293
294            if node.depth + 1 > cfg.max_depth {
295                open_lb = open_lb.min(node_lb);
296                had_open = true;
297                continue;
298            }
299
300            // Guided branching on the most-fractional integer variable.
301            let jb = select_branching_variable(
302                &res.solution,
303                &mask,
304                cfg.integer_feas_tol,
305                cfg.branching,
306            )
307            .expect("a non-integer-feasible Optimal relaxation has a fractional integer var");
308            let (down, up) = branch_bounds(&node.var_bounds, jb, res.solution[jb]);
309            q.push(node.child(down, node_lb));
310            q.push(node.child(up, node_lb));
311        } else {
312            // Relaxation did not solve to Optimal: a SuboptimalSolution from an IPM stall
313            // (box-only off-diagonal QP), MaxIterations, or NumericalError on a region
314            // with no interior (e.g. an equality constraint pins it to a point). Its
315            // objective is NOT a valid lower bound, so neither fathom nor tighten by it.
316            // Fall back to integer-box bisection so the search still reaches an all-fixed
317            // leaf (solved exactly by the fixed-point evaluator), never silently dropping
318            // a region. The inherited (valid) parent bound is carried forward.
319            match widest_splittable_integer(&node.var_bounds, &mask) {
320                Some(_) if node.depth + 1 > cfg.max_depth => {
321                    open_lb = open_lb.min(node.lower_bound);
322                    had_open = true;
323                    proof_uncertain = true;
324                }
325                Some(jb) => {
326                    let (down, up) = split_integer_box(&node.var_bounds, jb);
327                    q.push(node.child(down, node.lower_bound));
328                    q.push(node.child(up, node.lower_bound));
329                }
330                None => {
331                    // No integer box left to split and the relaxation is unsolvable
332                    // (e.g. continuous variables in a no-interior region). The region is
333                    // left unexplored without a reliable bound → mark the proof uncertain
334                    // so the final status never falsely claims Optimal.
335                    open_lb = open_lb.min(node.lower_bound);
336                    had_open = true;
337                    proof_uncertain = true;
338                }
339            }
340        }
341    }
342
343    if unbounded {
344        return (SolverResult::unbounded(), stats);
345    }
346
347    let remaining_lb = match q.best_lower_bound() {
348        Some(b) => open_lb.min(b),
349        None => open_lb,
350    };
351    let interrupted = deadline_stop || maxnodes_stop;
352
353    match state.incumbent.take() {
354        Some(mut inc) => {
355            let inc_obj = state.incumbent_obj.expect("incumbent objective set");
356            // Proven optimal only when (a) no unexplored region can beat the incumbent
357            // by more than the gap (within_gap on a *valid* lower bound), AND (b) no
358            // region was left unresolved by a non-Optimal relaxation. Otherwise we still
359            // return the incumbent (possibly suboptimal) but never disguise it as a
360            // proven optimum.
361            let proven = !proof_uncertain && within_gap(inc_obj, remaining_lb, cfg.gap_tol);
362            inc.solution = round_integers(inc.solution, problem.integer_vars());
363            inc.status = if proven {
364                // Clamp remaining_lb to inc_obj: when the queue is fully drained
365                // (no open regions), remaining_lb = ∞ and the effective lower bound
366                // equals the incumbent itself (gap = 0).
367                let effective_lb = remaining_lb.min(inc_obj);
368                let scale = 1.0_f64.max(inc_obj.abs());
369                let gap_rel = (inc_obj - effective_lb) / scale;
370                inc.bound_gap_cert = Some(BoundGapCertificate::new(
371                    inc_obj,
372                    effective_lb,
373                    gap_rel,
374                    cfg.gap_tol,
375                ));
376                SolveStatus::Optimal
377            } else if deadline_stop {
378                SolveStatus::Timeout
379            } else {
380                SolveStatus::SuboptimalSolution
381            };
382            (inc, stats)
383        }
384        None => (
385            finalize_no_incumbent(interrupted, had_open, q.is_empty(), deadline_stop),
386            stats,
387        ),
388    }
389}
390
391/// Classify the terminal status when no integer-feasible incumbent was found.
392///
393/// `Infeasible` may be claimed **only** when the whole tree was resolved: the
394/// queue is empty, there was no budget interruption, and **no region was left
395/// unexplored** (`had_open == false`). An unexplored region (a depth/budget limit,
396/// or an unsolvable no-interior relaxation that could not be bisected) means we
397/// cannot prove infeasibility, so a no-solution status is returned instead — never
398/// a silent false `Infeasible`.
399fn finalize_no_incumbent(
400    interrupted: bool,
401    had_open: bool,
402    queue_empty: bool,
403    deadline_stop: bool,
404) -> SolverResult {
405    let fully_resolved = !interrupted && !had_open && queue_empty;
406    if fully_resolved {
407        SolverResult::infeasible()
408    } else if deadline_stop {
409        no_solution_result(SolveStatus::Timeout)
410    } else {
411        no_solution_result(SolveStatus::MaxIterations)
412    }
413}
414
415/// Boolean mask of length `num_vars`; `true` where the variable is integral.
416fn integer_mask(num_vars: usize, integer_vars: &[usize]) -> Vec<bool> {
417    let mut mask = vec![false; num_vars];
418    for &j in integer_vars {
419        if j < num_vars {
420            mask[j] = true;
421        }
422    }
423    mask
424}
425
426/// A result tagging a non-convex (non-PSD `Q`) MIQP as out of scope.
427fn nonconvex_result() -> SolverResult {
428    SolverResult {
429        status: SolveStatus::NonConvex(
430            "convex MIQP only: Q is not positive semidefinite".to_string(),
431        ),
432        objective: f64::INFINITY,
433        solution: vec![],
434        ..Default::default()
435    }
436}
437
438fn deadline_hit(deadline: &Option<Instant>) -> bool {
439    deadline.is_some_and(|d| Instant::now() >= d)
440}
441
442/// Round the integer components of `sol` to exact integers (relaxation noise removal).
443fn round_integers(mut sol: Vec<f64>, integer_vars: &[usize]) -> Vec<f64> {
444    for &j in integer_vars {
445        if j < sol.len() {
446            sol[j] = sol[j].round();
447        }
448    }
449    sol
450}
451
452/// A result carrying no usable solution, tagged with `status`.
453fn no_solution_result(status: SolveStatus) -> SolverResult {
454    SolverResult {
455        status,
456        objective: f64::INFINITY,
457        solution: vec![],
458        ..Default::default()
459    }
460}
461
462/// Incumbent (best integer-feasible upper bound) tracking.
463struct MipState {
464    incumbent: Option<SolverResult>,
465    incumbent_obj: Option<f64>,
466}
467
468impl MipState {
469    fn new() -> Self {
470        Self { incumbent: None, incumbent_obj: None }
471    }
472
473    /// Adopt `res` as the new incumbent if it strictly improves the objective.
474    /// Returns `true` when the incumbent changed.
475    fn consider(&mut self, res: &SolverResult) -> bool {
476        let better = match self.incumbent_obj {
477            None => true,
478            Some(o) => res.objective < o,
479        };
480        if better {
481            self.incumbent_obj = Some(res.objective);
482            self.incumbent = Some(res.clone());
483        }
484        better
485    }
486}
487
488#[cfg(test)]
489mod tests;