Skip to main content

solverforge_solver/phase/localsearch/acceptor/
move_tabu.rs

1// Move tabu acceptor.
2
3use std::fmt::Debug;
4
5use solverforge_core::domain::PlanningSolution;
6use solverforge_core::Score;
7
8use super::Acceptor;
9
10/// Move tabu acceptor - maintains a tabu list based on move identifiers.
11///
12/// Unlike entity tabu (which forbids recently moved entities) or value tabu
13/// (which forbids recently assigned values), move tabu forbids the exact
14/// move combination (entity + value). This provides finer-grained control.
15///
16/// A move is identified by its hash, typically combining entity index and
17/// assigned value information.
18///
19/// # Example
20///
21/// ```
22/// use solverforge_solver::phase::localsearch::MoveTabuAcceptor;
23///
24/// let acceptor = MoveTabuAcceptor::new(7);
25/// assert!(!acceptor.is_move_tabu(42));
26/// ```
27pub struct MoveTabuAcceptor {
28    // Maximum number of moves to remember.
29    move_tabu_size: usize,
30    // List of tabu move hashes.
31    move_tabu_list: Vec<u64>,
32    // Current step's executed move hash.
33    current_step_move: Option<u64>,
34    // Whether to accept improving moves even if tabu (aspiration).
35    aspiration_enabled: bool,
36    // Best score seen so far (for aspiration criterion).
37    best_score: Option<i64>,
38}
39
40impl Debug for MoveTabuAcceptor {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("MoveTabuAcceptor")
43            .field("move_tabu_size", &self.move_tabu_size)
44            .field("tabu_list_len", &self.move_tabu_list.len())
45            .field("aspiration_enabled", &self.aspiration_enabled)
46            .finish()
47    }
48}
49
50impl Clone for MoveTabuAcceptor {
51    fn clone(&self) -> Self {
52        Self {
53            move_tabu_size: self.move_tabu_size,
54            move_tabu_list: self.move_tabu_list.clone(),
55            current_step_move: self.current_step_move,
56            aspiration_enabled: self.aspiration_enabled,
57            best_score: self.best_score,
58        }
59    }
60}
61
62impl MoveTabuAcceptor {
63    /// Creates a new move tabu acceptor with aspiration enabled.
64    ///
65    /// # Arguments
66    /// * `move_tabu_size` - Maximum number of moves to remember as tabu. Must be > 0.
67    ///
68    /// # Panics
69    ///
70    /// Panics if `move_tabu_size` is 0.
71    pub fn new(move_tabu_size: usize) -> Self {
72        assert!(move_tabu_size > 0, "move_tabu_size must be > 0, got 0");
73        Self {
74            move_tabu_size,
75            move_tabu_list: Vec::with_capacity(move_tabu_size),
76            current_step_move: None,
77            aspiration_enabled: true,
78            best_score: None,
79        }
80    }
81
82    /// Creates a move tabu acceptor without aspiration.
83    ///
84    /// Without aspiration, tabu moves are never accepted even if they
85    /// would lead to a new best solution.
86    ///
87    /// # Panics
88    ///
89    /// Panics if `move_tabu_size` is 0.
90    pub fn without_aspiration(move_tabu_size: usize) -> Self {
91        assert!(move_tabu_size > 0, "move_tabu_size must be > 0, got 0");
92        Self {
93            move_tabu_size,
94            move_tabu_list: Vec::with_capacity(move_tabu_size),
95            current_step_move: None,
96            aspiration_enabled: false,
97            best_score: None,
98        }
99    }
100
101    /// Records that a move was executed in the current step.
102    ///
103    /// Call this with the hash of the executed move.
104    pub fn record_move(&mut self, move_hash: u64) {
105        self.current_step_move = Some(move_hash);
106    }
107
108    pub fn is_move_tabu(&self, move_hash: u64) -> bool {
109        self.move_tabu_list.contains(&move_hash)
110    }
111
112    pub fn aspiration_enabled(&self) -> bool {
113        self.aspiration_enabled
114    }
115
116    fn score_to_i64<S: PlanningSolution>(score: &S::Score) -> i64 {
117        let levels = score.to_level_numbers();
118        // Use last level (soft score) as the primary comparison
119        *levels.last().unwrap_or(&0)
120    }
121}
122
123impl Default for MoveTabuAcceptor {
124    fn default() -> Self {
125        Self::new(7)
126    }
127}
128
129impl<S: PlanningSolution> Acceptor<S> for MoveTabuAcceptor {
130    fn is_accepted(&mut self, last_step_score: &S::Score, move_score: &S::Score) -> bool {
131        // Check aspiration criterion
132        if self.aspiration_enabled {
133            if let Some(best) = self.best_score {
134                let move_value = Self::score_to_i64::<S>(move_score);
135                if move_value > best {
136                    return true; // Aspiration: accept new best even if tabu
137                }
138            }
139        }
140
141        // Accept improving moves
142        if move_score > last_step_score {
143            return true;
144        }
145
146        // Accept equal moves for plateau exploration
147        if move_score >= last_step_score {
148            return true;
149        }
150
151        false
152    }
153
154    fn phase_started(&mut self, initial_score: &S::Score) {
155        self.move_tabu_list.clear();
156        self.current_step_move = None;
157        self.best_score = Some(Self::score_to_i64::<S>(initial_score));
158    }
159
160    fn phase_ended(&mut self) {
161        self.move_tabu_list.clear();
162    }
163
164    fn step_started(&mut self) {
165        self.current_step_move = None;
166    }
167
168    fn step_ended(&mut self, step_score: &S::Score) {
169        // Add current step's move to tabu list
170        if let Some(move_hash) = self.current_step_move {
171            if self.move_tabu_list.len() >= self.move_tabu_size {
172                self.move_tabu_list.remove(0);
173            }
174            self.move_tabu_list.push(move_hash);
175        }
176        self.current_step_move = None;
177
178        // Update best score
179        let step_value = Self::score_to_i64::<S>(step_score);
180        if let Some(best) = self.best_score {
181            if step_value > best {
182                self.best_score = Some(step_value);
183            }
184        } else {
185            self.best_score = Some(step_value);
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use solverforge_core::score::SoftScore;
194
195    #[derive(Clone)]
196    struct TestSolution;
197    impl PlanningSolution for TestSolution {
198        type Score = SoftScore;
199        fn score(&self) -> Option<Self::Score> {
200            None
201        }
202        fn set_score(&mut self, _: Option<Self::Score>) {}
203    }
204
205    #[test]
206    fn test_new_acceptor() {
207        let acceptor = MoveTabuAcceptor::new(5);
208        assert!(!acceptor.is_move_tabu(42));
209        assert!(acceptor.aspiration_enabled());
210    }
211
212    #[test]
213    fn test_without_aspiration() {
214        let acceptor = MoveTabuAcceptor::without_aspiration(5);
215        assert!(!acceptor.aspiration_enabled());
216    }
217
218    #[test]
219    fn test_record_and_check() {
220        let mut acceptor = MoveTabuAcceptor::new(5);
221        Acceptor::<TestSolution>::phase_started(&mut acceptor, &SoftScore::of(0));
222
223        acceptor.record_move(42);
224        Acceptor::<TestSolution>::step_ended(&mut acceptor, &SoftScore::of(0));
225
226        assert!(acceptor.is_move_tabu(42));
227        assert!(!acceptor.is_move_tabu(99));
228    }
229
230    #[test]
231    fn test_tabu_expiration() {
232        let mut acceptor = MoveTabuAcceptor::new(2);
233        Acceptor::<TestSolution>::phase_started(&mut acceptor, &SoftScore::of(0));
234
235        // First step: add move 1
236        acceptor.record_move(1);
237        Acceptor::<TestSolution>::step_ended(&mut acceptor, &SoftScore::of(0));
238        assert!(acceptor.is_move_tabu(1));
239
240        // Second step: add move 2
241        Acceptor::<TestSolution>::step_started(&mut acceptor);
242        acceptor.record_move(2);
243        Acceptor::<TestSolution>::step_ended(&mut acceptor, &SoftScore::of(0));
244        assert!(acceptor.is_move_tabu(1));
245        assert!(acceptor.is_move_tabu(2));
246
247        // Third step: add move 3 - this should evict move 1
248        Acceptor::<TestSolution>::step_started(&mut acceptor);
249        acceptor.record_move(3);
250        Acceptor::<TestSolution>::step_ended(&mut acceptor, &SoftScore::of(0));
251
252        assert!(!acceptor.is_move_tabu(1)); // Expired
253        assert!(acceptor.is_move_tabu(2));
254        assert!(acceptor.is_move_tabu(3));
255    }
256
257    #[test]
258    fn test_accepts_improving_move() {
259        let mut acceptor = MoveTabuAcceptor::new(5);
260        let last_score = SoftScore::of(-10);
261        let move_score = SoftScore::of(-5);
262        assert!(Acceptor::<TestSolution>::is_accepted(
263            &mut acceptor,
264            &last_score,
265            &move_score
266        ));
267    }
268
269    #[test]
270    fn test_phase_clears_tabu() {
271        let mut acceptor = MoveTabuAcceptor::new(5);
272        Acceptor::<TestSolution>::phase_started(&mut acceptor, &SoftScore::of(0));
273
274        acceptor.record_move(42);
275        Acceptor::<TestSolution>::step_ended(&mut acceptor, &SoftScore::of(0));
276        assert!(acceptor.is_move_tabu(42));
277
278        Acceptor::<TestSolution>::phase_ended(&mut acceptor);
279        assert!(!acceptor.is_move_tabu(42));
280    }
281
282    #[test]
283    fn test_no_move_recorded_no_tabu() {
284        let mut acceptor = MoveTabuAcceptor::new(5);
285        Acceptor::<TestSolution>::phase_started(&mut acceptor, &SoftScore::of(0));
286
287        // Don't record any move
288        Acceptor::<TestSolution>::step_ended(&mut acceptor, &SoftScore::of(0));
289
290        // No moves should be tabu
291        assert!(!acceptor.is_move_tabu(42));
292    }
293}