Skip to main content

otspot_core/mip/
mod.rs

1//! Mixed-integer programming (MILP / MIQP) via branch-and-bound.
2//!
3//! MILP (LP relaxation) and convex MIQP (QP relaxation) share one generic driver
4//! (`solve_mip_with_stats`); the per-node solver is abstracted by `Relaxation`.
5//! Pruning (`qp::global::pruning`) is reused from the spatial QP B&B. Non-convex
6//! MIQP is out of scope and reported as [`SolveStatus::NonConvex`].
7
8pub(crate) mod branch;
9pub(crate) mod heuristics;
10pub(crate) mod node;
11pub(crate) mod presolve;
12mod problem;
13pub(crate) mod queue;
14
15pub use problem::{MilpProblem, MipProblemError, MiqpProblem};
16
17use crate::options::{MipConfig, SolverOptions};
18use crate::problem::certificate::BoundGapCertificate;
19use crate::problem::{SolveStatus, SolverResult};
20use crate::qp::global::pruning::{should_prune, within_gap};
21use std::time::{Duration, Instant};
22
23use branch::{
24    branch_bounds, is_integer_feasible, select_branching_variable, split_integer_box,
25    widest_splittable_integer,
26};
27use node::MipNode;
28use queue::NodeQueue;
29
30/// A continuous relaxation the MIP branch-and-bound driver can solve over
31/// arbitrary variable bounds. MILP uses an LP relaxation; convex MIQP uses a
32/// QP one. Branching tightens the bounds, so the same driver works for both —
33/// only the relaxation solver differs.
34pub(crate) trait Relaxation {
35    fn num_vars(&self) -> usize;
36    fn root_bounds(&self) -> &[(f64, f64)];
37    fn integer_vars(&self) -> &[usize];
38    /// Solve the relaxation with `bounds` substituted for the original bounds.
39    /// `opts` already has multistart / global_optimization stripped and the
40    /// deadline fixed by the driver.
41    fn solve(&self, bounds: &[(f64, f64)], opts: &SolverOptions) -> SolverResult;
42}
43
44/// Search statistics returned by [`solve_milp_with_stats`] / [`solve_miqp_with_stats`].
45///
46/// Counters and timings instrument the branch-and-bound driver without changing
47/// its behaviour.  The timing fields help separate *exploration explosion* (many
48/// nodes) from *per-node cost* (slow relaxation solves).
49#[derive(Debug, Clone, Copy, Default)]
50pub struct MipStats {
51    /// Relaxation solves performed (root included).
52    pub nodes_processed: usize,
53    /// Maximum branching depth reached.
54    pub max_depth_seen: usize,
55    /// Nodes discarded by bound/infeasibility before branching.
56    pub pruned: usize,
57    /// Number of incumbent improvements (including the first one found).
58    pub incumbent_updates: usize,
59
60    // --- relaxation solve wall-clock timing (milliseconds) ---
61    /// Total wall time spent inside relaxation solves across all nodes (ms).
62    pub relaxation_time_total_ms: f64,
63    /// Wall time for the root node relaxation solve (ms).
64    pub relaxation_time_root_ms: f64,
65    /// Cumulative wall time for all descendant (non-root) relaxation solves (ms).
66    pub relaxation_time_desc_ms: f64,
67    /// Cumulative time in solves that returned `Optimal` (ms).
68    pub relaxation_time_optimal_ms: f64,
69    /// Cumulative time in solves that returned `Infeasible` (ms).
70    pub relaxation_time_infeasible_ms: f64,
71
72    /// Cumulative LP presolve microseconds across all nodes (zero when presolve does not reduce).
73    pub lp_presolve_us_total: u64,
74    /// Cumulative LP solve (simplex) microseconds across all nodes.
75    pub lp_solve_us_total: u64,
76    /// Cumulative LP postsolve microseconds across all nodes.
77    pub lp_postsolve_us_total: u64,
78
79    /// Approximate bytes per node for the bounds clone: `n_vars × 2 × size_of::<f64>()`.
80    /// Gives a rough idea of per-node memory traffic regardless of node count.
81    pub approx_bounds_bytes_per_node: usize,
82
83    /// Whether the feasibility pump found an initial incumbent before branch-and-bound.
84    pub fp_incumbent_found: bool,
85}
86
87/// Solve a MILP to (relative) ε-optimality via branch-and-bound.
88pub fn solve_milp(problem: &MilpProblem, options: &SolverOptions, cfg: &MipConfig) -> SolverResult {
89    solve_milp_with_stats(problem, options, cfg).0
90}
91
92/// Like [`solve_milp`] but also returns search statistics (test sentinel hook).
93///
94/// Returns `(NumericalError, default stats)` immediately if `options` fails
95/// validation (invalid tolerance, zero threads, etc.).
96pub fn solve_milp_with_stats(
97    problem: &MilpProblem,
98    options: &SolverOptions,
99    cfg: &MipConfig,
100) -> (SolverResult, MipStats) {
101    if options.validate().is_err() {
102        return (SolverResult::numerical_error(), MipStats::default());
103    }
104    // Establish a shared deadline before FP so that FP and B&B draw from the same
105    // budget.  Without this, each LP in FP gets a fresh `timeout_secs` window and
106    // `solve_mip_core` resets the clock again — allowing up to (MAX_FP_ITER + 1)×
107    // timeout consumption.  If the caller already set an explicit deadline, honour it.
108    let deadline = options.deadline.or_else(|| {
109        options
110            .timeout_secs
111            .map(|s| Instant::now() + Duration::from_secs_f64(s))
112    });
113    let mut opts_with_dl = options.clone();
114    opts_with_dl.deadline = deadline;
115    opts_with_dl.timeout_secs = None;
116
117    // MILP-specific root presolve: coefficient propagation tightens integer bounds.
118    // Presolve is skipped when there are no integer variables (pure LP fallback is
119    // handled inside the generic driver). Non-empty integer_vars with infeasible
120    // integer rounding return early here before entering the B&B.
121    if !problem.integer_vars.is_empty() {
122        let mask = integer_mask(problem.lp.num_vars, &problem.integer_vars);
123        match presolve::tighten_integer_bounds(&problem.lp, &mask) {
124            None => return (SolverResult::infeasible(), MipStats::default()),
125            Some(tightened) if tightened != problem.lp.bounds => {
126                let mut lp_bt = problem.lp.clone();
127                lp_bt.bounds = tightened;
128                let problem_bt = MilpProblem {
129                    lp: lp_bt,
130                    integer_vars: problem.integer_vars.clone(),
131                };
132                let fp_inc = heuristics::feasibility_pump::run_feasibility_pump(
133                    &problem_bt.lp,
134                    &problem_bt.integer_vars,
135                    cfg.integer_feas_tol,
136                    &opts_with_dl,
137                );
138                return solve_mip_core(&problem_bt, &opts_with_dl, cfg, mask, fp_inc);
139            }
140            Some(_) => {
141                let fp_inc = heuristics::feasibility_pump::run_feasibility_pump(
142                    &problem.lp,
143                    &problem.integer_vars,
144                    cfg.integer_feas_tol,
145                    &opts_with_dl,
146                );
147                return solve_mip_core(problem, &opts_with_dl, cfg, mask, fp_inc);
148            }
149        }
150    }
151    solve_mip_with_stats(problem, &opts_with_dl, cfg)
152}
153
154/// Solve a **convex** MIQP to (relative) ε-optimality via branch-and-bound.
155///
156/// Each node solves a convex QP relaxation (IP-PMM). A non-PSD `Q` (non-convex
157/// MIQP) is out of scope: the QP relaxation would not be a valid lower bound, so
158/// the solver returns [`SolveStatus::NonConvex`] rather than a silently wrong
159/// answer. Use `solve_qp_global` for non-convex continuous QP.
160pub fn solve_miqp(problem: &MiqpProblem, options: &SolverOptions, cfg: &MipConfig) -> SolverResult {
161    solve_miqp_with_stats(problem, options, cfg).0
162}
163
164/// Like [`solve_miqp`] but also returns search statistics (test sentinel hook).
165///
166/// Returns `(NumericalError, default stats)` immediately if `options` fails
167/// validation (invalid tolerance, zero threads, etc.).
168pub fn solve_miqp_with_stats(
169    problem: &MiqpProblem,
170    options: &SolverOptions,
171    cfg: &MipConfig,
172) -> (SolverResult, MipStats) {
173    if options.validate().is_err() {
174        return (SolverResult::numerical_error(), MipStats::default());
175    }
176    if !problem.is_convex() {
177        return (nonconvex_result(), MipStats::default());
178    }
179    // MIQP root presolve: bound tightening via coefficient propagation.
180    // `tighten_bounds_linear` ignores Q (quadratic term) and operates on the
181    // same linear constraints as MILP, so it is valid for convex MIQP too.
182    if !problem.integer_vars.is_empty() {
183        let n = problem.qp.num_vars;
184        let mask = integer_mask(n, &problem.integer_vars);
185        match presolve::tighten_bounds_linear(
186            n,
187            &problem.qp.a,
188            &problem.qp.b,
189            &problem.qp.constraint_types,
190            &problem.qp.bounds,
191            &mask,
192        ) {
193            None => return (SolverResult::infeasible(), MipStats::default()),
194            Some(tightened) if tightened != problem.qp.bounds => {
195                let mut qp_bt = problem.qp.clone();
196                qp_bt.bounds = tightened;
197                let problem_bt = MiqpProblem {
198                    qp: qp_bt,
199                    integer_vars: problem.integer_vars.clone(),
200                };
201                return solve_mip_core(&problem_bt, options, cfg, mask, None);
202            }
203            Some(_) => {
204                return solve_mip_core(problem, options, cfg, mask, None);
205            }
206        }
207    }
208    solve_mip_with_stats(problem, options, cfg)
209}
210
211/// Generic branch-and-bound driver shared by MILP (LP relaxation) and convex
212/// MIQP (QP relaxation). The only difference between the two is the relaxation
213/// solver, abstracted by [`Relaxation`].
214fn solve_mip_with_stats<R: Relaxation>(
215    problem: &R,
216    options: &SolverOptions,
217    cfg: &MipConfig,
218) -> (SolverResult, MipStats) {
219    let mask = integer_mask(problem.num_vars(), problem.integer_vars());
220    solve_mip_core(problem, options, cfg, mask, None)
221}
222
223/// Core B&B driver that accepts a precomputed `integer_mask` to avoid
224/// recomputing it when the caller (e.g. `solve_milp_with_stats`) already has it.
225///
226/// `initial_incumbent` is an optional integer-feasible solution found by a
227/// pre-B&B heuristic (e.g., feasibility pump). When provided it is adopted as
228/// the starting incumbent so B&B can immediately prune nodes whose relaxation
229/// bound is already within the gap tolerance.
230fn solve_mip_core<R: Relaxation>(
231    problem: &R,
232    options: &SolverOptions,
233    cfg: &MipConfig,
234    mask: Vec<bool>,
235    initial_incumbent: Option<SolverResult>,
236) -> (SolverResult, MipStats) {
237    let mut stats = MipStats {
238        approx_bounds_bytes_per_node: problem.num_vars() * 2 * std::mem::size_of::<f64>(),
239        ..MipStats::default()
240    };
241
242    // deadline: prefer an explicit deadline, else derive from timeout_secs.
243    let deadline = options.deadline.or_else(|| {
244        options
245            .timeout_secs
246            .map(|s| Instant::now() + Duration::from_secs_f64(s))
247    });
248    let mut shared = options.clone();
249    shared.deadline = deadline;
250    shared.timeout_secs = None;
251    shared.multistart = None;
252    shared.global_optimization = None;
253
254    // Degenerate: no integer variables → pure LP/QP passthrough.
255    // Return before applying MIP-specific warm-start mutations so the caller's
256    // `warm_start` and `recover_warm_start_basis` settings are preserved.
257    if problem.integer_vars().is_empty() {
258        return (problem.solve(problem.root_bounds(), &shared), stats);
259    }
260
261    // Enable basis recovery so LP solves return warm_start_basis for child nodes.
262    shared.recover_warm_start_basis = true;
263    shared.warm_start = None;
264
265    let mut state = MipState::new();
266
267    // Seed with an initial incumbent (e.g., from the feasibility pump heuristic).
268    if let Some(inc) = initial_incumbent {
269        if state.consider(&inc) {
270            stats.incumbent_updates += 1;
271            stats.fp_incumbent_found = true;
272        }
273    }
274
275    let mut q = NodeQueue::new();
276    // The root carries no valid lower bound yet (−∞): a bound is adopted only from an
277    // Optimal relaxation. The loop solves the root uniformly with every other node, so
278    // Infeasible / Unbounded / stalling roots are all handled in one place.
279    q.push(MipNode::root(
280        problem.root_bounds().to_vec(),
281        f64::NEG_INFINITY,
282    ));
283
284    let mut open_lb = f64::INFINITY; // smallest valid bound over unexplored regions
285    let mut had_open = false; // any region left unexplored?
286    let mut proof_uncertain = false; // an unexplored region stems from a non-Optimal relaxation
287    let mut deadline_stop = false;
288    let mut maxnodes_stop = false;
289    let mut unbounded = false;
290    let mut root_solved = false; // first relaxation solve distinguishes root timing
291
292    while let Some(node) = q.pop() {
293        if deadline_hit(&deadline) {
294            open_lb = open_lb.min(node.lower_bound);
295            had_open = true;
296            deadline_stop = true;
297            break;
298        }
299        if stats.nodes_processed >= cfg.max_nodes {
300            open_lb = open_lb.min(node.lower_bound);
301            had_open = true;
302            maxnodes_stop = true;
303            break;
304        }
305
306        // Fathom by the inherited (parent) bound before spending a relaxation solve.
307        if let Some(inc) = state.incumbent_obj {
308            if should_prune(node.lower_bound, Some(inc), cfg.gap_tol) {
309                stats.pruned += 1;
310                continue;
311            }
312        }
313
314        let t0 = Instant::now();
315        let res = if let Some(ref ws) = node.warm_start {
316            let mut no = shared.clone();
317            no.warm_start = Some(ws.clone());
318            problem.solve(&node.var_bounds, &no)
319        } else {
320            problem.solve(&node.var_bounds, &shared)
321        };
322        let elapsed_ms = t0.elapsed().as_secs_f64() * 1000.0;
323
324        stats.nodes_processed += 1;
325        stats.max_depth_seen = stats.max_depth_seen.max(node.depth);
326
327        // Accumulate timing.
328        stats.relaxation_time_total_ms += elapsed_ms;
329        if !root_solved {
330            stats.relaxation_time_root_ms = elapsed_ms;
331            root_solved = true;
332        } else {
333            stats.relaxation_time_desc_ms += elapsed_ms;
334        }
335        match res.status {
336            SolveStatus::Optimal => stats.relaxation_time_optimal_ms += elapsed_ms,
337            SolveStatus::Infeasible => stats.relaxation_time_infeasible_ms += elapsed_ms,
338            _ => {}
339        }
340        if let Some(tb) = res.timing_breakdown {
341            stats.lp_presolve_us_total += tb.presolve_us;
342            stats.lp_solve_us_total += tb.solve_us;
343            stats.lp_postsolve_us_total += tb.postsolve_us;
344        }
345
346        match res.status {
347            SolveStatus::Infeasible => {
348                stats.pruned += 1;
349                continue;
350            }
351            SolveStatus::Unbounded => {
352                unbounded = true;
353                break;
354            }
355            SolveStatus::Timeout => {
356                open_lb = open_lb.min(node.lower_bound);
357                had_open = true;
358                deadline_stop = true;
359                break;
360            }
361            _ => {}
362        }
363
364        // Trust ONLY an exactly-Optimal relaxation (this includes the fixed-point
365        // evaluator, which returns Optimal) as a lower bound. A SuboptimalSolution
366        // primal objective is an UPPER bound on the relaxation optimum, NOT a lower
367        // bound — using it to fathom would over-prune and could drop the true optimum
368        // (the box-only off-diagonal silent-wrong).
369        let trusted = matches!(res.status, SolveStatus::Optimal) && !res.solution.is_empty();
370
371        if trusted {
372            // Optimal relaxation objective is a valid lower bound for this region.
373            let node_lb = node.lower_bound.max(res.objective);
374
375            if let Some(inc) = state.incumbent_obj {
376                if should_prune(node_lb, Some(inc), cfg.gap_tol) {
377                    stats.pruned += 1;
378                    continue;
379                }
380            }
381
382            // Integer-feasible Optimal relaxation → incumbent (feasible point, exact UB).
383            if is_integer_feasible(&res.solution, &mask, cfg.integer_feas_tol) {
384                if state.consider(&res) {
385                    stats.incumbent_updates += 1;
386                }
387                continue; // integer-feasible leaf — nothing to branch on
388            }
389
390            if node.depth + 1 > cfg.max_depth {
391                open_lb = open_lb.min(node_lb);
392                had_open = true;
393                continue;
394            }
395
396            // Guided branching on the most-fractional integer variable.
397            let jb = select_branching_variable(
398                &res.solution,
399                &mask,
400                cfg.integer_feas_tol,
401                cfg.branching,
402            )
403            .expect("a non-integer-feasible Optimal relaxation has a fractional integer var");
404            let (down, up) = branch_bounds(&node.var_bounds, jb, res.solution[jb]);
405            // Propagate parent basis to children. Skip warm-start when the
406            // branching variable's bound type changes (e.g. ub=∞→finite adds
407            // a UB row in standard form, invalidating basis indices). The
408            // up-branch typically triggers lb-violation and cold-starts anyway.
409            let child_ws = res.warm_start_basis.clone();
410            let down_ws = if bound_layout_changes(&node.var_bounds, &down, jb) {
411                None
412            } else {
413                child_ws.clone()
414            };
415            let up_ws = if bound_layout_changes(&node.var_bounds, &up, jb) {
416                None
417            } else {
418                child_ws
419            };
420            q.push(node.child_warm(down, node_lb, down_ws));
421            q.push(node.child_warm(up, node_lb, up_ws));
422        } else {
423            // Relaxation did not solve to Optimal: a SuboptimalSolution from an IPM stall
424            // (box-only off-diagonal QP), MaxIterations, or NumericalError on a region
425            // with no interior (e.g. an equality constraint pins it to a point). Its
426            // objective is NOT a valid lower bound, so neither fathom nor tighten by it.
427            // Fall back to integer-box bisection so the search still reaches an all-fixed
428            // leaf (solved exactly by the fixed-point evaluator), never silently dropping
429            // a region. The inherited (valid) parent bound is carried forward.
430            match widest_splittable_integer(&node.var_bounds, &mask) {
431                Some(_) if node.depth + 1 > cfg.max_depth => {
432                    open_lb = open_lb.min(node.lower_bound);
433                    had_open = true;
434                    proof_uncertain = true;
435                }
436                Some(jb) => {
437                    let (down, up) = split_integer_box(&node.var_bounds, jb);
438                    q.push(node.child(down, node.lower_bound));
439                    q.push(node.child(up, node.lower_bound));
440                }
441                None => {
442                    // No integer box left to split and the relaxation is unsolvable
443                    // (e.g. continuous variables in a no-interior region). The region is
444                    // left unexplored without a reliable bound → mark the proof uncertain
445                    // so the final status never falsely claims Optimal.
446                    open_lb = open_lb.min(node.lower_bound);
447                    had_open = true;
448                    proof_uncertain = true;
449                }
450            }
451        }
452    }
453
454    if unbounded {
455        return (SolverResult::unbounded(), stats);
456    }
457
458    let remaining_lb = match q.best_lower_bound() {
459        Some(b) => open_lb.min(b),
460        None => open_lb,
461    };
462    let interrupted = deadline_stop || maxnodes_stop;
463
464    match state.incumbent.take() {
465        Some(mut inc) => {
466            let inc_obj = state.incumbent_obj.expect("incumbent objective set");
467            // Proven optimal only when (a) no unexplored region can beat the incumbent
468            // by more than the gap (within_gap on a *valid* lower bound), AND (b) no
469            // region was left unresolved by a non-Optimal relaxation. Otherwise we still
470            // return the incumbent (possibly suboptimal) but never disguise it as a
471            // proven optimum.
472            let proven = !proof_uncertain && within_gap(inc_obj, remaining_lb, cfg.gap_tol);
473            inc.solution = round_integers(inc.solution, problem.integer_vars());
474            inc.status = if proven {
475                // Clamp remaining_lb to inc_obj: when the queue is fully drained
476                // (no open regions), remaining_lb = ∞ and the effective lower bound
477                // equals the incumbent itself (gap = 0).
478                let effective_lb = remaining_lb.min(inc_obj);
479                let scale = 1.0_f64.max(inc_obj.abs());
480                let gap_rel = (inc_obj - effective_lb) / scale;
481                inc.bound_gap_cert = Some(BoundGapCertificate::new(
482                    inc_obj,
483                    effective_lb,
484                    gap_rel,
485                    cfg.gap_tol,
486                ));
487                SolveStatus::Optimal
488            } else if deadline_stop {
489                SolveStatus::Timeout
490            } else {
491                SolveStatus::SuboptimalSolution
492            };
493            (inc, stats)
494        }
495        None => (
496            finalize_no_incumbent(interrupted, had_open, q.is_empty(), deadline_stop),
497            stats,
498        ),
499    }
500}
501
502/// Classify the terminal status when no integer-feasible incumbent was found.
503///
504/// `Infeasible` may be claimed **only** when the whole tree was resolved: the
505/// queue is empty, there was no budget interruption, and **no region was left
506/// unexplored** (`had_open == false`). An unexplored region (a depth/budget limit,
507/// or an unsolvable no-interior relaxation that could not be bisected) means we
508/// cannot prove infeasibility, so a no-solution status is returned instead — never
509/// a silent false `Infeasible`.
510fn finalize_no_incumbent(
511    interrupted: bool,
512    had_open: bool,
513    queue_empty: bool,
514    deadline_stop: bool,
515) -> SolverResult {
516    let fully_resolved = !interrupted && !had_open && queue_empty;
517    if fully_resolved {
518        SolverResult::infeasible()
519    } else if deadline_stop {
520        no_solution_result(SolveStatus::Timeout)
521    } else {
522        no_solution_result(SolveStatus::MaxIterations)
523    }
524}
525
526/// Boolean mask of length `num_vars`; `true` where the variable is integral.
527pub(crate) fn integer_mask(num_vars: usize, integer_vars: &[usize]) -> Vec<bool> {
528    let mut mask = vec![false; num_vars];
529    for &j in integer_vars {
530        if j < num_vars {
531            mask[j] = true;
532        }
533    }
534    mask
535}
536
537/// A result tagging a non-convex (non-PSD `Q`) MIQP as out of scope.
538fn nonconvex_result() -> SolverResult {
539    SolverResult {
540        status: SolveStatus::NonConvex(
541            "convex MIQP only: Q is not positive semidefinite".to_string(),
542        ),
543        objective: f64::INFINITY,
544        solution: vec![],
545        ..Default::default()
546    }
547}
548
549fn deadline_hit(deadline: &Option<Instant>) -> bool {
550    deadline.is_some_and(|d| Instant::now() >= d)
551}
552
553/// Round the integer components of `sol` to exact integers (relaxation noise removal).
554fn round_integers(mut sol: Vec<f64>, integer_vars: &[usize]) -> Vec<f64> {
555    for &j in integer_vars {
556        if j < sol.len() {
557            sol[j] = sol[j].round();
558        }
559    }
560    sol
561}
562
563/// Returns `true` when tightening var `j`'s bound changes the standard-form
564/// column layout vs the parent. An infinite bound becoming finite (ub: ∞→boxed,
565/// or lb: free→lower-bounded) changes the number of structural columns or adds
566/// a UB constraint row, making the parent basis index-incompatible.
567fn bound_layout_changes(
568    parent_bounds: &[(f64, f64)],
569    child_bounds: &[(f64, f64)],
570    j: usize,
571) -> bool {
572    let (p_lb, p_ub) = parent_bounds[j];
573    let (c_lb, c_ub) = child_bounds[j];
574    (p_ub.is_infinite() && c_ub.is_finite()) || (p_lb.is_infinite() && c_lb.is_finite())
575}
576
577/// A result carrying no usable solution, tagged with `status`.
578fn no_solution_result(status: SolveStatus) -> SolverResult {
579    SolverResult {
580        status,
581        objective: f64::INFINITY,
582        solution: vec![],
583        ..Default::default()
584    }
585}
586
587/// Incumbent (best integer-feasible upper bound) tracking.
588struct MipState {
589    incumbent: Option<SolverResult>,
590    incumbent_obj: Option<f64>,
591}
592
593impl MipState {
594    fn new() -> Self {
595        Self {
596            incumbent: None,
597            incumbent_obj: None,
598        }
599    }
600
601    /// Adopt `res` as the new incumbent if it strictly improves the objective.
602    /// Returns `true` when the incumbent changed.
603    fn consider(&mut self, res: &SolverResult) -> bool {
604        let better = match self.incumbent_obj {
605            None => true,
606            Some(o) => res.objective < o,
607        };
608        if better {
609            self.incumbent_obj = Some(res.objective);
610            self.incumbent = Some(res.clone());
611        }
612        better
613    }
614}
615
616#[cfg(test)]
617mod tests;