solverforge_solver/heuristic/move/
composite.rs

1//! CompositeMove - applies two moves in sequence.
2//!
3//! Combines two typed moves into a single atomic move, enabling cartesian
4//! product move selection and multi-variable moves.
5//!
6//! # Zero-Erasure Design
7//!
8//! Both inner moves are stored as concrete types `M1` and `M2`. No trait objects,
9//! no boxing. The combined entity indices are stored inline in a SmallVec.
10//!
11//! # Example
12//!
13//! ```
14//! use solverforge_solver::heuristic::r#move::{CompositeMove, ChangeMove, Move};
15//! use solverforge_core::domain::PlanningSolution;
16//! use solverforge_core::score::SimpleScore;
17//! use solverforge_scoring::{ScoreDirector, SimpleScoreDirector};
18//! use solverforge_core::domain::SolutionDescriptor;
19//! use std::any::TypeId;
20//!
21//! #[derive(Clone, Debug)]
22//! struct Task { x: Option<i32>, y: Option<i32> }
23//!
24//! #[derive(Clone, Debug)]
25//! struct Sol { tasks: Vec<Task>, score: Option<SimpleScore> }
26//!
27//! impl PlanningSolution for Sol {
28//!     type Score = SimpleScore;
29//!     fn score(&self) -> Option<Self::Score> { self.score }
30//!     fn set_score(&mut self, score: Option<Self::Score>) { self.score = score; }
31//! }
32//!
33//! fn get_x(s: &Sol, i: usize) -> Option<i32> { s.tasks.get(i).and_then(|t| t.x) }
34//! fn set_x(s: &mut Sol, i: usize, v: Option<i32>) {
35//!     if let Some(t) = s.tasks.get_mut(i) { t.x = v; }
36//! }
37//! fn get_y(s: &Sol, i: usize) -> Option<i32> { s.tasks.get(i).and_then(|t| t.y) }
38//! fn set_y(s: &mut Sol, i: usize, v: Option<i32>) {
39//!     if let Some(t) = s.tasks.get_mut(i) { t.y = v; }
40//! }
41//!
42//! // Create two change moves for different variables
43//! let move_x = ChangeMove::new(0, Some(5), get_x, set_x, "x", 0);
44//! let move_y = ChangeMove::new(0, Some(10), get_y, set_y, "y", 0);
45//!
46//! // Combine into a composite move
47//! let composite = CompositeMove::new(move_x, move_y);
48//!
49//! // Create a score director for testing
50//! let solution = Sol { tasks: vec![Task { x: Some(1), y: Some(2) }], score: None };
51//! let descriptor = SolutionDescriptor::new("Sol", TypeId::of::<Sol>());
52//! let mut director = SimpleScoreDirector::with_calculator(
53//!     solution, descriptor, |_| SimpleScore::of(0)
54//! );
55//!
56//! // Composite is doable if both moves are doable
57//! assert!(composite.is_doable(&director));
58//!
59//! // Execute applies both moves in sequence
60//! composite.do_move(&mut director);
61//!
62//! // Both changes applied
63//! assert_eq!(get_x(director.working_solution(), 0), Some(5));
64//! assert_eq!(get_y(director.working_solution(), 0), Some(10));
65//! ```
66
67use std::fmt::Debug;
68
69use smallvec::SmallVec;
70use solverforge_core::domain::PlanningSolution;
71use solverforge_scoring::ScoreDirector;
72
73use super::Move;
74
75/// A move that applies two moves in sequence.
76///
77/// Combines typed moves `M1` and `M2` into a single atomic move.
78/// Execution order is: first, then second.
79///
80/// # Type Parameters
81/// * `S` - The planning solution type
82/// * `M1` - The first move type
83/// * `M2` - The second move type
84///
85/// # Zero-Erasure
86///
87/// Both moves are stored inline as concrete types. No `Box<dyn Move>`,
88/// no trait objects in the hot path.
89#[derive(Clone)]
90pub struct CompositeMove<S, M1, M2>
91where
92    S: PlanningSolution,
93    M1: Move<S>,
94    M2: Move<S>,
95{
96    first: M1,
97    second: M2,
98    /// Combined entity indices from both moves
99    combined_indices: SmallVec<[usize; 8]>,
100    _phantom: std::marker::PhantomData<S>,
101}
102
103impl<S, M1, M2> Debug for CompositeMove<S, M1, M2>
104where
105    S: PlanningSolution,
106    M1: Move<S>,
107    M2: Move<S>,
108{
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.debug_struct("CompositeMove")
111            .field("first", &self.first)
112            .field("second", &self.second)
113            .field("combined_indices", &self.combined_indices.as_slice())
114            .finish()
115    }
116}
117
118impl<S, M1, M2> CompositeMove<S, M1, M2>
119where
120    S: PlanningSolution,
121    M1: Move<S>,
122    M2: Move<S>,
123{
124    /// Creates a new composite move combining two moves.
125    ///
126    /// # Arguments
127    /// * `first` - The first move to apply
128    /// * `second` - The second move to apply
129    ///
130    /// The moves are executed in order: first, then second.
131    pub fn new(first: M1, second: M2) -> Self {
132        // Combine entity indices from both moves
133        let mut combined_indices = SmallVec::new();
134        combined_indices.extend_from_slice(first.entity_indices());
135        for idx in second.entity_indices() {
136            if !combined_indices.contains(idx) {
137                combined_indices.push(*idx);
138            }
139        }
140
141        Self {
142            first,
143            second,
144            combined_indices,
145            _phantom: std::marker::PhantomData,
146        }
147    }
148
149    /// Returns a reference to the first move.
150    pub fn first(&self) -> &M1 {
151        &self.first
152    }
153
154    /// Returns a reference to the second move.
155    pub fn second(&self) -> &M2 {
156        &self.second
157    }
158}
159
160impl<S, M1, M2> Move<S> for CompositeMove<S, M1, M2>
161where
162    S: PlanningSolution,
163    M1: Move<S>,
164    M2: Move<S>,
165{
166    fn is_doable(&self, score_director: &dyn ScoreDirector<S>) -> bool {
167        // Both moves must be doable
168        self.first.is_doable(score_director) && self.second.is_doable(score_director)
169    }
170
171    fn do_move(&self, score_director: &mut dyn ScoreDirector<S>) {
172        // Execute in sequence: first, then second
173        self.first.do_move(score_director);
174        self.second.do_move(score_director);
175    }
176
177    fn descriptor_index(&self) -> usize {
178        // Use first move's descriptor
179        self.first.descriptor_index()
180    }
181
182    fn entity_indices(&self) -> &[usize] {
183        &self.combined_indices
184    }
185
186    fn variable_name(&self) -> &str {
187        // Use first move's variable name
188        self.first.variable_name()
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::heuristic::r#move::ChangeMove;
196    use solverforge_core::domain::SolutionDescriptor;
197    use solverforge_core::score::SimpleScore;
198    use solverforge_scoring::{RecordingScoreDirector, SimpleScoreDirector};
199    use std::any::TypeId;
200
201    #[derive(Clone, Debug)]
202    struct Task {
203        x: Option<i32>,
204        y: Option<i32>,
205    }
206
207    #[derive(Clone, Debug)]
208    struct Sol {
209        tasks: Vec<Task>,
210        score: Option<SimpleScore>,
211    }
212
213    impl PlanningSolution for Sol {
214        type Score = SimpleScore;
215        fn score(&self) -> Option<Self::Score> {
216            self.score
217        }
218        fn set_score(&mut self, score: Option<Self::Score>) {
219            self.score = score;
220        }
221    }
222
223    fn get_x(s: &Sol, i: usize) -> Option<i32> {
224        s.tasks.get(i).and_then(|t| t.x)
225    }
226    fn set_x(s: &mut Sol, i: usize, v: Option<i32>) {
227        if let Some(t) = s.tasks.get_mut(i) {
228            t.x = v;
229        }
230    }
231    fn get_y(s: &Sol, i: usize) -> Option<i32> {
232        s.tasks.get(i).and_then(|t| t.y)
233    }
234    fn set_y(s: &mut Sol, i: usize, v: Option<i32>) {
235        if let Some(t) = s.tasks.get_mut(i) {
236            t.y = v;
237        }
238    }
239
240    fn create_director(tasks: Vec<Task>) -> SimpleScoreDirector<Sol, impl Fn(&Sol) -> SimpleScore> {
241        let solution = Sol { tasks, score: None };
242        let descriptor = SolutionDescriptor::new("Sol", TypeId::of::<Sol>());
243        SimpleScoreDirector::with_calculator(solution, descriptor, |_| SimpleScore::of(0))
244    }
245
246    #[test]
247    fn composite_applies_both_moves() {
248        let tasks = vec![Task {
249            x: Some(1),
250            y: Some(2),
251        }];
252        let mut director = create_director(tasks);
253
254        let move_x = ChangeMove::new(0, Some(5), get_x, set_x, "x", 0);
255        let move_y = ChangeMove::new(0, Some(10), get_y, set_y, "y", 0);
256        let composite = CompositeMove::new(move_x, move_y);
257
258        assert!(composite.is_doable(&director));
259        composite.do_move(&mut director);
260
261        assert_eq!(get_x(director.working_solution(), 0), Some(5));
262        assert_eq!(get_y(director.working_solution(), 0), Some(10));
263    }
264
265    #[test]
266    fn composite_undo_restores_both() {
267        let tasks = vec![Task {
268            x: Some(1),
269            y: Some(2),
270        }];
271        let mut director = create_director(tasks);
272
273        let move_x = ChangeMove::new(0, Some(5), get_x, set_x, "x", 0);
274        let move_y = ChangeMove::new(0, Some(10), get_y, set_y, "y", 0);
275        let composite = CompositeMove::new(move_x, move_y);
276
277        {
278            let mut recording = RecordingScoreDirector::new(&mut director);
279            composite.do_move(&mut recording);
280
281            assert_eq!(get_x(recording.working_solution(), 0), Some(5));
282            assert_eq!(get_y(recording.working_solution(), 0), Some(10));
283
284            recording.undo_changes();
285        }
286
287        // Both restored
288        assert_eq!(get_x(director.working_solution(), 0), Some(1));
289        assert_eq!(get_y(director.working_solution(), 0), Some(2));
290    }
291
292    #[test]
293    fn composite_not_doable_if_first_not_doable() {
294        let tasks = vec![Task {
295            x: Some(5),
296            y: Some(2),
297        }];
298        let director = create_director(tasks);
299
300        // First move: x is already 5, so not doable
301        let move_x = ChangeMove::new(0, Some(5), get_x, set_x, "x", 0);
302        let move_y = ChangeMove::new(0, Some(10), get_y, set_y, "y", 0);
303        let composite = CompositeMove::new(move_x, move_y);
304
305        assert!(!composite.is_doable(&director));
306    }
307
308    #[test]
309    fn composite_not_doable_if_second_not_doable() {
310        let tasks = vec![Task {
311            x: Some(1),
312            y: Some(10),
313        }];
314        let director = create_director(tasks);
315
316        let move_x = ChangeMove::new(0, Some(5), get_x, set_x, "x", 0);
317        // Second move: y is already 10, so not doable
318        let move_y = ChangeMove::new(0, Some(10), get_y, set_y, "y", 0);
319        let composite = CompositeMove::new(move_x, move_y);
320
321        assert!(!composite.is_doable(&director));
322    }
323
324    #[test]
325    fn composite_combines_entity_indices() {
326        // Different entities
327        let move_x = ChangeMove::new(0, Some(5), get_x, set_x, "x", 0);
328        let move_y = ChangeMove::new(1, Some(10), get_y, set_y, "y", 0);
329        let composite = CompositeMove::new(move_x, move_y);
330
331        assert_eq!(composite.entity_indices(), &[0, 1]);
332    }
333
334    #[test]
335    fn composite_deduplicates_entity_indices() {
336        // Same entity
337        let move_x = ChangeMove::new(0, Some(5), get_x, set_x, "x", 0);
338        let move_y = ChangeMove::new(0, Some(10), get_y, set_y, "y", 0);
339        let composite = CompositeMove::new(move_x, move_y);
340
341        // Should only have entity 0 once
342        assert_eq!(composite.entity_indices(), &[0]);
343    }
344
345    #[test]
346    fn composite_uses_first_move_descriptor() {
347        let move_x = ChangeMove::new(0, Some(5), get_x, set_x, "x", 1);
348        let move_y = ChangeMove::new(0, Some(10), get_y, set_y, "y", 2);
349        let composite = CompositeMove::new(move_x, move_y);
350
351        assert_eq!(composite.descriptor_index(), 1);
352        assert_eq!(composite.variable_name(), "x");
353    }
354}