1use std::fmt::{self, Debug};
2use std::hash::Hash;
3use std::marker::PhantomData;
4
5use solverforge_config::{ConstructionHeuristicConfig, PhaseConfig, SolverConfig};
6use solverforge_core::domain::{PlanningSolution, SolutionDescriptor};
7use solverforge_core::score::{ParseableScore, Score};
8
9use crate::builder::{build_local_search, LocalSearchStrategy, RuntimeModel};
10use crate::descriptor::{
11 build_descriptor_construction_from_bindings, scalar_work_remaining_with_frontier,
12};
13use crate::heuristic::selector::nearby_list_change::CrossEntityDistanceMeter;
14use crate::manager::solve_specialized_list_construction;
15use crate::phase::construction::{select_construction_capabilities, ConstructionRoute};
16use crate::phase::{sequence::PhaseSequence, Phase};
17use crate::scope::{ProgressCallback, SolverScope};
18
19#[path = "runtime/defaults.rs"]
20mod defaults;
21
22#[cfg(test)]
23mod tests;
24
25#[cfg(test)]
26mod list_tests;
27
28pub struct ListVariableMetadata<S, DM, IDM> {
29 pub cross_distance_meter: DM,
30 pub intra_distance_meter: IDM,
31 pub route_get_fn: Option<fn(&S, usize) -> Vec<usize>>,
32 pub route_set_fn: Option<fn(&mut S, usize, Vec<usize>)>,
33 pub route_depot_fn: Option<fn(&S, usize) -> usize>,
34 pub route_distance_fn: Option<fn(&S, usize, usize, usize) -> i64>,
35 pub route_feasible_fn: Option<fn(&S, usize, &[usize]) -> bool>,
36 pub savings_depot_fn: Option<fn(&S, usize) -> usize>,
37 pub savings_metric_class_fn: Option<fn(&S, usize) -> usize>,
38 pub savings_distance_fn: Option<fn(&S, usize, usize, usize) -> i64>,
39 pub savings_feasible_fn: Option<fn(&S, usize, &[usize]) -> bool>,
40 pub element_owner_fn: Option<fn(&S, &usize) -> Option<usize>>,
41 _phantom: PhantomData<fn() -> S>,
42}
43
44pub trait ListVariableEntity<S> {
45 type CrossDistanceMeter: CrossEntityDistanceMeter<S> + Clone + fmt::Debug;
46 type IntraDistanceMeter: CrossEntityDistanceMeter<S> + Clone + fmt::Debug + 'static;
47
48 const HAS_LIST_VARIABLE: bool;
49 const LIST_VARIABLE_NAME: &'static str;
50 const LIST_ELEMENT_SOURCE: Option<&'static str>;
51
52 fn list_field(entity: &Self) -> &[usize];
53 fn list_field_mut(entity: &mut Self) -> &mut Vec<usize>;
54 fn list_metadata() -> ListVariableMetadata<S, Self::CrossDistanceMeter, Self::IntraDistanceMeter>;
55}
56
57impl<S, DM, IDM> ListVariableMetadata<S, DM, IDM> {
58 #[allow(clippy::too_many_arguments)]
59 pub fn new(
60 cross_distance_meter: DM,
61 intra_distance_meter: IDM,
62 route_get_fn: Option<fn(&S, usize) -> Vec<usize>>,
63 route_set_fn: Option<fn(&mut S, usize, Vec<usize>)>,
64 route_depot_fn: Option<fn(&S, usize) -> usize>,
65 route_distance_fn: Option<fn(&S, usize, usize, usize) -> i64>,
66 route_feasible_fn: Option<fn(&S, usize, &[usize]) -> bool>,
67 savings_depot_fn: Option<fn(&S, usize) -> usize>,
68 savings_metric_class_fn: Option<fn(&S, usize) -> usize>,
69 savings_distance_fn: Option<fn(&S, usize, usize, usize) -> i64>,
70 savings_feasible_fn: Option<fn(&S, usize, &[usize]) -> bool>,
71 ) -> Self {
72 Self {
73 cross_distance_meter,
74 intra_distance_meter,
75 route_get_fn,
76 route_set_fn,
77 route_depot_fn,
78 route_distance_fn,
79 route_feasible_fn,
80 savings_depot_fn,
81 savings_metric_class_fn,
82 savings_distance_fn,
83 savings_feasible_fn,
84 element_owner_fn: None,
85 _phantom: PhantomData,
86 }
87 }
88
89 pub fn with_element_owner_fn(
90 mut self,
91 element_owner_fn: Option<fn(&S, &usize) -> Option<usize>>,
92 ) -> Self {
93 self.element_owner_fn = element_owner_fn;
94 self
95 }
96}
97
98pub struct Construction<S, V, DM, IDM>
99where
100 S: PlanningSolution,
101 V: Clone + Copy + PartialEq + Eq + Hash + Into<usize> + Send + Sync + Debug + 'static,
102 DM: Clone + Debug + Send + 'static,
103 IDM: Clone + Debug + Send + 'static,
104{
105 config: Option<ConstructionHeuristicConfig>,
106 descriptor: SolutionDescriptor,
107 model: RuntimeModel<S, V, DM, IDM>,
108}
109
110impl<S, V, DM, IDM> Construction<S, V, DM, IDM>
111where
112 S: PlanningSolution,
113 V: Clone + Copy + PartialEq + Eq + Hash + Into<usize> + Send + Sync + Debug + 'static,
114 DM: Clone + Debug + Send + 'static,
115 IDM: Clone + Debug + Send + 'static,
116{
117 fn new(
118 config: Option<ConstructionHeuristicConfig>,
119 descriptor: SolutionDescriptor,
120 model: RuntimeModel<S, V, DM, IDM>,
121 ) -> Self {
122 let model = model
123 .resolve_dynamic_descriptor_indexes(&descriptor)
124 .unwrap_or_else(|message| panic!("{message}"));
125 Self {
126 config,
127 descriptor,
128 model,
129 }
130 }
131
132 fn solve_list<D, ProgressCb>(
133 &self,
134 config: &ConstructionHeuristicConfig,
135 solver_scope: &mut SolverScope<'_, S, D, ProgressCb>,
136 list_variables: &[crate::builder::ListVariableSlot<S, V, DM, IDM>],
137 ) -> bool
138 where
139 D: solverforge_scoring::Director<S>,
140 ProgressCb: ProgressCallback<S>,
141 {
142 if list_variables.is_empty() {
143 panic!("list construction configured against a scalar-only model");
144 }
145
146 solve_specialized_list_construction(
147 config.construction_heuristic_type,
148 config.k,
149 solver_scope,
150 list_variables,
151 )
152 }
153
154 pub(super) fn solve_configured<D, ProgressCb>(
155 &self,
156 config: Option<&ConstructionHeuristicConfig>,
157 solver_scope: &mut SolverScope<'_, S, D, ProgressCb>,
158 required_only: bool,
159 ) -> bool
160 where
161 S: PlanningSolution + 'static,
162 S::Score: Score + Copy,
163 DM: CrossEntityDistanceMeter<S> + Clone + Debug + Send + 'static,
164 IDM: CrossEntityDistanceMeter<S> + Clone + Debug + Send + 'static,
165 D: solverforge_scoring::Director<S>,
166 ProgressCb: ProgressCallback<S>,
167 {
168 let capabilities = select_construction_capabilities(config, &self.descriptor, &self.model);
169
170 match capabilities.route {
171 ConstructionRoute::SpecializedList => {
172 let Some(config) = config else {
173 panic!("specialized list construction requires explicit configuration");
174 };
175 self.solve_list(config, solver_scope, &capabilities.list_variables)
176 }
177 ConstructionRoute::Descriptor => {
178 let scalar_remaining = scalar_work_remaining_with_frontier(
179 &self.descriptor,
180 solver_scope.construction_frontier(),
181 solver_scope.solution_revision(),
182 capabilities.entity_class.as_deref(),
183 capabilities.variable_name.as_deref(),
184 solver_scope.working_solution(),
185 );
186 if scalar_remaining {
187 build_descriptor_construction_from_bindings(
188 config,
189 &self.descriptor,
190 capabilities.scalar_bindings.clone(),
191 )
192 .solve(solver_scope);
193 true
194 } else {
195 false
196 }
197 }
198 ConstructionRoute::GroupedScalar => {
199 let Some((group_index, group)) = capabilities.scalar_group.as_ref() else {
200 unreachable!("grouped scalar route requires a selected scalar group");
201 };
202 if !scalar_group_work_remaining(group, solver_scope.working_solution()) {
203 return false;
204 }
205 record_scalar_assignment_remaining(group, solver_scope);
206 let mut phase = crate::phase::construction::build_scalar_group_construction(
207 config,
208 *group_index,
209 group.clone(),
210 capabilities.scalar_bindings.clone(),
211 required_only,
212 );
213 phase.solve(solver_scope);
214 record_scalar_assignment_remaining(group, solver_scope);
215 true
216 }
217 ConstructionRoute::GenericMixed => {
218 crate::phase::construction::solve_construction(config, &self.model, solver_scope)
219 }
220 }
221 }
222}
223
224fn scalar_group_work_remaining<S>(
225 group: &crate::builder::ScalarGroupBinding<S>,
226 solution: &S,
227) -> bool {
228 if let Some(assignment) = group.assignment() {
229 return assignment.unassigned_count(solution) > 0;
230 }
231 group.members.iter().any(|member| {
232 (0..member.entity_count(solution))
233 .any(|entity_index| member.current_value(solution, entity_index).is_none())
234 })
235}
236
237fn record_scalar_assignment_remaining<S, D, ProgressCb>(
238 group: &crate::builder::ScalarGroupBinding<S>,
239 solver_scope: &mut SolverScope<'_, S, D, ProgressCb>,
240) where
241 S: PlanningSolution,
242 D: solverforge_scoring::Director<S>,
243 ProgressCb: ProgressCallback<S>,
244{
245 if let Some(assignment) = group.assignment() {
246 let remaining = assignment.remaining_required_count(solver_scope.working_solution());
247 solver_scope
248 .stats_mut()
249 .record_scalar_assignment_required_remaining(group.group_name, remaining);
250 }
251}
252
253impl<S, V, DM, IDM> Debug for Construction<S, V, DM, IDM>
254where
255 S: PlanningSolution,
256 V: Clone + Copy + PartialEq + Eq + Hash + Into<usize> + Send + Sync + Debug + 'static,
257 DM: Clone + Debug + Send + 'static,
258 IDM: Clone + Debug + Send + 'static,
259{
260 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261 f.debug_struct("Construction")
262 .field("config", &self.config)
263 .field("variable_count", &self.model.variables().len())
264 .finish()
265 }
266}
267
268impl<S, V, DM, IDM, D, ProgressCb> Phase<S, D, ProgressCb> for Construction<S, V, DM, IDM>
269where
270 S: PlanningSolution + 'static,
271 S::Score: Score + Copy,
272 V: Clone + Copy + PartialEq + Eq + Hash + Into<usize> + Send + Sync + Debug + 'static,
273 DM: CrossEntityDistanceMeter<S> + Clone + Debug + Send + 'static,
274 IDM: CrossEntityDistanceMeter<S> + Clone + Debug + Send + 'static,
275 D: solverforge_scoring::Director<S>,
276 ProgressCb: ProgressCallback<S>,
277{
278 fn solve(&mut self, solver_scope: &mut SolverScope<'_, S, D, ProgressCb>) {
279 let ran_child_phase = match self.config.as_ref() {
280 None => defaults::solve_default_construction(self, solver_scope),
281 Some(config) => self.solve_configured(Some(config), solver_scope, false),
282 };
283 if !ran_child_phase {
284 finalize_noop_construction(solver_scope);
285 }
286 }
287
288 fn phase_type_name(&self) -> &'static str {
289 "Construction"
290 }
291}
292
293fn finalize_noop_construction<S, D, ProgressCb>(
294 solver_scope: &mut SolverScope<'_, S, D, ProgressCb>,
295) where
296 S: PlanningSolution,
297 D: solverforge_scoring::Director<S>,
298 ProgressCb: ProgressCallback<S>,
299{
300 let had_best = solver_scope.best_score().is_some();
301 solver_scope.update_best_solution();
302 if had_best {
303 solver_scope.promote_current_solution_on_score_tie();
304 }
305}
306
307pub enum RuntimePhase<C, LS> {
308 Construction(C),
309 LocalSearch(LS),
310}
311
312impl<C, LS> Debug for RuntimePhase<C, LS>
313where
314 C: Debug,
315 LS: Debug,
316{
317 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318 match self {
319 Self::Construction(phase) => write!(f, "RuntimePhase::Construction({phase:?})"),
320 Self::LocalSearch(phase) => write!(f, "RuntimePhase::LocalSearch({phase:?})"),
321 }
322 }
323}
324
325impl<S, D, ProgressCb, C, LS> Phase<S, D, ProgressCb> for RuntimePhase<C, LS>
326where
327 S: PlanningSolution,
328 D: solverforge_scoring::Director<S>,
329 ProgressCb: ProgressCallback<S>,
330 C: Phase<S, D, ProgressCb> + Debug,
331 LS: Phase<S, D, ProgressCb> + Debug,
332{
333 fn solve(&mut self, solver_scope: &mut SolverScope<'_, S, D, ProgressCb>) {
334 match self {
335 Self::Construction(phase) => phase.solve(solver_scope),
336 Self::LocalSearch(phase) => phase.solve(solver_scope),
337 }
338 }
339
340 fn phase_type_name(&self) -> &'static str {
341 "RuntimePhase"
342 }
343}
344
345pub fn build_phases<S, V, DM, IDM>(
346 config: &SolverConfig,
347 descriptor: &SolutionDescriptor,
348 model: &RuntimeModel<S, V, DM, IDM>,
349) -> PhaseSequence<RuntimePhase<Construction<S, V, DM, IDM>, LocalSearchStrategy<S, V, DM, IDM>>>
350where
351 S: PlanningSolution + 'static,
352 S::Score: Score + ParseableScore,
353 V: Clone + Copy + PartialEq + Eq + Hash + Into<usize> + Send + Sync + Debug + 'static,
354 DM: CrossEntityDistanceMeter<S> + Clone + Debug + Send + 'static,
355 IDM: CrossEntityDistanceMeter<S> + Clone + Debug + Send + 'static,
356{
357 let mut phases = Vec::new();
358 let model = model
359 .clone()
360 .resolve_dynamic_descriptor_indexes(descriptor)
361 .unwrap_or_else(|message| panic!("{message}"));
362
363 if config.phases.is_empty() {
364 phases.push(RuntimePhase::Construction(Construction::new(
365 None,
366 descriptor.clone(),
367 model.clone(),
368 )));
369 if !model.has_dynamic_variables() {
370 for phase in crate::builder::search::defaults::default_local_search_phases(
371 &model,
372 config.random_seed,
373 ) {
374 phases.push(RuntimePhase::LocalSearch(phase));
375 }
376 }
377 return PhaseSequence::new(phases);
378 }
379
380 for phase in &config.phases {
381 match phase {
382 PhaseConfig::ConstructionHeuristic(ch) => {
383 phases.push(RuntimePhase::Construction(Construction::new(
384 Some(ch.clone()),
385 descriptor.clone(),
386 model.clone(),
387 )));
388 }
389 PhaseConfig::LocalSearch(ls) => {
390 phases.push(RuntimePhase::LocalSearch(build_local_search(
391 Some(ls),
392 &model,
393 config.random_seed,
394 )));
395 }
396 PhaseConfig::Custom(custom) => {
397 panic!(
398 "custom phase `{}` requires a typed solution search function",
399 custom.name
400 );
401 }
402 PhaseConfig::PartitionedSearch(partitioned) => {
403 let name = partitioned
404 .partitioner
405 .as_deref()
406 .unwrap_or("<missing partitioner>");
407 panic!(
408 "partitioned_search partitioner `{name}` requires typed partitioner registration"
409 );
410 }
411 }
412 }
413
414 PhaseSequence::new(phases)
415}
416
417#[cfg(test)]
418mod construction_routing_tests {
419 use solverforge_config::ConstructionHeuristicType;
420
421 fn should_use_descriptor_path(
422 heuristic: ConstructionHeuristicType,
423 has_scalar_variables: bool,
424 has_list_variables: bool,
425 ) -> bool {
426 if !has_scalar_variables {
427 return false;
428 }
429
430 match heuristic {
431 ConstructionHeuristicType::ListRoundRobin
432 | ConstructionHeuristicType::ListCheapestInsertion
433 | ConstructionHeuristicType::ListRegretInsertion
434 | ConstructionHeuristicType::ListClarkeWright
435 | ConstructionHeuristicType::ListKOpt => false,
436 ConstructionHeuristicType::FirstFit | ConstructionHeuristicType::CheapestInsertion => {
437 !has_list_variables
438 }
439 ConstructionHeuristicType::FirstFitDecreasing
440 | ConstructionHeuristicType::WeakestFit
441 | ConstructionHeuristicType::WeakestFitDecreasing
442 | ConstructionHeuristicType::StrongestFit
443 | ConstructionHeuristicType::StrongestFitDecreasing
444 | ConstructionHeuristicType::AllocateEntityFromQueue
445 | ConstructionHeuristicType::AllocateToValueFromQueue => true,
446 }
447 }
448
449 #[test]
450 fn pure_scalar_first_fit_uses_descriptor_path() {
451 assert!(should_use_descriptor_path(
452 ConstructionHeuristicType::FirstFit,
453 true,
454 false,
455 ));
456 }
457
458 #[test]
459 fn pure_scalar_cheapest_insertion_uses_descriptor_path() {
460 assert!(should_use_descriptor_path(
461 ConstructionHeuristicType::CheapestInsertion,
462 true,
463 false,
464 ));
465 }
466
467 #[test]
468 fn mixed_first_fit_keeps_generic_construction_path() {
469 assert!(!should_use_descriptor_path(
470 ConstructionHeuristicType::FirstFit,
471 true,
472 true,
473 ));
474 }
475
476 #[test]
477 fn mixed_cheapest_insertion_keeps_generic_construction_path() {
478 assert!(!should_use_descriptor_path(
479 ConstructionHeuristicType::CheapestInsertion,
480 true,
481 true,
482 ));
483 }
484
485 #[test]
486 fn scalar_only_heuristics_still_route_to_descriptor_path() {
487 assert!(should_use_descriptor_path(
488 ConstructionHeuristicType::StrongestFit,
489 true,
490 true,
491 ));
492 }
493}