solverforge_solver/heuristic/move/
list_change.rs

1//! ListChangeMove - relocates an element within or between list variables.
2//!
3//! This move removes an element from one position and inserts it at another.
4//! Essential for vehicle routing and scheduling problems.
5//!
6//! # Zero-Erasure Design
7//!
8//! Uses typed function pointers for list operations. No `dyn Any`, no downcasting.
9
10use std::fmt::Debug;
11
12use solverforge_core::domain::PlanningSolution;
13use solverforge_scoring::ScoreDirector;
14
15use super::Move;
16
17/// A move that relocates an element from one list position to another.
18///
19/// Supports both intra-list moves (within same entity) and inter-list moves
20/// (between different entities). Uses typed function pointers for zero-erasure.
21///
22/// # Type Parameters
23/// * `S` - The planning solution type
24/// * `V` - The list element value type
25///
26/// # Example
27///
28/// ```
29/// use solverforge_solver::heuristic::r#move::ListChangeMove;
30/// use solverforge_core::domain::PlanningSolution;
31/// use solverforge_core::score::SimpleScore;
32///
33/// #[derive(Clone, Debug)]
34/// struct Vehicle { id: usize, visits: Vec<i32> }
35///
36/// #[derive(Clone, Debug)]
37/// struct Solution { vehicles: Vec<Vehicle>, score: Option<SimpleScore> }
38///
39/// impl PlanningSolution for Solution {
40///     type Score = SimpleScore;
41///     fn score(&self) -> Option<Self::Score> { self.score }
42///     fn set_score(&mut self, score: Option<Self::Score>) { self.score = score; }
43/// }
44///
45/// fn list_len(s: &Solution, entity_idx: usize) -> usize {
46///     s.vehicles.get(entity_idx).map_or(0, |v| v.visits.len())
47/// }
48/// fn list_remove(s: &mut Solution, entity_idx: usize, pos: usize) -> Option<i32> {
49///     s.vehicles.get_mut(entity_idx).map(|v| v.visits.remove(pos))
50/// }
51/// fn list_insert(s: &mut Solution, entity_idx: usize, pos: usize, val: i32) {
52///     if let Some(v) = s.vehicles.get_mut(entity_idx) { v.visits.insert(pos, val); }
53/// }
54///
55/// // Move element from vehicle 0 position 2 to vehicle 1 position 0
56/// let m = ListChangeMove::<Solution, i32>::new(
57///     0, 2, 1, 0,
58///     list_len, list_remove, list_insert,
59///     "visits", 0,
60/// );
61/// ```
62pub struct ListChangeMove<S, V> {
63    /// Source entity index (which entity's list to remove from)
64    source_entity_index: usize,
65    /// Position in source list to remove from
66    source_position: usize,
67    /// Destination entity index (which entity's list to insert into)
68    dest_entity_index: usize,
69    /// Position in destination list to insert at
70    dest_position: usize,
71    /// Get list length for an entity
72    list_len: fn(&S, usize) -> usize,
73    /// Remove element at position, returns removed value
74    list_remove: fn(&mut S, usize, usize) -> Option<V>,
75    /// Insert element at position
76    list_insert: fn(&mut S, usize, usize, V),
77    variable_name: &'static str,
78    descriptor_index: usize,
79    /// Store indices for entity_indices()
80    indices: [usize; 2],
81}
82
83impl<S, V> Clone for ListChangeMove<S, V> {
84    fn clone(&self) -> Self {
85        *self
86    }
87}
88
89impl<S, V> Copy for ListChangeMove<S, V> {}
90
91impl<S, V: Debug> Debug for ListChangeMove<S, V> {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.debug_struct("ListChangeMove")
94            .field("source_entity", &self.source_entity_index)
95            .field("source_position", &self.source_position)
96            .field("dest_entity", &self.dest_entity_index)
97            .field("dest_position", &self.dest_position)
98            .field("variable_name", &self.variable_name)
99            .finish()
100    }
101}
102
103impl<S, V> ListChangeMove<S, V> {
104    /// Creates a new list change move with typed function pointers.
105    ///
106    /// # Arguments
107    /// * `source_entity_index` - Entity index to remove from
108    /// * `source_position` - Position in source list
109    /// * `dest_entity_index` - Entity index to insert into
110    /// * `dest_position` - Position in destination list
111    /// * `list_len` - Function to get list length
112    /// * `list_remove` - Function to remove element at position
113    /// * `list_insert` - Function to insert element at position
114    /// * `variable_name` - Name of the list variable
115    /// * `descriptor_index` - Entity descriptor index
116    #[allow(clippy::too_many_arguments)]
117    pub fn new(
118        source_entity_index: usize,
119        source_position: usize,
120        dest_entity_index: usize,
121        dest_position: usize,
122        list_len: fn(&S, usize) -> usize,
123        list_remove: fn(&mut S, usize, usize) -> Option<V>,
124        list_insert: fn(&mut S, usize, usize, V),
125        variable_name: &'static str,
126        descriptor_index: usize,
127    ) -> Self {
128        Self {
129            source_entity_index,
130            source_position,
131            dest_entity_index,
132            dest_position,
133            list_len,
134            list_remove,
135            list_insert,
136            variable_name,
137            descriptor_index,
138            indices: [source_entity_index, dest_entity_index],
139        }
140    }
141
142    /// Returns the source entity index.
143    pub fn source_entity_index(&self) -> usize {
144        self.source_entity_index
145    }
146
147    /// Returns the source position.
148    pub fn source_position(&self) -> usize {
149        self.source_position
150    }
151
152    /// Returns the destination entity index.
153    pub fn dest_entity_index(&self) -> usize {
154        self.dest_entity_index
155    }
156
157    /// Returns the destination position.
158    pub fn dest_position(&self) -> usize {
159        self.dest_position
160    }
161
162    /// Returns true if this is an intra-list move (same entity).
163    pub fn is_intra_list(&self) -> bool {
164        self.source_entity_index == self.dest_entity_index
165    }
166}
167
168impl<S, V> Move<S> for ListChangeMove<S, V>
169where
170    S: PlanningSolution,
171    V: Clone + PartialEq + Send + Sync + Debug + 'static,
172{
173    fn is_doable<D: ScoreDirector<S>>(&self, score_director: &D) -> bool {
174        let solution = score_director.working_solution();
175
176        // Check source position is valid
177        let source_len = (self.list_len)(solution, self.source_entity_index);
178        if self.source_position >= source_len {
179            return false;
180        }
181
182        // Check destination position is valid
183        // For intra-list, dest can be 0..=len-1 (after removal)
184        // For inter-list, dest can be 0..=len
185        let dest_len = (self.list_len)(solution, self.dest_entity_index);
186        let max_dest = if self.is_intra_list() {
187            source_len - 1 // After removal, list is shorter
188        } else {
189            dest_len
190        };
191
192        if self.dest_position > max_dest {
193            return false;
194        }
195
196        // For intra-list moves, check for no-ops
197        // Moving to same position is obviously a no-op
198        // Moving forward by 1 position is also a no-op due to index adjustment:
199        //   remove at source, adjusted_dest = dest-1 = source, insert at source → same list
200        if self.is_intra_list() {
201            if self.source_position == self.dest_position {
202                return false;
203            }
204            // Forward move by exactly 1 is a no-op
205            if self.dest_position == self.source_position + 1 {
206                return false;
207            }
208        }
209
210        true
211    }
212
213    fn do_move<D: ScoreDirector<S>>(&self, score_director: &mut D) {
214        // Notify before changes
215        score_director.before_variable_changed(
216            self.descriptor_index,
217            self.source_entity_index,
218            self.variable_name,
219        );
220        if !self.is_intra_list() {
221            score_director.before_variable_changed(
222                self.descriptor_index,
223                self.dest_entity_index,
224                self.variable_name,
225            );
226        }
227
228        // Remove from source
229        let value = (self.list_remove)(
230            score_director.working_solution_mut(),
231            self.source_entity_index,
232            self.source_position,
233        )
234        .expect("source position should be valid");
235
236        // For intra-list moves, adjust dest position if it was after source
237        let adjusted_dest = if self.is_intra_list() && self.dest_position > self.source_position {
238            self.dest_position - 1
239        } else {
240            self.dest_position
241        };
242
243        // Insert at destination
244        (self.list_insert)(
245            score_director.working_solution_mut(),
246            self.dest_entity_index,
247            adjusted_dest,
248            value.clone(),
249        );
250
251        // Notify after changes
252        score_director.after_variable_changed(
253            self.descriptor_index,
254            self.source_entity_index,
255            self.variable_name,
256        );
257        if !self.is_intra_list() {
258            score_director.after_variable_changed(
259                self.descriptor_index,
260                self.dest_entity_index,
261                self.variable_name,
262            );
263        }
264
265        // Register undo - reverse the operation
266        let list_remove = self.list_remove;
267        let list_insert = self.list_insert;
268        let src_entity = self.source_entity_index;
269        let src_pos = self.source_position;
270        let dest_entity = self.dest_entity_index;
271
272        score_director.register_undo(Box::new(move |s: &mut S| {
273            // Remove from where we inserted
274            let removed = list_remove(s, dest_entity, adjusted_dest).unwrap();
275            // Insert back at original source position
276            list_insert(s, src_entity, src_pos, removed);
277        }));
278    }
279
280    fn descriptor_index(&self) -> usize {
281        self.descriptor_index
282    }
283
284    fn entity_indices(&self) -> &[usize] {
285        if self.is_intra_list() {
286            &self.indices[0..1]
287        } else {
288            &self.indices
289        }
290    }
291
292    fn variable_name(&self) -> &str {
293        self.variable_name
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use solverforge_core::domain::{EntityDescriptor, SolutionDescriptor, TypedEntityExtractor};
301    use solverforge_core::score::SimpleScore;
302    use solverforge_scoring::{RecordingScoreDirector, SimpleScoreDirector};
303    use std::any::TypeId;
304
305    #[derive(Clone, Debug)]
306    struct Vehicle {
307        visits: Vec<i32>,
308    }
309
310    #[derive(Clone, Debug)]
311    struct RoutingSolution {
312        vehicles: Vec<Vehicle>,
313        score: Option<SimpleScore>,
314    }
315
316    impl PlanningSolution for RoutingSolution {
317        type Score = SimpleScore;
318        fn score(&self) -> Option<Self::Score> {
319            self.score
320        }
321        fn set_score(&mut self, score: Option<Self::Score>) {
322            self.score = score;
323        }
324    }
325
326    fn get_vehicles(s: &RoutingSolution) -> &Vec<Vehicle> {
327        &s.vehicles
328    }
329    fn get_vehicles_mut(s: &mut RoutingSolution) -> &mut Vec<Vehicle> {
330        &mut s.vehicles
331    }
332
333    fn list_len(s: &RoutingSolution, entity_idx: usize) -> usize {
334        s.vehicles.get(entity_idx).map_or(0, |v| v.visits.len())
335    }
336    fn list_remove(s: &mut RoutingSolution, entity_idx: usize, pos: usize) -> Option<i32> {
337        s.vehicles.get_mut(entity_idx).map(|v| v.visits.remove(pos))
338    }
339    fn list_insert(s: &mut RoutingSolution, entity_idx: usize, pos: usize, val: i32) {
340        if let Some(v) = s.vehicles.get_mut(entity_idx) {
341            v.visits.insert(pos, val);
342        }
343    }
344
345    fn create_director(
346        vehicles: Vec<Vehicle>,
347    ) -> SimpleScoreDirector<RoutingSolution, impl Fn(&RoutingSolution) -> SimpleScore> {
348        let solution = RoutingSolution {
349            vehicles,
350            score: None,
351        };
352        let extractor = Box::new(TypedEntityExtractor::new(
353            "Vehicle",
354            "vehicles",
355            get_vehicles,
356            get_vehicles_mut,
357        ));
358        let entity_desc = EntityDescriptor::new("Vehicle", TypeId::of::<Vehicle>(), "vehicles")
359            .with_extractor(extractor);
360        let descriptor =
361            SolutionDescriptor::new("RoutingSolution", TypeId::of::<RoutingSolution>())
362                .with_entity(entity_desc);
363        SimpleScoreDirector::with_calculator(solution, descriptor, |_| SimpleScore::of(0))
364    }
365
366    #[test]
367    fn intra_list_move_forward() {
368        let vehicles = vec![Vehicle {
369            visits: vec![1, 2, 3, 4, 5],
370        }];
371        let mut director = create_director(vehicles);
372
373        // Move element at position 1 (value=2) to position 3
374        let m = ListChangeMove::<RoutingSolution, i32>::new(
375            0,
376            1,
377            0,
378            3,
379            list_len,
380            list_remove,
381            list_insert,
382            "visits",
383            0,
384        );
385
386        assert!(m.is_doable(&director));
387
388        {
389            let mut recording = RecordingScoreDirector::new(&mut director);
390            m.do_move(&mut recording);
391
392            // After: [1, 3, 2, 4, 5] (2 moved to after 3)
393            let visits = &recording.working_solution().vehicles[0].visits;
394            assert_eq!(visits, &[1, 3, 2, 4, 5]);
395
396            recording.undo_changes();
397        }
398
399        // Verify restored
400        let visits = &director.working_solution().vehicles[0].visits;
401        assert_eq!(visits, &[1, 2, 3, 4, 5]);
402    }
403
404    #[test]
405    fn intra_list_move_backward() {
406        let vehicles = vec![Vehicle {
407            visits: vec![1, 2, 3, 4, 5],
408        }];
409        let mut director = create_director(vehicles);
410
411        // Move element at position 3 (value=4) to position 1
412        let m = ListChangeMove::<RoutingSolution, i32>::new(
413            0,
414            3,
415            0,
416            1,
417            list_len,
418            list_remove,
419            list_insert,
420            "visits",
421            0,
422        );
423
424        assert!(m.is_doable(&director));
425
426        {
427            let mut recording = RecordingScoreDirector::new(&mut director);
428            m.do_move(&mut recording);
429
430            // After: [1, 4, 2, 3, 5]
431            let visits = &recording.working_solution().vehicles[0].visits;
432            assert_eq!(visits, &[1, 4, 2, 3, 5]);
433
434            recording.undo_changes();
435        }
436
437        let visits = &director.working_solution().vehicles[0].visits;
438        assert_eq!(visits, &[1, 2, 3, 4, 5]);
439    }
440
441    #[test]
442    fn inter_list_move() {
443        let vehicles = vec![
444            Vehicle {
445                visits: vec![1, 2, 3],
446            },
447            Vehicle {
448                visits: vec![10, 20],
449            },
450        ];
451        let mut director = create_director(vehicles);
452
453        // Move element from vehicle 0 position 1 (value=2) to vehicle 1 position 1
454        let m = ListChangeMove::<RoutingSolution, i32>::new(
455            0,
456            1,
457            1,
458            1,
459            list_len,
460            list_remove,
461            list_insert,
462            "visits",
463            0,
464        );
465
466        assert!(m.is_doable(&director));
467
468        {
469            let mut recording = RecordingScoreDirector::new(&mut director);
470            m.do_move(&mut recording);
471
472            let sol = recording.working_solution();
473            assert_eq!(sol.vehicles[0].visits, vec![1, 3]);
474            assert_eq!(sol.vehicles[1].visits, vec![10, 2, 20]);
475
476            recording.undo_changes();
477        }
478
479        let sol = director.working_solution();
480        assert_eq!(sol.vehicles[0].visits, vec![1, 2, 3]);
481        assert_eq!(sol.vehicles[1].visits, vec![10, 20]);
482    }
483
484    #[test]
485    fn same_position_not_doable() {
486        let vehicles = vec![Vehicle {
487            visits: vec![1, 2, 3],
488        }];
489        let director = create_director(vehicles);
490
491        // Move to same effective position
492        let m = ListChangeMove::<RoutingSolution, i32>::new(
493            0,
494            1,
495            0,
496            1,
497            list_len,
498            list_remove,
499            list_insert,
500            "visits",
501            0,
502        );
503
504        assert!(!m.is_doable(&director));
505    }
506
507    #[test]
508    fn invalid_source_position_not_doable() {
509        let vehicles = vec![Vehicle {
510            visits: vec![1, 2, 3],
511        }];
512        let director = create_director(vehicles);
513
514        let m = ListChangeMove::<RoutingSolution, i32>::new(
515            0,
516            10,
517            0,
518            0,
519            list_len,
520            list_remove,
521            list_insert,
522            "visits",
523            0,
524        );
525
526        assert!(!m.is_doable(&director));
527    }
528}