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;
12use crate::heuristic::selector::move_selector::{MoveCandidateRef, MoveCursor};
13use crate::heuristic::selector::MoveSelector;
14use crate::phase::control::{
15    settle_search_interrupt, should_interrupt_evaluation, should_interrupt_generation,
16    StepInterrupt,
17};
18use crate::phase::hard_delta::{hard_score_delta, HardScoreDelta};
19use crate::phase::localsearch::{Acceptor, LocalSearchForager};
20use crate::phase::Phase;
21use crate::scope::ProgressCallback;
22use crate::scope::{PhaseScope, SolverScope, StepScope};
23use crate::stats::{format_duration, whole_units_per_second};
24
25/// Local search phase that improves an existing solution.
26///
27/// This phase iteratively:
28/// 1. Generates candidate moves into an arena
29/// 2. Evaluates each move by index
30/// 3. Accepts/rejects based on the acceptor
31/// 4. Takes ownership of the best accepted move from arena
32///
33/// # Type Parameters
34/// * `S` - The planning solution type
35/// * `M` - The move type
36/// * `MS` - The move selector type
37/// * `A` - The acceptor type
38/// * `Fo` - The forager type
39///
40/// # Zero-Clone Design
41///
42/// Uses index-based foraging. The forager stores `(usize, Score)` pairs.
43/// When a move is selected, ownership transfers via `arena.take(index)`.
44/// Moves are NEVER cloned.
45pub struct LocalSearchPhase<S, M, MS, A, Fo>
46where
47    S: PlanningSolution,
48    M: Move<S>,
49    MS: MoveSelector<S, M>,
50    A: Acceptor<S>,
51    Fo: LocalSearchForager<S, M>,
52{
53    move_selector: MS,
54    acceptor: A,
55    forager: Fo,
56    step_limit: Option<u64>,
57    _phantom: PhantomData<fn() -> (S, M)>,
58}
59
60fn candidate_selector_label<S, M>(mov: &MoveCandidateRef<'_, S, M>) -> String
61where
62    S: PlanningSolution,
63    M: Move<S>,
64{
65    if mov.variable_name() == "compound_scalar" || mov.variable_name() == "conflict_repair" {
66        return mov.variable_name().to_string();
67    }
68    let mut label = None;
69    mov.for_each_affected_entity(&mut |affected| {
70        if label.is_none() {
71            label = Some(affected.variable_name.to_string());
72        }
73    });
74    label.unwrap_or_else(|| "move".to_string())
75}
76
77impl<S, M, MS, A, Fo> LocalSearchPhase<S, M, MS, A, Fo>
78where
79    S: PlanningSolution,
80    M: Move<S> + 'static,
81    MS: MoveSelector<S, M>,
82    A: Acceptor<S>,
83    Fo: LocalSearchForager<S, M>,
84{
85    pub fn new(move_selector: MS, acceptor: A, forager: Fo, step_limit: Option<u64>) -> Self {
86        Self {
87            move_selector,
88            acceptor,
89            forager,
90            step_limit,
91            _phantom: PhantomData,
92        }
93    }
94}
95
96impl<S, M, MS, A, Fo> Debug for LocalSearchPhase<S, M, MS, A, Fo>
97where
98    S: PlanningSolution,
99    M: Move<S>,
100    MS: MoveSelector<S, M> + Debug,
101    A: Acceptor<S> + Debug,
102    Fo: LocalSearchForager<S, M> + Debug,
103{
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        f.debug_struct("LocalSearchPhase")
106            .field("move_selector", &self.move_selector)
107            .field("acceptor", &self.acceptor)
108            .field("forager", &self.forager)
109            .field("step_limit", &self.step_limit)
110            .finish()
111    }
112}
113
114impl<S, D, BestCb, M, MS, A, Fo> Phase<S, D, BestCb> for LocalSearchPhase<S, M, MS, A, Fo>
115where
116    S: PlanningSolution,
117    D: Director<S>,
118    BestCb: ProgressCallback<S>,
119    M: Move<S>,
120    MS: MoveSelector<S, M>,
121    A: Acceptor<S>,
122    Fo: LocalSearchForager<S, M>,
123{
124    fn solve(&mut self, solver_scope: &mut SolverScope<S, D, BestCb>) {
125        let mut phase_scope = PhaseScope::new(solver_scope, 0);
126        let phase_index = phase_scope.phase_index();
127
128        // Calculate initial score
129        let mut last_step_score = phase_scope.calculate_score();
130
131        info!(
132            event = "phase_start",
133            phase = "Local Search",
134            phase_index = phase_index,
135            score = %last_step_score,
136        );
137
138        // Notify acceptor of phase start
139        self.acceptor.phase_started(&last_step_score);
140
141        let start_time = Instant::now();
142        let mut local_moves_generated: u64 = 0;
143        let mut local_moves_evaluated: u64 = 0;
144        let mut last_progress_time = Instant::now();
145        let mut last_progress_moves: u64 = 0;
146        loop {
147            // Check early termination
148            if phase_scope.solver_scope_mut().should_terminate() {
149                break;
150            }
151
152            // Check step limit
153            if let Some(limit) = self.step_limit {
154                if phase_scope.step_count() >= limit {
155                    break;
156                }
157            }
158
159            let mut step_scope = StepScope::new(&mut phase_scope);
160
161            /* Reset forager and acceptor for this step.
162            Pass best and last-step scores so foragers that implement
163            pick-early-on-improvement strategies know their reference targets.
164            */
165            let best_score = step_scope
166                .phase_scope()
167                .solver_scope()
168                .best_score()
169                .copied()
170                .unwrap_or(last_step_score);
171            self.forager.step_started(best_score, last_step_score);
172            self.acceptor.step_started();
173            let requires_move_signatures = self.acceptor.requires_move_signatures();
174
175            let mut interrupted_step = false;
176            let mut generated_moves = 0usize;
177            let mut evaluated_moves = 0usize;
178            let mut accepted_moves_this_step = 0u64;
179            let generation_started = Instant::now();
180            let mut cursor = self.move_selector.open_cursor(step_scope.score_director());
181            step_scope
182                .phase_scope_mut()
183                .record_generation_time(generation_started.elapsed());
184
185            while !self.forager.is_quit_early() {
186                if should_interrupt_generation(&step_scope, generated_moves) {
187                    interrupted_step = true;
188                    break;
189                }
190
191                let generation_started = Instant::now();
192                let Some(candidate_id) = cursor.next_candidate() else {
193                    break;
194                };
195                let selector_index = cursor.selector_index(candidate_id);
196                let mov = cursor
197                    .candidate(candidate_id)
198                    .expect("discovered candidate id must remain borrowable");
199                let selector_label = selector_index.map(|_| candidate_selector_label(&mov));
200                let generation_elapsed = generation_started.elapsed();
201                generated_moves += 1;
202                local_moves_generated += 1;
203                if let Some(selector_index) = selector_index {
204                    step_scope
205                        .phase_scope_mut()
206                        .record_selector_generated_move_with_label(
207                            selector_index,
208                            selector_label.as_deref().unwrap_or("selector"),
209                            generation_elapsed,
210                        );
211                } else {
212                    step_scope
213                        .phase_scope_mut()
214                        .record_generated_move(generation_elapsed);
215                }
216
217                if should_interrupt_evaluation(&step_scope, evaluated_moves) {
218                    interrupted_step = true;
219                    break;
220                }
221                evaluated_moves += 1;
222                local_moves_evaluated += 1;
223
224                if local_moves_evaluated & 0x1FFF == 0 {
225                    let now = Instant::now();
226                    if now.duration_since(last_progress_time).as_secs() >= 1 {
227                        let current_speed = whole_units_per_second(
228                            local_moves_evaluated - last_progress_moves,
229                            now.duration_since(last_progress_time),
230                        );
231                        debug!(
232                            event = "progress",
233                            steps = step_scope.step_index(),
234                            moves_generated = local_moves_generated,
235                            moves_evaluated = local_moves_evaluated,
236                            moves_accepted = step_scope.phase_scope().solver_scope().stats().moves_accepted,
237                            score_calculations = step_scope.phase_scope().solver_scope().stats().score_calculations,
238                            speed = current_speed,
239                            acceptance_rate = format!(
240                                "{:.1}%",
241                                step_scope.phase_scope().solver_scope().stats().acceptance_rate() * 100.0
242                            ),
243                            current_score = %last_step_score,
244                            best_score = %best_score,
245                        );
246                        step_scope.phase_scope().solver_scope().report_progress();
247                        last_progress_time = now;
248                        last_progress_moves = local_moves_evaluated;
249                    }
250                }
251
252                let evaluation_started = Instant::now();
253                if !mov.is_doable(step_scope.score_director()) {
254                    if let Some(selector_index) = selector_index {
255                        step_scope.phase_scope_mut().record_selector_evaluated_move(
256                            selector_index,
257                            evaluation_started.elapsed(),
258                        );
259                        step_scope
260                            .phase_scope_mut()
261                            .record_selector_move_not_doable(selector_index);
262                    } else {
263                        step_scope
264                            .phase_scope_mut()
265                            .record_evaluated_move(evaluation_started.elapsed());
266                        step_scope.phase_scope_mut().record_move_not_doable();
267                    }
268                    continue;
269                }
270
271                let move_score = {
272                    let mut recording = RecordingDirector::new(step_scope.score_director_mut());
273                    mov.do_move(&mut recording);
274                    let score = recording.calculate_score();
275                    recording.undo_changes();
276                    score
277                };
278
279                step_scope.phase_scope_mut().record_score_calculation();
280
281                let hard_delta = hard_score_delta(last_step_score, move_score);
282                match hard_delta {
283                    Some(HardScoreDelta::Improving) => {
284                        step_scope.phase_scope_mut().record_move_hard_improving();
285                    }
286                    Some(HardScoreDelta::Neutral) => {
287                        step_scope.phase_scope_mut().record_move_hard_neutral();
288                    }
289                    Some(HardScoreDelta::Worse) => {
290                        step_scope.phase_scope_mut().record_move_hard_worse();
291                    }
292                    None => {}
293                }
294
295                if mov.requires_hard_improvement() && hard_delta != Some(HardScoreDelta::Improving)
296                {
297                    if let Some(selector_index) = selector_index {
298                        step_scope.phase_scope_mut().record_selector_evaluated_move(
299                            selector_index,
300                            evaluation_started.elapsed(),
301                        );
302                        step_scope
303                            .phase_scope_mut()
304                            .record_selector_move_acceptor_rejected(selector_index);
305                    } else {
306                        step_scope
307                            .phase_scope_mut()
308                            .record_evaluated_move(evaluation_started.elapsed());
309                        step_scope.phase_scope_mut().record_move_acceptor_rejected();
310                    }
311                    continue;
312                }
313
314                let move_signature = if requires_move_signatures {
315                    Some(mov.tabu_signature(step_scope.score_director()))
316                } else {
317                    None
318                };
319
320                let accepted = self.acceptor.is_accepted(
321                    &last_step_score,
322                    &move_score,
323                    move_signature.as_ref(),
324                );
325
326                if let Some(selector_index) = selector_index {
327                    step_scope.phase_scope_mut().record_selector_evaluated_move(
328                        selector_index,
329                        evaluation_started.elapsed(),
330                    );
331                } else {
332                    step_scope
333                        .phase_scope_mut()
334                        .record_evaluated_move(evaluation_started.elapsed());
335                }
336                if accepted {
337                    if let Some(selector_index) = selector_index {
338                        step_scope
339                            .phase_scope_mut()
340                            .record_selector_move_accepted(selector_index);
341                    } else {
342                        step_scope.phase_scope_mut().record_move_accepted();
343                    }
344                    accepted_moves_this_step += 1;
345                } else if let Some(selector_index) = selector_index {
346                    step_scope
347                        .phase_scope_mut()
348                        .record_selector_move_acceptor_rejected(selector_index);
349                } else {
350                    step_scope.phase_scope_mut().record_move_acceptor_rejected();
351                }
352
353                trace!(
354                    event = "step",
355                    step = step_scope.step_index(),
356                    move_index = candidate_id.index(),
357                    score = %move_score,
358                    accepted = accepted,
359                );
360
361                if accepted {
362                    self.forager.add_move_index(candidate_id, move_score);
363                }
364            }
365
366            if interrupted_step {
367                match settle_search_interrupt(&mut step_scope) {
368                    StepInterrupt::Restart => continue,
369                    StepInterrupt::TerminatePhase => break,
370                }
371            }
372
373            // Pick the best accepted move index
374            let mut accepted_move_signature = None;
375            if let Some((selected_index, selected_score)) = self.forager.pick_move_index() {
376                let selector_index = cursor.selector_index(selected_index);
377                let selected_move = cursor.take_candidate(selected_index);
378                if requires_move_signatures {
379                    accepted_move_signature =
380                        Some(selected_move.tabu_signature(step_scope.score_director()));
381                }
382                step_scope.apply_committed_move(&selected_move);
383                if let Some(selector_index) = selector_index {
384                    step_scope
385                        .phase_scope_mut()
386                        .record_selector_move_applied(selector_index);
387                } else {
388                    step_scope.phase_scope_mut().record_move_applied();
389                }
390                step_scope.set_step_score(selected_score);
391
392                // Update last step score
393                last_step_score = selected_score;
394
395                // Update best solution if improved
396                step_scope.phase_scope_mut().update_best_solution();
397                if accepted_moves_this_step > 1 {
398                    step_scope
399                        .phase_scope_mut()
400                        .record_moves_forager_ignored(accepted_moves_this_step - 1);
401                }
402            } else if accepted_moves_this_step > 0 {
403                step_scope
404                    .phase_scope_mut()
405                    .record_moves_forager_ignored(accepted_moves_this_step);
406            }
407            /* else: no accepted moves this step — that's fine, the acceptor
408            history still needs to advance so Late Acceptance / SA / etc.
409            can eventually escape the local optimum.
410            */
411
412            /* Always notify acceptor that step ended. For stateful acceptors
413            (Late Acceptance, Simulated Annealing, Great Deluge, SCHC),
414            the history must advance every step — even steps where no move
415            was accepted — otherwise the acceptor state stalls.
416            */
417            self.acceptor
418                .step_ended(&last_step_score, accepted_move_signature.as_ref());
419
420            step_scope.complete();
421        }
422
423        // Notify acceptor of phase end
424        self.acceptor.phase_ended();
425
426        let duration = start_time.elapsed();
427        let steps = phase_scope.step_count();
428        let stats = phase_scope.stats();
429        let speed = whole_units_per_second(stats.moves_evaluated, duration);
430        let acceptance_rate = stats.acceptance_rate() * 100.0;
431        let calc_speed = whole_units_per_second(stats.score_calculations, duration);
432
433        let best_score_str = phase_scope
434            .solver_scope()
435            .best_score()
436            .map(|s| format!("{}", s))
437            .unwrap_or_else(|| "none".to_string());
438
439        info!(
440            event = "phase_end",
441            phase = "Local Search",
442            phase_index = phase_index,
443            duration = %format_duration(duration),
444            steps = steps,
445            moves_generated = stats.moves_generated,
446            moves_evaluated = stats.moves_evaluated,
447            moves_accepted = stats.moves_accepted,
448            score_calculations = stats.score_calculations,
449            generation_time = %format_duration(stats.generation_time()),
450            evaluation_time = %format_duration(stats.evaluation_time()),
451            moves_speed = speed,
452            calc_speed = calc_speed,
453            acceptance_rate = format!("{:.1}%", acceptance_rate),
454            score = best_score_str,
455        );
456    }
457
458    fn phase_type_name(&self) -> &'static str {
459        "LocalSearch"
460    }
461}
462
463#[cfg(test)]
464mod tests;