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;