Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions src/bin/bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
//! cargo run --release --bin bench -- --scenario=precede # ordering within a route
//! cargo run --release --bin bench -- --scenario=softtw # priced lateness, no hard close
//! cargo run --release --bin bench -- --scenario=softcap # a tenth over capacity, priced
//! cargo run --release --bin bench -- --scenario=softcap --tight # soft bounds hard while constructing
//!
//! A `--scenario` picks a model variant instead of the plain CVRP: a new
//! constraint is a `*_model` transform plus a `check_*` arm, not a binary
Expand Down Expand Up @@ -54,6 +55,8 @@ fn main() {
.iter()
.find_map(|a| a.strip_prefix("--gls=")?.parse().ok());
let scenario = args.iter().find_map(|a| a.strip_prefix("--scenario="));
// `--tight` builds a scenario's first solution with `Construct::TightInsertion`.
let tight = args.iter().any(|a| a == "--tight");
// N randomized solves, cheapest wins. `--rcl=K` is the draw width.
let restarts: usize = args
.iter()
Expand Down Expand Up @@ -98,7 +101,7 @@ fn main() {
restarts,
rcl,
),
Some(name) => bench_scenario(name, &instances, log_search, gls),
Some(name) => bench_scenario(name, &instances, log_search, gls, tight),
}
}

Expand Down Expand Up @@ -209,7 +212,13 @@ fn bench_reference(
}
}

fn bench_scenario(scenario: &str, instances: &[PathBuf], log_search: bool, gls: Option<usize>) {
fn bench_scenario(
scenario: &str,
instances: &[PathBuf],
log_search: bool,
gls: Option<usize>,
tight: bool,
) {
println!(
"{:<14} {:>5} {:>5} {:>9} {:>9} {:>7} {:>7}",
"instance", "n", "note", "open", "scenario", "delta%", "ms"
Expand All @@ -221,7 +230,7 @@ fn bench_scenario(scenario: &str, instances: &[PathBuf], log_search: bool, gls:
let mut log = logger(log_search);

let started = Instant::now();
let open = solve(&cvrp_model(&inst, fleet), gls, &mut log).cost;
let open = solve(&cvrp_model(&inst, fleet), gls, false, &mut log).cost;
let mut note = String::new();
let model = cvrp_model_with(&inst, fleet, |b| match scenario {
"forbid" => forbid_model(&inst, b, &mut note),
Expand All @@ -232,7 +241,7 @@ fn bench_scenario(scenario: &str, instances: &[PathBuf], log_search: bool, gls:
"softcap" => softcap_model(&inst, b, &mut note),
_ => unreachable!("gated in main"),
});
let sol = solve(&model, gls, &mut log);
let sol = solve(&model, gls, tight, &mut log);
let ms = started.elapsed().as_secs_f64() * 1000.0;

// The whole point of the run: the constraint held.
Expand Down Expand Up @@ -470,12 +479,17 @@ fn free_fleet(inst: &Instance) -> usize {
inst.coords.len() - 1
}

fn solve(m: &Model, gls: Option<usize>, log: &mut dyn FnMut(SearchEvent)) -> Solution {
fn solve(m: &Model, gls: Option<usize>, tight: bool, log: &mut dyn FnMut(SearchEvent)) -> Solution {
let improve = match gls {
Some(iters) => Improve::Gls { iters },
None => Improve::HillClimb,
};
solve_with(m, Construct::CheapestInsertion, improve, log)
let construct = if tight {
Construct::TightInsertion { seed: 0, k: 1 }
} else {
Construct::CheapestInsertion
};
solve_with(m, construct, improve, log)
}

fn logger(on: bool) -> Box<dyn FnMut(SearchEvent)> {
Expand Down
8 changes: 8 additions & 0 deletions src/eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ pub fn eval_route(m: &Model, route: &[NodeId], v: VehicleId) -> Option<Cost> {
Some(arcs + soft)
}

/// `eval_route` with every soft bound treated as hard. A route that would
/// pay any penalty is infeasible here.
#[inline]
pub fn eval_route_tight(m: &Model, route: &[NodeId], v: VehicleId) -> Option<Cost> {
let (arcs, soft) = eval_route_split(m, route, v)?;
(soft == 0).then_some(arcs)
}

/// `eval_route` with the arc cost and the soft-bound penalty kept apart.
pub fn eval_route_split(m: &Model, route: &[NodeId], v: VehicleId) -> Option<(Cost, Cost)> {
// An unused vehicle is free, it never leaves the depot.
Expand Down
11 changes: 10 additions & 1 deletion src/solver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ mod operators;
#[cfg(test)]
mod tests;

pub use construct::{cheapest_insertion, first_solution_with, greedy_randomized};
pub use construct::{cheapest_insertion, first_solution_with, greedy_randomized, tight_insertion};
pub use descent::{local_search, local_search_with};
pub use gls::{guided_local_search, guided_local_search_with};

Expand Down Expand Up @@ -100,6 +100,15 @@ pub enum Construct {
seed: u64,
k: usize,
},
/// `GreedyRandomized` with the soft bounds held hard for as long as every
/// node still fits under them, then released. The first solution pays no
/// penalty when a penalty-free one is reachable. Fits overload-style
/// softness; a window-style one tends to open extra routes instead of
/// buying a cheap late slot.
TightInsertion {
seed: u64,
k: usize,
},
}

/// How that solution is then made cheaper.
Expand Down
51 changes: 40 additions & 11 deletions src/solver/construct.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
use super::{Construct, SearchEvent, candidate_vehicles, with_front};
use crate::eval::{Routes, eval_route};
use crate::eval::{Routes, eval_route, eval_route_tight};
use crate::model::Model;
use crate::types::{Cost, NodeId, VehicleId};

/// The route cost construction prices insertions with: `eval_route_tight`
/// while every node still fits under the soft bounds, `eval_route` after.
type Eval = fn(&Model, &[NodeId], VehicleId) -> Option<Cost>;

pub fn first_solution_with(
m: &Model,
construct: Construct,
Expand All @@ -11,6 +15,7 @@ pub fn first_solution_with(
match construct {
Construct::CheapestInsertion => cheapest_insertion(m, log),
Construct::GreedyRandomized { seed, k } => greedy_randomized(m, seed, k, log),
Construct::TightInsertion { seed, k } => tight_insertion(m, seed, k, log),
}
}

Expand All @@ -20,6 +25,7 @@ type Insertion = (Cost, usize, usize);
/// Cheapest feasible position for `u` in route `v`, or `None` if it has none.
fn best_in_route(
m: &Model,
eval: Eval,
sol: &Routes,
cost: &[Cost],
v: usize,
Expand All @@ -32,7 +38,7 @@ fn best_in_route(
if pos > 0 {
scratch.swap(pos - 1, pos);
}
let Some(c) = eval_route(m, scratch, VehicleId(v as u32)) else {
let Some(c) = eval(m, scratch, VehicleId(v as u32)) else {
continue;
};
let delta = c - cost[v];
Expand All @@ -46,6 +52,7 @@ fn best_in_route(
/// Cheapest feasible position for `u` across `vs`; ties go to the first `vs`.
fn best_over(
m: &Model,
eval: Eval,
sol: &Routes,
cost: &[Cost],
vs: &[usize],
Expand All @@ -54,7 +61,7 @@ fn best_over(
) -> Option<Insertion> {
let mut best: Option<Insertion> = None;
for &v in vs {
if let Some(c) = best_in_route(m, sol, cost, v, u, scratch)
if let Some(c) = best_in_route(m, eval, sol, cost, v, u, scratch)
&& best.is_none_or(|(bd, ..)| c.0 < bd)
{
best = Some(c);
Expand Down Expand Up @@ -85,18 +92,30 @@ impl Rng {

/// Always the cheapest insertion on offer.
pub fn cheapest_insertion(m: &Model, log: impl FnMut(SearchEvent)) -> Routes {
insertion(m, 1, 0, log)
insertion(m, 1, 0, false, 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 {
insertion(m, k, seed, log)
insertion(m, k, seed, false, log)
}

/// `greedy_randomized` with the soft bounds held hard until a node fits
/// nowhere under them; see `Construct::TightInsertion`.
pub fn tight_insertion(m: &Model, seed: u64, k: usize, log: impl FnMut(SearchEvent)) -> Routes {
insertion(m, k, seed, true, 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 tight: bool,
mut log: impl FnMut(SearchEvent),
) -> Routes {
let nv = m.vehicle_count();
let mut rng = Rng(seed);
let mut sol: Routes = vec![Vec::new(); nv];
Expand All @@ -117,6 +136,9 @@ fn insertion(m: &Model, k: usize, seed: u64, mut log: impl FnMut(SearchEvent)) -
let mut changed: Option<usize> = None;
// Each node's best this round, the draw picks from here. Reused.
let mut ranked: Vec<(Insertion, usize)> = Vec::new();
// ponytail: once loosened it stays loose; re-tightening per node costs a
// full rescan of the cache each time.
let mut eval: Eval = if tight { eval_route_tight } else { eval_route };

while !unrouted.is_empty() {
// `candidate_vehicles` keeps used routes in a fixed order, so a cached
Expand All @@ -132,23 +154,23 @@ fn insertion(m: &Model, k: usize, seed: u64, mut log: impl FnMut(SearchEvent)) -
for i in 0..unrouted.len() {
let u = unrouted[i];
if dirty[i] {
best[i] = best_over(m, &sol, &cost, used, u, &mut scratch);
best[i] = best_over(m, eval, &sol, &cost, used, u, &mut scratch);
dirty[i] = false;
} else if let Some(t) = changed {
let held = best[i];
let fresh_t = best_in_route(m, &sol, &cost, t, u, &mut scratch);
let fresh_t = best_in_route(m, eval, &sol, &cost, t, u, &mut scratch);
// Every route but `t` was already worse and none of them
// moved, so only a `t` that got worse needs a full rescan.
best[i] = match (held, fresh_t) {
(Some((d, v, _)), Some(c)) if v == t && c.0 <= d => Some(c),
(Some((_, v, _)), _) if v == t => {
best_over(m, &sol, &cost, used, u, &mut scratch)
best_over(m, eval, &sol, &cost, used, u, &mut scratch)
}
(_, Some(c)) if held.is_none_or(|b| c < b) => Some(c),
_ => held,
};
}
let fresh = best_over(m, &sol, &cost, empty, u, &mut scratch);
let fresh = best_over(m, eval, &sol, &cost, empty, u, &mut scratch);
let node_best = match (best[i], fresh) {
(Some(b), Some(f)) if f.0 < b.0 => Some(f),
(Some(b), _) => Some(b),
Expand All @@ -166,12 +188,19 @@ fn insertion(m: &Model, k: usize, seed: u64, mut log: impl FnMut(SearchEvent)) -
if widened {
let all: Vec<usize> = (0..nv).collect();
for (i, &u) in unrouted.iter().enumerate() {
if let Some(c) = best_over(m, &sol, &cost, &all, u, &mut scratch) {
if let Some(c) = best_over(m, eval, &sol, &cost, &all, u, &mut scratch) {
ranked.push((c, i));
}
}
}

if ranked.is_empty() && tight {
tight = false;
eval = eval_route;
dirty.iter_mut().for_each(|d| *d = true);
continue;
}

if ranked.is_empty() {
if let Some(n) = unrouted
.iter()
Expand Down
47 changes: 47 additions & 0 deletions tests/soft.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,50 @@ fn permutations(items: &mut [NodeId], k: usize, visit: &mut impl FnMut(&[NodeId]
items.swap(k, i);
}
}

/// Tight construction pays no penalty while a penalty-free placement exists,
/// and still routes a node that has none.
#[test]
fn tight_construction_starts_penalty_free_and_falls_back() {
use volare::solver::first_solution_with;

let dist = |a: NodeId, b: NodeId| (a.0 as i64 - b.0 as i64).abs() * 10;
let mut b = ModelBuilder::new(6);
let cost = b.cost_class(dist);
for _ in 0..3 {
b.vehicle(NodeId(0), NodeId(0), cost);
}
b.dimension(
"load",
|_, to| if to == NodeId(0) { 0 } else { 1 },
vec![5; 3],
);
// Two per vehicle is cheap to break, so the priced greedy overloads one
// route; tight construction spreads the five parcels over three.
for v in 0..3 {
b.soft_max_cumul("load", VehicleId(v), 2, 1);
}
let m = b.build();
let priced = first_solution_with(&m, Construct::CheapestInsertion, |_| {});
let tight = first_solution_with(&m, Construct::TightInsertion { seed: 0, k: 1 }, |_| {});
assert!(
!violations(&m, &priced).is_empty(),
"priced greedy overloads"
);
assert!(
violations(&m, &tight).is_empty(),
"tight construction does not"
);

// Node 5 can never meet a soft close of zero: tight construction has to
// loosen for it rather than leave it unrouted.
let mut b = ModelBuilder::new(6);
let cost = b.cost_class(dist);
b.vehicle(NodeId(0), NodeId(0), cost);
b.dimension("time", dist, vec![i64::MAX]);
b.soft_upper_bound("time", NodeId(5), 0, 1);
let m = b.build();
let tight = first_solution_with(&m, Construct::TightInsertion { seed: 0, k: 1 }, |_| {});
assert_eq!(tight[0].len(), 5, "every node routed");
assert_eq!(violations(&m, &tight).len(), 1);
}
Loading