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