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
67    pub fn new(move_tabu_size: usize) -> Self {
68        Self {
69            move_tabu_size,
70            move_tabu_list: Vec::with_capacity(move_tabu_size),
71            current_step_move: None,
72            aspiration_enabled: true,
73            best_score: None,
74        }
75    }
76
77    /// Creates a move tabu acceptor without aspiration.
78    ///
79    /// Without aspiration, tabu moves are never accepted even if they
80    /// would lead to a new best solution.
81    pub fn without_aspiration(move_tabu_size: usize) -> Self {
82        Self {
83            move_tabu_size,
84            move_tabu_list: Vec::with_capacity(move_tabu_size),
85            current_step_move: None,
86            aspiration_enabled: false,
87            best_score: None,
88        }
89    }
90
91    /// Records that a move was executed in the current step.
92    ///
93    /// Call this with the hash of the executed move.
94    pub fn record_move(&mut self, move_hash: u64) {
95        self.current_step_move = Some(move_hash);
96    }
97
98    /// Returns true if the given move hash is in the tabu list.
99    pub fn is_move_tabu(&self, move_hash: u64) -> bool {
100        self.move_tabu_list.contains(&move_hash)
101    }
102
103    /// Returns true if aspiration is enabled.
104    pub fn aspiration_enabled(&self) -> bool {
105        self.aspiration_enabled
106    }
107
108    fn score_to_i64<S: PlanningSolution>(score: &S::Score) -> i64 {
109        let levels = score.to_level_numbers();
110        // Use last level (soft score) as the primary comparison
111        *levels.last().unwrap_or(&0)
112    }
113}
114
115impl Default for MoveTabuAcceptor {
116    fn default() -> Self {
117        Self::new(7)
118    }
119}
120
121impl<S: PlanningSolution> Acceptor<S> for MoveTabuAcceptor {
122    fn is_accepted(&self, last_step_score: &S::Score, move_score: &S::Score) -> bool {
123        // Check aspiration criterion
124        if self.aspiration_enabled {
125            if let Some(best) = self.best_score {
126                let move_value = Self::score_to_i64::<S>(move_score);
127                if move_value > best {
128                    return true; // Aspiration: accept new best even if tabu
129                }
130            }
131        }
132
133        // Accept improving moves
134        if move_score > last_step_score {
135            return true;
136        }
137
138        // Accept equal moves for plateau exploration
139        if move_score >= last_step_score {
140            return true;
141        }
142
143        false
144    }
145
146    fn phase_started(&mut self, initial_score: &S::Score) {
147        self.move_tabu_list.clear();
148        self.current_step_move = None;
149        self.best_score = Some(Self::score_to_i64::<S>(initial_score));
150    }
151
152    fn phase_ended(&mut self) {
153        self.move_tabu_list.clear();
154    }
155
156    fn step_started(&mut self) {
157        self.current_step_move = None;
158    }
159
160    fn step_ended(&mut self, step_score: &S::Score) {
161        // Add current step's move to tabu list
162        if let Some(move_hash) = self.current_step_move {
163            if self.move_tabu_list.len() >= self.move_tabu_size {
164                self.move_tabu_list.remove(0);
165            }
166            self.move_tabu_list.push(move_hash);
167        }
168        self.current_step_move = None;
169
170        // Update best score
171        let step_value = Self::score_to_i64::<S>(step_score);
172        if let Some(best) = self.best_score {
173            if step_value > best {
174                self.best_score = Some(step_value);
175            }
176        } else {
177            self.best_score = Some(step_value);
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use solverforge_core::score::SimpleScore;
186
187    #[derive(Clone)]
188    struct TestSolution;
189    impl PlanningSolution for TestSolution {
190        type Score = SimpleScore;
191        fn score(&self) -> Option<Self::Score> {
192            None
193        }
194        fn set_score(&mut self, _: Option<Self::Score>) {}
195    }
196
197    #[test]
198    fn test_new_acceptor() {
199        let acceptor = MoveTabuAcceptor::new(5);
200        assert!(!acceptor.is_move_tabu(42));
201        assert!(acceptor.aspiration_enabled());
202    }
203
204    #[test]
205    fn test_without_aspiration() {
206        let acceptor = MoveTabuAcceptor::without_aspiration(5);
207        assert!(!acceptor.aspiration_enabled());
208    }
209
210    #[test]
211    fn test_record_and_check() {
212        let mut acceptor = MoveTabuAcceptor::new(5);
213        Acceptor::<TestSolution>::phase_started(&mut acceptor, &SimpleScore::of(0));
214
215        acceptor.record_move(42);
216        Acceptor::<TestSolution>::step_ended(&mut acceptor, &SimpleScore::of(0));
217
218        assert!(acceptor.is_move_tabu(42));
219        assert!(!acceptor.is_move_tabu(99));
220    }
221
222    #[test]
223    fn test_tabu_expiration() {
224        let mut acceptor = MoveTabuAcceptor::new(2);
225        Acceptor::<TestSolution>::phase_started(&mut acceptor, &SimpleScore::of(0));
226
227        // First step: add move 1
228        acceptor.record_move(1);
229        Acceptor::<TestSolution>::step_ended(&mut acceptor, &SimpleScore::of(0));
230        assert!(acceptor.is_move_tabu(1));
231
232        // Second step: add move 2
233        Acceptor::<TestSolution>::step_started(&mut acceptor);
234        acceptor.record_move(2);
235        Acceptor::<TestSolution>::step_ended(&mut acceptor, &SimpleScore::of(0));
236        assert!(acceptor.is_move_tabu(1));
237        assert!(acceptor.is_move_tabu(2));
238
239        // Third step: add move 3 - this should evict move 1
240        Acceptor::<TestSolution>::step_started(&mut acceptor);
241        acceptor.record_move(3);
242        Acceptor::<TestSolution>::step_ended(&mut acceptor, &SimpleScore::of(0));
243
244        assert!(!acceptor.is_move_tabu(1)); // Expired
245        assert!(acceptor.is_move_tabu(2));
246        assert!(acceptor.is_move_tabu(3));
247    }
248
249    #[test]
250    fn test_accepts_improving_move() {
251        let acceptor = MoveTabuAcceptor::new(5);
252        let last_score = SimpleScore::of(-10);
253        let move_score = SimpleScore::of(-5);
254        assert!(Acceptor::<TestSolution>::is_accepted(
255            &acceptor,
256            &last_score,
257            &move_score
258        ));
259    }
260
261    #[test]
262    fn test_phase_clears_tabu() {
263        let mut acceptor = MoveTabuAcceptor::new(5);
264        Acceptor::<TestSolution>::phase_started(&mut acceptor, &SimpleScore::of(0));
265
266        acceptor.record_move(42);
267        Acceptor::<TestSolution>::step_ended(&mut acceptor, &SimpleScore::of(0));
268        assert!(acceptor.is_move_tabu(42));
269
270        Acceptor::<TestSolution>::phase_ended(&mut acceptor);
271        assert!(!acceptor.is_move_tabu(42));
272    }
273
274    #[test]
275    fn test_no_move_recorded_no_tabu() {
276        let mut acceptor = MoveTabuAcceptor::new(5);
277        Acceptor::<TestSolution>::phase_started(&mut acceptor, &SimpleScore::of(0));
278
279        // Don't record any move
280        Acceptor::<TestSolution>::step_ended(&mut acceptor, &SimpleScore::of(0));
281
282        // No moves should be tabu
283        assert!(!acceptor.is_move_tabu(42));
284    }
285}