From 66803c2fe17f53350d52b17e1d3a36895372375a Mon Sep 17 00:00:00 2001 From: Thiago Moretto Date: Thu, 8 Oct 2026 10:58:45 -0300 Subject: [PATCH 1/2] feat: let the search callback stop the search The SearchEvent callback now returns ControlFlow<()>. Break ends the descent or guided search at once, keeps the best solution, and still reports Done. Construction ignores it: no solution exists before every node is placed. Guided search now fires GuidedRound every round, so a monitor gets a tick even when nothing improves. examples/early_stop stops once no gain of 1% or more lands for 2 seconds. Breaking: callbacks must return ControlFlow::Continue(()) to go on. --- README.md | 5 ++- examples/README.md | 1 + examples/early_stop.rs | 82 +++++++++++++++++++++++++++++++++++++++++ src/bin/bench.rs | 11 ++++-- src/solver.rs | 36 ++++++++++++------ src/solver/construct.rs | 22 ++++++++--- src/solver/descent.rs | 37 +++++++++++++------ src/solver/gls.rs | 24 ++++++++---- src/solver/tests.rs | 78 +++++++++++++++++++++++++++++++++++---- tests/gls.rs | 5 ++- 10 files changed, 253 insertions(+), 48 deletions(-) create mode 100644 examples/early_stop.rs diff --git a/README.md b/README.md index 1b26836..97fc89b 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,10 @@ Arc costs are closures, so distances can come from coordinates, a precomputed matrix or a live routing service. The solver itself never sees a coordinate. `solve_with` takes the same arguments plus a callback, if you want to watch the -search progress. `search_log()` is a ready made one that prints to stderr. +search progress. `search_log()` is a ready made one that prints to stderr. The +callback can also stop the search: return `ControlFlow::Break(())` and the +solver hands back the best solution so far. The +[`early_stop`](examples/early_stop.rs) example stops once the search stalls. Full API docs with `cargo doc --open`. diff --git a/examples/README.md b/examples/README.md index 0362072..c102024 100644 --- a/examples/README.md +++ b/examples/README.md @@ -12,3 +12,4 @@ cargo run --release --example | [`forbidden_nodes`](forbidden_nodes.rs) | Block a vehicle from serving a customer with `forbid`, and see the solver route around it | | [`precedence`](precedence.rs) | Order two stops within a route with `precede`, and see a pair split across vehicles go unordered | | [`time_windows`](time_windows.rs) | Serve each stop inside its window with `cumul_bounds`, price idle time with `wait_cost`, and read the timetable back with `walk_route` | +| [`early_stop`](early_stop.rs) | Stop a guided search from the `solve_with` callback once it finds no gain of 1% or more for a few seconds | diff --git a/examples/early_stop.rs b/examples/early_stop.rs new file mode 100644 index 0000000..ed13f62 --- /dev/null +++ b/examples/early_stop.rs @@ -0,0 +1,82 @@ +//! Stop a long guided search once it stalls. Run with: +//! +//! ```sh +//! cargo run --release --example early_stop +//! ``` +//! +//! The callback handed to `solve_with` sees every `SearchEvent` and can end +//! the search by returning `ControlFlow::Break(())`. The rule below is one +//! choice among many: stop when no improvement of at least `MIN_GAIN_PCT` +//! has landed for `PATIENCE`. Set `MIN_GAIN_PCT` to 0 to count any +//! improvement at all. + +use std::ops::ControlFlow; +use std::time::{Duration, Instant}; + +use volare::{Construct, Cost, Improve, ModelBuilder, NodeId, SearchEvent, search_log, solve_with}; + +const PATIENCE: Duration = Duration::from_secs(2); +const MIN_GAIN_PCT: Cost = 1; + +fn main() { + // Random stops on a 1000 x 1000 square, from a fixed seed. + let mut seed: u64 = 42; + let mut next = move || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + (seed >> 33) as f64 / (1u64 << 31) as f64 * 1000.0 + }; + let n = 201; + let coords: Vec<(f64, f64)> = (0..n).map(|_| (next(), next())).collect(); + let demands: Vec = (0..n) + .map(|i| if i == 0 { 0 } else { 1 + i as i64 % 9 }) + .collect(); + + let mut b = ModelBuilder::new(n); + let cost = b.cost_class(move |from, to| { + let (p, q) = (coords[from.index()], coords[to.index()]); + (p.0 - q.0).hypot(p.1 - q.1).round() as i64 + }); + let fleet = 40; + for _ in 0..fleet { + b.vehicle(NodeId(0), NodeId(0), cost); + } + b.dimension( + "demand", + move |_from, to| demands[to.index()], + vec![50; fleet], + ); + let model = b.build(); + + let started = Instant::now(); + let mut log = search_log(); + // Time and cost of the last improvement big enough to count. + let mut mark: Option<(Instant, Cost)> = None; + let monitor = |event| { + let _ = log(event); + if let SearchEvent::GuidedBest { cost, .. } = event { + match mark { + Some((_, at)) if cost * 100 > at * (100 - MIN_GAIN_PCT) => {} + _ => mark = Some((Instant::now(), cost)), + } + } + match mark { + Some((at, _)) if at.elapsed() > PATIENCE => ControlFlow::Break(()), + _ => ControlFlow::Continue(()), + } + }; + + // No round limit: the monitor alone decides when to stop. + let sol = solve_with( + &model, + Construct::CheapestInsertion, + Improve::Gls { iters: usize::MAX }, + monitor, + ); + + println!( + "stopped after {:.1}s: no gain of {MIN_GAIN_PCT}% or more in {}s", + started.elapsed().as_secs_f64(), + PATIENCE.as_secs(), + ); + println!("total cost: {}", sol.cost); +} diff --git a/src/bin/bench.rs b/src/bin/bench.rs index 253e6ec..ab60764 100644 --- a/src/bin/bench.rs +++ b/src/bin/bench.rs @@ -25,6 +25,7 @@ use std::collections::HashMap; use std::fs; +use std::ops::ControlFlow; use std::path::{Path, PathBuf}; use std::time::Instant; @@ -466,7 +467,11 @@ fn free_fleet(inst: &Instance) -> usize { inst.coords.len() - 1 } -fn solve(m: &Model, gls: Option, log: &mut dyn FnMut(SearchEvent)) -> Solution { +fn solve( + m: &Model, + gls: Option, + log: &mut dyn FnMut(SearchEvent) -> ControlFlow<()>, +) -> Solution { let improve = match gls { Some(iters) => Improve::Gls { iters }, None => Improve::HillClimb, @@ -474,11 +479,11 @@ fn solve(m: &Model, gls: Option, log: &mut dyn FnMut(SearchEvent)) -> Sol solve_with(m, Construct::CheapestInsertion, improve, log) } -fn logger(on: bool) -> Box { +fn logger(on: bool) -> Box ControlFlow<()>> { if on { Box::new(search_log()) } else { - Box::new(|_| {}) + Box::new(|_| ControlFlow::Continue(())) } } diff --git a/src/solver.rs b/src/solver.rs index f2bc3b9..ef7d80d 100644 --- a/src/solver.rs +++ b/src/solver.rs @@ -1,3 +1,4 @@ +use std::ops::ControlFlow; use std::time::Instant; use crate::eval::{Routes, eval_routes}; @@ -38,9 +39,14 @@ impl std::fmt::Display for Operator { } /// A progress point during a solve. Hand a callback to `solve_with`, -/// `first_solution_with` or `local_search_with` to observe them; `search_log` -/// builds one that prints progress lines. Costs are whole-solution -/// totals. +/// `first_solution_with`, `local_search_with` or `guided_local_search_with` to +/// observe them; `search_log` builds one that prints progress lines. Costs are +/// whole-solution totals. +/// +/// The callback is also a search monitor: return `ControlFlow::Break(())` and +/// the search stops at once, keeps the best solution found, and reports +/// `Done`. Construction ignores it: there is no solution to return until every +/// node is placed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SearchEvent { /// Construction placed every node; the first complete solution exists. @@ -51,12 +57,17 @@ pub enum SearchEvent { /// than anything before it. `cost` is the true cost, never the penalized /// one the descent was reading. GuidedBest { iter: usize, cost: Cost }, - /// Local search converged; the solution is final. + /// Guided local search finished round `iter`; `cost` is the true cost of + /// the round's own solution. Fires every round, improving or not, so a + /// monitor can stop a search that has stalled. + GuidedRound { iter: usize, cost: Cost }, + /// The search converged or the callback stopped it; the solution is final. Done { cost: Cost }, } -/// An event callback that prints progress lines to stderr, -/// prefixed with elapsed time since the closure was created: +/// An event callback that prints progress lines to stderr, prefixed with +/// elapsed time since the closure was created. It skips `GuidedRound`, which +/// fires too often to read, and never stops the search: /// /// ```text /// #search 0.012s relocate improved, cost 5900 @@ -72,7 +83,7 @@ pub enum SearchEvent { /// search_log(), /// ); /// ``` -pub fn search_log() -> impl FnMut(SearchEvent) { +pub fn search_log() -> impl FnMut(SearchEvent) -> ControlFlow<()> { let started = Instant::now(); move |event| { let t = started.elapsed().as_secs_f64(); @@ -86,8 +97,10 @@ pub fn search_log() -> impl FnMut(SearchEvent) { SearchEvent::GuidedBest { iter, cost } => { eprintln!("#search {t:7.3}s gls round {iter}, new best cost {cost}") } + SearchEvent::GuidedRound { .. } => {} SearchEvent::Done { cost } => eprintln!("#search {t:7.3}s done, cost {cost}"), } + ControlFlow::Continue(()) } } @@ -132,16 +145,17 @@ impl Solution { /// penalties included, is owned by the call. One model can therefore back any /// number of solves running at once. pub fn solve(m: &Model, construct: Construct, improve: Improve) -> Solution { - solve_with(m, construct, improve, |_| {}) + solve_with(m, construct, improve, |_| ControlFlow::Continue(())) } -/// `solve` with an observer for search progress. The callback runs on the -/// solver thread; keep it cheap or it becomes part of the measured time. +/// `solve` with a monitor: it sees every `SearchEvent` and can stop the search +/// early (see `SearchEvent`). The callback runs on the solver thread; keep it +/// cheap or it becomes part of the measured time. pub fn solve_with( m: &Model, construct: Construct, improve: Improve, - mut log: impl FnMut(SearchEvent), + mut log: impl FnMut(SearchEvent) -> ControlFlow<()>, ) -> Solution { let mut sol = first_solution_with(m, construct, &mut log); match improve { diff --git a/src/solver/construct.rs b/src/solver/construct.rs index 20c3386..d04834b 100644 --- a/src/solver/construct.rs +++ b/src/solver/construct.rs @@ -1,3 +1,5 @@ +use std::ops::ControlFlow; + use super::{Construct, SearchEvent, candidate_vehicles, with_front}; use crate::eval::{Routes, eval_route}; use crate::model::Model; @@ -6,7 +8,7 @@ use crate::types::{Cost, NodeId, VehicleId}; pub fn first_solution_with( m: &Model, construct: Construct, - log: impl FnMut(SearchEvent), + log: impl FnMut(SearchEvent) -> ControlFlow<()>, ) -> Routes { match construct { Construct::CheapestInsertion => cheapest_insertion(m, log), @@ -84,19 +86,29 @@ impl Rng { } /// Always the cheapest insertion on offer. -pub fn cheapest_insertion(m: &Model, log: impl FnMut(SearchEvent)) -> Routes { +pub fn cheapest_insertion(m: &Model, log: impl FnMut(SearchEvent) -> ControlFlow<()>) -> Routes { insertion(m, 1, 0, log) } /// Draws from the `k` cheapest. Not a better first solution, a different one /// per seed, so callers can race several and keep the cheapest. -pub fn greedy_randomized(m: &Model, seed: u64, k: usize, log: impl FnMut(SearchEvent)) -> Routes { +pub fn greedy_randomized( + m: &Model, + seed: u64, + k: usize, + log: impl FnMut(SearchEvent) -> ControlFlow<()>, +) -> Routes { insertion(m, k, seed, log) } /// Insertion with a candidate list of width `k`. The cache below is /// indifferent to the draw: one route still grows per step. -fn insertion(m: &Model, k: usize, seed: u64, mut log: impl FnMut(SearchEvent)) -> Routes { +fn insertion( + m: &Model, + k: usize, + seed: u64, + mut log: impl FnMut(SearchEvent) -> ControlFlow<()>, +) -> Routes { let nv = m.vehicle_count(); let mut rng = Rng(seed); let mut sol: Routes = vec![Vec::new(); nv]; @@ -200,7 +212,7 @@ fn insertion(m: &Model, k: usize, seed: u64, mut log: impl FnMut(SearchEvent)) - dirty.iter_mut().for_each(|d| *d = true); } } - log(SearchEvent::FirstSolution { + let _ = log(SearchEvent::FirstSolution { cost: cost.iter().sum(), }); sol diff --git a/src/solver/descent.rs b/src/solver/descent.rs index 070370f..a216b38 100644 --- a/src/solver/descent.rs +++ b/src/solver/descent.rs @@ -1,4 +1,5 @@ use std::collections::VecDeque; +use std::ops::ControlFlow; use super::operators::{try_or_opt, try_relocate, try_swap, try_two_opt, try_two_opt_star}; use super::{Operator, RouteEval, Scratch, SearchEvent}; @@ -18,12 +19,16 @@ pub(super) struct Move { /// stays out of the per-node cascade — it fires once the fine operators reach /// a fixpoint, as a partition-level kick, then the sweep resumes. pub fn local_search(m: &Model, sol: &mut Routes) { - local_search_with(m, sol, |_| {}) + local_search_with(m, sol, |_| ControlFlow::Continue(())) } /// `local_search` reporting an `Improvement` per accepted move and a final /// `Done`. -pub fn local_search_with(m: &Model, sol: &mut Routes, log: impl FnMut(SearchEvent)) { +pub fn local_search_with( + m: &Model, + sol: &mut Routes, + log: impl FnMut(SearchEvent) -> ControlFlow<()>, +) { descend(m, sol, eval_route, log) } @@ -72,7 +77,7 @@ pub(super) fn descend( m: &Model, sol: &mut Routes, eval: impl RouteEval, - mut log: impl FnMut(SearchEvent), + mut log: impl FnMut(SearchEvent) -> ControlFlow<()>, ) { let mut cost: Vec = (0..sol.len()) .map(|v| eval(m, &sol[v], VehicleId(v as u32)).expect("infeasible start solution")) @@ -88,7 +93,7 @@ pub(super) fn descend( // Draining the queue is not a fixpoint: a move only re-wakes the two // routes it touched, and a node elsewhere may now have an improving move // into them. Re-sweep everything until a whole sweep finds nothing. - loop { + 'search: loop { // Future: Switch picking strategy. // Now is going from 0...n. Deterministic order. let mut queue: VecDeque = sol.iter().flatten().copied().collect(); @@ -133,10 +138,14 @@ pub(super) fn descend( }; improved = true; - log(SearchEvent::Improvement { + if log(SearchEvent::Improvement { operator, cost: cost.iter().sum(), - }); + }) + .is_break() + { + break 'search; + } for t in [Some(r), other_route.filter(|&v| v != r)] .into_iter() .flatten() @@ -166,19 +175,23 @@ pub(super) fn descend( route_stale[r] = true; route_stale[v] = true; improved = true; - log(SearchEvent::Improvement { + if log(SearchEvent::Improvement { operator: Operator::TwoOptStar, cost: cost.iter().sum(), - }); + }) + .is_break() + { + break 'search; + } } } } if !improved { - log(SearchEvent::Done { - cost: cost.iter().sum(), - }); - return; + break; } } + let _ = log(SearchEvent::Done { + cost: cost.iter().sum(), + }); } diff --git a/src/solver/gls.rs b/src/solver/gls.rs index 7d7efa3..b1656a8 100644 --- a/src/solver/gls.rs +++ b/src/solver/gls.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::hash::{BuildHasherDefault, Hasher}; +use std::ops::ControlFlow; use super::SearchEvent; use super::descent::{descend, local_search}; @@ -118,11 +119,12 @@ fn eval_route_penalized(m: &Model, p: &Penalties, route: &[NodeId], v: VehicleId /// full sweep anyway — tighten that first, then seed. Both are worth it when /// the iteration count needs to go past a few hundred. pub fn guided_local_search(m: &Model, sol: &mut Routes, iters: usize) { - guided_local_search_with(m, sol, iters, |_| {}) + guided_local_search_with(m, sol, iters, |_| ControlFlow::Continue(())) } -/// `guided_local_search` reporting a `GuidedBest` per new best true cost and a -/// final `Done`. +/// `guided_local_search` reporting a `GuidedBest` per new best true cost, a +/// `GuidedRound` per round, and a final `Done`. Stopped early by the callback, +/// it still returns the best solution so far. /// /// The descents themselves run silent on purpose. Every round after the first /// is minimizing penalized cost, so its `Improvement` costs would be numbers no @@ -132,7 +134,7 @@ pub fn guided_local_search_with( m: &Model, sol: &mut Routes, iters: usize, - mut log: impl FnMut(SearchEvent), + mut log: impl FnMut(SearchEvent) -> ControlFlow<()>, ) { local_search(m, sol); @@ -149,7 +151,7 @@ pub fn guided_local_search_with( arc_cost += arcs; } } - log(SearchEvent::GuidedBest { + let mut flow = log(SearchEvent::GuidedBest { iter: 0, cost: best_cost, }); @@ -162,12 +164,15 @@ pub fn guided_local_search_with( let mut penalties = Penalties::new(lambda); for iter in 1..=iters { + if flow.is_break() { + break; + } penalize_worst_arcs(m, &mut penalties, sol); descend( m, sol, |m, route, v| eval_route_penalized(m, &penalties, route, v), - |_| {}, + |_| ControlFlow::Continue(()), ); // The descent just optimized penalized cost, which is not the cost we @@ -176,13 +181,16 @@ pub fn guided_local_search_with( if cost < best_cost { best_cost = cost; best.clone_from(sol); - log(SearchEvent::GuidedBest { iter, cost }); + flow = log(SearchEvent::GuidedBest { iter, cost }); + } + if flow.is_continue() { + flow = log(SearchEvent::GuidedRound { iter, cost }); } } // `penalties` dies here; no caller can ever see a penalized cost. *sol = best; - log(SearchEvent::Done { cost: best_cost }); + let _ = log(SearchEvent::Done { cost: best_cost }); } /// Penalize every arc of `sol` with maximal utility `cost / (1 + penalty)` — diff --git a/src/solver/tests.rs b/src/solver/tests.rs index e287937..f2e7d15 100644 --- a/src/solver/tests.rs +++ b/src/solver/tests.rs @@ -1,3 +1,5 @@ +use std::ops::ControlFlow; + use super::operators::{try_or_opt, try_two_opt_star}; use super::*; use crate::eval::{eval_route, visits_all_nodes}; @@ -23,22 +25,22 @@ fn line_model() -> Model { #[test] fn randomized_insertion_is_seeded_not_arbitrary() { let m = line_model(); - let greedy = cheapest_insertion(&m, |_| {}); + let greedy = cheapest_insertion(&m, |_| ControlFlow::Continue(())); assert_eq!( - greedy_randomized(&m, 7, 1, |_| {}), + greedy_randomized(&m, 7, 1, |_| ControlFlow::Continue(())), greedy, "k = 1 is greedy" ); assert_eq!( - greedy_randomized(&m, 7, 3, |_| {}), - greedy_randomized(&m, 7, 3, |_| {}), + greedy_randomized(&m, 7, 3, |_| ControlFlow::Continue(())), + greedy_randomized(&m, 7, 3, |_| ControlFlow::Continue(())), "same seed, same solution" ); let mut diverged = 0; for seed in 0..32 { - let sol = greedy_randomized(&m, seed, 3, |_| {}); + let sol = greedy_randomized(&m, seed, 3, |_| ControlFlow::Continue(())); assert!(visits_all_nodes(&m, &sol), "seed {seed} lost a node"); assert!(eval_routes(&m, &sol).is_some(), "seed {seed} is infeasible"); if sol != greedy { @@ -67,7 +69,7 @@ fn randomized_insertion_respects_drops_and_forbids() { let m = b.build(); for seed in 0..16 { - let sol = greedy_randomized(&m, seed, 4, |_| {}); + let sol = greedy_randomized(&m, seed, 4, |_| ControlFlow::Continue(())); assert!(visits_all_nodes(&m, &sol), "seed {seed} lost a node"); assert!(eval_routes(&m, &sol).is_some(), "seed {seed} is infeasible"); assert!( @@ -131,6 +133,7 @@ fn swap_finds_what_relocate_cannot() { if let SearchEvent::Improvement { operator, .. } = e { ops.push(operator); } + ControlFlow::Continue(()) }); assert!( ops.contains(&Operator::Swap), @@ -266,7 +269,8 @@ fn search_events_trace_the_solve() { let m = line_model(); let mut events = Vec::new(); let sol = solve_with(&m, Construct::CheapestInsertion, Improve::HillClimb, |e| { - events.push(e) + events.push(e); + ControlFlow::Continue(()) }); let SearchEvent::FirstSolution { cost: first } = events[0] else { @@ -290,3 +294,63 @@ fn search_events_trace_the_solve() { let silent = solve(&m, Construct::CheapestInsertion, Improve::HillClimb); assert_eq!(silent.cost, done); } + +/// `Break` ends the search on the spot, and the answer is still the best one +/// seen, not whatever the stopped round held. +#[test] +fn monitor_stops_guided_search() { + let m = line_model(); + let (mut rounds, mut best, mut done) = (0, i64::MAX, None); + let sol = solve_with( + &m, + Construct::CheapestInsertion, + Improve::Gls { iters: 1000 }, + |e| { + match e { + SearchEvent::GuidedBest { cost, .. } => best = cost, + SearchEvent::GuidedRound { .. } => rounds += 1, + SearchEvent::Done { cost } => done = Some(cost), + _ => {} + } + if rounds == 5 { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }, + ); + assert_eq!(rounds, 5); + assert_eq!(done, Some(best)); + assert_eq!(sol.cost, best); +} + +#[test] +fn monitor_stops_hill_climb() { + let m = line_model(); + let start = vec![ + vec![NodeId(5), NodeId(1), NodeId(3)], + vec![NodeId(2), NodeId(4)], + ]; + let improvements = |stop: bool| { + let mut sol = start.clone(); + let mut costs = Vec::new(); + local_search_with(&m, &mut sol, |e| { + if let SearchEvent::Improvement { cost, .. } = e { + costs.push(cost); + if stop { + return ControlFlow::Break(()); + } + } + ControlFlow::Continue(()) + }); + (costs, eval_routes(&m, &sol)) + }; + let (all, _) = improvements(false); + assert!( + all.len() > 1, + "start must take more than one move, took {all:?}" + ); + let (first, cost) = improvements(true); + assert_eq!(first, all[..1]); + assert_eq!(cost, Some(first[0])); +} diff --git a/tests/gls.rs b/tests/gls.rs index fc7e844..f8ce5fe 100644 --- a/tests/gls.rs +++ b/tests/gls.rs @@ -6,6 +6,7 @@ //! the test covered. use std::fs; +use std::ops::ControlFlow; use std::path::Path; use volare::cvrplib::{Instance, cvrp_model}; @@ -19,7 +20,9 @@ fn gls_improves_on_the_hill_climb() { let n = inst.coords.len(); let m = cvrp_model(&inst, n - 1); - let mut hill = first_solution_with(&m, Construct::CheapestInsertion, |_| {}); + let mut hill = first_solution_with(&m, Construct::CheapestInsertion, |_| { + ControlFlow::Continue(()) + }); local_search(&m, &mut hill); let hill_cost = eval_routes(&m, &hill).expect("infeasible hill climb"); From d96ceb20518783f0948f97b9225aa386f6436760 Mon Sep 17 00:00:00 2001 From: Thiago Moretto Date: Thu, 8 Oct 2026 11:05:59 -0300 Subject: [PATCH 2/2] refactor: simplify the search stop Construction keeps a plain observer: it cannot stop, so its type no longer pretends it can. solve_with reads a Break on FirstSolution and skips the improvement phase. One stop closure in descend, a bool in GLS instead of a ControlFlow carried across rounds, and the example runs on X-n101-k25 through cvrplib instead of a hand-rolled instance. --- examples/early_stop.rs | 35 +++++++---------------------------- src/bin/bench.rs | 4 +++- src/solver.rs | 24 ++++++++++++++++-------- src/solver/construct.rs | 22 +++++----------------- src/solver/descent.rs | 21 +++++++++------------ src/solver/gls.rs | 13 ++++++------- src/solver/tests.rs | 32 ++++++++++++++++++++++++++------ tests/gls.rs | 5 +---- 8 files changed, 73 insertions(+), 83 deletions(-) diff --git a/examples/early_stop.rs b/examples/early_stop.rs index ed13f62..8822804 100644 --- a/examples/early_stop.rs +++ b/examples/early_stop.rs @@ -10,42 +10,21 @@ //! has landed for `PATIENCE`. Set `MIN_GAIN_PCT` to 0 to count any //! improvement at all. +use std::fs; use std::ops::ControlFlow; +use std::path::Path; use std::time::{Duration, Instant}; -use volare::{Construct, Cost, Improve, ModelBuilder, NodeId, SearchEvent, search_log, solve_with}; +use volare::cvrplib::{Instance, cvrp_model}; +use volare::{Construct, Cost, Improve, SearchEvent, search_log, solve_with}; const PATIENCE: Duration = Duration::from_secs(2); const MIN_GAIN_PCT: Cost = 1; fn main() { - // Random stops on a 1000 x 1000 square, from a fixed seed. - let mut seed: u64 = 42; - let mut next = move || { - seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); - (seed >> 33) as f64 / (1u64 << 31) as f64 * 1000.0 - }; - let n = 201; - let coords: Vec<(f64, f64)> = (0..n).map(|_| (next(), next())).collect(); - let demands: Vec = (0..n) - .map(|i| if i == 0 { 0 } else { 1 + i as i64 % 9 }) - .collect(); - - let mut b = ModelBuilder::new(n); - let cost = b.cost_class(move |from, to| { - let (p, q) = (coords[from.index()], coords[to.index()]); - (p.0 - q.0).hypot(p.1 - q.1).round() as i64 - }); - let fleet = 40; - for _ in 0..fleet { - b.vehicle(NodeId(0), NodeId(0), cost); - } - b.dimension( - "demand", - move |_from, to| demands[to.index()], - vec![50; fleet], - ); - let model = b.build(); + let vrp = Path::new(env!("CARGO_MANIFEST_DIR")).join("instances/X-n101-k25.vrp"); + let inst = Instance::parse(&fs::read_to_string(&vrp).unwrap()); + let model = cvrp_model(&inst, inst.coords.len() - 1); let started = Instant::now(); let mut log = search_log(); diff --git a/src/bin/bench.rs b/src/bin/bench.rs index ab60764..7be7ffd 100644 --- a/src/bin/bench.rs +++ b/src/bin/bench.rs @@ -146,7 +146,9 @@ fn bench_reference( seed: start as u64, k: if restarts == 1 { 1 } else { rcl }, }; - let mut sol = first_solution_with(&model, construct, &mut log); + let mut sol = first_solution_with(&model, construct, |e| { + let _ = log(e); + }); let ctor = eval_routes(&model, &sol).expect("infeasible construction"); match gls { Some(iters) => guided_local_search_with(&model, &mut sol, iters, &mut log), diff --git a/src/solver.rs b/src/solver.rs index ef7d80d..004e846 100644 --- a/src/solver.rs +++ b/src/solver.rs @@ -43,10 +43,12 @@ impl std::fmt::Display for Operator { /// observe them; `search_log` builds one that prints progress lines. Costs are /// whole-solution totals. /// -/// The callback is also a search monitor: return `ControlFlow::Break(())` and -/// the search stops at once, keeps the best solution found, and reports -/// `Done`. Construction ignores it: there is no solution to return until every -/// node is placed. +/// The callback handed to `solve_with`, `local_search_with` or +/// `guided_local_search_with` is also a search monitor: return +/// `ControlFlow::Break(())` and the search stops at once, keeps the best +/// solution found, and reports `Done`. Construction cannot stop before every +/// node is placed, so `first_solution_with` takes a plain observer; a `Break` +/// on `FirstSolution` makes `solve_with` skip the improvement phase. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SearchEvent { /// Construction placed every node; the first complete solution exists. @@ -157,12 +159,18 @@ pub fn solve_with( improve: Improve, mut log: impl FnMut(SearchEvent) -> ControlFlow<()>, ) -> Solution { - let mut sol = first_solution_with(m, construct, &mut log); - match improve { - Improve::HillClimb => local_search_with(m, &mut sol, &mut log), - Improve::Gls { iters } => guided_local_search_with(m, &mut sol, iters, &mut log), + let mut stopped = false; + let mut sol = first_solution_with(m, construct, |e| stopped = log(e).is_break()); + if !stopped { + match improve { + Improve::HillClimb => local_search_with(m, &mut sol, &mut log), + Improve::Gls { iters } => guided_local_search_with(m, &mut sol, iters, &mut log), + } } let cost = eval_routes(m, &sol).expect("solver produced an infeasible solution"); + if stopped { + let _ = log(SearchEvent::Done { cost }); + } Solution { routes: sol, cost } } diff --git a/src/solver/construct.rs b/src/solver/construct.rs index d04834b..20c3386 100644 --- a/src/solver/construct.rs +++ b/src/solver/construct.rs @@ -1,5 +1,3 @@ -use std::ops::ControlFlow; - use super::{Construct, SearchEvent, candidate_vehicles, with_front}; use crate::eval::{Routes, eval_route}; use crate::model::Model; @@ -8,7 +6,7 @@ use crate::types::{Cost, NodeId, VehicleId}; pub fn first_solution_with( m: &Model, construct: Construct, - log: impl FnMut(SearchEvent) -> ControlFlow<()>, + log: impl FnMut(SearchEvent), ) -> Routes { match construct { Construct::CheapestInsertion => cheapest_insertion(m, log), @@ -86,29 +84,19 @@ impl Rng { } /// Always the cheapest insertion on offer. -pub fn cheapest_insertion(m: &Model, log: impl FnMut(SearchEvent) -> ControlFlow<()>) -> Routes { +pub fn cheapest_insertion(m: &Model, log: impl FnMut(SearchEvent)) -> Routes { insertion(m, 1, 0, log) } /// Draws from the `k` cheapest. Not a better first solution, a different one /// per seed, so callers can race several and keep the cheapest. -pub fn greedy_randomized( - m: &Model, - seed: u64, - k: usize, - log: impl FnMut(SearchEvent) -> ControlFlow<()>, -) -> Routes { +pub fn greedy_randomized(m: &Model, seed: u64, k: usize, log: impl FnMut(SearchEvent)) -> Routes { insertion(m, k, seed, log) } /// Insertion with a candidate list of width `k`. The cache below is /// indifferent to the draw: one route still grows per step. -fn insertion( - m: &Model, - k: usize, - seed: u64, - mut log: impl FnMut(SearchEvent) -> ControlFlow<()>, -) -> Routes { +fn insertion(m: &Model, k: usize, seed: u64, mut log: impl FnMut(SearchEvent)) -> Routes { let nv = m.vehicle_count(); let mut rng = Rng(seed); let mut sol: Routes = vec![Vec::new(); nv]; @@ -212,7 +200,7 @@ fn insertion( dirty.iter_mut().for_each(|d| *d = true); } } - let _ = log(SearchEvent::FirstSolution { + log(SearchEvent::FirstSolution { cost: cost.iter().sum(), }); sol diff --git a/src/solver/descent.rs b/src/solver/descent.rs index a216b38..542f1d4 100644 --- a/src/solver/descent.rs +++ b/src/solver/descent.rs @@ -89,6 +89,13 @@ pub(super) fn descend( // node, so without this it rescans one route once per node in it. let mut route_stale = vec![true; sol.len()]; let mut sx = Scratch::default(); + let mut stop = |operator, cost: &[Cost]| { + log(SearchEvent::Improvement { + operator, + cost: cost.iter().sum(), + }) + .is_break() + }; // Draining the queue is not a fixpoint: a move only re-wakes the two // routes it touched, and a node elsewhere may now have an improving move @@ -138,12 +145,7 @@ pub(super) fn descend( }; improved = true; - if log(SearchEvent::Improvement { - operator, - cost: cost.iter().sum(), - }) - .is_break() - { + if stop(operator, &cost) { break 'search; } for t in [Some(r), other_route.filter(|&v| v != r)] @@ -175,12 +177,7 @@ pub(super) fn descend( route_stale[r] = true; route_stale[v] = true; improved = true; - if log(SearchEvent::Improvement { - operator: Operator::TwoOptStar, - cost: cost.iter().sum(), - }) - .is_break() - { + if stop(Operator::TwoOptStar, &cost) { break 'search; } } diff --git a/src/solver/gls.rs b/src/solver/gls.rs index b1656a8..c36afb0 100644 --- a/src/solver/gls.rs +++ b/src/solver/gls.rs @@ -151,10 +151,11 @@ pub fn guided_local_search_with( arc_cost += arcs; } } - let mut flow = log(SearchEvent::GuidedBest { + let mut stopped = log(SearchEvent::GuidedBest { iter: 0, cost: best_cost, - }); + }) + .is_break(); // Scale one penalty into arc-cost units, so lambda * penalty is comparable // to the distances the operators are trading against it. Soft-bound @@ -164,7 +165,7 @@ pub fn guided_local_search_with( let mut penalties = Penalties::new(lambda); for iter in 1..=iters { - if flow.is_break() { + if stopped { break; } penalize_worst_arcs(m, &mut penalties, sol); @@ -181,11 +182,9 @@ pub fn guided_local_search_with( if cost < best_cost { best_cost = cost; best.clone_from(sol); - flow = log(SearchEvent::GuidedBest { iter, cost }); - } - if flow.is_continue() { - flow = log(SearchEvent::GuidedRound { iter, cost }); + stopped = log(SearchEvent::GuidedBest { iter, cost }).is_break(); } + stopped = stopped || log(SearchEvent::GuidedRound { iter, cost }).is_break(); } // `penalties` dies here; no caller can ever see a penalized cost. diff --git a/src/solver/tests.rs b/src/solver/tests.rs index f2e7d15..c3617cc 100644 --- a/src/solver/tests.rs +++ b/src/solver/tests.rs @@ -25,22 +25,22 @@ fn line_model() -> Model { #[test] fn randomized_insertion_is_seeded_not_arbitrary() { let m = line_model(); - let greedy = cheapest_insertion(&m, |_| ControlFlow::Continue(())); + let greedy = cheapest_insertion(&m, |_| {}); assert_eq!( - greedy_randomized(&m, 7, 1, |_| ControlFlow::Continue(())), + greedy_randomized(&m, 7, 1, |_| {}), greedy, "k = 1 is greedy" ); assert_eq!( - greedy_randomized(&m, 7, 3, |_| ControlFlow::Continue(())), - greedy_randomized(&m, 7, 3, |_| ControlFlow::Continue(())), + greedy_randomized(&m, 7, 3, |_| {}), + greedy_randomized(&m, 7, 3, |_| {}), "same seed, same solution" ); let mut diverged = 0; for seed in 0..32 { - let sol = greedy_randomized(&m, seed, 3, |_| ControlFlow::Continue(())); + let sol = greedy_randomized(&m, seed, 3, |_| {}); assert!(visits_all_nodes(&m, &sol), "seed {seed} lost a node"); assert!(eval_routes(&m, &sol).is_some(), "seed {seed} is infeasible"); if sol != greedy { @@ -69,7 +69,7 @@ fn randomized_insertion_respects_drops_and_forbids() { let m = b.build(); for seed in 0..16 { - let sol = greedy_randomized(&m, seed, 4, |_| ControlFlow::Continue(())); + let sol = greedy_randomized(&m, seed, 4, |_| {}); assert!(visits_all_nodes(&m, &sol), "seed {seed} lost a node"); assert!(eval_routes(&m, &sol).is_some(), "seed {seed} is infeasible"); assert!( @@ -324,6 +324,26 @@ fn monitor_stops_guided_search() { assert_eq!(sol.cost, best); } +#[test] +fn monitor_stops_before_improving() { + let m = line_model(); + let mut events = Vec::new(); + let sol = solve_with(&m, Construct::CheapestInsertion, Improve::HillClimb, |e| { + events.push(e); + ControlFlow::Break(()) + }); + let first = cheapest_insertion(&m, |_| {}); + let cost = eval_routes(&m, &first).unwrap(); + assert_eq!(sol.routes, first); + assert_eq!( + events, + [ + SearchEvent::FirstSolution { cost }, + SearchEvent::Done { cost } + ] + ); +} + #[test] fn monitor_stops_hill_climb() { let m = line_model(); diff --git a/tests/gls.rs b/tests/gls.rs index f8ce5fe..fc7e844 100644 --- a/tests/gls.rs +++ b/tests/gls.rs @@ -6,7 +6,6 @@ //! the test covered. use std::fs; -use std::ops::ControlFlow; use std::path::Path; use volare::cvrplib::{Instance, cvrp_model}; @@ -20,9 +19,7 @@ fn gls_improves_on_the_hill_climb() { let n = inst.coords.len(); let m = cvrp_model(&inst, n - 1); - let mut hill = first_solution_with(&m, Construct::CheapestInsertion, |_| { - ControlFlow::Continue(()) - }); + let mut hill = first_solution_with(&m, Construct::CheapestInsertion, |_| {}); local_search(&m, &mut hill); let hill_cost = eval_routes(&m, &hill).expect("infeasible hill climb");