Skip to content

Commit 5b24b14

Browse files
hutchinsp01claude
andcommitted
test(objectives): cover pinned work and breaks in prefer-early-tours
vrp-core unit tests for the new rule: a shift holding only unmovable work still charges its delay for the first job that could have gone elsewhere, a job which cannot change shift charges nothing, a shift already holding movable work stays free, and fitness ignores shifts held open by unmovable work alone. The paired regression test asserts an earlier empty shift beats one held open by standing work - under the previous behaviour those score 432000 against 345600, so joining the late shift wins. vrp-pragmatic behavioural tests over a plan where a relation pins an appointment to a later shift, parked next to the jobs so cost prefers joining it. Covered for an any relation and a strict one, since the two reach the solver by different paths. A third test asserts the break on that shift is still taken. Each test was checked against the unfixed code: with nothing excluded all three fail, and with pinned jobs excluded but breaks still counted, only the break test fails. minimize-tours is left out of the objective list in these tests on purpose - ranked above, it consolidates onto the pinned shift whatever this objective prefers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ac3cdbe commit 5b24b14

2 files changed

Lines changed: 221 additions & 2 deletions

File tree

vrp-core/tests/unit/construction/features/early_tours_test.rs

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,51 @@ fn create_fleet(days: usize) -> Fleet {
2828
.build()
2929
}
3030

31+
/// A job here stands for work the solver cannot move off its shift: one pinned by a relation, or a
32+
/// service job the vehicle generates for that shift alone, such as a break.
33+
const PINNED_LOCATION: Location = 100;
34+
3135
fn create_objective(fleet: &Fleet) -> Arc<dyn FeatureObjective> {
32-
create_prefer_early_tours_feature("prefer_early_tours", get_earliest_shift_start(fleet)).unwrap().objective.unwrap()
36+
create_objective_with(fleet, Arc::new(|_| true))
37+
}
38+
39+
/// Builds an objective which treats jobs at [`PINNED_LOCATION`] as unmovable.
40+
fn create_pinning_objective(fleet: &Fleet) -> Arc<dyn FeatureObjective> {
41+
create_objective_with(fleet, Arc::new(|job: &Job| job_location(job) != Some(PINNED_LOCATION)))
42+
}
43+
44+
fn create_objective_with(fleet: &Fleet, is_discretionary_fn: DiscretionaryJobFn) -> Arc<dyn FeatureObjective> {
45+
create_prefer_early_tours_feature("prefer_early_tours", get_earliest_shift_start(fleet), is_discretionary_fn)
46+
.unwrap()
47+
.objective
48+
.unwrap()
49+
}
50+
51+
fn job_location(job: &Job) -> Option<Location> {
52+
job.as_single().and_then(|single| single.places.first()).and_then(|place| place.location)
3353
}
3454

3555
/// Creates a route on the given day's shift with `job_count` jobs in its tour.
3656
fn create_route_ctx(fleet: &Fleet, day: usize, job_count: usize) -> RouteContext {
57+
create_route_ctx_at(fleet, day, (0..job_count).map(|idx| idx + 1).collect())
58+
}
59+
60+
/// Creates a route on the given day's shift with a job at each of the given locations.
61+
fn create_route_ctx_at(fleet: &Fleet, day: usize, locations: Vec<Location>) -> RouteContext {
3762
let route = RouteBuilder::default()
3863
.with_vehicle(fleet, format!("day_{day}").as_str())
39-
.add_activities((0..job_count).map(|idx| ActivityBuilder::with_location(idx + 1).build()))
64+
.add_activities(locations.into_iter().map(|location| ActivityBuilder::with_location(location).build()))
4065
.build();
4166

4267
RouteContextBuilder::default().with_route(route).build()
4368
}
4469

70+
fn get_fitness(objective: &Arc<dyn FeatureObjective>, fleet: &Fleet, days: Vec<(usize, Vec<Location>)>) -> Cost {
71+
let routes = days.into_iter().map(|(day, locations)| create_route_ctx_at(fleet, day, locations)).collect();
72+
73+
objective.fitness(&TestInsertionContextBuilder::default().with_routes(routes).build())
74+
}
75+
4576
parameterized_test! {can_estimate_delay_of_opening_a_shift, (day, expected), {
4677
can_estimate_delay_of_opening_a_shift_impl(day, expected);
4778
}}
@@ -150,3 +181,93 @@ fn can_use_first_job_arrival_floor_when_shift_start_is_relaxed() {
150181
assert_eq!(actor.detail.time.start, 0.);
151182
assert_eq!(get_shift_start(actor.as_ref()), 3. * DAY);
152183
}
184+
185+
parameterized_test! {can_estimate_delay_for_a_shift_holding_only_unmovable_work, (day, expected), {
186+
can_estimate_delay_for_a_shift_holding_only_unmovable_work_impl(day, expected);
187+
}}
188+
189+
can_estimate_delay_for_a_shift_holding_only_unmovable_work! {
190+
case_01_earliest_shift_is_still_free: (0, 0.),
191+
case_02_last_day: (4, 4. * DAY),
192+
}
193+
194+
fn can_estimate_delay_for_a_shift_holding_only_unmovable_work_impl(day: usize, expected: Cost) {
195+
// a standing appointment does not pay for the shift, so the first job which could have gone
196+
// elsewhere still does - otherwise that day reads as already paid for and draws in new work
197+
let fleet = create_fleet(5);
198+
let objective = create_pinning_objective(&fleet);
199+
let route_ctx = create_route_ctx_at(&fleet, day, vec![PINNED_LOCATION]);
200+
let solution_ctx = TestInsertionContextBuilder::default().build().solution;
201+
let job = TestSingleBuilder::default().location(Some(1)).build_as_job_ref();
202+
203+
let result = objective.estimate(&MoveContext::route(&solution_ctx, &route_ctx, &job));
204+
205+
assert_eq!(result, expected);
206+
}
207+
208+
#[test]
209+
fn can_estimate_nothing_for_a_shift_already_holding_movable_work() {
210+
// once some movable work is on the shift, the delay is already paid and the rest is free
211+
let fleet = create_fleet(5);
212+
let objective = create_pinning_objective(&fleet);
213+
let route_ctx = create_route_ctx_at(&fleet, 4, vec![PINNED_LOCATION, 1]);
214+
let solution_ctx = TestInsertionContextBuilder::default().build().solution;
215+
let job = TestSingleBuilder::default().location(Some(2)).build_as_job_ref();
216+
217+
let result = objective.estimate(&MoveContext::route(&solution_ctx, &route_ctx, &job));
218+
219+
assert_eq!(result, 0.);
220+
}
221+
222+
#[test]
223+
fn can_estimate_nothing_for_a_job_which_cannot_change_shift() {
224+
// the job has nowhere else to go, so charging its shift would only add noise
225+
let fleet = create_fleet(5);
226+
let objective = create_pinning_objective(&fleet);
227+
let route_ctx = create_route_ctx_at(&fleet, 4, vec![]);
228+
let solution_ctx = TestInsertionContextBuilder::default().build().solution;
229+
let job = TestSingleBuilder::default().location(Some(PINNED_LOCATION)).build_as_job_ref();
230+
231+
let result = objective.estimate(&MoveContext::route(&solution_ctx, &route_ctx, &job));
232+
233+
assert_eq!(result, 0.);
234+
}
235+
236+
parameterized_test! {can_estimate_fitness_ignoring_unmovable_work, (days, expected), {
237+
can_estimate_fitness_ignoring_unmovable_work_impl(days, expected);
238+
}}
239+
240+
can_estimate_fitness_ignoring_unmovable_work! {
241+
case_01_a_shift_of_standing_work_alone_is_free: (vec![(4, vec![PINNED_LOCATION])], 0.),
242+
case_02_new_work_on_the_earliest_shift: (vec![(0, vec![1]), (4, vec![PINNED_LOCATION])], 0.),
243+
case_03_new_work_joining_the_standing_shift: (vec![(4, vec![PINNED_LOCATION, 1])], 4. * DAY),
244+
case_04_new_work_on_its_own_second_day: (vec![(1, vec![1]), (4, vec![PINNED_LOCATION])], DAY),
245+
// standing work on a shift which is worked anyway changes nothing
246+
case_05_mixed_shift_costs_its_delay_once: (vec![(1, vec![PINNED_LOCATION, 1, 2])], DAY),
247+
}
248+
249+
fn can_estimate_fitness_ignoring_unmovable_work_impl(days: Vec<(usize, Vec<Location>)>, expected: Cost) {
250+
let fleet = create_fleet(5);
251+
let objective = create_pinning_objective(&fleet);
252+
253+
let result = get_fitness(&objective, &fleet, days);
254+
255+
assert_eq!(result, expected);
256+
}
257+
258+
#[test]
259+
fn can_prefer_an_earlier_empty_shift_over_one_held_open_by_standing_work() {
260+
// the regression this rule exists for: a recurring Thursday appointment must not make Thursday
261+
// a free home for work which could have been done on Tuesday. Counting the pinned shift makes
262+
// joining it score delay(Thu) against delay(Tue) + delay(Thu), so joining always wins.
263+
let fleet = create_fleet(5);
264+
let objective = create_pinning_objective(&fleet);
265+
266+
let join_standing_shift = get_fitness(&objective, &fleet, vec![(4, vec![PINNED_LOCATION, 1])]);
267+
let open_earlier_shift = get_fitness(&objective, &fleet, vec![(1, vec![1]), (4, vec![PINNED_LOCATION])]);
268+
269+
assert!(
270+
open_earlier_shift < join_standing_shift,
271+
"expected opening the earlier shift ({open_earlier_shift}) to beat joining the standing one ({join_standing_shift})"
272+
);
273+
}

vrp-pragmatic/tests/features/objectives/prefer_early_tours.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::format::problem::Objective::*;
22
use crate::format::problem::*;
3+
use crate::format::solution::Solution;
34
use crate::format_time;
45
use crate::helpers::*;
56

@@ -88,3 +89,100 @@ fn can_prefer_earliest_shift_with_out_of_hours_depot_travel() {
8889
fn objectives() -> Option<Vec<Objective>> {
8990
Some(vec![MinimizeUnassigned { breaks: None }, MinimizeTours, PreferEarlyTours, MinimizeCost])
9091
}
92+
93+
/// Builds a plan where a standing appointment pinned by a relation already holds the `late` shift
94+
/// open, plus one new job which either shift could serve. `late` is parked next to both jobs while
95+
/// `early` has to drive out to them, and the new job sits exactly where the appointment already is,
96+
/// so joining the standing shift is free and cost clearly prefers it. The new job therefore only
97+
/// lands on `early` if this objective says so.
98+
fn create_problem_with_standing_work(relation_type: RelationType, late_break: Option<VehicleBreak>) -> Problem {
99+
let shift = |earliest: f64, latest: f64, location: (f64, f64), breaks: Option<Vec<VehicleBreak>>| VehicleShift {
100+
start: ShiftStart { earliest: format_time(earliest), latest: None, location: location.to_loc() },
101+
end: Some(ShiftEnd { earliest: None, latest: format_time(latest), location: location.to_loc() }),
102+
breaks,
103+
..create_default_vehicle_shift()
104+
};
105+
106+
Problem {
107+
plan: Plan {
108+
jobs: vec![create_delivery_job("standing", (100., 0.)), create_delivery_job("new", (100., 0.))],
109+
relations: Some(vec![Relation {
110+
type_field: relation_type,
111+
jobs: to_strings(vec!["standing"]),
112+
vehicle_id: "late_1".to_string(),
113+
shift_index: None,
114+
}]),
115+
..create_empty_plan()
116+
},
117+
fleet: Fleet {
118+
vehicles: vec![
119+
VehicleType { shifts: vec![shift(0., 1000., (0., 0.), None)], ..create_default_vehicle("early") },
120+
VehicleType {
121+
shifts: vec![shift(10000., 11000., (95., 0.), late_break.map(|item| vec![item]))],
122+
..create_default_vehicle("late")
123+
},
124+
],
125+
..create_default_fleet()
126+
},
127+
// `minimize-tours` is left out on purpose: ranked above, it would consolidate the new job
128+
// onto the standing shift to save a tour, whatever this objective prefers
129+
objectives: Some(vec![MinimizeUnassigned { breaks: None }, PreferEarlyTours, MinimizeCost]),
130+
}
131+
}
132+
133+
fn solve_problem(problem: Problem) -> Solution {
134+
let matrix = create_matrix_from_problem(&problem);
135+
136+
solve_with_metaheuristic(problem, Some(vec![matrix]))
137+
}
138+
139+
/// Returns the id of the vehicle serving the given job.
140+
fn get_vehicle_serving(solution: &Solution, job_id: &str) -> String {
141+
solution
142+
.tours
143+
.iter()
144+
.find(|tour| get_ids_from_tour(tour).iter().flatten().any(|id| id == job_id))
145+
.unwrap_or_else(|| panic!("'{job_id}' is not served in any tour"))
146+
.vehicle_id
147+
.clone()
148+
}
149+
150+
#[test]
151+
fn can_ignore_a_shift_held_open_by_a_pinned_job() {
152+
// a recurring appointment on a later day must not turn that day into a free home for new work:
153+
// it sits there whatever this objective prefers, so it does not pay for the shift
154+
let solution = solve_problem(create_problem_with_standing_work(RelationType::Any, None));
155+
156+
assert_eq!(get_vehicle_serving(&solution, "new"), "early_1");
157+
}
158+
159+
#[test]
160+
fn can_ignore_a_shift_held_open_by_a_strictly_pinned_job() {
161+
// `strict` and `sequence` relations reach the solver as locked jobs while `any` reaches it as
162+
// reserved ones, so the objective reads the locks rather than the solution's locked set
163+
let solution = solve_problem(create_problem_with_standing_work(RelationType::Strict, None));
164+
165+
assert_eq!(get_vehicle_serving(&solution, "new"), "early_1");
166+
}
167+
168+
#[test]
169+
fn can_keep_a_break_on_a_shift_held_open_by_a_pinned_job() {
170+
// a break belongs to one shift and can never be taken a day earlier, so it must not read as
171+
// work the solver chose to put there. Counting it would price the break at its shift's delay,
172+
// and since this objective outranks cost the solver would rather drop the break than pay it.
173+
let vehicle_break = VehicleBreak::Optional {
174+
time: VehicleOptionalBreakTime::TimeWindow(vec![format_time(10000.), format_time(10100.)]),
175+
places: vec![VehicleOptionalBreakPlace { duration: 2.0, location: None, tag: None }],
176+
policy: None,
177+
};
178+
179+
let solution = solve_problem(create_problem_with_standing_work(RelationType::Any, Some(vehicle_break)));
180+
181+
let late_tour = solution.tours.iter().find(|tour| tour.vehicle_id == "late_1").expect("no tour for 'late_1'");
182+
assert_eq!(
183+
get_ids_from_tour(late_tour),
184+
vec![vec!["departure"], vec!["standing", "break"], vec!["arrival"]],
185+
"the break should still be taken on the standing shift"
186+
);
187+
assert_eq!(get_vehicle_serving(&solution, "new"), "early_1");
188+
}

0 commit comments

Comments
 (0)