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::ProgressCallback;
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    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, BestCb, M, MS, A, Fo> Phase<S, D, BestCb> for LocalSearchPhase<S, M, MS, A, Fo>
96where
97    S: PlanningSolution,
98    D: Director<S>,
99    BestCb: ProgressCallback<S>,
100    M: Move<S>,
101    MS: MoveSelector<S, M>,
102    A: Acceptor<S>,
103    Fo: LocalSearchForager<S, M>,
104{
105    fn solve(&mut self, solver_scope: &mut SolverScope<S, D, BestCb>) {
106        let mut phase_scope = PhaseScope::new(solver_scope, 0);
107        let phase_index = phase_scope.phase_index();
108
109        // Calculate initial score
110        let mut last_step_score = phase_scope.calculate_score();
111
112        info!(
113            event = "phase_start",
114            phase = "Local Search",
115            phase_index = phase_index,
116        );
117
118        // Notify acceptor of phase start
119        self.acceptor.phase_started(&last_step_score);
120
121        let start_time = Instant::now();
122        let mut local_moves_evaluated: u64 = 0;
123        let mut last_progress_time = Instant::now();
124        let mut last_progress_moves: u64 = 0;
125        let mut rng = SmallRng::from_rng(&mut rand::rng());
126
127        loop {
128            // Check early termination
129            if phase_scope.solver_scope_mut().should_terminate() {
130                break;
131            }
132
133            // Check step limit
134            if let Some(limit) = self.step_limit {
135                if phase_scope.step_count() >= limit {
136                    break;
137                }
138            }
139
140            let mut step_scope = StepScope::new(&mut phase_scope);
141
142            /* Reset forager and acceptor for this step.
143            Pass best and last-step scores so foragers that implement
144            pick-early-on-improvement strategies know their reference targets.
145            */
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            */
159            self.arena.reset();
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                    {
170                        let iter = self.move_selector.iter_moves(step_scope.score_director());
171                        for mov in iter.take(64) {
172                            self.arena.push(mov);
173                        }
174                    }
175                    let batch_end = self.arena.len();
176                    if batch_end == batch_start {
177                        break;
178                    }
179
180                    for i in batch_start..batch_end {
181                        local_moves_evaluated += 1;
182
183                        // Log progress every ~8k moves (avoids Instant::now() syscall per move)
184                        if local_moves_evaluated & 0x1FFF == 0 {
185                            let now = Instant::now();
186                            if now.duration_since(last_progress_time).as_secs() >= 1 {
187                                let moves_delta = local_moves_evaluated - last_progress_moves;
188                                let elapsed_secs =
189                                    now.duration_since(last_progress_time).as_secs_f64();
190                                let current_speed = (moves_delta as f64 / elapsed_secs) as u64;
191                                debug!(
192                                    event = "progress",
193                                    steps = step_scope.step_index(),
194                                    moves_evaluated = local_moves_evaluated,
195                                    moves_accepted = step_scope.phase_scope().solver_scope().stats().moves_accepted,
196                                    score_calculations = step_scope.phase_scope().solver_scope().stats().score_calculations,
197                                    speed = current_speed,
198                                    acceptance_rate = format!(
199                                        "{:.1}%",
200                                        step_scope.phase_scope().solver_scope().stats().acceptance_rate() * 100.0
201                                    ),
202                                    current_score = %last_step_score,
203                                    best_score = %best_score,
204                                );
205                                step_scope.phase_scope().solver_scope().report_progress();
206                                last_progress_time = now;
207                                last_progress_moves = local_moves_evaluated;
208                            }
209                        }
210
211                        let m = self.arena.get(i).unwrap();
212
213                        if !m.is_doable(step_scope.score_director()) {
214                            step_scope
215                                .phase_scope_mut()
216                                .solver_scope_mut()
217                                .stats_mut()
218                                .record_move(false);
219                            continue;
220                        }
221
222                        let move_score = {
223                            let mut recording =
224                                RecordingDirector::new(step_scope.score_director_mut());
225                            m.do_move(&mut recording);
226                            let score = recording.calculate_score();
227                            recording.undo_changes();
228                            score
229                        };
230
231                        step_scope
232                            .phase_scope_mut()
233                            .solver_scope_mut()
234                            .stats_mut()
235                            .record_score_calculation();
236
237                        let accepted = self.acceptor.is_accepted(&last_step_score, &move_score);
238
239                        step_scope
240                            .phase_scope_mut()
241                            .solver_scope_mut()
242                            .stats_mut()
243                            .record_move(accepted);
244
245                        trace!(
246                            event = "step",
247                            step = step_scope.step_index(),
248                            move_index = i,
249                            score = %move_score,
250                            accepted = accepted,
251                        );
252
253                        if accepted {
254                            self.forager.add_move_index(i, move_score);
255                        }
256
257                        if self.forager.is_quit_early() {
258                            break 'streaming;
259                        }
260                    }
261                }
262            } else {
263                // Batch path: materialize full move space, shuffle, evaluate.
264                self.move_selector
265                    .append_moves(step_scope.score_director(), &mut self.arena);
266
267                // Shuffle arena in-place — O(n) Fisher-Yates, no allocation
268                self.arena.shuffle(&mut rng);
269
270                // Evaluate moves by index
271                for i in 0..self.arena.len() {
272                    local_moves_evaluated += 1;
273
274                    // Log progress every ~8k moves (avoids Instant::now() syscall per move)
275                    if local_moves_evaluated & 0x1FFF == 0 {
276                        let now = Instant::now();
277                        if now.duration_since(last_progress_time).as_secs() >= 1 {
278                            let moves_delta = local_moves_evaluated - last_progress_moves;
279                            let elapsed_secs = now.duration_since(last_progress_time).as_secs_f64();
280                            let current_speed = (moves_delta as f64 / elapsed_secs) as u64;
281                            debug!(
282                                event = "progress",
283                                steps = step_scope.step_index(),
284                                moves_evaluated = local_moves_evaluated,
285                                moves_accepted = step_scope.phase_scope().solver_scope().stats().moves_accepted,
286                                score_calculations = step_scope.phase_scope().solver_scope().stats().score_calculations,
287                                speed = current_speed,
288                                acceptance_rate = format!(
289                                    "{:.1}%",
290                                    step_scope.phase_scope().solver_scope().stats().acceptance_rate() * 100.0
291                                ),
292                                current_score = %last_step_score,
293                                best_score = %best_score,
294                            );
295                            step_scope.phase_scope().solver_scope().report_progress();
296                            last_progress_time = now;
297                            last_progress_moves = local_moves_evaluated;
298                        }
299                    }
300
301                    let m = self.arena.get(i).unwrap();
302
303                    if !m.is_doable(step_scope.score_director()) {
304                        // Record as evaluated but not accepted
305                        step_scope
306                            .phase_scope_mut()
307                            .solver_scope_mut()
308                            .stats_mut()
309                            .record_move(false);
310                        continue;
311                    }
312
313                    /* Use RecordingDirector for automatic undo
314                    This correctly handles state rollback for all moves including
315                    accepted-but-not-improving sidesteps (>= acceptance)
316                    */
317                    let move_score = {
318                        let mut recording = RecordingDirector::new(step_scope.score_director_mut());
319
320                        // Execute move
321                        m.do_move(&mut recording);
322
323                        // Calculate resulting score
324                        let score = recording.calculate_score();
325
326                        // Undo the move - state is fully restored regardless of acceptance
327                        recording.undo_changes();
328
329                        score
330                    };
331
332                    // Record score calculation (RecordingDirector bypasses scope interceptor)
333                    step_scope
334                        .phase_scope_mut()
335                        .solver_scope_mut()
336                        .stats_mut()
337                        .record_score_calculation();
338
339                    // Check if accepted (>= allows sidesteps for plateau exploration)
340                    let accepted = self.acceptor.is_accepted(&last_step_score, &move_score);
341
342                    // Record move evaluation in solver stats
343                    step_scope
344                        .phase_scope_mut()
345                        .solver_scope_mut()
346                        .stats_mut()
347                        .record_move(accepted);
348
349                    trace!(
350                        event = "step",
351                        step = step_scope.step_index(),
352                        move_index = i,
353                        score = %move_score,
354                        accepted = accepted,
355                    );
356
357                    // Add index to forager if accepted (not the move itself)
358                    if accepted {
359                        self.forager.add_move_index(i, move_score);
360                    }
361
362                    // Check if forager wants to quit early
363                    if self.forager.is_quit_early() {
364                        break;
365                    }
366                }
367            }
368
369            // Pick the best accepted move index
370            if let Some((selected_index, selected_score)) = self.forager.pick_move_index() {
371                /* Execute the selected move permanently (by reference — no ownership needed).
372                The RecordingDirector already undid the evaluation,
373                so this is a fresh application of the chosen move.
374                */
375                self.arena
376                    .get(selected_index)
377                    .unwrap()
378                    .do_move(step_scope.score_director_mut());
379                step_scope.set_step_score(selected_score);
380
381                // Update last step score
382                last_step_score = selected_score;
383
384                // Update best solution if improved
385                step_scope.phase_scope_mut().update_best_solution();
386            }
387            /* else: no accepted moves this step — that's fine, the acceptor
388            history still needs to advance so Late Acceptance / SA / etc.
389            can eventually escape the local optimum.
390            */
391
392            /* Always notify acceptor that step ended. For stateful acceptors
393            (Late Acceptance, Simulated Annealing, Great Deluge, SCHC),
394            the history must advance every step — even steps where no move
395            was accepted — otherwise the acceptor state stalls.
396            */
397            self.acceptor.step_ended(&last_step_score);
398
399            step_scope.complete();
400        }
401
402        // Notify acceptor of phase end
403        self.acceptor.phase_ended();
404
405        let duration = start_time.elapsed();
406        let steps = phase_scope.step_count();
407        let secs = duration.as_secs_f64();
408        let speed = if secs > 0.0 {
409            (local_moves_evaluated as f64 / secs) as u64
410        } else {
411            0
412        };
413
414        let stats = phase_scope.solver_scope().stats();
415        let acceptance_rate = stats.acceptance_rate() * 100.0;
416        let calc_speed = if secs > 0.0 {
417            (stats.score_calculations as f64 / secs) as u64
418        } else {
419            0
420        };
421
422        let best_score_str = phase_scope
423            .solver_scope()
424            .best_score()
425            .map(|s| format!("{}", s))
426            .unwrap_or_else(|| "none".to_string());
427
428        info!(
429            event = "phase_end",
430            phase = "Local Search",
431            phase_index = phase_index,
432            duration_ms = duration.as_millis() as u64,
433            steps = steps,
434            moves_evaluated = stats.moves_evaluated,
435            moves_accepted = stats.moves_accepted,
436            score_calculations = stats.score_calculations,
437            moves_speed = speed,
438            calc_speed = calc_speed,
439            acceptance_rate = format!("{:.1}%", acceptance_rate),
440            score = best_score_str,
441        );
442    }
443
444    fn phase_type_name(&self) -> &'static str {
445        "LocalSearch"
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use crate::heuristic::selector::ChangeMoveSelector;
453    use crate::phase::localsearch::{AcceptedCountForager, HillClimbingAcceptor};
454    use crate::test_utils::{
455        create_nqueens_director, get_queen_row, set_queen_row, NQueensSolution,
456    };
457    type NQueensMove = crate::heuristic::r#move::ChangeMove<NQueensSolution, i64>;
458
459    fn create_move_selector(
460        values: Vec<i64>,
461    ) -> ChangeMoveSelector<
462        NQueensSolution,
463        i64,
464        crate::heuristic::selector::FromSolutionEntitySelector,
465        crate::heuristic::selector::StaticValueSelector<NQueensSolution, i64>,
466    > {
467        ChangeMoveSelector::simple(get_queen_row, set_queen_row, 0, "row", values)
468    }
469
470    #[test]
471    fn test_local_search_hill_climbing() {
472        let director = create_nqueens_director(&[0, 0, 0, 0]);
473        let mut solver_scope = SolverScope::new(director);
474        solver_scope.start_solving();
475
476        let initial_score = solver_scope.calculate_score();
477
478        let values: Vec<i64> = (0..4).collect();
479        let move_selector = create_move_selector(values);
480        let acceptor = HillClimbingAcceptor::new();
481        let forager: AcceptedCountForager<_> = AcceptedCountForager::new(1);
482        let mut phase: LocalSearchPhase<_, NQueensMove, _, _, _> =
483            LocalSearchPhase::new(move_selector, acceptor, forager, Some(100));
484
485        phase.solve(&mut solver_scope);
486
487        let final_score = solver_scope.best_score().copied().unwrap_or(initial_score);
488        assert!(final_score >= initial_score);
489
490        // Verify stats were recorded
491        assert!(solver_scope.stats().moves_evaluated > 0);
492    }
493
494    #[test]
495    fn test_local_search_reaches_optimal() {
496        let director = create_nqueens_director(&[0, 2, 1, 3]);
497        let mut solver_scope = SolverScope::new(director);
498        solver_scope.start_solving();
499
500        let initial_score = solver_scope.calculate_score();
501
502        let values: Vec<i64> = (0..4).collect();
503        let move_selector = create_move_selector(values);
504        let acceptor = HillClimbingAcceptor::new();
505        let forager: AcceptedCountForager<_> = AcceptedCountForager::new(1);
506        let mut phase: LocalSearchPhase<_, NQueensMove, _, _, _> =
507            LocalSearchPhase::new(move_selector, acceptor, forager, Some(50));
508
509        phase.solve(&mut solver_scope);
510
511        let final_score = solver_scope.best_score().copied().unwrap_or(initial_score);
512        assert!(final_score >= initial_score);
513    }
514
515    #[test]
516    fn test_local_search_step_limit() {
517        let director = create_nqueens_director(&[0, 0, 0, 0]);
518        let mut solver_scope = SolverScope::new(director);
519        solver_scope.start_solving();
520
521        let values: Vec<i64> = (0..4).collect();
522        let move_selector = create_move_selector(values);
523        let acceptor = HillClimbingAcceptor::new();
524        let forager: AcceptedCountForager<_> = AcceptedCountForager::new(1);
525        let mut phase: LocalSearchPhase<_, NQueensMove, _, _, _> =
526            LocalSearchPhase::new(move_selector, acceptor, forager, Some(3));
527
528        phase.solve(&mut solver_scope);
529
530        // Steps should be limited
531        assert!(solver_scope.stats().step_count <= 3);
532    }
533}