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