Skip to main content

solverforge_solver/
runtime.rs

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}