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    settle_search_interrupt, should_interrupt_evaluation, should_interrupt_generation,
15    StepInterrupt,
16};
17use crate::phase::localsearch::{Acceptor, LocalSearchForager};
18use crate::phase::Phase;
19use crate::scope::ProgressCallback;
20use crate::scope::{PhaseScope, SolverScope, StepScope};
21use crate::stats::{format_duration, whole_units_per_second};
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_generated: u64 = 0;
126        let mut local_moves_evaluated: u64 = 0;
127        let mut last_progress_time = Instant::now();
128        let mut last_progress_moves: u64 = 0;
129        loop {
130            // Check early termination
131            if phase_scope.solver_scope_mut().should_terminate() {
132                break;
133            }
134
135            // Check step limit
136            if let Some(limit) = self.step_limit {
137                if phase_scope.step_count() >= limit {
138                    break;
139                }
140            }
141
142            let mut step_scope = StepScope::new(&mut phase_scope);
143
144            /* Reset forager and acceptor for this step.
145            Pass best and last-step scores so foragers that implement
146            pick-early-on-improvement strategies know their reference targets.
147            */
148            let best_score = step_scope
149                .phase_scope()
150                .solver_scope()
151                .best_score()
152                .copied()
153                .unwrap_or(last_step_score);
154            self.forager.step_started(best_score, last_step_score);
155            self.acceptor.step_started();
156
157            self.arena.reset();
158            let mut interrupted_step = false;
159            let mut generated_moves = 0usize;
160            let mut evaluated_moves = 0usize;
161            let generation_started = Instant::now();
162            let mut cursor = self.move_selector.open_cursor(step_scope.score_director());
163            step_scope
164                .phase_scope_mut()
165                .record_generation_time(generation_started.elapsed());
166
167            while !self.forager.is_quit_early() {
168                if should_interrupt_generation(&step_scope, generated_moves) {
169                    interrupted_step = true;
170                    break;
171                }
172
173                let generation_started = Instant::now();
174                let Some(mov) = cursor.next() else {
175                    break;
176                };
177                let generation_elapsed = generation_started.elapsed();
178                generated_moves += 1;
179                local_moves_generated += 1;
180                step_scope
181                    .phase_scope_mut()
182                    .record_generated_move(generation_elapsed);
183
184                if should_interrupt_evaluation(&step_scope, evaluated_moves) {
185                    interrupted_step = true;
186                    break;
187                }
188                evaluated_moves += 1;
189                local_moves_evaluated += 1;
190
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 current_speed = whole_units_per_second(
195                            local_moves_evaluated - last_progress_moves,
196                            now.duration_since(last_progress_time),
197                        );
198                        debug!(
199                            event = "progress",
200                            steps = step_scope.step_index(),
201                            moves_generated = local_moves_generated,
202                            moves_evaluated = local_moves_evaluated,
203                            moves_accepted = step_scope.phase_scope().solver_scope().stats().moves_accepted,
204                            score_calculations = step_scope.phase_scope().solver_scope().stats().score_calculations,
205                            speed = current_speed,
206                            acceptance_rate = format!(
207                                "{:.1}%",
208                                step_scope.phase_scope().solver_scope().stats().acceptance_rate() * 100.0
209                            ),
210                            current_score = %last_step_score,
211                            best_score = %best_score,
212                        );
213                        step_scope.phase_scope().solver_scope().report_progress();
214                        last_progress_time = now;
215                        last_progress_moves = local_moves_evaluated;
216                    }
217                }
218
219                let evaluation_started = Instant::now();
220                if !mov.is_doable(step_scope.score_director()) {
221                    step_scope
222                        .phase_scope_mut()
223                        .record_evaluated_move(evaluation_started.elapsed());
224                    continue;
225                }
226
227                let move_score = {
228                    let mut recording = RecordingDirector::new(step_scope.score_director_mut());
229                    mov.do_move(&mut recording);
230                    let score = recording.calculate_score();
231                    recording.undo_changes();
232                    score
233                };
234
235                step_scope.phase_scope_mut().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                    .record_evaluated_move(evaluation_started.elapsed());
242                if accepted {
243                    step_scope.phase_scope_mut().record_move_accepted();
244                }
245
246                trace!(
247                    event = "step",
248                    step = step_scope.step_index(),
249                    move_index = evaluated_moves - 1,
250                    score = %move_score,
251                    accepted = accepted,
252                );
253
254                if accepted {
255                    let accepted_index = self.arena.len();
256                    self.arena.push(mov);
257                    self.forager.add_move_index(accepted_index, move_score);
258                }
259            }
260
261            if interrupted_step {
262                match settle_search_interrupt(&mut step_scope) {
263                    StepInterrupt::Restart => continue,
264                    StepInterrupt::TerminatePhase => break,
265                }
266            }
267
268            // Pick the best accepted move index
269            if let Some((selected_index, selected_score)) = self.forager.pick_move_index() {
270                let selected_move = self.arena.take(selected_index);
271                selected_move.do_move(step_scope.score_director_mut());
272                step_scope.set_step_score(selected_score);
273
274                // Update last step score
275                last_step_score = selected_score;
276
277                // Update best solution if improved
278                step_scope.phase_scope_mut().update_best_solution();
279            }
280            /* else: no accepted moves this step — that's fine, the acceptor
281            history still needs to advance so Late Acceptance / SA / etc.
282            can eventually escape the local optimum.
283            */
284
285            /* Always notify acceptor that step ended. For stateful acceptors
286            (Late Acceptance, Simulated Annealing, Great Deluge, SCHC),
287            the history must advance every step — even steps where no move
288            was accepted — otherwise the acceptor state stalls.
289            */
290            self.acceptor.step_ended(&last_step_score);
291
292            step_scope.complete();
293        }
294
295        // Notify acceptor of phase end
296        self.acceptor.phase_ended();
297
298        let duration = start_time.elapsed();
299        let steps = phase_scope.step_count();
300        let stats = phase_scope.stats();
301        let speed = whole_units_per_second(stats.moves_evaluated, duration);
302        let acceptance_rate = stats.acceptance_rate() * 100.0;
303        let calc_speed = whole_units_per_second(stats.score_calculations, duration);
304
305        let best_score_str = phase_scope
306            .solver_scope()
307            .best_score()
308            .map(|s| format!("{}", s))
309            .unwrap_or_else(|| "none".to_string());
310
311        info!(
312            event = "phase_end",
313            phase = "Local Search",
314            phase_index = phase_index,
315            duration = %format_duration(duration),
316            steps = steps,
317            moves_generated = stats.moves_generated,
318            moves_evaluated = stats.moves_evaluated,
319            moves_accepted = stats.moves_accepted,
320            score_calculations = stats.score_calculations,
321            generation_time = %format_duration(stats.generation_time()),
322            evaluation_time = %format_duration(stats.evaluation_time()),
323            moves_speed = speed,
324            calc_speed = calc_speed,
325            acceptance_rate = format!("{:.1}%", acceptance_rate),
326            score = best_score_str,
327        );
328    }
329
330    fn phase_type_name(&self) -> &'static str {
331        "LocalSearch"
332    }
333}
334
335#[cfg(test)]
336#[path = "phase_tests.rs"]
337mod tests;