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_core::score::Score;
10use solverforge_scoring::Director;
11use tracing::{debug, info, trace};
12
13use crate::heuristic::r#move::Move;
14use crate::heuristic::selector::move_selector::{MoveCandidateRef, MoveCursor, MoveStreamContext};
15use crate::heuristic::selector::MoveSelector;
16use crate::phase::control::{
17    settle_search_interrupt, should_interrupt_after_step, should_interrupt_before_candidate,
18    should_interrupt_before_evaluation, StepInterrupt,
19};
20use crate::phase::localsearch::evaluation::{
21    evaluate_candidate, record_evaluated_move, CandidateEvaluation,
22};
23use crate::phase::localsearch::{Acceptor, LocalSearchForager};
24use crate::phase::Phase;
25use crate::scope::ProgressCallback;
26use crate::scope::{PhaseScope, SolverScope, StepScope};
27use crate::stats::{format_duration, whole_units_per_second, AppliedMoveTelemetry};
28
29const STEP_ACCEPTED_LABEL_LIMIT: usize = 32;
30
31#[derive(Clone, Copy)]
32struct StepMoveLabelCount {
33    label: &'static str,
34    count: u64,
35}
36
37struct StepMoveLabelCounts {
38    entries: [StepMoveLabelCount; STEP_ACCEPTED_LABEL_LIMIT],
39    overflow: u64,
40}
41
42impl StepMoveLabelCounts {
43    const EMPTY_ENTRY: StepMoveLabelCount = StepMoveLabelCount {
44        label: "",
45        count: 0,
46    };
47
48    fn new() -> Self {
49        Self {
50            entries: [Self::EMPTY_ENTRY; STEP_ACCEPTED_LABEL_LIMIT],
51            overflow: 0,
52        }
53    }
54
55    fn record(&mut self, label: &'static str) {
56        for entry in &mut self.entries {
57            if entry.count > 0 && entry.label == label {
58                entry.count += 1;
59                return;
60            }
61        }
62        for entry in &mut self.entries {
63            if entry.count == 0 {
64                entry.label = label;
65                entry.count = 1;
66                return;
67            }
68        }
69        self.overflow += 1;
70    }
71
72    fn for_each_ignored_except_selected(
73        &self,
74        selected_label: Option<&'static str>,
75        mut visitor: impl FnMut(&'static str, u64),
76    ) {
77        let mut selected_remaining = selected_label;
78        for entry in &self.entries {
79            if entry.count == 0 {
80                continue;
81            }
82            let ignored = if selected_remaining == Some(entry.label) {
83                selected_remaining = None;
84                entry.count.saturating_sub(1)
85            } else {
86                entry.count
87            };
88            if ignored > 0 {
89                visitor(entry.label, ignored);
90            }
91        }
92        if self.overflow > 0 {
93            visitor("move", self.overflow);
94        }
95    }
96}
97
98/// Local search phase that improves an existing solution.
99///
100/// This phase iteratively:
101/// 1. Generates candidate moves into an arena
102/// 2. Evaluates each move by index
103/// 3. Accepts/rejects based on the acceptor
104/// 4. Takes ownership of the best accepted move from arena
105///
106/// # Type Parameters
107/// * `S` - The planning solution type
108/// * `M` - The move type
109/// * `MS` - The move selector type
110/// * `A` - The acceptor type
111/// * `Fo` - The forager type
112///
113/// # Zero-Clone Design
114///
115/// Uses index-based foraging. The forager stores `(usize, Score)` pairs.
116/// When a move is selected, ownership transfers via `arena.take(index)`.
117/// Moves are NEVER cloned.
118pub struct LocalSearchPhase<S, M, MS, A, Fo>
119where
120    S: PlanningSolution,
121    M: Move<S>,
122    MS: MoveSelector<S, M>,
123    A: Acceptor<S>,
124    Fo: LocalSearchForager<S, M>,
125{
126    move_selector: MS,
127    acceptor: A,
128    forager: Fo,
129    step_limit: Option<u64>,
130    _phantom: PhantomData<fn() -> (S, M)>,
131}
132
133fn candidate_selector_label<S, M>(mov: &MoveCandidateRef<'_, S, M>) -> String
134where
135    S: PlanningSolution,
136    M: Move<S>,
137{
138    if mov.variable_name() == "compound_scalar" || mov.variable_name() == "conflict_repair" {
139        return mov.variable_name().to_string();
140    }
141    let mut label = None;
142    mov.for_each_affected_entity(&mut |affected| {
143        if label.is_none() {
144            label = Some(affected.variable_name.to_string());
145        }
146    });
147    label.unwrap_or_else(|| "move".to_string())
148}
149
150impl<S, M, MS, A, Fo> LocalSearchPhase<S, M, MS, A, Fo>
151where
152    S: PlanningSolution,
153    M: Move<S> + 'static,
154    MS: MoveSelector<S, M>,
155    A: Acceptor<S>,
156    Fo: LocalSearchForager<S, M>,
157{
158    pub fn new(move_selector: MS, acceptor: A, forager: Fo, step_limit: Option<u64>) -> Self {
159        Self {
160            move_selector,
161            acceptor,
162            forager,
163            step_limit,
164            _phantom: PhantomData,
165        }
166    }
167}
168
169impl<S, M, MS, A, Fo> Debug for LocalSearchPhase<S, M, MS, A, Fo>
170where
171    S: PlanningSolution,
172    M: Move<S>,
173    MS: MoveSelector<S, M> + Debug,
174    A: Acceptor<S> + Debug,
175    Fo: LocalSearchForager<S, M> + Debug,
176{
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        f.debug_struct("LocalSearchPhase")
179            .field("move_selector", &self.move_selector)
180            .field("acceptor", &self.acceptor)
181            .field("forager", &self.forager)
182            .field("step_limit", &self.step_limit)
183            .finish()
184    }
185}
186
187impl<S, D, BestCb, M, MS, A, Fo> Phase<S, D, BestCb> for LocalSearchPhase<S, M, MS, A, Fo>
188where
189    S: PlanningSolution,
190    D: Director<S>,
191    BestCb: ProgressCallback<S>,
192    M: Move<S>,
193    MS: MoveSelector<S, M>,
194    A: Acceptor<S>,
195    Fo: LocalSearchForager<S, M>,
196{
197    fn solve(&mut self, solver_scope: &mut SolverScope<S, D, BestCb>) {
198        let mut phase_scope = PhaseScope::new(solver_scope, 0);
199        let phase_index = phase_scope.phase_index();
200
201        // Calculate initial score
202        let mut last_step_score = phase_scope.calculate_score();
203
204        info!(
205            event = "phase_start",
206            phase = "Local Search",
207            phase_index = phase_index,
208            score = %last_step_score,
209        );
210
211        // Notify acceptor of phase start
212        self.acceptor.phase_started(&last_step_score);
213
214        let start_time = Instant::now();
215        let mut local_moves_generated: u64 = 0;
216        let mut local_moves_evaluated: u64 = 0;
217        let mut last_progress_time = Instant::now();
218        let mut last_progress_moves: u64 = 0;
219        loop {
220            // Check early termination
221            if phase_scope.solver_scope_mut().should_terminate() {
222                break;
223            }
224
225            // Check step limit
226            if let Some(limit) = self.step_limit {
227                if phase_scope.step_count() >= limit {
228                    break;
229                }
230            }
231
232            let mut step_scope = StepScope::new(&mut phase_scope);
233
234            /* Reset forager and acceptor for this step.
235            Pass best and last-step scores so foragers that implement
236            pick-early-on-improvement strategies know their reference targets.
237            */
238            let best_score = step_scope
239                .phase_scope()
240                .solver_scope()
241                .best_score()
242                .copied()
243                .unwrap_or(last_step_score);
244            self.forager.step_started(best_score, last_step_score);
245            self.acceptor.step_started();
246            let requires_move_signatures = self.acceptor.requires_move_signatures();
247
248            let mut interrupted_step = false;
249            let mut accepted_moves_this_step = 0u64;
250            let mut moves_generated_this_step = 0u64;
251            let mut moves_evaluated_this_step = 0u64;
252            let mut accepted_move_labels_this_step = StepMoveLabelCounts::new();
253            if should_interrupt_before_candidate(&step_scope) {
254                interrupted_step = true;
255            }
256            let generation_started = Instant::now();
257            let step_index = step_scope.step_index();
258            let stream_context = MoveStreamContext::new(
259                step_index,
260                step_scope
261                    .phase_scope_mut()
262                    .solver_scope_mut()
263                    .rng()
264                    .random::<u64>(),
265                self.forager.accepted_count_limit(),
266            );
267            let mut cursor = self
268                .move_selector
269                .open_cursor_with_context(step_scope.score_director(), stream_context);
270            step_scope
271                .phase_scope_mut()
272                .record_generation_time(generation_started.elapsed());
273
274            while !self.forager.is_quit_early() {
275                if interrupted_step || should_interrupt_before_candidate(&step_scope) {
276                    interrupted_step = true;
277                    break;
278                }
279                if step_scope
280                    .phase_scope_mut()
281                    .solver_scope_mut()
282                    .should_terminate()
283                {
284                    interrupted_step = true;
285                    break;
286                }
287
288                let generation_started = Instant::now();
289                let Some(candidate_id) = cursor.next_candidate() else {
290                    break;
291                };
292                let selector_index = cursor.selector_index(candidate_id);
293                let mov = cursor
294                    .candidate(candidate_id)
295                    .expect("discovered candidate id must remain borrowable");
296                let move_label = mov.telemetry_label();
297                let selector_label = selector_index.map(|_| candidate_selector_label(&mov));
298                let generation_elapsed = generation_started.elapsed();
299                local_moves_generated += 1;
300                moves_generated_this_step += 1;
301                step_scope
302                    .phase_scope_mut()
303                    .record_move_kind_generated(move_label);
304                if let Some(selector_index) = selector_index {
305                    step_scope
306                        .phase_scope_mut()
307                        .record_selector_generated_move_with_label(
308                            selector_index,
309                            selector_label.as_deref().unwrap_or("selector"),
310                            generation_elapsed,
311                        );
312                } else {
313                    step_scope
314                        .phase_scope_mut()
315                        .record_generated_move(generation_elapsed);
316                }
317
318                if should_interrupt_before_evaluation(&step_scope) {
319                    interrupted_step = true;
320                    break;
321                }
322                if step_scope
323                    .phase_scope_mut()
324                    .solver_scope_mut()
325                    .should_terminate()
326                {
327                    interrupted_step = true;
328                    break;
329                }
330                local_moves_evaluated += 1;
331                moves_evaluated_this_step += 1;
332
333                if local_moves_evaluated & 0x1FFF == 0 {
334                    let now = Instant::now();
335                    if now.duration_since(last_progress_time).as_secs() >= 1 {
336                        let current_speed = whole_units_per_second(
337                            local_moves_evaluated - last_progress_moves,
338                            now.duration_since(last_progress_time),
339                        );
340                        debug!(
341                            event = "progress",
342                            steps = step_scope.step_index(),
343                            moves_generated = local_moves_generated,
344                            moves_evaluated = local_moves_evaluated,
345                            moves_accepted = step_scope.phase_scope().solver_scope().stats().moves_accepted,
346                            score_calculations = step_scope.phase_scope().solver_scope().stats().score_calculations,
347                            speed = current_speed,
348                            acceptance_rate = format!(
349                                "{:.1}%",
350                                step_scope.phase_scope().solver_scope().stats().acceptance_rate() * 100.0
351                            ),
352                            current_score = %last_step_score,
353                            best_score = %best_score,
354                        );
355                        step_scope.phase_scope().solver_scope().report_progress();
356                        last_progress_time = now;
357                        last_progress_moves = local_moves_evaluated;
358                    }
359                }
360
361                let evaluation_started = Instant::now();
362                let move_score = match evaluate_candidate(
363                    &mov,
364                    &mut step_scope,
365                    last_step_score,
366                    selector_index,
367                    evaluation_started,
368                ) {
369                    CandidateEvaluation::Scored(score) => {
370                        step_scope.phase_scope_mut().record_move_kind_evaluated(
371                            move_label,
372                            score.compare(&last_step_score),
373                        );
374                        score
375                    }
376                    CandidateEvaluation::NotDoable
377                    | CandidateEvaluation::RejectedByHardImprovement(_) => continue,
378                };
379                let move_signature = if requires_move_signatures {
380                    Some(mov.tabu_signature(step_scope.score_director()))
381                } else {
382                    None
383                };
384
385                let accepted = self.acceptor.is_accepted(
386                    &last_step_score,
387                    &move_score,
388                    move_signature.as_ref(),
389                );
390
391                record_evaluated_move(&mut step_scope, selector_index, evaluation_started);
392                if accepted {
393                    step_scope
394                        .phase_scope_mut()
395                        .record_move_kind_accepted(move_label);
396                    accepted_move_labels_this_step.record(move_label);
397                    if let Some(selector_index) = selector_index {
398                        step_scope
399                            .phase_scope_mut()
400                            .record_selector_move_accepted(selector_index);
401                    } else {
402                        step_scope.phase_scope_mut().record_move_accepted();
403                    }
404                    accepted_moves_this_step += 1;
405                } else if let Some(selector_index) = selector_index {
406                    step_scope
407                        .phase_scope_mut()
408                        .record_selector_move_acceptor_rejected(selector_index);
409                    step_scope
410                        .phase_scope_mut()
411                        .record_move_kind_acceptor_rejected(
412                            move_label,
413                            move_score.compare(&last_step_score),
414                        );
415                } else {
416                    step_scope.phase_scope_mut().record_move_acceptor_rejected();
417                    step_scope
418                        .phase_scope_mut()
419                        .record_move_kind_acceptor_rejected(
420                            move_label,
421                            move_score.compare(&last_step_score),
422                        );
423                }
424
425                trace!(
426                    event = "step",
427                    step = step_scope.step_index(),
428                    move_index = candidate_id.index(),
429                    score = %move_score,
430                    accepted = accepted,
431                );
432
433                if accepted {
434                    self.forager.add_move_index(candidate_id, move_score);
435                }
436            }
437
438            if !interrupted_step && should_interrupt_after_step(&step_scope) {
439                interrupted_step = true;
440            }
441
442            if interrupted_step {
443                match settle_search_interrupt(&mut step_scope) {
444                    StepInterrupt::Restart => continue,
445                    StepInterrupt::TerminatePhase => break,
446                }
447            }
448
449            // Pick the best accepted move index
450            let mut accepted_move_signature = None;
451            if let Some((selected_index, selected_score)) = self.forager.pick_move_index() {
452                let selector_index = cursor.selector_index(selected_index);
453                let selected_move = cursor
454                    .candidate(selected_index)
455                    .expect("selected candidate id must remain borrowable until commit");
456                let selected_move_label = selected_move.telemetry_label();
457                if requires_move_signatures {
458                    accepted_move_signature =
459                        Some(selected_move.tabu_signature(step_scope.score_director()));
460                }
461                let previous_score = last_step_score;
462                step_scope.apply_committed_move(&selected_move);
463                if let Some(selector_index) = selector_index {
464                    step_scope
465                        .phase_scope_mut()
466                        .record_selector_move_applied(selector_index);
467                } else {
468                    step_scope.phase_scope_mut().record_move_applied();
469                }
470                step_scope.set_step_score(selected_score);
471                let score_improvement =
472                    if previous_score.is_feasible() && selected_score > previous_score {
473                        selected_score.to_scalar() - previous_score.to_scalar()
474                    } else {
475                        0.0
476                    };
477                step_scope
478                    .phase_scope_mut()
479                    .record_move_kind_applied(selected_move_label, score_improvement);
480                if step_scope.phase_scope().can_record_applied_move_trace() {
481                    let score_before = previous_score.to_scalar();
482                    let score_after = selected_score.to_scalar();
483                    step_scope
484                        .phase_scope_mut()
485                        .record_applied_move_trace(AppliedMoveTelemetry {
486                            step_index,
487                            move_label: selected_move_label,
488                            selected_candidate_index: selected_index.index(),
489                            moves_generated_this_step,
490                            moves_evaluated_this_step,
491                            moves_accepted_this_step: accepted_moves_this_step,
492                            moves_forager_ignored_this_step: accepted_moves_this_step
493                                .saturating_sub(1),
494                            score_before,
495                            score_after,
496                            score_delta: score_after - score_before,
497                            hard_feasible_before: previous_score.is_feasible(),
498                            hard_feasible_after: selected_score.is_feasible(),
499                        });
500                }
501
502                // Update last step score
503                last_step_score = selected_score;
504
505                // Update best solution if improved
506                step_scope.phase_scope_mut().update_best_solution();
507                if accepted_moves_this_step > 1 {
508                    step_scope
509                        .phase_scope_mut()
510                        .record_moves_forager_ignored(accepted_moves_this_step - 1);
511                    accepted_move_labels_this_step.for_each_ignored_except_selected(
512                        Some(selected_move_label),
513                        |label, count| {
514                            step_scope
515                                .phase_scope_mut()
516                                .record_move_kind_forager_ignored(label, count);
517                        },
518                    );
519                }
520            } else if accepted_moves_this_step > 0 {
521                step_scope
522                    .phase_scope_mut()
523                    .record_moves_forager_ignored(accepted_moves_this_step);
524                accepted_move_labels_this_step.for_each_ignored_except_selected(
525                    None,
526                    |label, count| {
527                        step_scope
528                            .phase_scope_mut()
529                            .record_move_kind_forager_ignored(label, count);
530                    },
531                );
532            }
533            /* else: no accepted moves this step — that's fine, the acceptor
534            history still needs to advance so Late Acceptance / SA / etc.
535            can eventually escape the local optimum.
536            */
537
538            /* Always notify acceptor that step ended. For stateful acceptors
539            (Late Acceptance, Simulated Annealing, Great Deluge, SCHC),
540            the history must advance every step — even steps where no move
541            was accepted — otherwise the acceptor state stalls.
542            */
543            self.acceptor
544                .step_ended(&last_step_score, accepted_move_signature.as_ref());
545
546            step_scope.complete();
547        }
548
549        // Notify acceptor of phase end
550        self.acceptor.phase_ended();
551
552        let duration = start_time.elapsed();
553        let steps = phase_scope.step_count();
554        let stats = phase_scope.stats();
555        let speed = whole_units_per_second(stats.moves_evaluated, duration);
556        let acceptance_rate = stats.acceptance_rate() * 100.0;
557        let calc_speed = whole_units_per_second(stats.score_calculations, duration);
558
559        let best_score_str = phase_scope
560            .solver_scope()
561            .best_score()
562            .map(|s| format!("{}", s))
563            .unwrap_or_else(|| "none".to_string());
564
565        info!(
566            event = "phase_end",
567            phase = "Local Search",
568            phase_index = phase_index,
569            duration = %format_duration(duration),
570            steps = steps,
571            moves_generated = stats.moves_generated,
572            moves_evaluated = stats.moves_evaluated,
573            moves_accepted = stats.moves_accepted,
574            score_calculations = stats.score_calculations,
575            generation_time = %format_duration(stats.generation_time()),
576            evaluation_time = %format_duration(stats.evaluation_time()),
577            moves_speed = speed,
578            calc_speed = calc_speed,
579            acceptance_rate = format!("{:.1}%", acceptance_rate),
580            score = best_score_str,
581        );
582    }
583
584    fn phase_type_name(&self) -> &'static str {
585        "LocalSearch"
586    }
587}
588
589#[cfg(test)]
590mod tests;