@@ -10,7 +10,6 @@ mod vehicle_distance_test;
1010
1111use super :: * ;
1212
13- custom_solution_state ! ( VehicleDistancePenalty typeof Cost ) ;
1413custom_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
154162impl 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
193209struct VehicleDistanceState {
194- transport : Arc < dyn TransportCost + Send + Sync > ,
195- actors : Vec < Arc < Actor > > ,
196- compatibility_fn : ActorJobCompatibilityFn ,
210+ shared : Arc < VehicleDistanceShared > ,
197211}
198212
199213impl 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
236221impl 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