1use 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::parallel::EvaluatorPool;
14use crate::phase::localsearch::{
15 Acceptor, LocalSearchForager, MoveCursorSource, SelectorCursorSource,
16};
17use crate::phase::Phase;
18use crate::scope::{PhaseScope, ProgressCallback, SolverScope};
19use crate::stats::{format_duration, whole_units_per_second, CandidateTracePullToken};
20
21mod candidates;
22mod decision;
23mod evaluator;
24mod sequential;
25mod step;
26
27pub(in crate::phase::localsearch) use decision::WindowSlot;
28
29use evaluator::{SequentialEvaluator, StepEvaluator};
30use step::{execute_step, StepOutcome};
31
32const STEP_ACCEPTED_LABEL_LIMIT: usize = 32;
33
34#[derive(Clone, Copy)]
35struct StepMoveLabelCount {
36 label: &'static str,
37 count: u64,
38}
39
40struct StepMoveLabelCounts {
41 entries: [StepMoveLabelCount; STEP_ACCEPTED_LABEL_LIMIT],
42 overflow: u64,
43}
44
45impl StepMoveLabelCounts {
46 const EMPTY_ENTRY: StepMoveLabelCount = StepMoveLabelCount {
47 label: "",
48 count: 0,
49 };
50
51 fn new() -> Self {
52 Self {
53 entries: [Self::EMPTY_ENTRY; STEP_ACCEPTED_LABEL_LIMIT],
54 overflow: 0,
55 }
56 }
57
58 fn record(&mut self, label: &'static str) {
59 for entry in &mut self.entries {
60 if entry.count > 0 && entry.label == label {
61 entry.count += 1;
62 return;
63 }
64 }
65 for entry in &mut self.entries {
66 if entry.count == 0 {
67 entry.label = label;
68 entry.count = 1;
69 return;
70 }
71 }
72 self.overflow += 1;
73 }
74
75 fn for_each_ignored_except_selected(
76 &self,
77 selected_label: Option<&'static str>,
78 mut visitor: impl FnMut(&'static str, u64),
79 ) {
80 let mut selected_remaining = selected_label;
81 for entry in &self.entries {
82 if entry.count == 0 {
83 continue;
84 }
85 let ignored = if selected_remaining == Some(entry.label) {
86 selected_remaining = None;
87 entry.count.saturating_sub(1)
88 } else {
89 entry.count
90 };
91 if ignored > 0 {
92 visitor(entry.label, ignored);
93 }
94 }
95 if self.overflow > 0 {
96 visitor("move", self.overflow);
97 }
98 }
99}
100
101fn take_trace_token(
102 tokens: &mut Vec<(CandidateId, CandidateTracePullToken)>,
103 candidate_id: CandidateId,
104) -> Option<CandidateTracePullToken> {
105 tokens
106 .iter()
107 .position(|(recorded_id, _)| *recorded_id == candidate_id)
108 .map(|index| tokens.swap_remove(index).1)
109}
110
111pub struct LocalSearchPhase<S, M, Source, A, Fo>
132where
133 S: PlanningSolution,
134 M: Move<S>,
135 Source: MoveCursorSource<S, M>,
136 A: Acceptor<S>,
137 Fo: LocalSearchForager<S, M>,
138{
139 move_source: Source,
140 resources: Source::Resources,
141 acceptor: A,
142 forager: Fo,
143 step_limit: Option<u64>,
144 _phantom: PhantomData<fn() -> (S, M)>,
145}
146
147fn candidate_selector_label<S, M>(mov: &MoveCandidateRef<'_, S, M>) -> String
148where
149 S: PlanningSolution,
150 M: Move<S>,
151{
152 let move_label = mov.telemetry_label();
153 if mov.variable_name() == "compound_scalar" || mov.variable_name() == "conflict_repair" {
154 return format!("{}:{move_label}", mov.variable_name());
155 }
156 let mut label = None;
157 mov.for_each_affected_entity(&mut |affected| {
158 if label.is_none() {
159 label = Some(affected.variable_name.to_string());
160 }
161 });
162 label
163 .map(|variable| format!("{variable}:{move_label}"))
164 .unwrap_or_else(|| format!("move:{move_label}"))
165}
166
167impl<S, M, Source, A, Fo> LocalSearchPhase<S, M, Source, A, Fo>
168where
169 S: PlanningSolution,
170 M: Move<S> + 'static,
171 Source: MoveCursorSource<S, M>,
172 A: Acceptor<S>,
173 Fo: LocalSearchForager<S, M>,
174{
175 pub(crate) fn with_cursor_source(
182 move_source: Source,
183 resources: Source::Resources,
184 acceptor: A,
185 forager: Fo,
186 step_limit: Option<u64>,
187 ) -> Self {
188 Self {
189 move_source,
190 resources,
191 acceptor,
192 forager,
193 step_limit,
194 _phantom: PhantomData,
195 }
196 }
197}
198
199impl<S, M, MS, A, Fo> LocalSearchPhase<S, M, SelectorCursorSource<MS>, A, Fo>
200where
201 S: PlanningSolution,
202 M: Move<S> + 'static,
203 MS: crate::heuristic::selector::MoveSelector<S, M>,
204 A: Acceptor<S>,
205 Fo: LocalSearchForager<S, M>,
206{
207 pub fn new(move_selector: MS, acceptor: A, forager: Fo, step_limit: Option<u64>) -> Self {
212 Self::with_cursor_source(
213 SelectorCursorSource::new(move_selector),
214 (),
215 acceptor,
216 forager,
217 step_limit,
218 )
219 }
220}
221
222impl<S, M, Source, A, Fo> Debug for LocalSearchPhase<S, M, Source, A, Fo>
223where
224 S: PlanningSolution,
225 M: Move<S>,
226 Source: MoveCursorSource<S, M> + Debug,
227 A: Acceptor<S> + Debug,
228 Fo: LocalSearchForager<S, M> + Debug,
229{
230 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231 f.debug_struct("LocalSearchPhase")
232 .field("move_source", &self.move_source)
233 .field("acceptor", &self.acceptor)
234 .field("forager", &self.forager)
235 .field("step_limit", &self.step_limit)
236 .finish()
237 }
238}
239
240#[allow(clippy::too_many_arguments)]
246pub(crate) fn solve_local_search_with_resources<S, D, BestCb, M, Source, A, Fo>(
247 move_source: &mut Source,
248 resources: &mut Source::Resources,
249 acceptor: &mut A,
250 forager: &mut Fo,
251 step_limit: Option<u64>,
252 evaluator_count: usize,
253 solver_scope: &mut SolverScope<S, D, BestCb>,
254) where
255 S: PlanningSolution,
256 D: Director<S>,
257 BestCb: ProgressCallback<S>,
258 M: Move<S>,
259 Source: MoveCursorSource<S, M> + Debug + Send,
260 A: Acceptor<S>,
261 Fo: LocalSearchForager<S, M>,
262{
263 if evaluator_count > 1 {
264 run_local_search(
265 move_source,
266 resources,
267 acceptor,
268 forager,
269 step_limit,
270 &mut EvaluatorPool::<S, D, M>::new(evaluator_count),
271 solver_scope,
272 );
273 } else {
274 run_local_search(
275 move_source,
276 resources,
277 acceptor,
278 forager,
279 step_limit,
280 &mut SequentialEvaluator,
281 solver_scope,
282 );
283 }
284}
285
286#[allow(clippy::too_many_arguments)]
287fn run_local_search<S, D, BestCb, M, Source, A, Fo, E>(
288 move_source: &mut Source,
289 resources: &mut Source::Resources,
290 acceptor: &mut A,
291 forager: &mut Fo,
292 step_limit: Option<u64>,
293 evaluator: &mut E,
294 solver_scope: &mut SolverScope<S, D, BestCb>,
295) where
296 S: PlanningSolution,
297 D: Director<S>,
298 BestCb: ProgressCallback<S>,
299 M: Move<S>,
300 Source: MoveCursorSource<S, M> + Debug + Send,
301 A: Acceptor<S>,
302 Fo: LocalSearchForager<S, M>,
303 E: StepEvaluator<S, D, M>,
304{
305 let mut phase_scope = PhaseScope::with_phase_type(solver_scope, 0, "Local Search");
306 let phase_index = phase_scope.phase_index();
307 let mut last_step_score = phase_scope.calculate_score();
308
309 info!(
310 event = "phase_start",
311 phase = "Local Search",
312 phase_index = phase_index,
313 score = %last_step_score,
314 );
315 acceptor.phase_started(&last_step_score);
316
317 let start_time = Instant::now();
318 loop {
319 if phase_scope.solver_scope_mut().should_terminate() {
320 break;
321 }
322 if step_limit.is_some_and(|limit| phase_scope.step_count() >= limit) {
323 break;
324 }
325
326 match execute_step(
327 move_source,
328 resources,
329 acceptor,
330 forager,
331 &mut phase_scope,
332 &mut last_step_score,
333 evaluator,
334 ) {
335 StepOutcome::Continue | StepOutcome::Restart => continue,
336 StepOutcome::Terminate => break,
337 }
338 }
339
340 acceptor.phase_ended();
341
342 let duration = start_time.elapsed();
343 let steps = phase_scope.step_count();
344 let stats = phase_scope.stats();
345 let speed = whole_units_per_second(stats.moves_evaluated, duration);
346 let acceptance_rate = stats.acceptance_rate() * 100.0;
347 let calc_speed = whole_units_per_second(stats.score_calculations, duration);
348 let best_score_str = phase_scope
349 .solver_scope()
350 .best_score()
351 .map(|s| format!("{s}"))
352 .unwrap_or_else(|| "none".to_string());
353
354 info!(
355 event = "phase_end",
356 phase = "Local Search",
357 phase_index = phase_index,
358 duration = %format_duration(duration),
359 steps = steps,
360 moves_generated = stats.moves_generated,
361 moves_evaluated = stats.moves_evaluated,
362 moves_accepted = stats.moves_accepted,
363 moves_score_improving = stats.moves_score_improving(),
364 moves_applied_improving = stats.moves_applied_improving(),
365 score_calculations = stats.score_calculations,
366 generation_time = %format_duration(stats.generation_time()),
367 evaluation_time = %format_duration(stats.evaluation_time()),
368 moves_speed = speed,
369 calc_speed = calc_speed,
370 acceptance_rate = format!("{acceptance_rate:.1}%"),
371 score = best_score_str,
372 );
373}
374
375impl<S, D, BestCb, M, Source, A, Fo> Phase<S, D, BestCb> for LocalSearchPhase<S, M, Source, A, Fo>
376where
377 S: PlanningSolution,
378 D: Director<S>,
379 BestCb: ProgressCallback<S>,
380 M: Move<S>,
381 Source: MoveCursorSource<S, M> + Debug + Send,
382 A: Acceptor<S>,
383 Fo: LocalSearchForager<S, M>,
384{
385 fn solve(&mut self, solver_scope: &mut SolverScope<S, D, BestCb>) {
386 let Self {
387 move_source,
388 resources,
389 acceptor,
390 forager,
391 step_limit,
392 ..
393 } = self;
394 solve_local_search_with_resources(
395 move_source,
396 resources,
397 acceptor,
398 forager,
399 *step_limit,
400 1,
401 solver_scope,
402 );
403 }
404
405 fn phase_type_name(&self) -> &'static str {
406 "LocalSearch"
407 }
408}
409
410#[cfg(test)]
411mod tests;