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