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