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 solverforge_core::domain::PlanningSolution;
12
13/// Trait for collecting and selecting moves in local search.
14///
15/// Foragers are responsible for:
16/// - Collecting accepted move indices during move evaluation
17/// - Deciding when to quit evaluating early
18/// - Selecting the best move index to apply
19///
20/// # Type Parameters
21/// * `S` - The planning solution type
22/// * `M` - The move type (for trait bounds only, moves are never stored)
23pub trait LocalSearchForager<S, M>: Send + Debug
24where
25    S: PlanningSolution,
26    M: Move<S>,
27{
28    /* Called at the start of each step to reset state.
29
30    `best_score` is the best solution score ever seen.
31    `last_step_score` is the score at the end of the previous step.
32    Foragers that implement pick-early-on-improvement use these to decide
33    when to stop evaluating moves.
34    */
35    fn step_started(&mut self, best_score: S::Score, last_step_score: S::Score);
36
37    /* Adds an accepted move index to the forager.
38
39    The index refers to a position in the MoveArena.
40    */
41    fn add_move_index(&mut self, index: usize, score: S::Score);
42
43    // Returns true if the forager has collected enough moves and
44    // wants to stop evaluating more.
45    fn is_quit_early(&self) -> bool;
46
47    /* Picks the best move index from those collected.
48
49    Returns None if no moves were accepted.
50    */
51    fn pick_move_index(&mut self) -> Option<(usize, S::Score)>;
52}
53
54mod improving;
55
56pub use improving::{FirstBestScoreImprovingForager, FirstLastStepScoreImprovingForager};
57
58/// A forager that collects a limited number of accepted moves.
59///
60/// Once the limit is reached, it quits early. It picks the best
61/// move among those collected.
62pub struct AcceptedCountForager<S>
63where
64    S: PlanningSolution,
65{
66    // Maximum number of accepted moves to collect.
67    accepted_count_limit: usize,
68    // Collected move indices with their scores.
69    accepted_moves: Vec<(usize, S::Score)>,
70    _phantom: PhantomData<fn() -> S>,
71}
72
73impl<S> AcceptedCountForager<S>
74where
75    S: PlanningSolution,
76{
77    /// Creates a new forager with the given limit.
78    ///
79    /// # Panics
80    /// Panics if `accepted_count_limit` is 0 — a zero limit would quit before
81    /// evaluating any move, silently skipping every step.
82    ///
83    /// # Arguments
84    /// * `accepted_count_limit` - Stop after collecting this many accepted moves
85    pub fn new(accepted_count_limit: usize) -> Self {
86        assert!(
87            accepted_count_limit > 0,
88            "AcceptedCountForager: accepted_count_limit must be > 0, got 0"
89        );
90        Self {
91            accepted_count_limit,
92            accepted_moves: Vec::new(),
93            _phantom: PhantomData,
94        }
95    }
96}
97
98impl<S> Clone for AcceptedCountForager<S>
99where
100    S: PlanningSolution,
101{
102    fn clone(&self) -> Self {
103        Self {
104            accepted_count_limit: self.accepted_count_limit,
105            accepted_moves: Vec::new(), // Fresh vec for clone
106            _phantom: PhantomData,
107        }
108    }
109}
110
111impl<S> Debug for AcceptedCountForager<S>
112where
113    S: PlanningSolution,
114{
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.debug_struct("AcceptedCountForager")
117            .field("accepted_count_limit", &self.accepted_count_limit)
118            .field("accepted_count", &self.accepted_moves.len())
119            .finish()
120    }
121}
122
123impl<S, M> LocalSearchForager<S, M> for AcceptedCountForager<S>
124where
125    S: PlanningSolution,
126    M: Move<S>,
127{
128    fn step_started(&mut self, _best_score: S::Score, _last_step_score: S::Score) {
129        self.accepted_moves.clear();
130    }
131
132    fn add_move_index(&mut self, index: usize, score: S::Score) {
133        self.accepted_moves.push((index, score));
134    }
135
136    fn is_quit_early(&self) -> bool {
137        self.accepted_moves.len() >= self.accepted_count_limit
138    }
139
140    fn pick_move_index(&mut self) -> Option<(usize, S::Score)> {
141        if self.accepted_moves.is_empty() {
142            return None;
143        }
144
145        // Find the best move (highest score)
146        let mut best_idx = 0;
147        let mut best_score = self.accepted_moves[0].1;
148
149        for (i, &(_, score)) in self.accepted_moves.iter().enumerate().skip(1) {
150            if score > best_score {
151                best_idx = i;
152                best_score = score;
153            }
154        }
155
156        // Return the best move index
157        Some(self.accepted_moves.swap_remove(best_idx))
158    }
159}
160
161/// A forager that picks the first accepted move.
162///
163/// This is the simplest forager - it quits after the first accepted move.
164pub struct FirstAcceptedForager<S>
165where
166    S: PlanningSolution,
167{
168    // The first accepted move index.
169    accepted_move: Option<(usize, S::Score)>,
170    _phantom: PhantomData<fn() -> S>,
171}
172
173impl<S> Debug for FirstAcceptedForager<S>
174where
175    S: PlanningSolution,
176{
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        f.debug_struct("FirstAcceptedForager")
179            .field("has_move", &self.accepted_move.is_some())
180            .finish()
181    }
182}
183
184impl<S> FirstAcceptedForager<S>
185where
186    S: PlanningSolution,
187{
188    pub fn new() -> Self {
189        Self {
190            accepted_move: None,
191            _phantom: PhantomData,
192        }
193    }
194}
195
196impl<S> Clone for FirstAcceptedForager<S>
197where
198    S: PlanningSolution,
199{
200    fn clone(&self) -> Self {
201        Self {
202            accepted_move: None, // Fresh state for clone
203            _phantom: PhantomData,
204        }
205    }
206}
207
208impl<S> Default for FirstAcceptedForager<S>
209where
210    S: PlanningSolution,
211{
212    fn default() -> Self {
213        Self::new()
214    }
215}
216
217impl<S, M> LocalSearchForager<S, M> for FirstAcceptedForager<S>
218where
219    S: PlanningSolution,
220    M: Move<S>,
221{
222    fn step_started(&mut self, _best_score: S::Score, _last_step_score: S::Score) {
223        self.accepted_move = None;
224    }
225
226    fn add_move_index(&mut self, index: usize, score: S::Score) {
227        if self.accepted_move.is_none() {
228            self.accepted_move = Some((index, score));
229        }
230    }
231
232    fn is_quit_early(&self) -> bool {
233        self.accepted_move.is_some()
234    }
235
236    fn pick_move_index(&mut self) -> Option<(usize, S::Score)> {
237        self.accepted_move.take()
238    }
239}
240
241/// A forager that evaluates all accepted moves and picks the best.
242///
243/// Unlike `AcceptedCountForager(N)`, this forager never quits early — it
244/// always evaluates the full move space before selecting the best score.
245pub struct BestScoreForager<S>
246where
247    S: PlanningSolution,
248{
249    // Collected move indices with their scores.
250    accepted_moves: Vec<(usize, S::Score)>,
251    _phantom: PhantomData<fn() -> S>,
252}
253
254impl<S> BestScoreForager<S>
255where
256    S: PlanningSolution,
257{
258    pub fn new() -> Self {
259        Self {
260            accepted_moves: Vec::new(),
261            _phantom: PhantomData,
262        }
263    }
264}
265
266impl<S> Default for BestScoreForager<S>
267where
268    S: PlanningSolution,
269{
270    fn default() -> Self {
271        Self::new()
272    }
273}
274
275impl<S> Debug for BestScoreForager<S>
276where
277    S: PlanningSolution,
278{
279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280        f.debug_struct("BestScoreForager")
281            .field("accepted_count", &self.accepted_moves.len())
282            .finish()
283    }
284}
285
286impl<S> Clone for BestScoreForager<S>
287where
288    S: PlanningSolution,
289{
290    fn clone(&self) -> Self {
291        Self {
292            accepted_moves: Vec::new(),
293            _phantom: PhantomData,
294        }
295    }
296}
297
298impl<S, M> LocalSearchForager<S, M> for BestScoreForager<S>
299where
300    S: PlanningSolution,
301    M: Move<S>,
302{
303    fn step_started(&mut self, _best_score: S::Score, _last_step_score: S::Score) {
304        self.accepted_moves.clear();
305    }
306
307    fn add_move_index(&mut self, index: usize, score: S::Score) {
308        self.accepted_moves.push((index, score));
309    }
310
311    fn is_quit_early(&self) -> bool {
312        false // Never quit early — always evaluate the full move space
313    }
314
315    fn pick_move_index(&mut self) -> Option<(usize, S::Score)> {
316        if self.accepted_moves.is_empty() {
317            return None;
318        }
319        let mut best_idx = 0;
320        let mut best_score = self.accepted_moves[0].1;
321        for (i, &(_, score)) in self.accepted_moves.iter().enumerate().skip(1) {
322            if score > best_score {
323                best_idx = i;
324                best_score = score;
325            }
326        }
327        Some(self.accepted_moves.swap_remove(best_idx))
328    }
329}
330
331#[cfg(test)]
332#[path = "forager_tests.rs"]
333mod tests;