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