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;