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 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}