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