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