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