Skip to main content

solverforge_solver/heuristic/move/
k_opt.rs

1/* K-opt move for tour optimization.
2
3K-opt removes k edges from a tour and reconnects the resulting segments
4in a different order, potentially reversing some segments. This is a
5fundamental move for TSP and VRP optimization.
6
7# Zero-Erasure Design
8
9- Fixed arrays for cut points (no SmallVec for static data)
10- Reconnection pattern stored by value (`KOptReconnection` is `Copy`)
11- Concrete function pointers for all list operations
12
13# Example
14
15```
16use solverforge_solver::heuristic::r#move::{KOptMove, CutPoint};
17use solverforge_solver::heuristic::r#move::k_opt_reconnection::THREE_OPT_RECONNECTIONS;
18use solverforge_core::domain::PlanningSolution;
19use solverforge_core::score::SoftScore;
20
21#[derive(Clone, Debug)]
22struct Tour { cities: Vec<i32>, score: Option<SoftScore> }
23
24impl PlanningSolution for Tour {
25type Score = SoftScore;
26fn score(&self) -> Option<Self::Score> { self.score }
27fn set_score(&mut self, score: Option<Self::Score>) { self.score = score; }
28}
29
30fn list_len(s: &Tour, _: usize) -> usize { s.cities.len() }
31fn sublist_remove(s: &mut Tour, _: usize, start: usize, end: usize) -> Vec<i32> {
32s.cities.drain(start..end).collect()
33}
34fn sublist_insert(s: &mut Tour, _: usize, pos: usize, items: Vec<i32>) {
35for (i, item) in items.into_iter().enumerate() {
36s.cities.insert(pos + i, item);
37}
38}
39
40// Create a 3-opt move with cuts at positions 2, 4, 6
41// This creates 4 segments: [0..2), [2..4), [4..6), [6..)
42let cuts = [
43CutPoint::new(0, 2),
44CutPoint::new(0, 4),
45CutPoint::new(0, 6),
46];
47let reconnection = &THREE_OPT_RECONNECTIONS[3]; // Swap middle segments
48
49let m = KOptMove::<Tour, i32>::new(
50&cuts,
51reconnection,
52list_len,
53sublist_remove,
54sublist_insert,
55"cities",
560,
57);
58```
59*/
60
61use std::fmt::Debug;
62use std::marker::PhantomData;
63
64use solverforge_core::domain::PlanningSolution;
65use solverforge_scoring::Director;
66
67use super::k_opt_reconnection::KOptReconnection;
68use super::list_kernel::{
69    k_opt_do_move, k_opt_do_move_into, k_opt_is_doable, k_opt_tabu_signature, k_opt_undo_move,
70    StaticListWindowAccess,
71};
72use super::metadata::hash_str;
73use super::ListUndoJournal;
74use super::{Move, MoveTabuSignature};
75
76/* A cut point in a route, defining where an edge is removed.
77
78For k-opt, we have k cut points which divide the route into k+1 segments.
79
80# Example
81
82```
83use solverforge_solver::heuristic::r#move::CutPoint;
84
85// Cut at position 5 in entity 0
86let cut = CutPoint::new(0, 5);
87assert_eq!(cut.entity_index(), 0);
88assert_eq!(cut.position(), 5);
89```
90*/
91#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
92pub struct CutPoint {
93    // Entity (route/vehicle) index.
94    entity_index: usize,
95    // Position in the route where the cut occurs.
96    // The edge between position-1 and position is removed.
97    position: usize,
98}
99
100impl CutPoint {
101    #[inline]
102    pub const fn new(entity_index: usize, position: usize) -> Self {
103        Self {
104            entity_index,
105            position,
106        }
107    }
108
109    #[inline]
110    pub const fn entity_index(&self) -> usize {
111        self.entity_index
112    }
113
114    #[inline]
115    pub const fn position(&self) -> usize {
116        self.position
117    }
118}
119
120/// A k-opt move that removes k edges and reconnects segments.
121///
122/// This is the generalized k-opt move supporting k=2,3,4,5.
123/// For k=2, this is equivalent to a 2-opt (segment reversal).
124///
125/// # Zero-Erasure Design
126///
127/// - Fixed array `[CutPoint; 5]` for up to 5 cuts (5-opt)
128/// - `KOptReconnection` stored by value (`Copy` type, no heap allocation)
129/// - Concrete function pointers for list operations
130///
131/// # Type Parameters
132///
133/// * `S` - The planning solution type
134/// * `V` - The list element value type
135pub struct KOptMove<S, V> {
136    // Cut points (up to 5 for 5-opt).
137    cuts: [CutPoint; 5],
138    // Number of actual cuts (k value).
139    cut_count: u8,
140    // Reconnection pattern to apply (stored by value — `KOptReconnection` is `Copy`).
141    reconnection: KOptReconnection,
142    list_len: fn(&S, usize) -> usize,
143    list_get: fn(&S, usize, usize) -> Option<V>,
144    // Remove sublist [start, end), returns removed elements.
145    sublist_remove: fn(&mut S, usize, usize, usize) -> Vec<V>,
146    // Insert elements at position.
147    sublist_insert: fn(&mut S, usize, usize, Vec<V>),
148    // Variable name.
149    variable_name: &'static str,
150    variable_id: u64,
151    // Descriptor index.
152    descriptor_index: usize,
153    // Entity index (for intra-route moves).
154    entity_index: usize,
155    _phantom: PhantomData<fn() -> V>,
156}
157
158impl<S, V: Debug> Debug for KOptMove<S, V> {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        let cuts: Vec<_> = self.cuts[..self.cut_count as usize]
161            .iter()
162            .map(|c| c.position)
163            .collect();
164        f.debug_struct("KOptMove")
165            .field("k", &self.cut_count)
166            .field("entity", &self.entity_index)
167            .field("cuts", &cuts)
168            .field("reconnection", &self.reconnection)
169            .field("variable_name", &self.variable_name)
170            .finish()
171    }
172}
173
174impl<S: PlanningSolution, V: Clone + Send + Sync + Debug + 'static> KOptMove<S, V> {
175    /* Creates a new k-opt move.
176
177    # Arguments
178
179    * `cuts` - Slice of cut points (must be sorted by position for intra-route)
180    * `reconnection` - How to reconnect the segments
181    * `list_len` - Function to get list length
182    * `sublist_remove` - Function to remove a range
183    * `sublist_insert` - Function to insert elements
184    * `variable_name` - Name of the list variable
185    * `descriptor_index` - Entity descriptor index
186
187    # Panics
188
189    Panics if cuts is empty or has more than 5 elements.
190    */
191    #[allow(clippy::too_many_arguments)]
192    pub fn new(
193        cuts: &[CutPoint],
194        reconnection: &KOptReconnection,
195        list_len: fn(&S, usize) -> usize,
196        list_get: fn(&S, usize, usize) -> Option<V>,
197        sublist_remove: fn(&mut S, usize, usize, usize) -> Vec<V>,
198        sublist_insert: fn(&mut S, usize, usize, Vec<V>),
199        variable_name: &'static str,
200        descriptor_index: usize,
201    ) -> Self {
202        Self::new_with_variable_id(
203            cuts,
204            reconnection,
205            list_len,
206            list_get,
207            sublist_remove,
208            sublist_insert,
209            variable_name,
210            hash_str(variable_name),
211            descriptor_index,
212        )
213    }
214
215    #[allow(clippy::too_many_arguments)]
216    pub(crate) fn new_with_variable_id(
217        cuts: &[CutPoint],
218        reconnection: &KOptReconnection,
219        list_len: fn(&S, usize) -> usize,
220        list_get: fn(&S, usize, usize) -> Option<V>,
221        sublist_remove: fn(&mut S, usize, usize, usize) -> Vec<V>,
222        sublist_insert: fn(&mut S, usize, usize, Vec<V>),
223        variable_name: &'static str,
224        variable_id: u64,
225        descriptor_index: usize,
226    ) -> Self {
227        assert!(!cuts.is_empty() && cuts.len() <= 5, "k must be 1-5");
228
229        let mut cut_array = [CutPoint::default(); 5];
230        for (i, cut) in cuts.iter().enumerate() {
231            cut_array[i] = *cut;
232        }
233
234        // For now, assume intra-route (all cuts on same entity)
235        let entity_index = cuts[0].entity_index;
236
237        Self {
238            cuts: cut_array,
239            cut_count: cuts.len() as u8,
240            reconnection: *reconnection,
241            list_len,
242            list_get,
243            sublist_remove,
244            sublist_insert,
245            variable_name,
246            variable_id,
247            descriptor_index,
248            entity_index,
249            _phantom: PhantomData,
250        }
251    }
252
253    #[inline]
254    pub fn k(&self) -> usize {
255        self.cut_count as usize
256    }
257
258    #[inline]
259    pub fn cuts(&self) -> &[CutPoint] {
260        &self.cuts[..self.cut_count as usize]
261    }
262
263    pub fn is_intra_route(&self) -> bool {
264        let first = self.cuts[0].entity_index;
265        self.cuts[..self.cut_count as usize]
266            .iter()
267            .all(|c| c.entity_index == first)
268    }
269
270    fn access(&self) -> StaticListWindowAccess<S, V> {
271        StaticListWindowAccess {
272            list_len: self.list_len,
273            list_get: self.list_get,
274            sublist_remove: self.sublist_remove,
275            sublist_insert: self.sublist_insert,
276            variable_name: self.variable_name,
277            descriptor_index: self.descriptor_index,
278        }
279    }
280
281    /// Scratch-carrying do: the pre-kopt route is returned through the
282    /// caller-owned journal so its inlined capacity is reused across trials.
283    pub(crate) fn do_move_into_journal<D: Director<S>>(
284        &self,
285        score_director: &mut D,
286        journal: &mut ListUndoJournal<V>,
287    ) {
288        k_opt_do_move_into(
289            &self.access(),
290            self.cuts(),
291            &self.reconnection,
292            self.entity_index,
293            score_director,
294            journal,
295        );
296    }
297}
298
299impl<S, V> Move<S> for KOptMove<S, V>
300where
301    S: PlanningSolution,
302    V: Clone + Send + Sync + Debug + 'static,
303{
304    type Undo = ListUndoJournal<V>;
305
306    fn is_doable<D: Director<S>>(&self, score_director: &D) -> bool {
307        k_opt_is_doable(
308            &self.access(),
309            self.cuts(),
310            &self.reconnection,
311            self.entity_index,
312            score_director,
313        )
314    }
315
316    fn do_move<D: Director<S>>(&self, score_director: &mut D) -> Self::Undo {
317        k_opt_do_move(
318            &self.access(),
319            self.cuts(),
320            &self.reconnection,
321            self.entity_index,
322            score_director,
323        )
324    }
325
326    fn undo_move<D: Director<S>>(&self, score_director: &mut D, undo: Self::Undo) {
327        k_opt_undo_move(&self.access(), self.entity_index, undo, score_director);
328    }
329
330    fn do_move_with_scratch<D: Director<S>>(
331        &self,
332        score_director: &mut D,
333        journal_pool: &mut Vec<Self::Undo>,
334    ) -> Self::Undo {
335        let mut journal = journal_pool.pop().unwrap_or_default();
336        k_opt_do_move_into(
337            &self.access(),
338            self.cuts(),
339            &self.reconnection,
340            self.entity_index,
341            score_director,
342            &mut journal,
343        );
344        journal
345    }
346
347    fn undo_move_with_scratch<D: Director<S>>(
348        &self,
349        score_director: &mut D,
350        undo: Self::Undo,
351        journal_pool: &mut Vec<Self::Undo>,
352    ) {
353        k_opt_undo_move(&self.access(), self.entity_index, undo, score_director);
354        journal_pool.push(ListUndoJournal::new());
355    }
356
357    fn descriptor_index(&self) -> usize {
358        self.descriptor_index
359    }
360
361    fn entity_indices(&self) -> &[usize] {
362        std::slice::from_ref(&self.entity_index)
363    }
364
365    fn variable_name(&self) -> &str {
366        self.variable_name
367    }
368
369    fn telemetry_label(&self) -> &'static str {
370        "k_opt"
371    }
372
373    fn tabu_signature<D: Director<S>>(&self, score_director: &D) -> MoveTabuSignature {
374        k_opt_tabu_signature(
375            &self.access(),
376            self.cuts(),
377            &self.reconnection,
378            self.variable_id,
379            self.entity_index,
380            score_director,
381        )
382    }
383}