Skip to main content

solverforge_solver/phase/localsearch/
forager.rs

1/* Foragers for local search move selection
2
3Foragers collect accepted move indices during a step and select the
4best one to apply. Uses index-based API for zero-clone operation.
5*/
6
7use std::fmt::Debug;
8use std::marker::PhantomData;
9
10use crate::heuristic::r#move::Move;
11use crate::heuristic::selector::move_selector::CandidateId;
12use solverforge_core::domain::PlanningSolution;
13
14/// Trait for collecting and selecting moves in local search.
15///
16/// Foragers are responsible for:
17/// - Collecting accepted move indices during move evaluation
18/// - Deciding when to quit evaluating early
19/// - Selecting the best move index to apply
20///
21/// # Type Parameters
22/// * `S` - The planning solution type
23/// * `M` - The move type (for trait bounds only, moves are never stored)
24pub trait LocalSearchForager<S, M>: Send + Debug
25where
26    S: PlanningSolution,
27    M: Move<S>,
28{
29    /* Called at the start of each step to reset state.
30
31    `best_score` is the best solution score ever seen.
32    `last_step_score` is the score at the end of the previous step.
33    Foragers that implement pick-early-on-improvement use these to decide
34    when to stop evaluating moves.
35    */
36    fn step_started(&mut self, best_score: S::Score, last_step_score: S::Score, step_seed: u64);
37
38    /* Adds an accepted move index to the forager.
39
40    The ID refers to a live candidate in the current move cursor.
41    */
42    fn add_move_index(&mut self, index: CandidateId, score: S::Score) -> ForagerDecision;
43
44    // Returns true if the forager has collected enough moves and
45    // wants to stop evaluating more.
46    fn is_quit_early(&self) -> bool;
47
48    fn accepted_count_limit(&self) -> Option<usize> {
49        None
50    }
51
52    /* Picks the best move index from those collected.
53
54    Returns None if no moves were accepted.
55    */
56    fn pick_move_index(&mut self) -> Option<(CandidateId, S::Score)>;
57}
58
59/// Tells the phase which candidate payload remains owned after online foraging.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum ForagerDecision {
62    /// Retain the newly accepted candidate.
63    Keep,
64    /// Release the newly accepted candidate; the current winner remains retained.
65    Release,
66    /// Retain the new candidate and release the replaced candidate immediately.
67    Replace(CandidateId),
68}
69
70pub(super) struct BestCandidate<S>
71where
72    S: PlanningSolution,
73{
74    selected: Option<(CandidateId, S::Score)>,
75    equal_count: u64,
76    step_seed: u64,
77    random_ties: bool,
78}
79
80impl<S> BestCandidate<S>
81where
82    S: PlanningSolution,
83{
84    fn new(random_ties: bool) -> Self {
85        Self {
86            selected: None,
87            equal_count: 0,
88            step_seed: 0,
89            random_ties,
90        }
91    }
92
93    fn reset(&mut self, step_seed: u64) {
94        self.selected = None;
95        self.equal_count = 0;
96        self.step_seed = step_seed;
97    }
98
99    fn consider(&mut self, index: CandidateId, score: S::Score) -> ForagerDecision {
100        let Some((selected, best_score)) = self.selected else {
101            self.selected = Some((index, score));
102            self.equal_count = 1;
103            return ForagerDecision::Keep;
104        };
105
106        match score.cmp(&best_score) {
107            std::cmp::Ordering::Less => ForagerDecision::Release,
108            std::cmp::Ordering::Greater => self.replace(index, score),
109            std::cmp::Ordering::Equal => {
110                self.equal_count += 1;
111                if self.random_ties && reservoir_pick(self.step_seed, self.equal_count) {
112                    self.selected = Some((index, score));
113                    ForagerDecision::Replace(selected)
114                } else {
115                    ForagerDecision::Release
116                }
117            }
118        }
119    }
120
121    pub(super) fn replace(&mut self, index: CandidateId, score: S::Score) -> ForagerDecision {
122        self.equal_count = 1;
123        match self.selected.replace((index, score)) {
124            Some((replaced, _)) => ForagerDecision::Replace(replaced),
125            None => ForagerDecision::Keep,
126        }
127    }
128
129    pub(super) fn take(&mut self) -> Option<(CandidateId, S::Score)> {
130        self.equal_count = 0;
131        self.selected.take()
132    }
133
134    pub(super) fn has_selection(&self) -> bool {
135        self.selected.is_some()
136    }
137
138    fn random_ties(&self) -> bool {
139        self.random_ties
140    }
141}
142
143fn reservoir_pick(step_seed: u64, equal_count: u64) -> bool {
144    let mixed = splitmix64(
145        step_seed ^ equal_count.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0xF04A_63E2_39B7_4D11,
146    );
147    mixed.is_multiple_of(equal_count)
148}
149
150fn splitmix64(mut value: u64) -> u64 {
151    value = value.wrapping_add(0x9E37_79B9_7F4A_7C15);
152    value = (value ^ (value >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
153    value = (value ^ (value >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
154    value ^ (value >> 31)
155}
156
157mod improving;
158
159pub use improving::{FirstBestScoreImprovingForager, FirstLastStepScoreImprovingForager};
160
161/// A forager that stops after `N` accepted moves and picks the best of them.
162///
163/// The limit is the step evaluation horizon, not a storage cap for a full
164/// neighborhood scan. `AcceptedCountForager(1)` therefore behaves like first
165/// accepted selection, while larger limits select the best candidate among the
166/// first `N` accepted moves.
167pub struct AcceptedCountForager<S>
168where
169    S: PlanningSolution,
170{
171    // Number of accepted moves to collect before ending the step.
172    accepted_count_limit: usize,
173    accepted_count: usize,
174    best_move: BestCandidate<S>,
175    _phantom: PhantomData<fn() -> S>,
176}
177
178impl<S> AcceptedCountForager<S>
179where
180    S: PlanningSolution,
181{
182    /// Creates a new forager with the given limit.
183    ///
184    /// # Panics
185    /// Panics if `accepted_count_limit` is 0 — a zero limit would quit before
186    /// evaluating any move, silently skipping every step.
187    ///
188    /// # Arguments
189    /// * `accepted_count_limit` - Collect this many accepted moves before quitting early
190    pub fn new(accepted_count_limit: usize, random_ties: bool) -> Self {
191        assert!(
192            accepted_count_limit > 0,
193            "AcceptedCountForager: accepted_count_limit must be > 0, got 0"
194        );
195        Self {
196            accepted_count_limit,
197            accepted_count: 0,
198            best_move: BestCandidate::new(random_ties),
199            _phantom: PhantomData,
200        }
201    }
202}
203
204impl<S> Clone for AcceptedCountForager<S>
205where
206    S: PlanningSolution,
207{
208    fn clone(&self) -> Self {
209        Self {
210            accepted_count_limit: self.accepted_count_limit,
211            accepted_count: 0,
212            best_move: BestCandidate::new(self.best_move.random_ties()),
213            _phantom: PhantomData,
214        }
215    }
216}
217
218impl<S> Debug for AcceptedCountForager<S>
219where
220    S: PlanningSolution,
221{
222    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223        f.debug_struct("AcceptedCountForager")
224            .field("accepted_count_limit", &self.accepted_count_limit)
225            .field("accepted_count", &self.accepted_count)
226            .finish()
227    }
228}
229
230impl<S, M> LocalSearchForager<S, M> for AcceptedCountForager<S>
231where
232    S: PlanningSolution,
233    M: Move<S>,
234{
235    fn step_started(&mut self, _best_score: S::Score, _last_step_score: S::Score, step_seed: u64) {
236        self.accepted_count = 0;
237        self.best_move.reset(step_seed);
238    }
239
240    fn add_move_index(&mut self, index: CandidateId, score: S::Score) -> ForagerDecision {
241        if self.accepted_count >= self.accepted_count_limit {
242            return ForagerDecision::Release;
243        }
244        self.accepted_count += 1;
245        self.best_move.consider(index, score)
246    }
247
248    fn is_quit_early(&self) -> bool {
249        self.accepted_count >= self.accepted_count_limit
250    }
251
252    fn accepted_count_limit(&self) -> Option<usize> {
253        Some(self.accepted_count_limit)
254    }
255
256    fn pick_move_index(&mut self) -> Option<(CandidateId, S::Score)> {
257        self.best_move.take()
258    }
259}
260
261/// A forager that picks the first accepted move.
262///
263/// This is the simplest forager - it quits after the first accepted move.
264pub struct FirstAcceptedForager<S>
265where
266    S: PlanningSolution,
267{
268    // The first accepted move index.
269    accepted_move: Option<(CandidateId, S::Score)>,
270    _phantom: PhantomData<fn() -> S>,
271}
272
273impl<S> Debug for FirstAcceptedForager<S>
274where
275    S: PlanningSolution,
276{
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        f.debug_struct("FirstAcceptedForager")
279            .field("has_move", &self.accepted_move.is_some())
280            .finish()
281    }
282}
283
284impl<S> FirstAcceptedForager<S>
285where
286    S: PlanningSolution,
287{
288    pub fn new() -> Self {
289        Self {
290            accepted_move: None,
291            _phantom: PhantomData,
292        }
293    }
294}
295
296impl<S> Clone for FirstAcceptedForager<S>
297where
298    S: PlanningSolution,
299{
300    fn clone(&self) -> Self {
301        Self {
302            accepted_move: None, // Fresh state for clone
303            _phantom: PhantomData,
304        }
305    }
306}
307
308impl<S> Default for FirstAcceptedForager<S>
309where
310    S: PlanningSolution,
311{
312    fn default() -> Self {
313        Self::new()
314    }
315}
316
317impl<S, M> LocalSearchForager<S, M> for FirstAcceptedForager<S>
318where
319    S: PlanningSolution,
320    M: Move<S>,
321{
322    fn step_started(&mut self, _best_score: S::Score, _last_step_score: S::Score, _step_seed: u64) {
323        self.accepted_move = None;
324    }
325
326    fn add_move_index(&mut self, index: CandidateId, score: S::Score) -> ForagerDecision {
327        if self.accepted_move.is_none() {
328            self.accepted_move = Some((index, score));
329            ForagerDecision::Keep
330        } else {
331            ForagerDecision::Release
332        }
333    }
334
335    fn is_quit_early(&self) -> bool {
336        self.accepted_move.is_some()
337    }
338
339    fn accepted_count_limit(&self) -> Option<usize> {
340        Some(1)
341    }
342
343    fn pick_move_index(&mut self) -> Option<(CandidateId, S::Score)> {
344        self.accepted_move.take()
345    }
346}
347
348/// A forager that evaluates all accepted moves and picks the best.
349///
350/// Unlike `AcceptedCountForager(N)`, this forager never quits early - it
351/// always evaluates the full move space before selecting the best score.
352pub struct BestScoreForager<S>
353where
354    S: PlanningSolution,
355{
356    // Best accepted move index and score seen in the current step.
357    best_move: BestCandidate<S>,
358    _phantom: PhantomData<fn() -> S>,
359}
360
361impl<S> BestScoreForager<S>
362where
363    S: PlanningSolution,
364{
365    pub fn new(random_ties: bool) -> Self {
366        Self {
367            best_move: BestCandidate::new(random_ties),
368            _phantom: PhantomData,
369        }
370    }
371}
372
373impl<S> Default for BestScoreForager<S>
374where
375    S: PlanningSolution,
376{
377    fn default() -> Self {
378        Self::new(true)
379    }
380}
381
382impl<S> Debug for BestScoreForager<S>
383where
384    S: PlanningSolution,
385{
386    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387        f.debug_struct("BestScoreForager")
388            .field("has_move", &self.best_move.has_selection())
389            .finish()
390    }
391}
392
393impl<S> Clone for BestScoreForager<S>
394where
395    S: PlanningSolution,
396{
397    fn clone(&self) -> Self {
398        Self {
399            best_move: BestCandidate::new(self.best_move.random_ties()),
400            _phantom: PhantomData,
401        }
402    }
403}
404
405impl<S, M> LocalSearchForager<S, M> for BestScoreForager<S>
406where
407    S: PlanningSolution,
408    M: Move<S>,
409{
410    fn step_started(&mut self, _best_score: S::Score, _last_step_score: S::Score, step_seed: u64) {
411        self.best_move.reset(step_seed);
412    }
413
414    fn add_move_index(&mut self, index: CandidateId, score: S::Score) -> ForagerDecision {
415        self.best_move.consider(index, score)
416    }
417
418    fn is_quit_early(&self) -> bool {
419        false // Never quit early — always evaluate the full move space
420    }
421
422    fn pick_move_index(&mut self) -> Option<(CandidateId, S::Score)> {
423        self.best_move.take()
424    }
425}
426
427#[cfg(test)]
428mod any_tests;
429#[cfg(test)]
430mod tests;