Skip to content

Commit a81c8b8

Browse files
hutchinsp01claude
andcommitted
test(objectives): add tests for prefer-early-tours objective
vrp-core unit tests covering the delay charged for opening a shift, that a shift already in use costs nothing however late it starts, the fitness sum over shifts in use, empty routes scoring nothing, and the first-job arrival floor fallback used when out-of-hours depot travel relaxes the shift start bound. vrp-pragmatic round-trip plus behavioural tests: a baseline asserting the solver picks the cheaper later shift when no objective is set, so the paired test that it picks the earliest shift cannot pass vacuously, and a case covering the out-of-hours depot travel interaction. E1608 validation tests covering the unguarded goal, both accepted guards, a guard ranked below the objective, a guard sharing a tier with it, a guard in a higher multi-objective tier, and a goal that never mentions the objective. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 0b2aba0 commit a81c8b8

5 files changed

Lines changed: 295 additions & 0 deletions

File tree

vrp-core/src/construction/features/early_tours.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
//!
4040
//! Objective only - no constraint or state.
4141
42+
#[cfg(test)]
43+
#[path = "../../../tests/unit/construction/features/early_tours_test.rs"]
44+
mod early_tours_test;
45+
4246
use super::*;
4347
use crate::construction::enablers::FirstJobArrivalFloorDimension;
4448
use crate::models::solution::Route;
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
use super::*;
2+
use crate::construction::enablers::FirstJobArrivalFloorDimension;
3+
use crate::helpers::construction::heuristics::TestInsertionContextBuilder;
4+
use crate::helpers::models::problem::*;
5+
use crate::helpers::models::solution::*;
6+
use crate::models::common::TimeInterval;
7+
use crate::models::problem::{VehicleDetail, VehiclePlace};
8+
9+
const DAY: Timestamp = 86400.;
10+
11+
/// Builds a fleet where vehicle `i` is a shift starting on day `i`, mimicking a multi-day plan.
12+
fn create_fleet(days: usize) -> Fleet {
13+
(0..days)
14+
.fold(FleetBuilder::default().add_driver(test_driver()), |builder, day| {
15+
builder.add_vehicle(
16+
TestVehicleBuilder::default()
17+
.id(format!("day_{day}").as_str())
18+
.details(vec![VehicleDetail {
19+
start: Some(VehiclePlace {
20+
location: 0,
21+
time: TimeInterval { earliest: Some(day as Timestamp * DAY), latest: None },
22+
}),
23+
end: None,
24+
}])
25+
.build(),
26+
)
27+
})
28+
.build()
29+
}
30+
31+
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()
33+
}
34+
35+
/// Creates a route on the given day's shift with `job_count` jobs in its tour.
36+
fn create_route_ctx(fleet: &Fleet, day: usize, job_count: usize) -> RouteContext {
37+
let route = RouteBuilder::default()
38+
.with_vehicle(fleet, format!("day_{day}").as_str())
39+
.add_activities((0..job_count).map(|idx| ActivityBuilder::with_location(idx + 1).build()))
40+
.build();
41+
42+
RouteContextBuilder::default().with_route(route).build()
43+
}
44+
45+
parameterized_test! {can_estimate_delay_of_opening_a_shift, (day, expected), {
46+
can_estimate_delay_of_opening_a_shift_impl(day, expected);
47+
}}
48+
49+
can_estimate_delay_of_opening_a_shift! {
50+
case_01_earliest_shift_is_free: (0, 0.),
51+
case_02_next_day: (1, DAY),
52+
case_03_last_day: (4, 4. * DAY),
53+
}
54+
55+
fn can_estimate_delay_of_opening_a_shift_impl(day: usize, expected: Cost) {
56+
let fleet = create_fleet(5);
57+
let objective = create_objective(&fleet);
58+
let route_ctx = create_route_ctx(&fleet, day, 0);
59+
let solution_ctx = TestInsertionContextBuilder::default().build().solution;
60+
let job = TestSingleBuilder::default().location(Some(1)).build_as_job_ref();
61+
62+
let result = objective.estimate(&MoveContext::route(&solution_ctx, &route_ctx, &job));
63+
64+
assert_eq!(result, expected);
65+
}
66+
67+
parameterized_test! {can_estimate_nothing_for_a_shift_already_in_use, day, {
68+
can_estimate_nothing_for_a_shift_already_in_use_impl(day);
69+
}}
70+
71+
can_estimate_nothing_for_a_shift_already_in_use! {
72+
case_01_earliest_shift: 0,
73+
case_02_last_day: 4,
74+
}
75+
76+
fn can_estimate_nothing_for_a_shift_already_in_use_impl(day: usize) {
77+
let fleet = create_fleet(5);
78+
let objective = create_objective(&fleet);
79+
let route_ctx = create_route_ctx(&fleet, day, 2);
80+
let solution_ctx = TestInsertionContextBuilder::default().build().solution;
81+
let job = TestSingleBuilder::default().location(Some(1)).build_as_job_ref();
82+
83+
let result = objective.estimate(&MoveContext::route(&solution_ctx, &route_ctx, &job));
84+
85+
assert_eq!(result, 0.);
86+
}
87+
88+
#[test]
89+
fn can_ignore_activity_position_within_tour() {
90+
let fleet = create_fleet(5);
91+
let objective = create_objective(&fleet);
92+
let route_ctx = create_route_ctx(&fleet, 4, 2);
93+
let solution_ctx = TestInsertionContextBuilder::default().build().solution;
94+
95+
let result = objective.estimate(&MoveContext::activity(
96+
&solution_ctx,
97+
&route_ctx,
98+
&ActivityContext {
99+
index: 0,
100+
prev: &ActivityBuilder::with_location(1).build(),
101+
target: &ActivityBuilder::with_location(2).build(),
102+
next: None,
103+
},
104+
));
105+
106+
assert_eq!(result, 0.);
107+
}
108+
109+
parameterized_test! {can_estimate_fitness, (days_with_jobs, expected), {
110+
can_estimate_fitness_impl(days_with_jobs, expected);
111+
}}
112+
113+
can_estimate_fitness! {
114+
case_01_all_work_in_first_shift: (vec![(0, 4)], 0.),
115+
case_02_front_loaded: (vec![(0, 2), (1, 2)], DAY),
116+
case_03_back_loaded: (vec![(3, 2), (4, 2)], 7. * DAY),
117+
// the same two days cost the same however the work is split between them: the objective picks
118+
// which days are worked, not what goes on each
119+
case_04_split_does_not_matter: (vec![(0, 1), (1, 3)], DAY),
120+
case_05_empty_late_route_is_free: (vec![(0, 4), (4, 0)], 0.),
121+
}
122+
123+
fn can_estimate_fitness_impl(days_with_jobs: Vec<(usize, usize)>, expected: Cost) {
124+
let fleet = create_fleet(5);
125+
let objective = create_objective(&fleet);
126+
let routes = days_with_jobs.iter().map(|&(day, jobs)| create_route_ctx(&fleet, day, jobs)).collect();
127+
let insertion_ctx = TestInsertionContextBuilder::default().with_routes(routes).build();
128+
129+
let result = objective.fitness(&insertion_ctx);
130+
131+
assert_eq!(result, expected);
132+
}
133+
134+
#[test]
135+
fn can_use_first_job_arrival_floor_when_shift_start_is_relaxed() {
136+
// `allow_out_of_hours_depot_travel` relaxes the shift's start bound, so `detail.time.start`
137+
// collapses to zero and the floor carries the real shift start.
138+
let vehicle = {
139+
let mut builder = TestVehicleBuilder::default();
140+
builder.id("relaxed").details(vec![VehicleDetail {
141+
start: Some(VehiclePlace { location: 0, time: TimeInterval { earliest: None, latest: None } }),
142+
end: None,
143+
}]);
144+
builder.dimens_mut().set_first_job_arrival_floor(3. * DAY);
145+
builder.build()
146+
};
147+
let fleet = FleetBuilder::default().add_driver(test_driver()).add_vehicle(vehicle).build();
148+
let actor = get_test_actor_from_fleet(&fleet, "relaxed");
149+
150+
assert_eq!(actor.detail.time.start, 0.);
151+
assert_eq!(get_shift_start(actor.as_ref()), 3. * DAY);
152+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
mod minimize_depot_travel_time;
2+
mod prefer_early_tours;
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
use crate::format::problem::Objective::*;
2+
use crate::format::problem::*;
3+
use crate::format_time;
4+
use crate::helpers::*;
5+
6+
#[test]
7+
fn can_round_trip_prefer_early_tours_objective() {
8+
let json = r#"{"type":"prefer-early-tours"}"#;
9+
10+
let objective: Objective = serde_json::from_str(json).expect("cannot deserialize objective");
11+
assert!(matches!(objective, PreferEarlyTours));
12+
13+
let serialized = serde_json::to_string(&objective).expect("cannot serialize objective");
14+
assert_eq!(serialized, json);
15+
}
16+
17+
/// Builds a problem with one job and two single-shift vehicles: `early` starts first but its depot
18+
/// is far from the job, `late` starts a day later but is parked on top of it. Cost alone therefore
19+
/// prefers `late`.
20+
fn create_problem(objectives: Option<Vec<Objective>>, out_of_hours_travel: bool) -> Problem {
21+
let shift = |earliest: f64, latest: f64, location: (f64, f64)| VehicleShift {
22+
start: ShiftStart { earliest: format_time(earliest), latest: None, location: location.to_loc() },
23+
end: Some(ShiftEnd { earliest: None, latest: format_time(latest), location: location.to_loc() }),
24+
..create_default_vehicle_shift()
25+
};
26+
27+
let limits = || {
28+
out_of_hours_travel.then_some(VehicleLimits {
29+
max_distance: None,
30+
max_duration: None,
31+
tour_size: None,
32+
allow_out_of_hours_depot_travel: Some(true),
33+
})
34+
};
35+
36+
Problem {
37+
plan: Plan { jobs: vec![create_delivery_job("job1", (100., 0.))], ..create_empty_plan() },
38+
fleet: Fleet {
39+
vehicles: vec![
40+
VehicleType {
41+
shifts: vec![shift(0., 1000., (0., 0.))],
42+
limits: limits(),
43+
..create_default_vehicle("early")
44+
},
45+
VehicleType {
46+
shifts: vec![shift(10000., 11000., (100., 0.))],
47+
limits: limits(),
48+
..create_default_vehicle("late")
49+
},
50+
],
51+
..create_default_fleet()
52+
},
53+
objectives,
54+
}
55+
}
56+
57+
fn solve(objectives: Option<Vec<Objective>>, out_of_hours_travel: bool) -> String {
58+
let problem = create_problem(objectives, out_of_hours_travel);
59+
let matrix = create_matrix_from_problem(&problem);
60+
61+
let solution = solve_with_metaheuristic(problem, Some(vec![matrix]));
62+
63+
solution.tours.first().expect("no tours in solution").vehicle_id.clone()
64+
}
65+
66+
#[test]
67+
fn can_prefer_cheaper_later_shift_without_objective() {
68+
assert_eq!(solve(None, false), "late_1");
69+
}
70+
71+
#[test]
72+
fn can_prefer_earliest_shift_over_cost() {
73+
let vehicle_id = solve(objectives(), false);
74+
75+
assert_eq!(vehicle_id, "early_1");
76+
}
77+
78+
#[test]
79+
fn can_prefer_earliest_shift_with_out_of_hours_depot_travel() {
80+
// `allow_out_of_hours_depot_travel` relaxes the shift's start bound to `None`, so the actor's
81+
// start time collapses to zero and every shift would look equally early. The objective reads
82+
// the first job arrival floor instead, which keeps the real shift start.
83+
let vehicle_id = solve(objectives(), true);
84+
85+
assert_eq!(vehicle_id, "early_1");
86+
}
87+
88+
fn objectives() -> Option<Vec<Objective>> {
89+
Some(vec![MinimizeUnassigned { breaks: None }, MinimizeTours, PreferEarlyTours, MinimizeCost])
90+
}

vrp-pragmatic/tests/unit/validation/objectives_test.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,3 +207,51 @@ fn can_detect_missing_value_objective_impl(objectives: Option<Vec<Objective>>, e
207207

208208
assert_eq!(result.err().map(|e| e.code), expected);
209209
}
210+
211+
parameterized_test! {can_detect_unguarded_prefer_early_tours, (objectives, expected), {
212+
can_detect_unguarded_prefer_early_tours_impl(objectives, expected);
213+
}}
214+
215+
can_detect_unguarded_prefer_early_tours! {
216+
case01_no_guard: (vec![PreferEarlyTours, MinimizeTours, MinimizeDuration], Some("E1608".to_string())),
217+
case02_guarded_by_unassigned: (
218+
vec![MinimizeUnassigned { breaks: None }, PreferEarlyTours, MinimizeDuration], None),
219+
case03_guarded_by_value: (
220+
vec![MaximizeValue { breaks: None }, PreferEarlyTours, MinimizeDuration], None),
221+
// a guard ranked below never gets consulted: the first differing layer decides
222+
case04_guard_below_is_not_a_guard: (
223+
vec![PreferEarlyTours, MinimizeUnassigned { breaks: None }, MinimizeDuration],
224+
Some("E1608".to_string())),
225+
// the same tier is not "above": neither outranks the other inside one layer
226+
case05_guard_in_same_tier: (
227+
vec![
228+
MultiObjective {
229+
strategy: MultiStrategy::Sum,
230+
objectives: vec![MinimizeUnassigned { breaks: None }, PreferEarlyTours],
231+
},
232+
MinimizeDuration,
233+
],
234+
Some("E1608".to_string())),
235+
case06_guard_in_higher_tier: (
236+
vec![
237+
MultiObjective {
238+
strategy: MultiStrategy::Sum,
239+
objectives: vec![MinimizeUnassigned { breaks: None }, MinimizeTours],
240+
},
241+
PreferEarlyTours,
242+
MinimizeDuration,
243+
],
244+
None),
245+
case07_objective_absent: (vec![MinimizeTours, MinimizeDuration], None),
246+
}
247+
248+
fn can_detect_unguarded_prefer_early_tours_impl(objectives: Vec<Objective>, expected: Option<String>) {
249+
let problem = Problem { objectives: Some(objectives), ..create_empty_problem() };
250+
let coord_index = CoordIndex::new(&problem);
251+
let ctx = ValidationContext::new(&problem, None, &coord_index);
252+
let objectives = get_objectives(&ctx).unwrap();
253+
254+
let result = check_e1608_unguarded_prefer_early_tours(&objectives);
255+
256+
assert_eq!(result.err().map(|err| err.code), expected);
257+
}

0 commit comments

Comments
 (0)