solverforge_solver/heuristic/move/
k_opt.rs

1//! K-opt move for tour optimization.
2//!
3//! K-opt removes k edges from a tour and reconnects the resulting segments
4//! in a different order, potentially reversing some segments. This is a
5//! fundamental move for TSP and VRP optimization.
6//!
7//! # Zero-Erasure Design
8//!
9//! - Fixed arrays for cut points (no SmallVec for static data)
10//! - Reference to static reconnection pattern
11//! - Typed function pointers for all list operations
12//!
13//! # Example
14//!
15//! ```
16//! use solverforge_solver::heuristic::r#move::{KOptMove, CutPoint};
17//! use solverforge_solver::heuristic::r#move::k_opt_reconnection::THREE_OPT_RECONNECTIONS;
18//! use solverforge_core::domain::PlanningSolution;
19//! use solverforge_core::score::SimpleScore;
20//!
21//! #[derive(Clone, Debug)]
22//! struct Tour { cities: Vec<i32>, score: Option<SimpleScore> }
23//!
24//! impl PlanningSolution for Tour {
25//!     type Score = SimpleScore;
26//!     fn score(&self) -> Option<Self::Score> { self.score }
27//!     fn set_score(&mut self, score: Option<Self::Score>) { self.score = score; }
28//! }
29//!
30//! fn list_len(s: &Tour, _: usize) -> usize { s.cities.len() }
31//! fn sublist_remove(s: &mut Tour, _: usize, start: usize, end: usize) -> Vec<i32> {
32//!     s.cities.drain(start..end).collect()
33//! }
34//! fn sublist_insert(s: &mut Tour, _: usize, pos: usize, items: Vec<i32>) {
35//!     for (i, item) in items.into_iter().enumerate() {
36//!         s.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..)
42//! let cuts = [
43//!     CutPoint::new(0, 2),
44//!     CutPoint::new(0, 4),
45//!     CutPoint::new(0, 6),
46//! ];
47//! let reconnection = &THREE_OPT_RECONNECTIONS[3]; // Swap middle segments
48//!
49//! let m = KOptMove::<Tour, i32>::new(
50//!     &cuts,
51//!     reconnection,
52//!     list_len,
53//!     sublist_remove,
54//!     sublist_insert,
55//!     "cities",
56//!     0,
57//! );
58//! ```
59
60use std::fmt::Debug;
61use std::marker::PhantomData;
62
63use solverforge_core::domain::PlanningSolution;
64use solverforge_scoring::ScoreDirector;
65
66use super::k_opt_reconnection::KOptReconnection;
67use super::Move;
68
69/// A cut point in a route, defining where an edge is removed.
70///
71/// For k-opt, we have k cut points which divide the route into k+1 segments.
72///
73/// # Example
74///
75/// ```
76/// use solverforge_solver::heuristic::r#move::CutPoint;
77///
78/// // Cut at position 5 in entity 0
79/// let cut = CutPoint::new(0, 5);
80/// assert_eq!(cut.entity_index(), 0);
81/// assert_eq!(cut.position(), 5);
82/// ```
83#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
84pub struct CutPoint {
85    /// Entity (route/vehicle) index.
86    entity_index: usize,
87    /// Position in the route where the cut occurs.
88    /// The edge between position-1 and position is removed.
89    position: usize,
90}
91
92impl CutPoint {
93    /// Creates a new cut point.
94    #[inline]
95    pub const fn new(entity_index: usize, position: usize) -> Self {
96        Self {
97            entity_index,
98            position,
99        }
100    }
101
102    /// Returns the entity index.
103    #[inline]
104    pub const fn entity_index(&self) -> usize {
105        self.entity_index
106    }
107
108    /// Returns the position.
109    #[inline]
110    pub const fn position(&self) -> usize {
111        self.position
112    }
113}
114
115/// A k-opt move that removes k edges and reconnects segments.
116///
117/// This is the generalized k-opt move supporting k=2,3,4,5.
118/// For k=2, this is equivalent to a 2-opt (segment reversal).
119///
120/// # Zero-Erasure Design
121///
122/// - Fixed array `[CutPoint; 5]` for up to 5 cuts (5-opt)
123/// - Reference to static `&'static KOptReconnection`
124/// - Typed function pointers for list operations
125///
126/// # Type Parameters
127///
128/// * `S` - The planning solution type
129/// * `V` - The list element value type
130#[derive(Clone)]
131pub struct KOptMove<S, V> {
132    /// Cut points (up to 5 for 5-opt).
133    cuts: [CutPoint; 5],
134    /// Number of actual cuts (k value).
135    cut_count: u8,
136    /// Reconnection pattern to apply.
137    reconnection: &'static KOptReconnection,
138    /// Get list length for an entity.
139    list_len: fn(&S, usize) -> usize,
140    /// Remove sublist [start, end), returns removed elements.
141    sublist_remove: fn(&mut S, usize, usize, usize) -> Vec<V>,
142    /// Insert elements at position.
143    sublist_insert: fn(&mut S, usize, usize, Vec<V>),
144    /// Variable name.
145    variable_name: &'static str,
146    /// Descriptor index.
147    descriptor_index: usize,
148    /// Entity index (for intra-route moves).
149    entity_index: usize,
150    _phantom: PhantomData<V>,
151}
152
153impl<S, V: Debug> Debug for KOptMove<S, V> {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        let cuts: Vec<_> = self.cuts[..self.cut_count as usize]
156            .iter()
157            .map(|c| c.position)
158            .collect();
159        f.debug_struct("KOptMove")
160            .field("k", &self.cut_count)
161            .field("entity", &self.entity_index)
162            .field("cuts", &cuts)
163            .field("reconnection", &self.reconnection)
164            .field("variable_name", &self.variable_name)
165            .finish()
166    }
167}
168
169impl<S, V> KOptMove<S, V> {
170    /// Creates a new k-opt move.
171    ///
172    /// # Arguments
173    ///
174    /// * `cuts` - Slice of cut points (must be sorted by position for intra-route)
175    /// * `reconnection` - How to reconnect the segments
176    /// * `list_len` - Function to get list length
177    /// * `sublist_remove` - Function to remove a range
178    /// * `sublist_insert` - Function to insert elements
179    /// * `variable_name` - Name of the list variable
180    /// * `descriptor_index` - Entity descriptor index
181    ///
182    /// # Panics
183    ///
184    /// Panics if cuts is empty or has more than 5 elements.
185    #[allow(clippy::too_many_arguments)]
186    pub fn new(
187        cuts: &[CutPoint],
188        reconnection: &'static KOptReconnection,
189        list_len: fn(&S, usize) -> usize,
190        sublist_remove: fn(&mut S, usize, usize, usize) -> Vec<V>,
191        sublist_insert: fn(&mut S, usize, usize, Vec<V>),
192        variable_name: &'static str,
193        descriptor_index: usize,
194    ) -> Self {
195        assert!(!cuts.is_empty() && cuts.len() <= 5, "k must be 1-5");
196
197        let mut cut_array = [CutPoint::default(); 5];
198        for (i, cut) in cuts.iter().enumerate() {
199            cut_array[i] = *cut;
200        }
201
202        // For now, assume intra-route (all cuts on same entity)
203        let entity_index = cuts[0].entity_index;
204
205        Self {
206            cuts: cut_array,
207            cut_count: cuts.len() as u8,
208            reconnection,
209            list_len,
210            sublist_remove,
211            sublist_insert,
212            variable_name,
213            descriptor_index,
214            entity_index,
215            _phantom: PhantomData,
216        }
217    }
218
219    /// Returns the k value (number of edges removed).
220    #[inline]
221    pub fn k(&self) -> usize {
222        self.cut_count as usize
223    }
224
225    /// Returns the cut points.
226    #[inline]
227    pub fn cuts(&self) -> &[CutPoint] {
228        &self.cuts[..self.cut_count as usize]
229    }
230
231    /// Returns true if this is an intra-route move (all cuts on same entity).
232    pub fn is_intra_route(&self) -> bool {
233        let first = self.cuts[0].entity_index;
234        self.cuts[..self.cut_count as usize]
235            .iter()
236            .all(|c| c.entity_index == first)
237    }
238}
239
240impl<S, V> Move<S> for KOptMove<S, V>
241where
242    S: PlanningSolution,
243    V: Clone + Send + Sync + Debug + 'static,
244{
245    fn is_doable(&self, score_director: &dyn ScoreDirector<S>) -> bool {
246        let solution = score_director.working_solution();
247        let k = self.cut_count as usize;
248
249        // Must have at least 2 cuts for meaningful k-opt
250        if k < 2 {
251            return false;
252        }
253
254        // Verify reconnection pattern matches k
255        if self.reconnection.k() != k {
256            return false;
257        }
258
259        // For intra-route, verify cuts are sorted and within bounds
260        let len = (self.list_len)(solution, self.entity_index);
261
262        // Check cuts are valid positions
263        for cut in &self.cuts[..k] {
264            if cut.position > len {
265                return false;
266            }
267        }
268
269        // For intra-route, cuts must be strictly increasing
270        if self.is_intra_route() {
271            for i in 1..k {
272                if self.cuts[i].position <= self.cuts[i - 1].position {
273                    return false;
274                }
275            }
276            // Need at least 1 element between cuts for meaningful segments
277            // Actually, empty segments are allowed in some cases
278        }
279
280        true
281    }
282
283    fn do_move(&self, score_director: &mut dyn ScoreDirector<S>) {
284        let k = self.cut_count as usize;
285        let entity = self.entity_index;
286
287        // Notify before change
288        score_director.before_variable_changed(self.descriptor_index, entity, self.variable_name);
289
290        // For intra-route k-opt, we need to:
291        // 1. Extract all segments
292        // 2. Reorder according to reconnection pattern
293        // 3. Reverse segments as needed
294        // 4. Rebuild the list
295
296        // Calculate segment boundaries (segments are between consecutive cuts)
297        // For k cuts at positions [p0, p1, ..., pk-1], we have k+1 segments:
298        // Segment 0: [0, p0)
299        // Segment 1: [p0, p1)
300        // ...
301        // Segment k: [pk-1, len)
302
303        let solution = score_director.working_solution_mut();
304        let len = (self.list_len)(solution, entity);
305
306        // Extract all elements
307        let all_elements = (self.sublist_remove)(solution, entity, 0, len);
308
309        // Build segment boundaries
310        let mut boundaries = Vec::with_capacity(k + 2);
311        boundaries.push(0);
312        for i in 0..k {
313            boundaries.push(self.cuts[i].position);
314        }
315        boundaries.push(len);
316
317        // Extract segments
318        let mut segments: Vec<Vec<V>> = Vec::with_capacity(k + 1);
319        for i in 0..=k {
320            let start = boundaries[i];
321            let end = boundaries[i + 1];
322            segments.push(all_elements[start..end].to_vec());
323        }
324
325        // Reorder and reverse according to reconnection pattern
326        let mut new_elements = Vec::with_capacity(len);
327        for pos in 0..self.reconnection.segment_count() {
328            let seg_idx = self.reconnection.segment_at(pos);
329            let mut seg = segments[seg_idx].clone();
330            if self.reconnection.should_reverse(seg_idx) {
331                seg.reverse();
332            }
333            new_elements.extend(seg);
334        }
335
336        // Insert reordered elements back
337        (self.sublist_insert)(
338            score_director.working_solution_mut(),
339            entity,
340            0,
341            new_elements.clone(),
342        );
343
344        // Notify after change
345        score_director.after_variable_changed(self.descriptor_index, entity, self.variable_name);
346
347        // Register undo - need to restore original order
348        let sublist_remove = self.sublist_remove;
349        let sublist_insert = self.sublist_insert;
350
351        score_director.register_undo(Box::new(move |s: &mut S| {
352            // Remove current elements
353            let current_len = new_elements.len();
354            let _ = sublist_remove(s, entity, 0, current_len);
355            // Insert original elements
356            sublist_insert(s, entity, 0, all_elements);
357        }));
358    }
359
360    fn descriptor_index(&self) -> usize {
361        self.descriptor_index
362    }
363
364    fn entity_indices(&self) -> &[usize] {
365        std::slice::from_ref(&self.entity_index)
366    }
367
368    fn variable_name(&self) -> &str {
369        self.variable_name
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use crate::heuristic::r#move::k_opt_reconnection::THREE_OPT_RECONNECTIONS;
377    use solverforge_core::domain::{EntityDescriptor, SolutionDescriptor, TypedEntityExtractor};
378    use solverforge_core::score::SimpleScore;
379    use solverforge_scoring::{RecordingScoreDirector, SimpleScoreDirector};
380    use std::any::TypeId;
381
382    #[derive(Clone, Debug)]
383    struct Tour {
384        cities: Vec<i32>,
385    }
386
387    #[derive(Clone, Debug)]
388    struct TspSolution {
389        tours: Vec<Tour>,
390        score: Option<SimpleScore>,
391    }
392
393    impl PlanningSolution for TspSolution {
394        type Score = SimpleScore;
395        fn score(&self) -> Option<Self::Score> {
396            self.score
397        }
398        fn set_score(&mut self, score: Option<Self::Score>) {
399            self.score = score;
400        }
401    }
402
403    fn get_tours(s: &TspSolution) -> &Vec<Tour> {
404        &s.tours
405    }
406    fn get_tours_mut(s: &mut TspSolution) -> &mut Vec<Tour> {
407        &mut s.tours
408    }
409
410    fn list_len(s: &TspSolution, entity_idx: usize) -> usize {
411        s.tours.get(entity_idx).map_or(0, |t| t.cities.len())
412    }
413    fn sublist_remove(
414        s: &mut TspSolution,
415        entity_idx: usize,
416        start: usize,
417        end: usize,
418    ) -> Vec<i32> {
419        s.tours
420            .get_mut(entity_idx)
421            .map(|t| t.cities.drain(start..end).collect())
422            .unwrap_or_default()
423    }
424    fn sublist_insert(s: &mut TspSolution, entity_idx: usize, pos: usize, items: Vec<i32>) {
425        if let Some(t) = s.tours.get_mut(entity_idx) {
426            for (i, item) in items.into_iter().enumerate() {
427                t.cities.insert(pos + i, item);
428            }
429        }
430    }
431
432    fn create_director(
433        tours: Vec<Tour>,
434    ) -> SimpleScoreDirector<TspSolution, impl Fn(&TspSolution) -> SimpleScore> {
435        let solution = TspSolution { tours, score: None };
436        let extractor = Box::new(TypedEntityExtractor::new(
437            "Tour",
438            "tours",
439            get_tours,
440            get_tours_mut,
441        ));
442        let entity_desc =
443            EntityDescriptor::new("Tour", TypeId::of::<Tour>(), "tours").with_extractor(extractor);
444        let descriptor = SolutionDescriptor::new("TspSolution", TypeId::of::<TspSolution>())
445            .with_entity(entity_desc);
446        SimpleScoreDirector::with_calculator(solution, descriptor, |_| SimpleScore::of(0))
447    }
448
449    #[test]
450    fn three_opt_swap_segments() {
451        // Tour: [1, 2, 3, 4, 5, 6, 7, 8]
452        // Cuts at positions 2, 4, 6 creates segments:
453        //   Segment 0: [1, 2]
454        //   Segment 1: [3, 4]
455        //   Segment 2: [5, 6]
456        //   Segment 3: [7, 8]
457        // Pattern 3 (swap B and C, no reversal): [A, C, B, D]
458        // Result: [1, 2, 5, 6, 3, 4, 7, 8]
459
460        let tours = vec![Tour {
461            cities: vec![1, 2, 3, 4, 5, 6, 7, 8],
462        }];
463        let mut director = create_director(tours);
464
465        let cuts = [
466            CutPoint::new(0, 2),
467            CutPoint::new(0, 4),
468            CutPoint::new(0, 6),
469        ];
470        let reconnection = &THREE_OPT_RECONNECTIONS[3]; // [0,2,1,3] no reversal
471
472        let m = KOptMove::<TspSolution, i32>::new(
473            &cuts,
474            reconnection,
475            list_len,
476            sublist_remove,
477            sublist_insert,
478            "cities",
479            0,
480        );
481
482        assert!(m.is_doable(&director));
483        assert_eq!(m.k(), 3);
484
485        {
486            let mut recording = RecordingScoreDirector::new(&mut director);
487            m.do_move(&mut recording);
488
489            let cities = &recording.working_solution().tours[0].cities;
490            assert_eq!(cities, &[1, 2, 5, 6, 3, 4, 7, 8]);
491
492            recording.undo_changes();
493        }
494
495        let cities = &director.working_solution().tours[0].cities;
496        assert_eq!(cities, &[1, 2, 3, 4, 5, 6, 7, 8]);
497    }
498
499    #[test]
500    fn three_opt_reverse_segment() {
501        // Tour: [1, 2, 3, 4, 5, 6]
502        // Cuts at 2, 4 (only using 2 cuts for simpler test, but with 3-opt pattern)
503        // Wait, 3-opt needs 3 cuts. Let me use proper 3 cuts.
504
505        // Tour: [1, 2, 3, 4, 5, 6, 7, 8]
506        // Cuts at 2, 4, 6
507        // Pattern 0 (reverse B only): segments [A, B', C, D]
508        // B = [3, 4], reversed = [4, 3]
509        // Result: [1, 2, 4, 3, 5, 6, 7, 8]
510
511        let tours = vec![Tour {
512            cities: vec![1, 2, 3, 4, 5, 6, 7, 8],
513        }];
514        let mut director = create_director(tours);
515
516        let cuts = [
517            CutPoint::new(0, 2),
518            CutPoint::new(0, 4),
519            CutPoint::new(0, 6),
520        ];
521        let reconnection = &THREE_OPT_RECONNECTIONS[0]; // Reverse B only
522
523        let m = KOptMove::<TspSolution, i32>::new(
524            &cuts,
525            reconnection,
526            list_len,
527            sublist_remove,
528            sublist_insert,
529            "cities",
530            0,
531        );
532
533        assert!(m.is_doable(&director));
534
535        {
536            let mut recording = RecordingScoreDirector::new(&mut director);
537            m.do_move(&mut recording);
538
539            let cities = &recording.working_solution().tours[0].cities;
540            assert_eq!(cities, &[1, 2, 4, 3, 5, 6, 7, 8]);
541
542            recording.undo_changes();
543        }
544
545        let cities = &director.working_solution().tours[0].cities;
546        assert_eq!(cities, &[1, 2, 3, 4, 5, 6, 7, 8]);
547    }
548
549    #[test]
550    fn invalid_cuts_not_doable() {
551        let tours = vec![Tour {
552            cities: vec![1, 2, 3],
553        }];
554        let director = create_director(tours);
555
556        // Cuts out of bounds
557        let cuts = [
558            CutPoint::new(0, 2),
559            CutPoint::new(0, 4),
560            CutPoint::new(0, 10), // Out of bounds
561        ];
562        let reconnection = &THREE_OPT_RECONNECTIONS[0];
563
564        let m = KOptMove::<TspSolution, i32>::new(
565            &cuts,
566            reconnection,
567            list_len,
568            sublist_remove,
569            sublist_insert,
570            "cities",
571            0,
572        );
573
574        assert!(!m.is_doable(&director));
575    }
576
577    #[test]
578    fn cuts_not_sorted_not_doable() {
579        let tours = vec![Tour {
580            cities: vec![1, 2, 3, 4, 5, 6, 7, 8],
581        }];
582        let director = create_director(tours);
583
584        // Cuts not in order
585        let cuts = [
586            CutPoint::new(0, 4),
587            CutPoint::new(0, 2), // Out of order
588            CutPoint::new(0, 6),
589        ];
590        let reconnection = &THREE_OPT_RECONNECTIONS[0];
591
592        let m = KOptMove::<TspSolution, i32>::new(
593            &cuts,
594            reconnection,
595            list_len,
596            sublist_remove,
597            sublist_insert,
598            "cities",
599            0,
600        );
601
602        assert!(!m.is_doable(&director));
603    }
604}