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