Skip to main content

solverforge_solver/phase/localsearch/
phase.rs

1//! Local search phase implementation.
2
3use std::fmt::Debug;
4use std::marker::PhantomData;
5use std::time::Instant;
6
7use rand::rngs::SmallRng;
8use rand::SeedableRng as _;
9use solverforge_core::domain::PlanningSolution;
10use solverforge_scoring::{Director, RecordingDirector};
11use tracing::{debug, info, trace};
12
13use crate::heuristic::r#move::{Move, MoveArena};
14use crate::heuristic::selector::MoveSelector;
15use crate::phase::localsearch::{Acceptor, LocalSearchForager};
16use crate::phase::Phase;
17use crate::scope::BestSolutionCallback;
18use crate::scope::{PhaseScope, SolverScope, StepScope};
19
20/// Local search phase that improves an existing solution.
21///
22/// This phase iteratively:
23/// 1. Generates candidate moves into an arena
24/// 2. Evaluates each move by index
25/// 3. Accepts/rejects based on the acceptor
26/// 4. Takes ownership of the best accepted move from arena
27///
28/// # Type Parameters
29/// * `S` - The planning solution type
30/// * `M` - The move type
31/// * `MS` - The move selector type
32/// * `A` - The acceptor type
33/// * `Fo` - The forager type
34///
35/// # Zero-Clone Design
36///
37/// Uses index-based foraging. The forager stores `(usize, Score)` pairs.
38/// When a move is selected, ownership transfers via `arena.take(index)`.
39/// Moves are NEVER cloned.
40pub struct LocalSearchPhase<S, M, MS, A, Fo>
41where
42    S: PlanningSolution,
43    M: Move<S>,
44    MS: MoveSelector<S, M>,
45    A: Acceptor<S>,
46    Fo: LocalSearchForager<S, M>,
47{
48    move_selector: MS,
49    acceptor: A,
50    forager: Fo,
51    arena: MoveArena<M>,
52    step_limit: Option<u64>,
53    _phantom: PhantomData<fn() -> (S, M)>,
54}
55
56impl<S, M, MS, A, Fo> LocalSearchPhase<S, M, MS, A, Fo>
57where
58    S: PlanningSolution,
59    M: Move<S> + 'static,
60    MS: MoveSelector<S, M>,
61    A: Acceptor<S>,
62    Fo: LocalSearchForager<S, M>,
63{
64    /// Creates a new local search phase.
65    pub fn new(move_selector: MS, acceptor: A, forager: Fo, step_limit: Option<u64>) -> Self {
66        Self {
67            move_selector,
68            acceptor,
69            forager,
70            arena: MoveArena::new(),
71            step_limit,
72            _phantom: PhantomData,
73        }
74    }
75}
76
77impl<S, M, MS, A, Fo> Debug for LocalSearchPhase<S, M, MS, A, Fo>
78where
79    S: PlanningSolution,
80    M: Move<S>,
81    MS: MoveSelector<S, M> + Debug,
82    A: Acceptor<S> + Debug,
83    Fo: LocalSearchForager<S, M> + Debug,
84{
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("LocalSearchPhase")
87            .field("move_selector", &self.move_selector)
88            .field("acceptor", &self.acceptor)
89            .field("forager", &self.forager)
90            .field("arena", &self.arena)
91            .field("step_limit", &self.step_limit)
92            .finish()
93    }
94}
95
96impl<S, D, BestCb, M, MS, A, Fo> Phase<S, D, BestCb> for LocalSearchPhase<S, M, MS, A, Fo>
97where
98    S: PlanningSolution,
99    D: Director<S>,
100    BestCb: BestSolutionCallback<S>,
101    M: Move<S>,
102    MS: MoveSelector<S, M>,
103    A: Acceptor<S>,
104    Fo: LocalSearchForager<S, M>,
105{
106    fn solve(&mut self, solver_scope: &mut SolverScope<S, D, BestCb>) {
107        let mut phase_scope = PhaseScope::new(solver_scope, 0);
108        let phase_index = phase_scope.phase_index();
109
110        // Calculate initial score
111        let mut last_step_score = phase_scope.calculate_score();
112
113        info!(
114            event = "phase_start",
115            phase = "Local Search",
116            phase_index = phase_index,
117        );
118
119        // Notify acceptor of phase start
120        self.acceptor.phase_started(&last_step_score);
121
122        let start_time = Instant::now();
123        let mut local_moves_evaluated: u64 = 0;
124        let mut last_progress_time = Instant::now();
125        let mut last_progress_moves: u64 = 0;
126        let mut rng = SmallRng::from_rng(&mut rand::rng());
127
128        loop {
129            // Check early termination
130            if phase_scope.solver_scope().should_terminate() {
131                break;
132            }
133
134            // Check step limit
135            if let Some(limit) = self.step_limit {
136                if phase_scope.step_count() >= limit {
137                    break;
138                }
139            }
140
141            let mut step_scope = StepScope::new(&mut phase_scope);
142
143            // Reset forager and acceptor for this step.
144            // Pass best and last-step scores so foragers that implement
145            // pick-early-on-improvement strategies know their reference targets.
146            let best_score = step_scope
147                .phase_scope()
148                .solver_scope()
149                .best_score()
150                .copied()
151                .unwrap_or(last_step_score);
152            self.forager.step_started(best_score, last_step_score);
153            self.acceptor.step_started();
154
155            // Regenerate moves every step so the move space reflects the
156            // current solution topology. This is essential for list variables
157            // (VRP-style) where positions change after each accepted move.
158            self.arena.reset();
159
160            if self.move_selector.is_never_ending() {
161                // Streaming path for random-sampling (never-ending) selectors.
162                // Pull moves in small batches to avoid holding an immutable borrow
163                // on the score director across the mutable evaluation calls.
164                // The selector provides its own randomization; no shuffle needed.
165                'streaming: loop {
166                    let batch_start = self.arena.len();
167                    {
168                        let iter = self.move_selector.iter_moves(step_scope.score_director());
169                        for mov in iter.take(64) {
170                            self.arena.push(mov);
171                        }
172                    }
173                    let batch_end = self.arena.len();
174                    if batch_end == batch_start {
175                        break;
176                    }
177
178                    for i in batch_start..batch_end {
179                        local_moves_evaluated += 1;
180
181                        // Log progress every ~8k moves (avoids Instant::now() syscall per move)
182                        if local_moves_evaluated & 0x1FFF == 0 {
183                            let now = Instant::now();
184                            if now.duration_since(last_progress_time).as_secs() >= 1 {
185                                let moves_delta = local_moves_evaluated - last_progress_moves;
186                                let elapsed_secs =
187                                    now.duration_since(last_progress_time).as_secs_f64();
188                                let current_speed = (moves_delta as f64 / elapsed_secs) as u64;
189                                debug!(
190                                    event = "progress",
191                                    steps = step_scope.step_index(),
192                                    moves_evaluated = local_moves_evaluated,
193                                    speed = current_speed,
194                                    score = %last_step_score,
195                                );
196                                last_progress_time = now;
197                                last_progress_moves = local_moves_evaluated;
198                            }
199                        }
200
201                        let m = self.arena.get(i).unwrap();
202
203                        if !m.is_doable(step_scope.score_director()) {
204                            step_scope
205                                .phase_scope_mut()
206                                .solver_scope_mut()
207                                .stats_mut()
208                                .record_move(false);
209                            continue;
210                        }
211
212                        let move_score = {
213                            let mut recording =
214                                RecordingDirector::new(step_scope.score_director_mut());
215                            m.do_move(&mut recording);
216                            let score = recording.calculate_score();
217                            recording.undo_changes();
218                            score
219                        };
220
221                        step_scope
222                            .phase_scope_mut()
223                            .solver_scope_mut()
224                            .stats_mut()
225                            .record_score_calculation();
226
227                        let accepted = self.acceptor.is_accepted(&last_step_score, &move_score);
228
229                        step_scope
230                            .phase_scope_mut()
231                            .solver_scope_mut()
232                            .stats_mut()
233                            .record_move(accepted);
234
235                        trace!(
236                            event = "step",
237                            step = step_scope.step_index(),
238                            move_index = i,
239                            score = %move_score,
240                            accepted = accepted,
241                        );
242
243                        if accepted {
244                            self.forager.add_move_index(i, move_score);
245                        }
246
247                        if self.forager.is_quit_early() {
248                            break 'streaming;
249                        }
250                    }
251                }
252            } else {
253                // Batch path: materialize full move space, shuffle, evaluate.
254                self.arena
255                    .extend(self.move_selector.iter_moves(step_scope.score_director()));
256
257                // Shuffle arena in-place — O(n) Fisher-Yates, no allocation
258                self.arena.shuffle(&mut rng);
259
260                // Evaluate moves by index
261                for i in 0..self.arena.len() {
262                    local_moves_evaluated += 1;
263
264                    // Log progress every ~8k moves (avoids Instant::now() syscall per move)
265                    if local_moves_evaluated & 0x1FFF == 0 {
266                        let now = Instant::now();
267                        if now.duration_since(last_progress_time).as_secs() >= 1 {
268                            let moves_delta = local_moves_evaluated - last_progress_moves;
269                            let elapsed_secs = now.duration_since(last_progress_time).as_secs_f64();
270                            let current_speed = (moves_delta as f64 / elapsed_secs) as u64;
271                            debug!(
272                                event = "progress",
273                                steps = step_scope.step_index(),
274                                moves_evaluated = local_moves_evaluated,
275                                speed = current_speed,
276                                score = %last_step_score,
277                            );
278                            last_progress_time = now;
279                            last_progress_moves = local_moves_evaluated;
280                        }
281                    }
282
283                    let m = self.arena.get(i).unwrap();
284
285                    if !m.is_doable(step_scope.score_director()) {
286                        // Record as evaluated but not accepted
287                        step_scope
288                            .phase_scope_mut()
289                            .solver_scope_mut()
290                            .stats_mut()
291                            .record_move(false);
292                        continue;
293                    }
294
295                    // Use RecordingDirector for automatic undo
296                    // This correctly handles state rollback for all moves including
297                    // accepted-but-not-improving sidesteps (>= acceptance)
298                    let move_score = {
299                        let mut recording = RecordingDirector::new(step_scope.score_director_mut());
300
301                        // Execute move
302                        m.do_move(&mut recording);
303
304                        // Calculate resulting score
305                        let score = recording.calculate_score();
306
307                        // Undo the move - state is fully restored regardless of acceptance
308                        recording.undo_changes();
309
310                        score
311                    };
312
313                    // Record score calculation (RecordingDirector bypasses scope interceptor)
314                    step_scope
315                        .phase_scope_mut()
316                        .solver_scope_mut()
317                        .stats_mut()
318                        .record_score_calculation();
319
320                    // Check if accepted (>= allows sidesteps for plateau exploration)
321                    let accepted = self.acceptor.is_accepted(&last_step_score, &move_score);
322
323                    // Record move evaluation in solver stats
324                    step_scope
325                        .phase_scope_mut()
326                        .solver_scope_mut()
327                        .stats_mut()
328                        .record_move(accepted);
329
330                    trace!(
331                        event = "step",
332                        step = step_scope.step_index(),
333                        move_index = i,
334                        score = %move_score,
335                        accepted = accepted,
336                    );
337
338                    // Add index to forager if accepted (not the move itself)
339                    if accepted {
340                        self.forager.add_move_index(i, move_score);
341                    }
342
343                    // Check if forager wants to quit early
344                    if self.forager.is_quit_early() {
345                        break;
346                    }
347                }
348            }
349
350            // Pick the best accepted move index
351            if let Some((selected_index, selected_score)) = self.forager.pick_move_index() {
352                // Execute the selected move permanently (by reference — no ownership needed).
353                // The RecordingDirector already undid the evaluation,
354                // so this is a fresh application of the chosen move.
355                self.arena
356                    .get(selected_index)
357                    .unwrap()
358                    .do_move(step_scope.score_director_mut());
359                step_scope.set_step_score(selected_score);
360
361                // Update last step score
362                last_step_score = selected_score;
363
364                // Update best solution if improved
365                step_scope.phase_scope_mut().update_best_solution();
366            }
367            // else: no accepted moves this step — that's fine, the acceptor
368            // history still needs to advance so Late Acceptance / SA / etc.
369            // can eventually escape the local optimum.
370
371            // Always notify acceptor that step ended. For stateful acceptors
372            // (Late Acceptance, Simulated Annealing, Great Deluge, SCHC),
373            // the history must advance every step — even steps where no move
374            // was accepted — otherwise the acceptor state stalls.
375            self.acceptor.step_ended(&last_step_score);
376
377            step_scope.complete();
378        }
379
380        // Notify acceptor of phase end
381        self.acceptor.phase_ended();
382
383        let duration = start_time.elapsed();
384        let steps = phase_scope.step_count();
385        let secs = duration.as_secs_f64();
386        let speed = if secs > 0.0 {
387            (local_moves_evaluated as f64 / secs) as u64
388        } else {
389            0
390        };
391
392        let stats = phase_scope.solver_scope().stats();
393        let acceptance_rate = stats.acceptance_rate() * 100.0;
394        let calc_speed = if secs > 0.0 {
395            (stats.score_calculations as f64 / secs) as u64
396        } else {
397            0
398        };
399
400        let best_score_str = phase_scope
401            .solver_scope()
402            .best_score()
403            .map(|s| format!("{}", s))
404            .unwrap_or_else(|| "none".to_string());
405
406        info!(
407            event = "phase_end",
408            phase = "Local Search",
409            phase_index = phase_index,
410            duration_ms = duration.as_millis() as u64,
411            steps = steps,
412            moves_speed = speed,
413            calc_speed = calc_speed,
414            acceptance_rate = format!("{:.1}%", acceptance_rate),
415            score = best_score_str,
416        );
417    }
418
419    fn phase_type_name(&self) -> &'static str {
420        "LocalSearch"
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use crate::heuristic::selector::ChangeMoveSelector;
428    use crate::phase::localsearch::{AcceptedCountForager, HillClimbingAcceptor};
429    use crate::test_utils::{
430        create_nqueens_director, get_queen_row, set_queen_row, NQueensSolution,
431    };
432    type NQueensMove = crate::heuristic::r#move::ChangeMove<NQueensSolution, i64>;
433
434    fn create_move_selector(
435        values: Vec<i64>,
436    ) -> ChangeMoveSelector<
437        NQueensSolution,
438        i64,
439        crate::heuristic::selector::FromSolutionEntitySelector,
440        crate::heuristic::selector::StaticTypedValueSelector<NQueensSolution, i64>,
441    > {
442        ChangeMoveSelector::simple(get_queen_row, set_queen_row, 0, "row", values)
443    }
444
445    #[test]
446    fn test_local_search_hill_climbing() {
447        let director = create_nqueens_director(&[0, 0, 0, 0]);
448        let mut solver_scope = SolverScope::new(director);
449        solver_scope.start_solving();
450
451        let initial_score = solver_scope.calculate_score();
452
453        let values: Vec<i64> = (0..4).collect();
454        let move_selector = create_move_selector(values);
455        let acceptor = HillClimbingAcceptor::new();
456        let forager: AcceptedCountForager<_> = AcceptedCountForager::new(1);
457        let mut phase: LocalSearchPhase<_, NQueensMove, _, _, _> =
458            LocalSearchPhase::new(move_selector, acceptor, forager, Some(100));
459
460        phase.solve(&mut solver_scope);
461
462        let final_score = solver_scope.best_score().copied().unwrap_or(initial_score);
463        assert!(final_score >= initial_score);
464
465        // Verify stats were recorded
466        assert!(solver_scope.stats().moves_evaluated > 0);
467    }
468
469    #[test]
470    fn test_local_search_reaches_optimal() {
471        let director = create_nqueens_director(&[0, 2, 1, 3]);
472        let mut solver_scope = SolverScope::new(director);
473        solver_scope.start_solving();
474
475        let initial_score = solver_scope.calculate_score();
476
477        let values: Vec<i64> = (0..4).collect();
478        let move_selector = create_move_selector(values);
479        let acceptor = HillClimbingAcceptor::new();
480        let forager: AcceptedCountForager<_> = AcceptedCountForager::new(1);
481        let mut phase: LocalSearchPhase<_, NQueensMove, _, _, _> =
482            LocalSearchPhase::new(move_selector, acceptor, forager, Some(50));
483
484        phase.solve(&mut solver_scope);
485
486        let final_score = solver_scope.best_score().copied().unwrap_or(initial_score);
487        assert!(final_score >= initial_score);
488    }
489
490    #[test]
491    fn test_local_search_step_limit() {
492        let director = create_nqueens_director(&[0, 0, 0, 0]);
493        let mut solver_scope = SolverScope::new(director);
494        solver_scope.start_solving();
495
496        let values: Vec<i64> = (0..4).collect();
497        let move_selector = create_move_selector(values);
498        let acceptor = HillClimbingAcceptor::new();
499        let forager: AcceptedCountForager<_> = AcceptedCountForager::new(1);
500        let mut phase: LocalSearchPhase<_, NQueensMove, _, _, _> =
501            LocalSearchPhase::new(move_selector, acceptor, forager, Some(3));
502
503        phase.solve(&mut solver_scope);
504
505        // Steps should be limited
506        assert!(solver_scope.stats().step_count <= 3);
507    }
508}