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