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