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..8822804 --- /dev/null +++ b/examples/early_stop.rs @@ -0,0 +1,61 @@ +//! 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::fs; +use std::ops::ControlFlow; +use std::path::Path; +use std::time::{Duration, Instant}; + +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() { + 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(); + // 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..7be7ffd 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; @@ -145,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), @@ -466,7 +469,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 +481,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..004e846 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,16 @@ 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 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. @@ -51,12 +59,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 +85,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 +99,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,23 +147,30 @@ 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 { - 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/descent.rs b/src/solver/descent.rs index 070370f..542f1d4 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")) @@ -84,11 +89,18 @@ 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 // 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 +145,9 @@ pub(super) fn descend( }; improved = true; - log(SearchEvent::Improvement { - operator, - cost: cost.iter().sum(), - }); + if stop(operator, &cost) { + break 'search; + } for t in [Some(r), other_route.filter(|&v| v != r)] .into_iter() .flatten() @@ -166,19 +177,18 @@ pub(super) fn descend( route_stale[r] = true; route_stale[v] = true; improved = true; - log(SearchEvent::Improvement { - operator: Operator::TwoOptStar, - cost: cost.iter().sum(), - }); + if stop(Operator::TwoOptStar, &cost) { + 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..c36afb0 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,10 +151,11 @@ pub fn guided_local_search_with( arc_cost += arcs; } } - 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 @@ -162,12 +165,15 @@ pub fn guided_local_search_with( let mut penalties = Penalties::new(lambda); for iter in 1..=iters { + if stopped { + 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 +182,14 @@ pub fn guided_local_search_with( if cost < best_cost { best_cost = cost; best.clone_from(sol); - log(SearchEvent::GuidedBest { 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. *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..c3617cc 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}; @@ -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,83 @@ 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_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(); + 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])); +}