Skip to main content

solverforge_solver/phase/localsearch/
phase.rs

1// Local-search phase definition and outer solve lifecycle.
2
3use std::fmt::Debug;
4use std::marker::PhantomData;
5use std::time::Instant;
6
7use solverforge_core::domain::PlanningSolution;
8use solverforge_scoring::Director;
9use tracing::info;
10
11use crate::heuristic::r#move::Move;
12use crate::heuristic::selector::move_selector::{CandidateId, MoveCandidateRef};
13use crate::phase::localsearch::{
14    Acceptor, LocalSearchForager, MoveCursorSource, SelectorCursorSource,
15};
16use crate::phase::Phase;
17use crate::scope::{PhaseScope, ProgressCallback, SolverScope};
18use crate::stats::{format_duration, whole_units_per_second, CandidateTracePullToken};
19
20mod candidates;
21mod step;
22
23use step::{execute_step, StepOutcome};
24
25const STEP_ACCEPTED_LABEL_LIMIT: usize = 32;
26
27#[derive(Clone, Copy)]
28struct StepMoveLabelCount {
29    label: &'static str,
30    count: u64,
31}
32
33struct StepMoveLabelCounts {
34    entries: [StepMoveLabelCount; STEP_ACCEPTED_LABEL_LIMIT],
35    overflow: u64,
36}
37
38impl StepMoveLabelCounts {
39    const EMPTY_ENTRY: StepMoveLabelCount = StepMoveLabelCount {
40        label: "",
41        count: 0,
42    };
43
44    fn new() -> Self {
45        Self {
46            entries: [Self::EMPTY_ENTRY; STEP_ACCEPTED_LABEL_LIMIT],
47            overflow: 0,
48        }
49    }
50
51    fn record(&mut self, label: &'static str) {
52        for entry in &mut self.entries {
53            if entry.count > 0 && entry.label == label {
54                entry.count += 1;
55                return;
56            }
57        }
58        for entry in &mut self.entries {
59            if entry.count == 0 {
60                entry.label = label;
61                entry.count = 1;
62                return;
63            }
64        }
65        self.overflow += 1;
66    }
67
68    fn for_each_ignored_except_selected(
69        &self,
70        selected_label: Option<&'static str>,
71        mut visitor: impl FnMut(&'static str, u64),
72    ) {
73        let mut selected_remaining = selected_label;
74        for entry in &self.entries {
75            if entry.count == 0 {
76                continue;
77            }
78            let ignored = if selected_remaining == Some(entry.label) {
79                selected_remaining = None;
80                entry.count.saturating_sub(1)
81            } else {
82                entry.count
83            };
84            if ignored > 0 {
85                visitor(entry.label, ignored);
86            }
87        }
88        if self.overflow > 0 {
89            visitor("move", self.overflow);
90        }
91    }
92}
93
94fn take_trace_token(
95    tokens: &mut Vec<(CandidateId, CandidateTracePullToken)>,
96    candidate_id: CandidateId,
97) -> Option<CandidateTracePullToken> {
98    tokens
99        .iter()
100        .position(|(recorded_id, _)| *recorded_id == candidate_id)
101        .map(|index| tokens.swap_remove(index).1)
102}
103
104/// Local search phase that improves an existing solution.
105///
106/// This phase iteratively:
107/// 1. Streams candidate moves through a cursor-owned store
108/// 2. Evaluates each move by stable candidate ID
109/// 3. Accepts/rejects based on the acceptor
110/// 4. Releases losers immediately and transfers ownership of the selected move
111///
112/// # Type Parameters
113/// * `S` - The planning solution type
114/// * `M` - The move type
115/// * `Source` - The phase cursor-source type
116/// * `A` - The acceptor type
117/// * `Fo` - The forager type
118///
119/// # Zero-Clone Design
120///
121/// Uses ID-based online foraging. Stock foragers retain only the current winner's
122/// `CandidateId` and score. The cursor releases rejected and replaced candidates,
123/// then transfers the selected move by value without cloning it.
124pub struct LocalSearchPhase<S, M, Source, A, Fo>
125where
126    S: PlanningSolution,
127    M: Move<S>,
128    Source: MoveCursorSource<S, M>,
129    A: Acceptor<S>,
130    Fo: LocalSearchForager<S, M>,
131{
132    move_source: Source,
133    resources: Source::Resources,
134    acceptor: A,
135    forager: Fo,
136    step_limit: Option<u64>,
137    _phantom: PhantomData<fn() -> (S, M)>,
138}
139
140fn candidate_selector_label<S, M>(mov: &MoveCandidateRef<'_, S, M>) -> String
141where
142    S: PlanningSolution,
143    M: Move<S>,
144{
145    let move_label = mov.telemetry_label();
146    if mov.variable_name() == "compound_scalar" || mov.variable_name() == "conflict_repair" {
147        return format!("{}:{move_label}", mov.variable_name());
148    }
149    let mut label = None;
150    mov.for_each_affected_entity(&mut |affected| {
151        if label.is_none() {
152            label = Some(affected.variable_name.to_string());
153        }
154    });
155    label
156        .map(|variable| format!("{variable}:{move_label}"))
157        .unwrap_or_else(|| format!("move:{move_label}"))
158}
159
160impl<S, M, Source, A, Fo> LocalSearchPhase<S, M, Source, A, Fo>
161where
162    S: PlanningSolution,
163    M: Move<S> + 'static,
164    Source: MoveCursorSource<S, M>,
165    A: Acceptor<S>,
166    Fo: LocalSearchForager<S, M>,
167{
168    /// Builds a phase from an already-owned execution source.
169    ///
170    /// Configured selector compositions use this path so their stream state
171    /// survives every cursor open. Ordinary public selector construction uses
172    /// [`Self::new`] and reaches the same phase loop through the one
173    /// `SelectorCursorSource` adapter.
174    pub(crate) fn with_cursor_source(
175        move_source: Source,
176        resources: Source::Resources,
177        acceptor: A,
178        forager: Fo,
179        step_limit: Option<u64>,
180    ) -> Self {
181        Self {
182            move_source,
183            resources,
184            acceptor,
185            forager,
186            step_limit,
187            _phantom: PhantomData,
188        }
189    }
190}
191
192impl<S, M, MS, A, Fo> LocalSearchPhase<S, M, SelectorCursorSource<MS>, A, Fo>
193where
194    S: PlanningSolution,
195    M: Move<S> + 'static,
196    MS: crate::heuristic::selector::MoveSelector<S, M>,
197    A: Acceptor<S>,
198    Fo: LocalSearchForager<S, M>,
199{
200    /// Builds a local-search phase from an ordinary move selector.
201    ///
202    /// The selector is adapted to the shared phase cursor-source contract;
203    /// no second local-search loop or cursor-opening path is introduced.
204    pub fn new(move_selector: MS, acceptor: A, forager: Fo, step_limit: Option<u64>) -> Self {
205        Self::with_cursor_source(
206            SelectorCursorSource::new(move_selector),
207            (),
208            acceptor,
209            forager,
210            step_limit,
211        )
212    }
213}
214
215impl<S, M, Source, A, Fo> Debug for LocalSearchPhase<S, M, Source, A, Fo>
216where
217    S: PlanningSolution,
218    M: Move<S>,
219    Source: MoveCursorSource<S, M> + Debug,
220    A: Acceptor<S> + Debug,
221    Fo: LocalSearchForager<S, M> + Debug,
222{
223    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224        f.debug_struct("LocalSearchPhase")
225            .field("move_source", &self.move_source)
226            .field("acceptor", &self.acceptor)
227            .field("forager", &self.forager)
228            .field("step_limit", &self.step_limit)
229            .finish()
230    }
231}
232
233/// Executes the one local-search loop while borrowing its resource only for
234/// cursor opening and individual candidate pulls. Both stock phases and the
235/// compiled runtime call this function; neither owns a second loop.
236#[allow(clippy::too_many_arguments)]
237pub(crate) fn solve_local_search_with_resources<S, D, BestCb, M, Source, A, Fo>(
238    move_source: &mut Source,
239    resources: &mut Source::Resources,
240    acceptor: &mut A,
241    forager: &mut Fo,
242    step_limit: Option<u64>,
243    solver_scope: &mut SolverScope<S, D, BestCb>,
244) where
245    S: PlanningSolution,
246    D: Director<S>,
247    BestCb: ProgressCallback<S>,
248    M: Move<S>,
249    Source: MoveCursorSource<S, M> + Debug + Send,
250    A: Acceptor<S>,
251    Fo: LocalSearchForager<S, M>,
252{
253    let mut phase_scope = PhaseScope::with_phase_type(solver_scope, 0, "Local Search");
254    let phase_index = phase_scope.phase_index();
255    let mut last_step_score = phase_scope.calculate_score();
256
257    info!(
258        event = "phase_start",
259        phase = "Local Search",
260        phase_index = phase_index,
261        score = %last_step_score,
262    );
263    acceptor.phase_started(&last_step_score);
264
265    let start_time = Instant::now();
266    loop {
267        if phase_scope.solver_scope_mut().should_terminate() {
268            break;
269        }
270        if step_limit.is_some_and(|limit| phase_scope.step_count() >= limit) {
271            break;
272        }
273
274        match execute_step(
275            move_source,
276            resources,
277            acceptor,
278            forager,
279            &mut phase_scope,
280            &mut last_step_score,
281        ) {
282            StepOutcome::Continue | StepOutcome::Restart => continue,
283            StepOutcome::Terminate => break,
284        }
285    }
286
287    acceptor.phase_ended();
288
289    let duration = start_time.elapsed();
290    let steps = phase_scope.step_count();
291    let stats = phase_scope.stats();
292    let speed = whole_units_per_second(stats.moves_evaluated, duration);
293    let acceptance_rate = stats.acceptance_rate() * 100.0;
294    let calc_speed = whole_units_per_second(stats.score_calculations, duration);
295    let best_score_str = phase_scope
296        .solver_scope()
297        .best_score()
298        .map(|s| format!("{s}"))
299        .unwrap_or_else(|| "none".to_string());
300
301    info!(
302        event = "phase_end",
303        phase = "Local Search",
304        phase_index = phase_index,
305        duration = %format_duration(duration),
306        steps = steps,
307        moves_generated = stats.moves_generated,
308        moves_evaluated = stats.moves_evaluated,
309        moves_accepted = stats.moves_accepted,
310        moves_score_improving = stats.moves_score_improving(),
311        moves_applied_improving = stats.moves_applied_improving(),
312        score_calculations = stats.score_calculations,
313        generation_time = %format_duration(stats.generation_time()),
314        evaluation_time = %format_duration(stats.evaluation_time()),
315        moves_speed = speed,
316        calc_speed = calc_speed,
317        acceptance_rate = format!("{acceptance_rate:.1}%"),
318        score = best_score_str,
319    );
320}
321
322impl<S, D, BestCb, M, Source, A, Fo> Phase<S, D, BestCb> for LocalSearchPhase<S, M, Source, A, Fo>
323where
324    S: PlanningSolution,
325    D: Director<S>,
326    BestCb: ProgressCallback<S>,
327    M: Move<S>,
328    Source: MoveCursorSource<S, M> + Debug + Send,
329    A: Acceptor<S>,
330    Fo: LocalSearchForager<S, M>,
331{
332    fn solve(&mut self, solver_scope: &mut SolverScope<S, D, BestCb>) {
333        let Self {
334            move_source,
335            resources,
336            acceptor,
337            forager,
338            step_limit,
339            ..
340        } = self;
341        solve_local_search_with_resources(
342            move_source,
343            resources,
344            acceptor,
345            forager,
346            *step_limit,
347            solver_scope,
348        );
349    }
350
351    fn phase_type_name(&self) -> &'static str {
352        "LocalSearch"
353    }
354}
355
356#[cfg(test)]
357mod tests;