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