solverforge_solver/phase/localsearch/
mod.rs

1//! Local search phase
2//!
3//! Improves an existing solution by iteratively applying moves
4//! that are accepted according to an acceptance criterion.
5
6mod acceptor;
7mod forager;
8
9use std::fmt::Debug;
10use std::marker::PhantomData;
11
12use solverforge_core::domain::PlanningSolution;
13use solverforge_scoring::{RecordingScoreDirector, ScoreDirector};
14
15use crate::heuristic::r#move::{Move, MoveArena};
16use crate::heuristic::selector::MoveSelector;
17use crate::phase::Phase;
18use crate::scope::{PhaseScope, SolverScope, StepScope};
19
20pub use acceptor::{
21    Acceptor, DiversifiedLateAcceptanceAcceptor, EntityTabuAcceptor, GreatDelugeAcceptor,
22    HillClimbingAcceptor, LateAcceptanceAcceptor, MoveTabuAcceptor, SimulatedAnnealingAcceptor,
23    StepCountingHillClimbingAcceptor, TabuSearchAcceptor, ValueTabuAcceptor,
24};
25pub use forager::{AcceptedCountForager, FirstAcceptedForager, LocalSearchForager};
26
27/// Local search phase configuration.
28#[derive(Debug, Clone)]
29pub struct LocalSearchConfig {
30    /// The acceptor type to use.
31    pub acceptor_type: AcceptorType,
32    /// Maximum number of steps (None = unlimited).
33    pub step_limit: Option<u64>,
34    /// Number of accepted moves to collect before quitting early.
35    pub accepted_count_limit: Option<usize>,
36}
37
38impl Default for LocalSearchConfig {
39    fn default() -> Self {
40        Self {
41            acceptor_type: AcceptorType::HillClimbing,
42            step_limit: Some(1000),
43            accepted_count_limit: Some(1),
44        }
45    }
46}
47
48/// Type of acceptor to use in local search.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum AcceptorType {
51    /// Accept only improving moves.
52    HillClimbing,
53    /// Accept moves with probability based on temperature.
54    SimulatedAnnealing,
55}
56
57/// Local search phase that improves an existing solution.
58///
59/// This phase iteratively:
60/// 1. Generates candidate moves
61/// 2. Evaluates each move
62/// 3. Accepts/rejects based on the acceptor
63/// 4. Applies the best accepted move
64///
65/// # Type Parameters
66/// * `S` - The planning solution type
67/// * `M` - The move type (must implement `Move<S> + Clone`)
68///
69/// # Performance
70///
71/// Uses `MoveArena<M>` for O(1) per-step cleanup instead of allocating
72/// a new Vec each step.
73pub struct LocalSearchPhase<S, M>
74where
75    S: PlanningSolution,
76    M: Move<S>,
77{
78    /// The move selector.
79    move_selector: Box<dyn MoveSelector<S, M>>,
80    /// The acceptor.
81    acceptor: Box<dyn Acceptor<S>>,
82    /// The forager.
83    forager: Box<dyn LocalSearchForager<S, M>>,
84    /// Arena for moves - reused each step for O(1) cleanup.
85    arena: MoveArena<M>,
86    /// Maximum number of steps.
87    step_limit: Option<u64>,
88    _phantom: PhantomData<M>,
89}
90
91impl<S, M> LocalSearchPhase<S, M>
92where
93    S: PlanningSolution,
94    M: Move<S> + 'static,
95{
96    /// Creates a new local search phase.
97    pub fn new(
98        move_selector: Box<dyn MoveSelector<S, M>>,
99        acceptor: Box<dyn Acceptor<S>>,
100        forager: Box<dyn LocalSearchForager<S, M>>,
101        step_limit: Option<u64>,
102    ) -> Self {
103        Self {
104            move_selector,
105            acceptor,
106            forager,
107            arena: MoveArena::new(),
108            step_limit,
109            _phantom: PhantomData,
110        }
111    }
112}
113
114impl<S, M> Debug for LocalSearchPhase<S, M>
115where
116    S: PlanningSolution,
117    M: Move<S>,
118{
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        f.debug_struct("LocalSearchPhase")
121            .field("move_selector", &self.move_selector)
122            .field("acceptor", &self.acceptor)
123            .field("forager", &self.forager)
124            .field("arena", &self.arena)
125            .field("step_limit", &self.step_limit)
126            .finish()
127    }
128}
129
130impl<S, M> Phase<S> for LocalSearchPhase<S, M>
131where
132    S: PlanningSolution,
133    M: Move<S>,
134{
135    fn solve(&mut self, solver_scope: &mut SolverScope<S>) {
136        let mut phase_scope = PhaseScope::new(solver_scope, 0);
137
138        // Calculate initial score
139        let mut last_step_score = phase_scope.calculate_score();
140
141        // Notify acceptor of phase start
142        self.acceptor.phase_started(&last_step_score);
143
144        loop {
145            // Check early termination
146            if phase_scope.solver_scope().is_terminate_early() {
147                break;
148            }
149
150            // Check step limit
151            if let Some(limit) = self.step_limit {
152                if phase_scope.step_count() >= limit {
153                    break;
154                }
155            }
156
157            let mut step_scope = StepScope::new(&mut phase_scope);
158
159            // Reset forager for this step
160            self.forager.step_started();
161
162            // Reset arena and populate with moves - O(1) reset
163            self.arena.reset();
164            self.arena
165                .extend(self.move_selector.iter_moves(step_scope.score_director()));
166
167            // Evaluate moves
168            for i in 0..self.arena.len() {
169                let m = self.arena.get(i).unwrap();
170
171                if !m.is_doable(step_scope.score_director()) {
172                    continue;
173                }
174
175                // Use RecordingScoreDirector for automatic undo
176                {
177                    let mut recording =
178                        RecordingScoreDirector::new(step_scope.score_director_mut());
179
180                    // Execute move
181                    m.do_move(&mut recording);
182
183                    // Calculate resulting score
184                    let move_score = recording.calculate_score();
185
186                    // Check if accepted
187                    let accepted = self.acceptor.is_accepted(&last_step_score, &move_score);
188
189                    // Add to forager if accepted
190                    if accepted {
191                        self.forager.add_move(m.clone(), move_score);
192                    }
193
194                    // Undo the move
195                    recording.undo_changes();
196                }
197
198                // Check if forager wants to quit early
199                if self.forager.is_quit_early() {
200                    break;
201                }
202            }
203
204            // Pick the best accepted move
205            if let Some((selected_move, selected_score)) = self.forager.pick_move() {
206                // Execute the selected move (for real this time)
207                selected_move.do_move(step_scope.score_director_mut());
208                step_scope.set_step_score(selected_score.clone());
209
210                // Update last step score
211                last_step_score = selected_score;
212
213                // Update best solution if improved
214                step_scope.phase_scope_mut().update_best_solution();
215            } else {
216                // No accepted moves - we're stuck
217                break;
218            }
219
220            step_scope.complete();
221        }
222
223        // Notify acceptor of phase end
224        self.acceptor.phase_ended();
225    }
226
227    fn phase_type_name(&self) -> &'static str {
228        "LocalSearch"
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::heuristic::r#move::ChangeMove;
236    use crate::heuristic::selector::{ChangeMoveSelector, MoveSelector};
237    use crate::manager::SolverPhaseFactory;
238    use solverforge_core::domain::{EntityDescriptor, SolutionDescriptor, TypedEntityExtractor};
239    use solverforge_core::score::SimpleScore;
240    use solverforge_scoring::SimpleScoreDirector;
241    use std::any::TypeId;
242
243    #[derive(Clone, Debug)]
244    struct Queen {
245        column: i32,
246        row: Option<i32>,
247    }
248
249    #[derive(Clone, Debug)]
250    struct NQueensSolution {
251        queens: Vec<Queen>,
252        score: Option<SimpleScore>,
253    }
254
255    impl PlanningSolution for NQueensSolution {
256        type Score = SimpleScore;
257
258        fn score(&self) -> Option<Self::Score> {
259            self.score
260        }
261
262        fn set_score(&mut self, score: Option<Self::Score>) {
263            self.score = score;
264        }
265    }
266
267    fn get_queens(s: &NQueensSolution) -> &Vec<Queen> {
268        &s.queens
269    }
270
271    fn get_queens_mut(s: &mut NQueensSolution) -> &mut Vec<Queen> {
272        &mut s.queens
273    }
274
275    // Zero-erasure typed getter/setter for solution-level access
276    fn get_queen_row(s: &NQueensSolution, idx: usize) -> Option<i32> {
277        s.queens.get(idx).and_then(|q| q.row)
278    }
279
280    fn set_queen_row(s: &mut NQueensSolution, idx: usize, v: Option<i32>) {
281        if let Some(queen) = s.queens.get_mut(idx) {
282            queen.row = v;
283        }
284    }
285
286    fn calculate_conflicts(solution: &NQueensSolution) -> SimpleScore {
287        let mut conflicts = 0i64;
288
289        for (i, q1) in solution.queens.iter().enumerate() {
290            if let Some(row1) = q1.row {
291                for q2 in solution.queens.iter().skip(i + 1) {
292                    if let Some(row2) = q2.row {
293                        // Same row conflict
294                        if row1 == row2 {
295                            conflicts += 1;
296                        }
297                        // Diagonal conflict
298                        let col_diff = (q2.column - q1.column).abs();
299                        let row_diff = (row2 - row1).abs();
300                        if col_diff == row_diff {
301                            conflicts += 1;
302                        }
303                    }
304                }
305            }
306        }
307
308        SimpleScore::of(-conflicts)
309    }
310
311    fn create_test_director(
312        rows: &[i32],
313    ) -> SimpleScoreDirector<NQueensSolution, impl Fn(&NQueensSolution) -> SimpleScore> {
314        let queens: Vec<_> = rows
315            .iter()
316            .enumerate()
317            .map(|(col, &row)| Queen {
318                column: col as i32,
319                row: Some(row),
320            })
321            .collect();
322
323        let solution = NQueensSolution {
324            queens,
325            score: None,
326        };
327
328        let extractor = Box::new(TypedEntityExtractor::new(
329            "Queen",
330            "queens",
331            get_queens,
332            get_queens_mut,
333        ));
334        let entity_desc = EntityDescriptor::new("Queen", TypeId::of::<Queen>(), "queens")
335            .with_extractor(extractor);
336
337        let descriptor =
338            SolutionDescriptor::new("NQueensSolution", TypeId::of::<NQueensSolution>())
339                .with_entity(entity_desc);
340
341        SimpleScoreDirector::with_calculator(solution, descriptor, calculate_conflicts)
342    }
343
344    type NQueensMove = ChangeMove<NQueensSolution, i32>;
345
346    fn create_move_selector(
347        values: Vec<i32>,
348    ) -> Box<dyn MoveSelector<NQueensSolution, NQueensMove>> {
349        Box::new(ChangeMoveSelector::<NQueensSolution, i32>::simple(
350            get_queen_row,
351            set_queen_row,
352            0,
353            "row",
354            values,
355        ))
356    }
357
358    #[test]
359    fn test_local_search_hill_climbing() {
360        use crate::manager::LocalSearchPhaseFactory;
361
362        // Start with a suboptimal solution: queens all in row 0
363        let director = create_test_director(&[0, 0, 0, 0]);
364        let mut solver_scope = SolverScope::new(Box::new(director));
365
366        // Initial score should be negative (conflicts)
367        let initial_score = solver_scope.calculate_score();
368        assert!(initial_score < SimpleScore::of(0));
369
370        let values: Vec<i32> = (0..4).collect();
371        let factory =
372            LocalSearchPhaseFactory::<NQueensSolution, NQueensMove, _>::hill_climbing(move || {
373                create_move_selector(values.clone())
374            })
375            .with_step_limit(100);
376        let mut phase = factory.create_phase();
377
378        phase.solve(&mut solver_scope);
379
380        // Should have improved (or at least not gotten worse)
381        let final_score = solver_scope.best_score().cloned().unwrap_or(initial_score);
382        assert!(final_score >= initial_score);
383    }
384
385    #[test]
386    fn test_local_search_reaches_optimal() {
387        use crate::manager::LocalSearchPhaseFactory;
388
389        // Start with a solution that has one conflict
390        let director = create_test_director(&[0, 2, 1, 3]);
391        let mut solver_scope = SolverScope::new(Box::new(director));
392
393        let initial_score = solver_scope.calculate_score();
394
395        let values: Vec<i32> = (0..4).collect();
396        let factory =
397            LocalSearchPhaseFactory::<NQueensSolution, NQueensMove, _>::hill_climbing(move || {
398                create_move_selector(values.clone())
399            })
400            .with_step_limit(50);
401        let mut phase = factory.create_phase();
402
403        phase.solve(&mut solver_scope);
404
405        // Check that we didn't make it worse
406        let final_score = solver_scope.best_score().cloned().unwrap_or(initial_score);
407        assert!(final_score >= initial_score);
408    }
409
410    #[test]
411    fn test_local_search_step_limit() {
412        use crate::manager::LocalSearchPhaseFactory;
413
414        let director = create_test_director(&[0, 0, 0, 0]);
415        let mut solver_scope = SolverScope::new(Box::new(director));
416
417        let values: Vec<i32> = (0..4).collect();
418        let factory =
419            LocalSearchPhaseFactory::<NQueensSolution, NQueensMove, _>::hill_climbing(move || {
420                create_move_selector(values.clone())
421            })
422            .with_step_limit(3);
423        let mut phase = factory.create_phase();
424
425        phase.solve(&mut solver_scope);
426
427        // Should have run some steps
428        // Note: actual step count depends on whether moves are accepted
429    }
430}