Skip to main content

solverforge_solver/phase/localsearch/acceptor/
tabu_search.rs

1use std::fmt::Debug;
2
3use solverforge_core::domain::PlanningSolution;
4
5use super::Acceptor;
6use crate::heuristic::r#move::{
7    metadata::{MoveIdentity, ScopedEntityTabuToken, ScopedValueTabuToken},
8    MoveTabuSignature,
9};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub(crate) struct TabuSearchPolicy {
13    pub entity_tabu_size: Option<usize>,
14    pub value_tabu_size: Option<usize>,
15    pub move_tabu_size: Option<usize>,
16    pub undo_move_tabu_size: Option<usize>,
17    pub aspiration_enabled: bool,
18}
19
20impl TabuSearchPolicy {
21    pub(crate) fn move_only(move_tabu_size: usize) -> Self {
22        Self {
23            entity_tabu_size: None,
24            value_tabu_size: None,
25            move_tabu_size: Some(move_tabu_size),
26            undo_move_tabu_size: None,
27            aspiration_enabled: true,
28        }
29        .validated()
30    }
31
32    pub(crate) fn validated(self) -> Self {
33        let entity_tabu_size = validate_tabu_size("entity_tabu_size", self.entity_tabu_size);
34        let value_tabu_size = validate_tabu_size("value_tabu_size", self.value_tabu_size);
35        let move_tabu_size = validate_tabu_size("move_tabu_size", self.move_tabu_size);
36        let undo_move_tabu_size =
37            validate_tabu_size("undo_move_tabu_size", self.undo_move_tabu_size);
38
39        assert!(
40            entity_tabu_size.is_some()
41                || value_tabu_size.is_some()
42                || move_tabu_size.is_some()
43                || undo_move_tabu_size.is_some(),
44            "tabu_search requires at least one tabu dimension"
45        );
46
47        Self {
48            entity_tabu_size,
49            value_tabu_size,
50            move_tabu_size,
51            undo_move_tabu_size,
52            aspiration_enabled: self.aspiration_enabled,
53        }
54    }
55}
56
57fn validate_tabu_size(field_name: &str, value: Option<usize>) -> Option<usize> {
58    match value {
59        Some(0) => panic!("tabu_search field `{field_name}` must be greater than 0"),
60        _ => value,
61    }
62}
63
64#[derive(Clone, Debug, Default)]
65struct TabuMemory<T> {
66    tenure: Option<usize>,
67    entries: Vec<T>,
68}
69
70impl<T: Clone + PartialEq> TabuMemory<T> {
71    fn new(tenure: Option<usize>) -> Self {
72        Self {
73            tenure,
74            entries: Vec::new(),
75        }
76    }
77
78    fn contains(&self, entry: &T) -> bool {
79        self.entries.iter().any(|item| item == entry)
80    }
81
82    fn record(&mut self, entry: T) {
83        let Some(tenure) = self.tenure else {
84            return;
85        };
86        if self.entries.len() >= tenure {
87            self.entries.remove(0);
88        }
89        self.entries.push(entry);
90    }
91
92    fn record_many<'a>(&mut self, entries: impl IntoIterator<Item = &'a T>)
93    where
94        T: 'a,
95    {
96        for entry in entries {
97            self.record(entry.clone());
98        }
99    }
100
101    fn clear(&mut self) {
102        self.entries.clear();
103    }
104}
105
106/// Canonical metadata-based tabu search acceptor.
107///
108/// Unlike the previous score-tabu placeholder, this implementation uses the
109/// actual move signature emitted by the canonical move system. The score layer
110/// only participates through aspiration and phase-best tracking; admissible
111/// moves are left to the forager to rank.
112pub struct TabuSearchAcceptor<S: PlanningSolution> {
113    entity_memory: TabuMemory<ScopedEntityTabuToken>,
114    value_memory: TabuMemory<ScopedValueTabuToken>,
115    move_memory: TabuMemory<MoveIdentity>,
116    // Stores undo identities emitted by accepted moves; future reverse moves
117    // match this memory through their candidate move identity.
118    reverse_move_memory: TabuMemory<MoveIdentity>,
119    aspiration_enabled: bool,
120    best_score: Option<S::Score>,
121}
122
123impl<S: PlanningSolution> Debug for TabuSearchAcceptor<S> {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        f.debug_struct("TabuSearchAcceptor")
126            .field("entity_tabu_size", &self.entity_memory.tenure)
127            .field("value_tabu_size", &self.value_memory.tenure)
128            .field("move_tabu_size", &self.move_memory.tenure)
129            .field("undo_move_tabu_size", &self.reverse_move_memory.tenure)
130            .field("aspiration_enabled", &self.aspiration_enabled)
131            .finish()
132    }
133}
134
135impl<S: PlanningSolution> Clone for TabuSearchAcceptor<S> {
136    fn clone(&self) -> Self {
137        Self {
138            entity_memory: self.entity_memory.clone(),
139            value_memory: self.value_memory.clone(),
140            move_memory: self.move_memory.clone(),
141            reverse_move_memory: self.reverse_move_memory.clone(),
142            aspiration_enabled: self.aspiration_enabled,
143            best_score: self.best_score,
144        }
145    }
146}
147
148impl<S: PlanningSolution> TabuSearchAcceptor<S> {
149    pub(crate) fn new(policy: TabuSearchPolicy) -> Self {
150        let policy = policy.validated();
151
152        Self {
153            entity_memory: TabuMemory::new(policy.entity_tabu_size),
154            value_memory: TabuMemory::new(policy.value_tabu_size),
155            move_memory: TabuMemory::new(policy.move_tabu_size),
156            reverse_move_memory: TabuMemory::new(policy.undo_move_tabu_size),
157            aspiration_enabled: policy.aspiration_enabled,
158            best_score: None,
159        }
160    }
161
162    fn is_tabu(&self, signature: &MoveTabuSignature) -> bool {
163        signature
164            .entity_tokens
165            .iter()
166            .any(|entity_token| self.entity_memory.contains(entity_token))
167            || signature
168                .destination_value_tokens
169                .iter()
170                .any(|value_token| self.value_memory.contains(value_token))
171            || self.move_memory.contains(&signature.move_id)
172            || self.reverse_move_memory.contains(&signature.move_id)
173    }
174}
175
176impl<S: PlanningSolution> Acceptor<S> for TabuSearchAcceptor<S> {
177    fn requires_move_signatures(&self) -> bool {
178        true
179    }
180
181    fn is_accepted(
182        &mut self,
183        _last_step_score: &S::Score,
184        move_score: &S::Score,
185        move_signature: Option<&MoveTabuSignature>,
186    ) -> bool {
187        let signature = move_signature.expect("tabu search requires move signatures");
188        let aspirational = self
189            .best_score
190            .as_ref()
191            .is_some_and(|best| self.aspiration_enabled && move_score > best);
192        if !aspirational && self.is_tabu(signature) {
193            return false;
194        }
195        true
196    }
197
198    fn phase_started(&mut self, initial_score: &S::Score) {
199        self.entity_memory.clear();
200        self.value_memory.clear();
201        self.move_memory.clear();
202        self.reverse_move_memory.clear();
203        self.best_score = Some(*initial_score);
204    }
205
206    fn phase_ended(&mut self) {
207        self.entity_memory.clear();
208        self.value_memory.clear();
209        self.move_memory.clear();
210        self.reverse_move_memory.clear();
211        self.best_score = None;
212    }
213
214    fn step_ended(
215        &mut self,
216        step_score: &S::Score,
217        accepted_move_signature: Option<&MoveTabuSignature>,
218    ) {
219        if let Some(signature) = accepted_move_signature {
220            self.entity_memory
221                .record_many(signature.entity_tokens.iter());
222            self.value_memory
223                .record_many(signature.destination_value_tokens.iter());
224            self.move_memory.record(signature.move_id.clone());
225            self.reverse_move_memory
226                .record(signature.undo_move_id.clone());
227        }
228
229        if let Some(best) = &self.best_score {
230            if step_score > best {
231                self.best_score = Some(*step_score);
232            }
233        } else {
234            self.best_score = Some(*step_score);
235        }
236    }
237}