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