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