Skip to main content

solverforge_solver/
basic.rs

1//! Basic variable solver for simple assignment problems.
2//!
3//! This module provides `run_solver` for problems using `#[basic_variable_config]`,
4//! where each entity has a single planning variable that can be assigned from a
5//! fixed value range.
6//!
7//! Logging levels:
8//! - **INFO**: Solver start/end, phase summaries, problem scale
9//! - **DEBUG**: Individual steps with timing and scores
10//! - **TRACE**: Move evaluation details
11
12use std::fmt;
13use std::sync::atomic::AtomicBool;
14use std::time::Duration;
15
16use solverforge_config::{PhaseConfig, SolverConfig};
17use solverforge_core::domain::{PlanningSolution, SolutionDescriptor};
18use solverforge_core::score::{ParseableScore, Score};
19use solverforge_scoring::{ConstraintSet, Director, ScoreDirector};
20use tokio::sync::mpsc;
21use tracing::info;
22
23use crate::builder::basic_selector::BasicLeafSelector;
24use crate::builder::{
25    AcceptorBuilder, AnyAcceptor, BasicContext, BasicMoveSelectorBuilder, ForagerBuilder,
26};
27use crate::heuristic::r#move::EitherMove;
28use crate::heuristic::selector::decorator::UnionMoveSelector;
29use crate::heuristic::selector::decorator::VecUnionSelector;
30use crate::heuristic::selector::{
31    EitherChangeMoveSelector, EitherSwapMoveSelector, FromSolutionEntitySelector,
32    StaticTypedValueSelector,
33};
34use crate::phase::construction::{BestFitForager, ConstructionHeuristicPhase, QueuedEntityPlacer};
35use crate::phase::localsearch::{LocalSearchPhase, SimulatedAnnealingAcceptor};
36use crate::scope::BestSolutionCallback;
37use crate::scope::SolverScope;
38use crate::solver::Solver;
39use crate::termination::{
40    BestScoreTermination, OrTermination, StepCountTermination, Termination, TimeTermination,
41    UnimprovedStepCountTermination, UnimprovedTimeTermination,
42};
43
44/// Default time limit in seconds.
45const DEFAULT_TIME_LIMIT_SECS: u64 = 30;
46
47/// Monomorphized termination enum for basic solver configurations.
48///
49/// Avoids repeated branching across `solve_with_termination` overloads by
50/// capturing the selected termination variant upfront.
51pub(crate) enum AnyBasicTermination<S: PlanningSolution, D: Director<S>> {
52    Default(OrTermination<(TimeTermination,), S, D>),
53    WithBestScore(OrTermination<(TimeTermination, BestScoreTermination<S::Score>), S, D>),
54    WithStepCount(OrTermination<(TimeTermination, StepCountTermination), S, D>),
55    WithUnimprovedStep(OrTermination<(TimeTermination, UnimprovedStepCountTermination<S>), S, D>),
56    WithUnimprovedTime(OrTermination<(TimeTermination, UnimprovedTimeTermination<S>), S, D>),
57}
58
59impl<S: PlanningSolution, D: Director<S>> fmt::Debug for AnyBasicTermination<S, D> {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            Self::Default(_) => write!(f, "AnyBasicTermination::Default"),
63            Self::WithBestScore(_) => write!(f, "AnyBasicTermination::WithBestScore"),
64            Self::WithStepCount(_) => write!(f, "AnyBasicTermination::WithStepCount"),
65            Self::WithUnimprovedStep(_) => write!(f, "AnyBasicTermination::WithUnimprovedStep"),
66            Self::WithUnimprovedTime(_) => write!(f, "AnyBasicTermination::WithUnimprovedTime"),
67        }
68    }
69}
70
71impl<S: PlanningSolution, D: Director<S>, BestCb: BestSolutionCallback<S>> Termination<S, D, BestCb>
72    for AnyBasicTermination<S, D>
73where
74    S::Score: Score,
75{
76    fn is_terminated(&self, solver_scope: &SolverScope<S, D, BestCb>) -> bool {
77        match self {
78            Self::Default(t) => t.is_terminated(solver_scope),
79            Self::WithBestScore(t) => t.is_terminated(solver_scope),
80            Self::WithStepCount(t) => t.is_terminated(solver_scope),
81            Self::WithUnimprovedStep(t) => t.is_terminated(solver_scope),
82            Self::WithUnimprovedTime(t) => t.is_terminated(solver_scope),
83        }
84    }
85
86    fn install_inphase_limits(&self, solver_scope: &mut SolverScope<S, D, BestCb>) {
87        match self {
88            Self::Default(t) => t.install_inphase_limits(solver_scope),
89            Self::WithBestScore(t) => t.install_inphase_limits(solver_scope),
90            Self::WithStepCount(t) => t.install_inphase_limits(solver_scope),
91            Self::WithUnimprovedStep(t) => t.install_inphase_limits(solver_scope),
92            Self::WithUnimprovedTime(t) => t.install_inphase_limits(solver_scope),
93        }
94    }
95}
96
97// Type alias for the config-driven local search phase
98type ConfigLocalSearch<S> = LocalSearchPhase<
99    S,
100    EitherMove<S, usize>,
101    VecUnionSelector<S, EitherMove<S, usize>, BasicLeafSelector<S>>,
102    AnyAcceptor<S>,
103    crate::builder::AnyForager<S>,
104>;
105
106// Type alias for the default local search phase (SA + UnionMoveSelector)
107type DefaultLocalSearch<S> = LocalSearchPhase<
108    S,
109    EitherMove<S, usize>,
110    UnionMoveSelector<
111        S,
112        EitherMove<S, usize>,
113        EitherChangeMoveSelector<
114            S,
115            usize,
116            FromSolutionEntitySelector,
117            StaticTypedValueSelector<S, usize>,
118        >,
119        EitherSwapMoveSelector<S, usize, FromSolutionEntitySelector, FromSolutionEntitySelector>,
120    >,
121    SimulatedAnnealingAcceptor,
122    crate::phase::localsearch::AcceptedCountForager<S>,
123>;
124
125/// Monomorphized phase enum for config-driven basic solver.
126enum BasicLocalSearch<S: PlanningSolution>
127where
128    S::Score: Score,
129{
130    Default(DefaultLocalSearch<S>),
131    Config(ConfigLocalSearch<S>),
132}
133
134/// Solves a basic variable problem using construction heuristic + local search.
135///
136/// This function is called by macro-generated `solve()` methods for solutions
137/// using `#[basic_variable_config]`. When phases are configured in `solver.toml`,
138/// the acceptor, forager, and move selectors are built from config; otherwise
139/// defaults are used.
140///
141/// # Type Parameters
142///
143/// * `S` - The solution type (must implement `PlanningSolution`)
144/// * `C` - The constraint set type
145///
146/// # Arguments
147///
148/// * `solution` - The initial solution to solve
149/// * `finalize_fn` - Function to prepare derived fields before solving
150/// * `constraints_fn` - Function that creates the constraint set
151/// * `get_variable` - Gets the planning variable value for an entity
152/// * `set_variable` - Sets the planning variable value for an entity
153/// * `value_count` - Returns the number of valid values
154/// * `entity_count_fn` - Returns the number of entities
155/// * `descriptor` - Solution descriptor for solver infrastructure
156/// * `entity_count_by_descriptor` - Returns entity count for a given descriptor index
157/// * `terminate` - Optional external termination flag
158/// * `sender` - Channel for streaming best solutions as they are found
159/// * `variable_field` - Variable field name
160/// * `descriptor_index` - Descriptor index
161#[allow(clippy::too_many_arguments)]
162pub fn run_solver<S, C>(
163    mut solution: S,
164    finalize_fn: fn(&mut S),
165    constraints_fn: fn() -> C,
166    get_variable: fn(&S, usize) -> Option<usize>,
167    set_variable: fn(&mut S, usize, Option<usize>),
168    value_count: fn(&S) -> usize,
169    entity_count_fn: fn(&S) -> usize,
170    descriptor: fn() -> SolutionDescriptor,
171    entity_count_by_descriptor: fn(&S, usize) -> usize,
172    terminate: Option<&AtomicBool>,
173    sender: mpsc::UnboundedSender<(S, S::Score)>,
174    variable_field: &'static str,
175    descriptor_index: usize,
176) -> S
177where
178    S: PlanningSolution,
179    S::Score: Score + ParseableScore,
180    C: ConstraintSet<S, S::Score>,
181{
182    finalize_fn(&mut solution);
183
184    let config = SolverConfig::load("solver.toml").unwrap_or_default();
185    let n_entities = entity_count_fn(&solution);
186    let n_values = value_count(&solution);
187
188    info!(
189        event = "solve_start",
190        entity_count = n_entities,
191        value_count = n_values,
192    );
193
194    // Create score director with real entity counter for selector iteration
195    let constraints = constraints_fn();
196    let director = ScoreDirector::with_descriptor(
197        solution,
198        constraints,
199        descriptor(),
200        entity_count_by_descriptor,
201    );
202
203    // Handle empty case - nothing to solve, return immediately
204    if n_entities == 0 || n_values == 0 {
205        let mut solver_scope = SolverScope::new(director);
206        solver_scope.start_solving();
207        let score = solver_scope.calculate_score();
208        info!(event = "solve_end", score = %score);
209        let solution = solver_scope.take_best_or_working_solution();
210        let _ = sender.send((solution.clone(), score));
211        return solution;
212    }
213
214    // Build construction phase with BestFitForager
215    let values: Vec<usize> = (0..n_values).collect();
216    let entity_selector = FromSolutionEntitySelector::new(0);
217    let value_selector = StaticTypedValueSelector::new(values.clone());
218    let placer = QueuedEntityPlacer::new(
219        entity_selector,
220        value_selector,
221        get_variable,
222        set_variable,
223        0,
224        variable_field,
225    );
226    let construction = ConstructionHeuristicPhase::new(placer, BestFitForager::new());
227
228    // Build local search phase: config-driven or default
229    let local_search = build_local_search::<S>(
230        &config,
231        get_variable,
232        set_variable,
233        values,
234        variable_field,
235        descriptor_index,
236    );
237
238    // Build termination from config
239    let term_config = config.termination.as_ref();
240    let time_limit = term_config
241        .and_then(|c| c.time_limit())
242        .unwrap_or(Duration::from_secs(DEFAULT_TIME_LIMIT_SECS));
243    let time = TimeTermination::new(time_limit);
244
245    let best_score_target: Option<S::Score> = term_config
246        .and_then(|c| c.best_score_limit.as_ref())
247        .and_then(|s| S::Score::parse(s).ok());
248
249    let termination: AnyBasicTermination<S, ScoreDirector<S, C>> =
250        if let Some(target) = best_score_target {
251            AnyBasicTermination::WithBestScore(OrTermination::new((
252                time,
253                BestScoreTermination::new(target),
254            )))
255        } else if let Some(step_limit) = term_config.and_then(|c| c.step_count_limit) {
256            AnyBasicTermination::WithStepCount(OrTermination::new((
257                time,
258                StepCountTermination::new(step_limit),
259            )))
260        } else if let Some(unimproved_step_limit) =
261            term_config.and_then(|c| c.unimproved_step_count_limit)
262        {
263            AnyBasicTermination::WithUnimprovedStep(OrTermination::new((
264                time,
265                UnimprovedStepCountTermination::<S>::new(unimproved_step_limit),
266            )))
267        } else if let Some(unimproved_time) = term_config.and_then(|c| c.unimproved_time_limit()) {
268            AnyBasicTermination::WithUnimprovedTime(OrTermination::new((
269                time,
270                UnimprovedTimeTermination::<S>::new(unimproved_time),
271            )))
272        } else {
273            AnyBasicTermination::Default(OrTermination::new((time,)))
274        };
275
276    let callback_sender = sender.clone();
277    let callback = move |solution: &S| {
278        let score = solution.score().unwrap_or_default();
279        let _ = callback_sender.send((solution.clone(), score));
280    };
281
282    // Run solver with the selected local search type
283    let result = match local_search {
284        BasicLocalSearch::Default(ls) => {
285            let solver = Solver::new(((), construction, ls))
286                .with_termination(termination)
287                .with_time_limit(time_limit)
288                .with_best_solution_callback(callback);
289            if let Some(flag) = terminate {
290                solver.with_terminate(flag).solve(director)
291            } else {
292                solver.solve(director)
293            }
294        }
295        BasicLocalSearch::Config(ls) => {
296            let solver = Solver::new(((), construction, ls))
297                .with_termination(termination)
298                .with_time_limit(time_limit)
299                .with_best_solution_callback(callback);
300            if let Some(flag) = terminate {
301                solver.with_terminate(flag).solve(director)
302            } else {
303                solver.solve(director)
304            }
305        }
306    };
307
308    // Send the final solution so the receiver always gets the last state
309    let final_score = result.solution.score().unwrap_or_default();
310    let _ = sender.send((result.solution.clone(), final_score));
311
312    info!(
313        event = "solve_end",
314        score = %final_score,
315        steps = result.stats.step_count,
316        moves_evaluated = result.stats.moves_evaluated,
317    );
318    result.solution
319}
320
321/// Builds the local search phase from config or falls back to defaults.
322fn build_local_search<S>(
323    config: &SolverConfig,
324    get_variable: fn(&S, usize) -> Option<usize>,
325    set_variable: fn(&mut S, usize, Option<usize>),
326    values: Vec<usize>,
327    variable_field: &'static str,
328    descriptor_index: usize,
329) -> BasicLocalSearch<S>
330where
331    S: PlanningSolution,
332    S::Score: Score,
333{
334    // Find first local search phase config
335    let ls_config = config.phases.iter().find_map(|p| {
336        if let PhaseConfig::LocalSearch(ls) = p {
337            Some(ls)
338        } else {
339            None
340        }
341    });
342
343    let Some(ls) = ls_config else {
344        // No phases configured — use default SA + union(Change, Swap)
345        let change_selector = EitherChangeMoveSelector::simple(
346            get_variable,
347            set_variable,
348            descriptor_index,
349            variable_field,
350            values,
351        );
352        let swap_selector = EitherSwapMoveSelector::simple(
353            get_variable,
354            set_variable,
355            descriptor_index,
356            variable_field,
357        );
358        let move_selector = UnionMoveSelector::new(change_selector, swap_selector);
359        let acceptor = SimulatedAnnealingAcceptor::default();
360        let forager = crate::phase::localsearch::AcceptedCountForager::new(1);
361        return BasicLocalSearch::Default(LocalSearchPhase::new(
362            move_selector,
363            acceptor,
364            forager,
365            None,
366        ));
367    };
368
369    // Config-driven: build acceptor, forager, move selector from config
370    let acceptor = ls
371        .acceptor
372        .as_ref()
373        .map(|ac| AcceptorBuilder::build::<S>(ac))
374        .unwrap_or_else(|| AnyAcceptor::SimulatedAnnealing(SimulatedAnnealingAcceptor::default()));
375
376    let forager = ForagerBuilder::build::<S>(ls.forager.as_ref());
377
378    let ctx = BasicContext {
379        get_variable,
380        set_variable,
381        values,
382        descriptor_index,
383        variable_field,
384    };
385
386    let move_selector = BasicMoveSelectorBuilder::build(ls.move_selector.as_ref(), &ctx);
387
388    BasicLocalSearch::Config(LocalSearchPhase::new(
389        move_selector,
390        acceptor,
391        forager,
392        None,
393    ))
394}