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);
37
38    /* Adds an accepted move index to the forager.
39
40    The index refers to a position in the MoveArena.
41    */
42    fn add_move_index(&mut self, index: CandidateId, score: S::Score);
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
59mod improving;
60
61pub use improving::{FirstBestScoreImprovingForager, FirstLastStepScoreImprovingForager};
62
63/// A forager that stops after `N` accepted moves and picks the best of them.
64///
65/// The limit is the step evaluation horizon, not a storage cap for a full
66/// neighborhood scan. `AcceptedCountForager(1)` therefore behaves like first
67/// accepted selection, while larger limits select the best candidate among the
68/// first `N` accepted moves.
69pub struct AcceptedCountForager<S>
70where
71    S: PlanningSolution,
72{
73    // Number of accepted moves to collect before ending the step.
74    accepted_count_limit: usize,
75    // Collected move indices with their scores.
76    accepted_moves: Vec<(CandidateId, S::Score)>,
77    _phantom: PhantomData<fn() -> S>,
78}
79
80impl<S> AcceptedCountForager<S>
81where
82    S: PlanningSolution,
83{
84    /// Creates a new forager with the given limit.
85    ///
86    /// # Panics
87    /// Panics if `accepted_count_limit` is 0 — a zero limit would quit before
88    /// evaluating any move, silently skipping every step.
89    ///
90    /// # Arguments
91    /// * `accepted_count_limit` - Collect this many accepted moves before quitting early
92    pub fn new(accepted_count_limit: usize) -> Self {
93        assert!(
94            accepted_count_limit > 0,
95            "AcceptedCountForager: accepted_count_limit must be > 0, got 0"
96        );
97        Self {
98            accepted_count_limit,
99            accepted_moves: Vec::new(),
100            _phantom: PhantomData,
101        }
102    }
103}
104
105impl<S> Clone for AcceptedCountForager<S>
106where
107    S: PlanningSolution,
108{
109    fn clone(&self) -> Self {
110        Self {
111            accepted_count_limit: self.accepted_count_limit,
112            accepted_moves: Vec::new(), // Fresh vec for clone
113            _phantom: PhantomData,
114        }
115    }
116}
117
118impl<S> Debug for AcceptedCountForager<S>
119where
120    S: PlanningSolution,
121{
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        f.debug_struct("AcceptedCountForager")
124            .field("accepted_count_limit", &self.accepted_count_limit)
125            .field("accepted_count", &self.accepted_moves.len())
126            .finish()
127    }
128}
129
130impl<S, M> LocalSearchForager<S, M> for AcceptedCountForager<S>
131where
132    S: PlanningSolution,
133    M: Move<S>,
134{
135    fn step_started(&mut self, _best_score: S::Score, _last_step_score: S::Score) {
136        self.accepted_moves.clear();
137    }
138
139    fn add_move_index(&mut self, index: CandidateId, score: S::Score) {
140        if self.accepted_moves.len() >= self.accepted_count_limit {
141            return;
142        }
143        self.accepted_moves.push((index, score));
144    }
145
146    fn is_quit_early(&self) -> bool {
147        self.accepted_moves.len() >= self.accepted_count_limit
148    }
149
150    fn accepted_count_limit(&self) -> Option<usize> {
151        Some(self.accepted_count_limit)
152    }
153
154    fn pick_move_index(&mut self) -> Option<(CandidateId, S::Score)> {
155        if self.accepted_moves.is_empty() {
156            return None;
157        }
158
159        // Find the best move (highest score)
160        let mut best_idx = 0;
161        let mut best_score = self.accepted_moves[0].1;
162
163        for (i, &(_, score)) in self.accepted_moves.iter().enumerate().skip(1) {
164            if score > best_score {
165                best_idx = i;
166                best_score = score;
167            }
168        }
169
170        // Return the best move index
171        Some(self.accepted_moves.swap_remove(best_idx))
172    }
173}
174
175/// A forager that picks the first accepted move.
176///
177/// This is the simplest forager - it quits after the first accepted move.
178pub struct FirstAcceptedForager<S>
179where
180    S: PlanningSolution,
181{
182    // The first accepted move index.
183    accepted_move: Option<(CandidateId, S::Score)>,
184    _phantom: PhantomData<fn() -> S>,
185}
186
187impl<S> Debug for FirstAcceptedForager<S>
188where
189    S: PlanningSolution,
190{
191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        f.debug_struct("FirstAcceptedForager")
193            .field("has_move", &self.accepted_move.is_some())
194            .finish()
195    }
196}
197
198impl<S> FirstAcceptedForager<S>
199where
200    S: PlanningSolution,
201{
202    pub fn new() -> Self {
203        Self {
204            accepted_move: None,
205            _phantom: PhantomData,
206        }
207    }
208}
209
210impl<S> Clone for FirstAcceptedForager<S>
211where
212    S: PlanningSolution,
213{
214    fn clone(&self) -> Self {
215        Self {
216            accepted_move: None, // Fresh state for clone
217            _phantom: PhantomData,
218        }
219    }
220}
221
222impl<S> Default for FirstAcceptedForager<S>
223where
224    S: PlanningSolution,
225{
226    fn default() -> Self {
227        Self::new()
228    }
229}
230
231impl<S, M> LocalSearchForager<S, M> for FirstAcceptedForager<S>
232where
233    S: PlanningSolution,
234    M: Move<S>,
235{
236    fn step_started(&mut self, _best_score: S::Score, _last_step_score: S::Score) {
237        self.accepted_move = None;
238    }
239
240    fn add_move_index(&mut self, index: CandidateId, score: S::Score) {
241        if self.accepted_move.is_none() {
242            self.accepted_move = Some((index, score));
243        }
244    }
245
246    fn is_quit_early(&self) -> bool {
247        self.accepted_move.is_some()
248    }
249
250    fn accepted_count_limit(&self) -> Option<usize> {
251        Some(1)
252    }
253
254    fn pick_move_index(&mut self) -> Option<(CandidateId, S::Score)> {
255        self.accepted_move.take()
256    }
257}
258
259/// A forager that evaluates all accepted moves and picks the best.
260///
261/// Unlike `AcceptedCountForager(N)`, this forager never quits early - it
262/// always evaluates the full move space before selecting the best score.
263pub struct BestScoreForager<S>
264where
265    S: PlanningSolution,
266{
267    // Best accepted move index and score seen in the current step.
268    best_move: Option<(CandidateId, S::Score)>,
269    _phantom: PhantomData<fn() -> S>,
270}
271
272impl<S> BestScoreForager<S>
273where
274    S: PlanningSolution,
275{
276    pub fn new() -> Self {
277        Self {
278            best_move: None,
279            _phantom: PhantomData,
280        }
281    }
282}
283
284impl<S> Default for BestScoreForager<S>
285where
286    S: PlanningSolution,
287{
288    fn default() -> Self {
289        Self::new()
290    }
291}
292
293impl<S> Debug for BestScoreForager<S>
294where
295    S: PlanningSolution,
296{
297    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298        f.debug_struct("BestScoreForager")
299            .field("has_move", &self.best_move.is_some())
300            .finish()
301    }
302}
303
304impl<S> Clone for BestScoreForager<S>
305where
306    S: PlanningSolution,
307{
308    fn clone(&self) -> Self {
309        Self {
310            best_move: None,
311            _phantom: PhantomData,
312        }
313    }
314}
315
316impl<S, M> LocalSearchForager<S, M> for BestScoreForager<S>
317where
318    S: PlanningSolution,
319    M: Move<S>,
320{
321    fn step_started(&mut self, _best_score: S::Score, _last_step_score: S::Score) {
322        self.best_move = None;
323    }
324
325    fn add_move_index(&mut self, index: CandidateId, score: S::Score) {
326        match self.best_move {
327            Some((_, best_score)) if best_score >= score => {}
328            _ => self.best_move = Some((index, score)),
329        }
330    }
331
332    fn is_quit_early(&self) -> bool {
333        false // Never quit early — always evaluate the full move space
334    }
335
336    fn pick_move_index(&mut self) -> Option<(CandidateId, S::Score)> {
337        self.best_move.take()
338    }
339}
340
341#[cfg(test)]
342mod any_tests;
343#[cfg(test)]
344mod tests;