Skip to content

Commit 57732b1

Browse files
committed
fix(vehicle-distance): repair cache pipeline and use round-trip metric
The minimize-vehicle-distance objective silently reported 0 in production because the per-route penalty cache was populated for empty routes and never refreshed after tour mutation. accept_insertion was a no-op that relied on is_stale gating, but is_stale was reset before our state ran in several construction/search paths. Result: the solver believed every job was optimally assigned and skipped the objective entirely. Fixes: * accept_insertion now eagerly refreshes the affected route's per-route cache (WorkBalanceState pattern). * accept_solution_state unconditionally recomputes every route's penalty, no longer gated by is_stale. * fitness() reads per-route cache with live-recompute fallback per route instead of trusting a possibly-stale solution-level total. * compute_route_penalty and find_nearest_compatible_vehicle_dist are deduplicated into a shared helper. * find_nearest_compatible_vehicle_dist no longer hard-codes actors[0].vehicle.profile; it uses the asking route's profile. * Distance comparison switched from single-direction (job→depot) to round-trip (depot→job + job→depot). Routing matrices are typically asymmetric (motorway ramps, one-way streets); the previous choice was direction-arbitrary and led to "wrong vehicle is closest" in the other direction. Round-trip is direction-neutral and matches the real cost of serving a job from a depot. Pipeline integration tests added that exercise accept_insertion and accept_solution_state directly (previous unit tests only hit the pure compute helper, which is why the cache bug slipped through). Verified against the captured tenant-demo problem (981 jobs, 4 vehicles x 22 shifts): solver fitness for this objective starts at ~148k m (round-trip excess) and converges to 0 within ~400 generations. Offline verification confirms every assigned job is on its nearest compatible vehicle in both directions and in round-trip.
1 parent 86a6406 commit 57732b1

2 files changed

Lines changed: 205 additions & 117 deletions

File tree

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

Lines changed: 90 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ mod vehicle_distance_test;
1010

1111
use super::*;
1212

13-
custom_solution_state!(VehicleDistancePenalty typeof Cost);
1413
custom_tour_state!(VehicleDistanceRouteData typeof RouteVehicleDistanceData);
1514

1615
/// A function type that checks whether a given actor is compatible with a given job.
@@ -73,49 +72,39 @@ impl VehicleDistanceFeatureBuilder {
7372
.take()
7473
.ok_or_else(|| GenericError::from("compatibility_fn must be set for vehicle_distance feature"))?;
7574

76-
let objective = VehicleDistanceObjective {
77-
transport: transport.clone(),
78-
actors: actors.clone(),
79-
compatibility_fn: compatibility_fn.clone(),
80-
};
81-
let state = VehicleDistanceState { transport, actors, compatibility_fn };
75+
let shared = Arc::new(VehicleDistanceShared { transport, actors, compatibility_fn });
8276

83-
FeatureBuilder::default().with_name(self.name.as_str()).with_objective(objective).with_state(state).build()
84-
}
85-
}
77+
let objective = VehicleDistanceObjective { shared: shared.clone() };
78+
let state = VehicleDistanceState { shared };
8679

87-
/// Gets the primary location of a job.
88-
fn get_job_location(job: &Job) -> Option<Location> {
89-
match job {
90-
Job::Single(single) => single.places.first().and_then(|p| p.location),
91-
Job::Multi(multi) => multi.jobs.first().and_then(|s| s.places.first().and_then(|p| p.location)),
80+
FeatureBuilder::default().with_name(self.name.as_str()).with_objective(objective).with_state(state).build()
9281
}
9382
}
9483

95-
/// Finds the minimum distance from a job location to the start of any compatible vehicle.
96-
fn find_nearest_compatible_vehicle_dist(
97-
job_loc: Location,
98-
job: &Job,
99-
actors: &[Arc<Actor>],
100-
compatibility_fn: &ActorJobCompatibilityFn,
101-
transport: &(dyn TransportCost + Send + Sync),
102-
) -> Option<Float> {
103-
actors
104-
.iter()
105-
.filter(|actor| compatibility_fn(job, actor))
106-
.filter_map(|actor| actor.detail.start.as_ref().map(|s| s.location))
107-
.map(|start_loc| transport.distance_approx(&actors[0].vehicle.profile, job_loc, start_loc))
108-
.min_by(|a, b| a.total_cmp(b))
109-
}
110-
111-
struct VehicleDistanceObjective {
84+
/// Shared compute logic and dependencies for the vehicle-distance objective and state.
85+
///
86+
/// Both [`VehicleDistanceObjective`] and [`VehicleDistanceState`] go through the same
87+
/// per-route penalty calculation; keeping it in one place avoids the trap of fixing
88+
/// the formula in one copy and forgetting the other.
89+
struct VehicleDistanceShared {
11290
transport: Arc<dyn TransportCost + Send + Sync>,
11391
actors: Vec<Arc<Actor>>,
11492
compatibility_fn: ActorJobCompatibilityFn,
11593
}
11694

117-
impl VehicleDistanceObjective {
118-
/// Computes the penalty for a single route.
95+
impl VehicleDistanceShared {
96+
/// Round-trip distance between a depot and a job location for the given profile.
97+
///
98+
/// Routing matrices are usually asymmetric (one-way streets, motorway ramps).
99+
/// Picking a single direction makes the "nearest vehicle" assignment depend on
100+
/// which way the matrix happens to favor; summing both directions gives a
101+
/// direction-neutral measure of how much travel a vehicle incurs to serve the
102+
/// job from its depot and return.
103+
fn round_trip(&self, profile: &Profile, depot: Location, job_loc: Location) -> Float {
104+
self.transport.distance_approx(profile, depot, job_loc)
105+
+ self.transport.distance_approx(profile, job_loc, depot)
106+
}
107+
119108
fn compute_route_penalty(&self, route_ctx: &RouteContext) -> Cost {
120109
let route = route_ctx.route();
121110
let profile = &route.actor.vehicle.profile;
@@ -132,30 +121,63 @@ impl VehicleDistanceObjective {
132121
let job_loc = activity.place.location;
133122
let job = Job::Single(single.clone());
134123

135-
let dist_assigned = self.transport.distance_approx(profile, job_loc, assigned_start);
124+
let dist_assigned = self.round_trip(profile, assigned_start, job_loc);
136125

137-
let dist_nearest = find_nearest_compatible_vehicle_dist(
138-
job_loc,
139-
&job,
140-
&self.actors,
141-
&self.compatibility_fn,
142-
self.transport.as_ref(),
143-
)
144-
.unwrap_or(dist_assigned);
126+
let dist_nearest = self.find_nearest_compatible_vehicle_dist(job_loc, &job, profile).unwrap_or(dist_assigned);
145127

146128
let penalty = (dist_assigned - dist_nearest).max(0.0);
147129
total_penalty += penalty;
148130
}
149131

150132
total_penalty
151133
}
134+
135+
fn find_nearest_compatible_vehicle_dist(
136+
&self,
137+
job_loc: Location,
138+
job: &Job,
139+
profile: &Profile,
140+
) -> Option<Float> {
141+
self.actors
142+
.iter()
143+
.filter(|actor| (self.compatibility_fn)(job, actor))
144+
.filter_map(|actor| actor.detail.start.as_ref().map(|s| s.location))
145+
.map(|start_loc| self.round_trip(profile, start_loc, job_loc))
146+
.min_by(|a, b| a.total_cmp(b))
147+
}
148+
}
149+
150+
/// Gets the primary location of a job.
151+
fn get_job_location(job: &Job) -> Option<Location> {
152+
match job {
153+
Job::Single(single) => single.places.first().and_then(|p| p.location),
154+
Job::Multi(multi) => multi.jobs.first().and_then(|s| s.places.first().and_then(|p| p.location)),
155+
}
156+
}
157+
158+
struct VehicleDistanceObjective {
159+
shared: Arc<VehicleDistanceShared>,
152160
}
153161

154162
impl FeatureObjective for VehicleDistanceObjective {
155163
fn fitness(&self, solution: &InsertionContext) -> Cost {
156-
solution.solution.state.get_vehicle_distance_penalty().copied().unwrap_or_else(|| {
157-
solution.solution.routes.iter().map(|route_ctx| self.compute_route_penalty(route_ctx)).sum()
158-
})
164+
// We deliberately avoid `solution.solution.state.get_vehicle_distance_penalty()`
165+
// here: the cached value is maintained by `VehicleDistanceState` and is
166+
// sufficient for hot inner loops, but `fitness()` is the source of truth
167+
// reported back to the user. Summing the per-route cache directly keeps us
168+
// honest even if any pipeline ever desyncs the solution-level total.
169+
solution
170+
.solution
171+
.routes
172+
.iter()
173+
.map(|route_ctx| {
174+
route_ctx
175+
.state()
176+
.get_vehicle_distance_route_data()
177+
.map(|data| data.penalty)
178+
.unwrap_or_else(|| self.shared.compute_route_penalty(route_ctx))
179+
})
180+
.sum()
159181
}
160182

161183
fn estimate(&self, move_ctx: &MoveContext<'_>) -> Cost {
@@ -172,16 +194,10 @@ impl FeatureObjective for VehicleDistanceObjective {
172194
return Cost::default();
173195
};
174196

175-
let dist_assigned = self.transport.distance_approx(profile, job_loc, assigned_start);
197+
let dist_assigned = self.shared.round_trip(profile, assigned_start, job_loc);
176198

177-
let dist_nearest = find_nearest_compatible_vehicle_dist(
178-
job_loc,
179-
job,
180-
&self.actors,
181-
&self.compatibility_fn,
182-
self.transport.as_ref(),
183-
)
184-
.unwrap_or(dist_assigned);
199+
let dist_nearest =
200+
self.shared.find_nearest_compatible_vehicle_dist(job_loc, job, profile).unwrap_or(dist_assigned);
185201

186202
(dist_assigned - dist_nearest).max(0.0)
187203
}
@@ -191,67 +207,37 @@ impl FeatureObjective for VehicleDistanceObjective {
191207
}
192208

193209
struct VehicleDistanceState {
194-
transport: Arc<dyn TransportCost + Send + Sync>,
195-
actors: Vec<Arc<Actor>>,
196-
compatibility_fn: ActorJobCompatibilityFn,
210+
shared: Arc<VehicleDistanceShared>,
197211
}
198212

199213
impl VehicleDistanceState {
200-
/// Computes the penalty for a single route.
201-
fn compute_route_penalty(&self, route_ctx: &RouteContext) -> Cost {
202-
let route = route_ctx.route();
203-
let profile = &route.actor.vehicle.profile;
204-
205-
let assigned_start = match route.actor.detail.start.as_ref() {
206-
Some(start) => start.location,
207-
None => return 0.0,
208-
};
209-
210-
let mut total_penalty = 0.0;
211-
212-
for activity in route.tour.all_activities() {
213-
let Some(single) = activity.job.as_ref() else { continue };
214-
let job_loc = activity.place.location;
215-
let job = Job::Single(single.clone());
216-
217-
let dist_assigned = self.transport.distance_approx(profile, job_loc, assigned_start);
218-
219-
let dist_nearest = find_nearest_compatible_vehicle_dist(
220-
job_loc,
221-
&job,
222-
&self.actors,
223-
&self.compatibility_fn,
224-
self.transport.as_ref(),
225-
)
226-
.unwrap_or(dist_assigned);
227-
228-
let penalty = (dist_assigned - dist_nearest).max(0.0);
229-
total_penalty += penalty;
230-
}
231-
232-
total_penalty
214+
fn write_route_penalty(&self, route_ctx: &mut RouteContext) {
215+
let penalty = self.shared.compute_route_penalty(route_ctx);
216+
route_ctx.state_mut().set_vehicle_distance_route_data(RouteVehicleDistanceData { penalty });
233217
}
218+
234219
}
235220

236221
impl FeatureState for VehicleDistanceState {
237-
fn accept_insertion(&self, _: &mut SolutionContext, _: usize, _: &Job) {
238-
// Route will be marked stale, recomputed in accept_solution_state
222+
fn accept_insertion(&self, solution_ctx: &mut SolutionContext, route_index: usize, _: &Job) {
223+
// Eagerly refresh the affected route's penalty so the per-route cache is
224+
// always in sync with the tour. Stale-flag gating in accept_solution_state
225+
// was fragile because it relied on every caller to maintain `is_stale`
226+
// correctly across the construction and search pipelines.
227+
let route_ctx = solution_ctx.routes.get_mut(route_index).expect("route_index out of bounds");
228+
self.write_route_penalty(route_ctx);
239229
}
240230

241231
fn accept_route_state(&self, route_ctx: &mut RouteContext) {
242-
let penalty = self.compute_route_penalty(route_ctx);
243-
route_ctx.state_mut().set_vehicle_distance_route_data(RouteVehicleDistanceData { penalty });
232+
self.write_route_penalty(route_ctx);
244233
}
245234

246235
fn accept_solution_state(&self, solution_ctx: &mut SolutionContext) {
247-
solution_ctx.routes.iter_mut().filter(|rc| rc.is_stale()).for_each(|rc| self.accept_route_state(rc));
248-
249-
let total: Cost = solution_ctx
250-
.routes
251-
.iter()
252-
.map(|rc| rc.state().get_vehicle_distance_route_data().map(|data| data.penalty).unwrap_or(0.0))
253-
.sum();
254-
255-
solution_ctx.state.set_vehicle_distance_penalty(total);
236+
// Recompute every route's penalty regardless of the stale flag. This is the
237+
// canonical "rebuild from current tours" path and must not be skipped: at
238+
// every entry point that calls accept_solution_state (factories, restore,
239+
// finalize_insertion_ctx, search operators), tours may have been mutated
240+
// without our per-route cache being touched.
241+
solution_ctx.routes.iter_mut().for_each(|rc| self.write_route_penalty(rc));
256242
}
257243
}

0 commit comments

Comments
 (0)