Skip to main content

solverforge_solver/heuristic/move/
runtime_compound.rs

1//! Owned compound scalar moves emitted by the one runtime-provider kernel.
2//!
3//! Unlike the older typed `CompoundScalarMove`, this payload owns the callback
4//! reason and carries the shared static/dynamic scalar access protocol. It
5//! never converts a host label into a leaked `&'static str`.
6
7use std::fmt::{self, Debug};
8
9#[cfg(test)]
10use std::cell::Cell;
11
12use smallvec::{smallvec, SmallVec};
13use solverforge_core::domain::PlanningSolution;
14use solverforge_scoring::Director;
15
16use crate::builder::context::ProviderReasonId;
17use crate::builder::RuntimeScalarEdit;
18use crate::stats::{CandidateTraceCoordinate, CandidateTraceIdentity};
19
20use super::metadata::{encode_option_debug, encode_usize, hash_str, MoveTabuScope};
21use super::{Move, MoveAffectedEntity, MoveTabuSignature};
22
23#[cfg(test)]
24thread_local! {
25    static CLONE_COUNT: Cell<usize> = const { Cell::new(0) };
26}
27
28#[cfg(test)]
29pub(crate) fn reset_runtime_compound_move_clone_count() {
30    CLONE_COUNT.with(|count| count.set(0));
31}
32
33#[cfg(test)]
34pub(crate) fn runtime_compound_move_clone_count() -> usize {
35    CLONE_COUNT.with(Cell::get)
36}
37
38/// Stable provider-family discriminator. The value is part of the exact tabu
39/// identity, so identical edits emitted by distinct provider kinds cannot
40/// accidentally share tabu memory.
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
42#[repr(u8)]
43pub enum RuntimeCompoundMoveKind {
44    Grouped = 1,
45    ConflictRepair = 2,
46    CompoundConflictRepair = 3,
47}
48
49impl RuntimeCompoundMoveKind {
50    pub const fn telemetry_label(self) -> &'static str {
51        match self {
52            Self::Grouped => "runtime_scalar_grouped",
53            Self::ConflictRepair => "runtime_scalar_conflict_repair",
54            Self::CompoundConflictRepair => "runtime_scalar_compound_conflict_repair",
55        }
56    }
57
58    const fn trace_operation(self) -> &'static str {
59        match self {
60            Self::Grouped => "runtime_provider_grouped",
61            Self::ConflictRepair => "runtime_provider_conflict_repair",
62            Self::CompoundConflictRepair => "runtime_provider_compound_conflict_repair",
63        }
64    }
65}
66
67/// A selected runtime-provider result. The callback reason is a compact
68/// per-run arena ID. It deliberately never participates in tabu or
69/// candidate-trace identity, and candidate moves never clone/refcount labels.
70pub struct RuntimeCompoundMove<S> {
71    kind: RuntimeCompoundMoveKind,
72    reason: ProviderReasonId,
73    edits: Vec<RuntimeScalarEdit<S>>,
74    entity_indices: Vec<usize>,
75    descriptor_index: usize,
76    require_hard_improvement: bool,
77}
78
79impl<S> Clone for RuntimeCompoundMove<S> {
80    fn clone(&self) -> Self {
81        #[cfg(test)]
82        CLONE_COUNT.with(|count| count.set(count.get().saturating_add(1)));
83        Self {
84            kind: self.kind,
85            reason: self.reason,
86            edits: self.edits.clone(),
87            entity_indices: self.entity_indices.clone(),
88            descriptor_index: self.descriptor_index,
89            require_hard_improvement: self.require_hard_improvement,
90        }
91    }
92}
93
94impl<S> RuntimeCompoundMove<S> {
95    pub(crate) fn new(
96        kind: RuntimeCompoundMoveKind,
97        reason: ProviderReasonId,
98        edits: Vec<RuntimeScalarEdit<S>>,
99        require_hard_improvement: bool,
100    ) -> Self {
101        let descriptor_index = edits
102            .first()
103            .map(RuntimeScalarEdit::descriptor_index)
104            .unwrap_or(usize::MAX);
105        let mut entity_indices = edits
106            .iter()
107            .map(|edit| edit.entity_index)
108            .collect::<Vec<_>>();
109        entity_indices.sort_unstable();
110        entity_indices.dedup();
111        Self {
112            kind,
113            reason,
114            edits,
115            entity_indices,
116            descriptor_index,
117            require_hard_improvement,
118        }
119    }
120
121    pub(crate) fn is_doable_on(&self, solution: &S) -> bool {
122        if self.edits.is_empty() || has_duplicate_targets(&self.edits) {
123            return false;
124        }
125        let mut changes_value = false;
126        for edit in &self.edits {
127            if edit.entity_index >= edit.slot.entity_count(solution)
128                || !edit
129                    .slot
130                    .value_is_legal(solution, edit.entity_index, edit.to_value)
131            {
132                return false;
133            }
134            changes_value |= edit.slot.current_value(solution, edit.entity_index) != edit.to_value;
135        }
136        changes_value
137    }
138
139    #[cfg(test)]
140    pub(crate) fn reason_id(&self) -> ProviderReasonId {
141        self.reason
142    }
143}
144
145impl<S> Debug for RuntimeCompoundMove<S> {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        f.debug_struct("RuntimeCompoundMove")
148            .field("kind", &self.kind)
149            .field("reason_id", &self.reason)
150            .field("edits", &self.edits)
151            .field("require_hard_improvement", &self.require_hard_improvement)
152            .finish()
153    }
154}
155
156impl<S> Move<S> for RuntimeCompoundMove<S>
157where
158    S: PlanningSolution,
159{
160    type Undo = Vec<Option<usize>>;
161
162    fn is_doable<D: Director<S>>(&self, score_director: &D) -> bool {
163        self.is_doable_on(score_director.working_solution())
164    }
165
166    fn do_move<D: Director<S>>(&self, score_director: &mut D) -> Self::Undo {
167        let mut undo = Vec::with_capacity(self.edits.len());
168        let affected = unique_affected_entities(&self.edits);
169        for edit in &self.edits {
170            undo.push(
171                edit.slot
172                    .current_value(score_director.working_solution(), edit.entity_index),
173            );
174        }
175        for (descriptor_index, entity_index) in &affected {
176            score_director.before_variable_changed(*descriptor_index, *entity_index);
177        }
178        for edit in &self.edits {
179            edit.slot.set_value(
180                score_director.working_solution_mut(),
181                edit.entity_index,
182                edit.to_value,
183            );
184        }
185        for (descriptor_index, entity_index) in affected.iter().rev() {
186            score_director.after_variable_changed(*descriptor_index, *entity_index);
187        }
188        undo
189    }
190
191    fn undo_move<D: Director<S>>(&self, score_director: &mut D, undo: Self::Undo) {
192        let affected = unique_affected_entities(&self.edits);
193        for (descriptor_index, entity_index) in &affected {
194            score_director.before_variable_changed(*descriptor_index, *entity_index);
195        }
196        for (edit, old_value) in self.edits.iter().zip(undo) {
197            edit.slot.set_value(
198                score_director.working_solution_mut(),
199                edit.entity_index,
200                old_value,
201            );
202        }
203        for (descriptor_index, entity_index) in affected.iter().rev() {
204            score_director.after_variable_changed(*descriptor_index, *entity_index);
205        }
206    }
207
208    fn descriptor_index(&self) -> usize {
209        self.descriptor_index
210    }
211
212    fn entity_indices(&self) -> &[usize] {
213        &self.entity_indices
214    }
215
216    fn variable_name(&self) -> &str {
217        self.kind.telemetry_label()
218    }
219
220    fn telemetry_label(&self) -> &'static str {
221        self.kind.telemetry_label()
222    }
223
224    fn requires_hard_improvement(&self) -> bool {
225        self.require_hard_improvement
226    }
227
228    fn tabu_signature<D: Director<S>>(&self, score_director: &D) -> MoveTabuSignature {
229        let first = self
230            .edits
231            .first()
232            .expect("runtime compound move tabu signature requires an edit");
233        let scope = MoveTabuScope::new(first.descriptor_index(), first.variable_name());
234        let mut move_id = smallvec![0xDCA7_0000_0000_1000 ^ self.kind as u8 as u64];
235        let mut undo_move_id = move_id.clone();
236        let mut entity_tokens: SmallVec<[_; 8]> = SmallVec::new();
237        let mut destination_tokens: SmallVec<[_; 8]> = SmallVec::new();
238
239        for edit in &self.edits {
240            let current = edit
241                .slot
242                .current_value(score_director.working_solution(), edit.entity_index);
243            let descriptor = encode_usize(edit.descriptor_index());
244            let variable = hash_str(edit.variable_name());
245            let entity = encode_usize(edit.entity_index);
246            let from = encode_option_debug(current.as_ref());
247            let to = encode_option_debug(edit.to_value.as_ref());
248            let edit_scope = MoveTabuScope::new(edit.descriptor_index(), edit.variable_name());
249
250            move_id.extend([descriptor, variable, entity, from, to]);
251            undo_move_id.extend([descriptor, variable, entity, to, from]);
252            let entity_token = edit_scope.entity_token(entity);
253            if !entity_tokens.contains(&entity_token) {
254                entity_tokens.push(entity_token);
255            }
256            let destination_token = edit_scope.value_token(to);
257            if !destination_tokens.contains(&destination_token) {
258                destination_tokens.push(destination_token);
259            }
260        }
261
262        MoveTabuSignature::new(scope, move_id, undo_move_id)
263            .with_entity_tokens(entity_tokens)
264            .with_destination_value_tokens(destination_tokens)
265    }
266
267    fn candidate_trace_identity(&self) -> Option<CandidateTraceIdentity> {
268        Some(CandidateTraceIdentity::composite(
269            self.kind.trace_operation(),
270            self.edits.iter().map(|edit| {
271                CandidateTraceIdentity::logical_move(
272                    edit.descriptor_index(),
273                    edit.variable_name(),
274                    "scalar_change",
275                    vec![
276                        CandidateTraceCoordinate::from(edit.entity_index),
277                        CandidateTraceCoordinate::from(edit.to_value),
278                    ],
279                )
280            }),
281        ))
282    }
283
284    fn for_each_affected_entity(&self, visitor: &mut dyn FnMut(MoveAffectedEntity<'_>)) {
285        for edit in &self.edits {
286            visitor(MoveAffectedEntity {
287                descriptor_index: edit.descriptor_index(),
288                entity_index: edit.entity_index,
289                variable_name: edit.variable_name(),
290            });
291        }
292    }
293}
294
295fn has_duplicate_targets<S>(edits: &[RuntimeScalarEdit<S>]) -> bool {
296    let mut targets = Vec::with_capacity(edits.len());
297    edits.iter().any(|edit| {
298        let target = (
299            edit.descriptor_index(),
300            edit.variable_index(),
301            edit.entity_index,
302        );
303        if targets.contains(&target) {
304            true
305        } else {
306            targets.push(target);
307            false
308        }
309    })
310}
311
312fn unique_affected_entities<S>(edits: &[RuntimeScalarEdit<S>]) -> Vec<(usize, usize)> {
313    let mut affected = Vec::new();
314    for edit in edits {
315        let entity = (edit.descriptor_index(), edit.entity_index);
316        if !affected.contains(&entity) {
317            affected.push(entity);
318        }
319    }
320    affected
321}