1const PROGRESS_INTERVAL: Duration = Duration::from_secs(1);
2
3#[derive(Debug, Clone, Copy)]
4pub(crate) struct ProgressTick {
5 pub elapsed: Duration,
6 pub step_delta: u64,
7 pub move_delta: u64,
8}
9
10#[derive(Debug, Clone, Copy)]
11struct ProgressPulse {
12 phase_index: usize,
13 phase_type: &'static str,
14 initial_report_pending: bool,
15 next_deadline: Instant,
16 last_reported_at: Instant,
17 last_step_count: u64,
18 last_move_count: u64,
19}
20
21impl ProgressPulse {
22 fn new(
23 now: Instant,
24 phase_index: usize,
25 phase_type: &'static str,
26 step_count: u64,
27 move_count: u64,
28 ) -> Self {
29 Self {
30 phase_index,
31 phase_type,
32 initial_report_pending: true,
33 next_deadline: now + PROGRESS_INTERVAL,
34 last_reported_at: now,
35 last_step_count: step_count,
36 last_move_count: move_count,
37 }
38 }
39
40 fn take_due(
41 &mut self,
42 now: Instant,
43 phase_index: usize,
44 phase_type: &'static str,
45 step_count: u64,
46 move_count: u64,
47 ) -> Option<ProgressTick> {
48 if self.phase_index != phase_index || self.phase_type != phase_type {
49 *self = Self::new(now, phase_index, phase_type, step_count, move_count);
50 return None;
51 }
52 let has_new_work = step_count > self.last_step_count || move_count > self.last_move_count;
53 let is_initial_report = self.initial_report_pending && has_new_work;
54 if !is_initial_report && now < self.next_deadline {
55 return None;
56 }
57 let tick = ProgressTick {
58 elapsed: now.duration_since(self.last_reported_at),
59 step_delta: step_count.saturating_sub(self.last_step_count),
60 move_delta: move_count.saturating_sub(self.last_move_count),
61 };
62 self.last_reported_at = now;
63 self.last_step_count = step_count;
64 self.last_move_count = move_count;
65 if is_initial_report {
66 self.initial_report_pending = false;
67 self.next_deadline = now + PROGRESS_INTERVAL;
68 } else {
69 while self.next_deadline <= now {
70 self.next_deadline += PROGRESS_INTERVAL;
71 }
72 }
73 Some(tick)
74 }
75}
76
77pub struct SolverScope<'t, S: PlanningSolution, D: Director<S>, ProgressCb = ()> {
78 score_director: D,
79 best_solution: Option<S>,
80 current_score: Option<S::Score>,
81 best_score: Option<S::Score>,
82 rng: StdRng,
83 total_step_count: u64,
84 terminate: Option<&'t AtomicBool>,
85 runtime: Option<SolverRuntime<S>>,
86 publication: Publication,
87 best_solution_publication_enabled: bool,
88 yielded_to_parent: bool,
89 environment_mode: EnvironmentMode,
90 stats: SolverStats,
91 time_limit: Option<Duration>,
92 time_deadline: Option<Instant>,
93 progress_callback: ProgressCb,
94 progress_pulse: Option<ProgressPulse>,
95 terminal_reason: Option<SolverTerminalReason>,
96 last_best_elapsed: Option<Duration>,
97 best_solution_revision: Option<u64>,
98 solution_revision: u64,
99 working_solution_generation: u64,
104 construction_frontier: ConstructionFrontier,
105 phase_budget: Option<&'t PhaseBudget>,
106 pub inphase_step_count_limit: Option<u64>,
107 pub inphase_move_count_limit: Option<u64>,
108 pub inphase_score_calc_count_limit: Option<u64>,
109 inphase_best_score_limit: Option<S::Score>,
110 phase_termination: Option<ScopedPhaseTermination<S>>,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114enum Publication {
115 Enabled,
116 Disabled,
117}
118
119pub(crate) struct PhaseBudget {
120 step_count_limit: Option<u64>,
121 move_count_limit: Option<u64>,
122 score_calc_count_limit: Option<u64>,
123 step_count: AtomicU64,
124 moves_evaluated: AtomicU64,
125 score_calculations: AtomicU64,
126}
127
128impl PhaseBudget {
129 fn from_scope<S, D, ProgressCb>(scope: &SolverScope<'_, S, D, ProgressCb>) -> Self
130 where
131 S: PlanningSolution,
132 D: Director<S>,
133 ProgressCb: ProgressCallback<S>,
134 {
135 Self {
136 step_count_limit: remaining_limit(
137 scope.inphase_step_count_limit,
138 scope.total_step_count,
139 ),
140 move_count_limit: remaining_limit(
141 scope.inphase_move_count_limit,
142 scope.stats.moves_evaluated(),
143 ),
144 score_calc_count_limit: remaining_limit(
145 scope.inphase_score_calc_count_limit,
146 scope.stats.score_calculations(),
147 ),
148 step_count: AtomicU64::new(0),
149 moves_evaluated: AtomicU64::new(0),
150 score_calculations: AtomicU64::new(0),
151 }
152 }
153
154 fn has_limits(&self) -> bool {
155 self.step_count_limit.is_some()
156 || self.move_count_limit.is_some()
157 || self.score_calc_count_limit.is_some()
158 }
159
160 fn record_step(&self) {
161 self.step_count.fetch_add(1, Ordering::SeqCst);
162 }
163
164 fn record_evaluated_move(&self) {
165 self.moves_evaluated.fetch_add(1, Ordering::SeqCst);
166 }
167
168 fn record_score_calculation(&self) {
169 self.score_calculations.fetch_add(1, Ordering::SeqCst);
170 }
171
172 fn limit_reached(&self) -> bool {
173 limit_reached(self.step_count_limit, self.step_count.load(Ordering::SeqCst))
174 || limit_reached(
175 self.move_count_limit,
176 self.moves_evaluated.load(Ordering::SeqCst),
177 )
178 || limit_reached(
179 self.score_calc_count_limit,
180 self.score_calculations.load(Ordering::SeqCst),
181 )
182 }
183}
184
185#[derive(Clone, Copy)]
186struct ScopedPhaseTermination<S: PlanningSolution> {
187 start_step_count: u64,
188 start_elapsed: Duration,
189 time_limit: Option<Duration>,
190 step_count_limit: Option<u64>,
191 best_score_limit: Option<S::Score>,
192 unimproved_step_count_limit: Option<u64>,
193 unimproved_time_limit: Option<Duration>,
194 best_score: Option<S::Score>,
195 improvement_score: Option<S::Score>,
196 last_improvement_step_count: u64,
197 last_improvement_elapsed: Duration,
198}
199
200impl<S> ScopedPhaseTermination<S>
201where
202 S: PlanningSolution,
203{
204 fn from_config<D, ProgressCb>(
205 scope: &SolverScope<'_, S, D, ProgressCb>,
206 config: &TerminationConfig,
207 ) -> Option<Self>
208 where
209 D: Director<S>,
210 ProgressCb: ProgressCallback<S>,
211 S::Score: ParseableScore,
212 {
213 let best_score_limit = config
214 .best_score_limit
215 .as_deref()
216 .and_then(|score| S::Score::parse(score).ok());
217 let time_limit = config.time_limit();
218 let step_count_limit = config.step_count_limit;
219 let unimproved_step_count_limit = config.unimproved_step_count_limit;
220 let unimproved_time_limit = config.unimproved_time_limit();
221 if time_limit.is_none()
222 && step_count_limit.is_none()
223 && best_score_limit.is_none()
224 && unimproved_step_count_limit.is_none()
225 && unimproved_time_limit.is_none()
226 {
227 return None;
228 }
229 let elapsed = scope.elapsed().unwrap_or_default();
230 let best_score = match (scope.best_score, scope.current_score) {
231 (Some(best), Some(current)) => Some(best.max(current)),
232 (Some(best), None) | (None, Some(best)) => Some(best),
233 (None, None) => None,
234 };
235 Some(Self {
236 start_step_count: scope.total_step_count,
237 start_elapsed: elapsed,
238 time_limit,
239 step_count_limit,
240 best_score_limit,
241 unimproved_step_count_limit,
242 unimproved_time_limit,
243 best_score,
244 improvement_score: scope.current_score.or(scope.best_score),
245 last_improvement_step_count: scope.total_step_count,
246 last_improvement_elapsed: elapsed,
247 })
248 }
249
250 fn needs_clock(&self) -> bool {
251 self.time_limit.is_some() || self.unimproved_time_limit.is_some()
252 }
253
254 fn is_reached(
255 &self,
256 total_step_count: u64,
257 elapsed: Duration,
258 ) -> bool {
259 let phase_steps = total_step_count.saturating_sub(self.start_step_count);
260 let phase_elapsed = elapsed.saturating_sub(self.start_elapsed);
261 self.time_limit.is_some_and(|limit| phase_elapsed >= limit)
262 || self.step_count_limit.is_some_and(|limit| phase_steps >= limit)
263 || self
264 .best_score_limit
265 .is_some_and(|limit| self.best_score.is_some_and(|score| score >= limit))
266 || self.unimproved_step_count_limit.is_some_and(|limit| {
267 total_step_count.saturating_sub(self.last_improvement_step_count) >= limit
268 })
269 || self.unimproved_time_limit.is_some_and(|limit| {
270 elapsed.saturating_sub(self.last_improvement_elapsed) >= limit
271 })
272 }
273
274 fn record_improvement(&mut self, total_step_count: u64, elapsed: Duration) {
275 self.last_improvement_step_count = total_step_count;
276 self.last_improvement_elapsed = elapsed;
277 }
278
279 fn observe_score(
280 &mut self,
281 score: S::Score,
282 completed_step_count: u64,
283 elapsed: Duration,
284 ) {
285 if self.best_score.is_none_or(|best| score > best) {
286 self.best_score = Some(score);
287 }
288 if self.improvement_score.is_none_or(|best| score > best) {
289 self.improvement_score = Some(score);
290 self.record_improvement(completed_step_count, elapsed);
291 }
292 }
293
294 fn needs_score_observation(&self) -> bool {
295 self.best_score_limit.is_some()
296 || self.unimproved_step_count_limit.is_some()
297 || self.unimproved_time_limit.is_some()
298 }
299}
300
301fn remaining_limit(limit: Option<u64>, used: u64) -> Option<u64> {
302 limit.map(|limit| limit.saturating_sub(used))
303}
304
305fn limit_reached(limit: Option<u64>, used: u64) -> bool {
306 limit.is_some_and(|limit| used >= limit)
307}
308
309#[derive(Clone, Copy)]
310pub(crate) struct SolverScopeChildConfig<'t, S: PlanningSolution> {
311 terminate: Option<&'t AtomicBool>,
312 runtime: Option<SolverRuntime<S>>,
313 environment_mode: EnvironmentMode,
314 time_deadline: Option<Instant>,
315 phase_budget: Option<&'t PhaseBudget>,
316 inphase_step_count_limit: Option<u64>,
317 inphase_move_count_limit: Option<u64>,
318 inphase_score_calc_count_limit: Option<u64>,
319 inphase_best_score_limit: Option<S::Score>,
320}
321
322impl<'t, S: PlanningSolution> SolverScopeChildConfig<'t, S> {
323 pub(crate) fn build_scope<PD>(&self, score_director: PD, seed: u64) -> SolverScope<'t, S, PD>
324 where
325 PD: Director<S>,
326 {
327 let terminate = self
328 .terminate
329 .or_else(|| self.runtime.map(|runtime| runtime.cancel_flag()));
330 let mut scope = SolverScope::new(score_director)
331 .with_terminate(terminate)
332 .with_runtime(self.runtime)
333 .without_publication()
334 .with_environment_mode(self.environment_mode)
335 .with_seed(seed);
336 scope.time_deadline = self.time_deadline;
337 scope.phase_budget = self.phase_budget;
338 if self.phase_budget.is_none() {
339 scope.inphase_step_count_limit = self.inphase_step_count_limit;
340 scope.inphase_move_count_limit = self.inphase_move_count_limit;
341 scope.inphase_score_calc_count_limit = self.inphase_score_calc_count_limit;
342 }
343 scope.inphase_best_score_limit = self.inphase_best_score_limit;
344 scope
345 }
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq)]
349pub(crate) enum PendingControl {
350 Continue,
351 PauseRequested,
352 CancelRequested,
353 ConfigTerminationRequested,
354}
355
356impl<'t, S: PlanningSolution, D: Director<S>> SolverScope<'t, S, D, ()> {
357 pub fn new(score_director: D) -> Self {
358 let construction_frontier = ConstructionFrontier::new();
359 Self {
360 score_director,
361 best_solution: None,
362 current_score: None,
363 best_score: None,
364 rng: StdRng::from_rng(&mut rand::rng()),
365 total_step_count: 0,
366 terminate: None,
367 runtime: None,
368 publication: Publication::Enabled,
369 best_solution_publication_enabled: true,
370 yielded_to_parent: false,
371 environment_mode: EnvironmentMode::default(),
372 stats: SolverStats::default(),
373 time_limit: None,
374 time_deadline: None,
375 progress_callback: (),
376 progress_pulse: None,
377 terminal_reason: None,
378 last_best_elapsed: None,
379 best_solution_revision: None,
380 solution_revision: 1,
381 working_solution_generation: 0,
382 construction_frontier,
383 phase_budget: None,
384 inphase_step_count_limit: None,
385 inphase_move_count_limit: None,
386 inphase_score_calc_count_limit: None,
387 inphase_best_score_limit: None,
388 phase_termination: None,
389 }
390 }
391}
392
393impl<'t, S: PlanningSolution, D: Director<S>, ProgressCb: ProgressCallback<S>>
394 SolverScope<'t, S, D, ProgressCb>
395{
396 pub fn new_with_callback(
397 score_director: D,
398 callback: ProgressCb,
399 terminate: Option<&'t AtomicBool>,
400 runtime: Option<SolverRuntime<S>>,
401 ) -> Self {
402 let construction_frontier = ConstructionFrontier::new();
403 Self {
404 score_director,
405 best_solution: None,
406 current_score: None,
407 best_score: None,
408 rng: StdRng::from_rng(&mut rand::rng()),
409 total_step_count: 0,
410 terminate,
411 runtime,
412 publication: Publication::Enabled,
413 best_solution_publication_enabled: true,
414 yielded_to_parent: false,
415 environment_mode: EnvironmentMode::default(),
416 stats: SolverStats::default(),
417 time_limit: None,
418 time_deadline: None,
419 progress_callback: callback,
420 progress_pulse: None,
421 terminal_reason: None,
422 last_best_elapsed: None,
423 best_solution_revision: None,
424 solution_revision: 1,
425 working_solution_generation: 0,
426 construction_frontier,
427 phase_budget: None,
428 inphase_step_count_limit: None,
429 inphase_move_count_limit: None,
430 inphase_score_calc_count_limit: None,
431 inphase_best_score_limit: None,
432 phase_termination: None,
433 }
434 }
435
436 pub fn with_terminate(mut self, terminate: Option<&'t AtomicBool>) -> Self {
437 self.terminate = terminate;
438 self
439 }
440
441 pub fn with_runtime(mut self, runtime: Option<SolverRuntime<S>>) -> Self {
442 self.runtime = runtime;
443 self
444 }
445
446 pub(crate) fn without_publication(mut self) -> Self {
447 self.publication = Publication::Disabled;
448 self
449 }
450
451 pub(crate) fn defer_best_solution_publication(&mut self) {
452 self.best_solution_publication_enabled = false;
453 }
454
455 pub(crate) fn best_solution_publication_enabled(&self) -> bool {
456 self.best_solution_publication_enabled
457 }
458
459 pub(crate) fn yielded_to_parent(&self) -> bool {
460 self.yielded_to_parent
461 }
462
463 pub fn with_environment_mode(mut self, environment_mode: EnvironmentMode) -> Self {
464 self.environment_mode = environment_mode;
465 self
466 }
467
468 pub fn with_seed(mut self, seed: u64) -> Self {
469 self.rng = StdRng::seed_from_u64(seed);
470 self
471 }
472
473 pub(crate) fn child_phase_budget(&self) -> PhaseBudget {
474 PhaseBudget::from_scope(self)
475 }
476
477 pub(crate) fn child_config<'a>(
478 &'a self,
479 phase_budget: Option<&'a PhaseBudget>,
480 ) -> SolverScopeChildConfig<'a, S> {
481 let phase_budget = self
482 .phase_budget
483 .or_else(|| phase_budget.filter(|budget| budget.has_limits()));
484 SolverScopeChildConfig {
485 terminate: self.terminate,
486 runtime: self.runtime,
487 environment_mode: self.environment_mode,
488 time_deadline: self.child_time_deadline(),
489 phase_budget,
490 inphase_step_count_limit: self.inphase_step_count_limit,
491 inphase_move_count_limit: self.inphase_move_count_limit,
492 inphase_score_calc_count_limit: self.inphase_score_calc_count_limit,
493 inphase_best_score_limit: self.inphase_best_score_limit,
494 }
495 }
496
497 pub(crate) fn with_phase_termination<T>(
506 &mut self,
507 config: Option<&TerminationConfig>,
508 work: impl FnOnce(&mut Self) -> T,
509 ) -> T
510 where
511 S::Score: ParseableScore,
512 {
513 let previous = self.phase_termination.take();
514 self.phase_termination =
515 config.and_then(|config| ScopedPhaseTermination::from_config(self, config));
516 let result = work(self);
517 self.phase_termination = previous;
518 result
519 }
520
521 fn phase_termination_reached(&self) -> bool {
522 self.phase_termination.as_ref().is_some_and(|termination| {
523 let elapsed = if termination.needs_clock() {
527 self.elapsed().unwrap_or_default()
528 } else {
529 Duration::ZERO
530 };
531 termination.is_reached(self.total_step_count, elapsed)
532 })
533 }
534
535 pub(crate) fn phase_termination_requires_score_observation(&self) -> bool {
536 self.phase_termination
537 .as_ref()
538 .is_some_and(ScopedPhaseTermination::needs_score_observation)
539 }
540
541 fn observe_phase_score(&mut self, score: S::Score, completed_step_count: u64) {
542 let elapsed = self.elapsed().unwrap_or_default();
543 if let Some(termination) = &mut self.phase_termination {
544 termination.observe_score(score, completed_step_count, elapsed);
545 }
546 }
547
548 pub(crate) fn observe_phase_step_score(&mut self, score: S::Score) {
549 self.observe_phase_score(score, self.total_step_count.saturating_add(1));
550 }
551
552 fn child_time_deadline(&self) -> Option<Instant> {
553 self.time_deadline.or_else(|| {
554 self.time_limit.map(|limit| {
555 self.stats
556 .started_at()
557 .map(|start| start + limit)
558 .unwrap_or_else(|| Instant::now() + limit)
559 })
560 })
561 }
562
563 pub fn with_progress_callback<F: ProgressCallback<S>>(
564 self,
565 callback: F,
566 ) -> SolverScope<'t, S, D, F> {
567 SolverScope {
568 score_director: self.score_director,
569 best_solution: self.best_solution,
570 current_score: self.current_score,
571 best_score: self.best_score,
572 rng: self.rng,
573 total_step_count: self.total_step_count,
574 terminate: self.terminate,
575 runtime: self.runtime,
576 publication: self.publication,
577 best_solution_publication_enabled: self.best_solution_publication_enabled,
578 yielded_to_parent: self.yielded_to_parent,
579 environment_mode: self.environment_mode,
580 stats: self.stats,
581 time_limit: self.time_limit,
582 time_deadline: self.time_deadline,
583 progress_callback: callback,
584 progress_pulse: self.progress_pulse,
585 terminal_reason: self.terminal_reason,
586 last_best_elapsed: self.last_best_elapsed,
587 best_solution_revision: self.best_solution_revision,
588 solution_revision: self.solution_revision,
589 working_solution_generation: self.working_solution_generation,
590 construction_frontier: self.construction_frontier,
591 phase_budget: self.phase_budget,
592 inphase_step_count_limit: self.inphase_step_count_limit,
593 inphase_move_count_limit: self.inphase_move_count_limit,
594 inphase_score_calc_count_limit: self.inphase_score_calc_count_limit,
595 inphase_best_score_limit: self.inphase_best_score_limit,
596 phase_termination: self.phase_termination,
597 }
598 }
599
600 pub fn start_solving(&mut self) {
601 self.total_step_count = 0;
602 self.terminal_reason = None;
603 self.last_best_elapsed = None;
604 self.yielded_to_parent = false;
605 self.best_solution_revision = None;
606 self.solution_revision = 1;
607 self.progress_pulse = None;
608 self.construction_frontier.reset();
609 self.stats.start();
610 }
611
612 pub fn elapsed(&self) -> Option<Duration> {
613 self.stats.active_elapsed()
614 }
615
616 pub fn time_since_last_improvement(&self) -> Option<Duration> {
617 let elapsed = self.elapsed()?;
618 let last_best_elapsed = self.last_best_elapsed?;
619 Some(elapsed.saturating_sub(last_best_elapsed))
620 }
621
622 pub fn score_director(&self) -> &D {
623 &self.score_director
624 }
625
626 pub(crate) fn score_director_mut(&mut self) -> &mut D {
627 &mut self.score_director
628 }
629
630 pub fn working_solution(&self) -> &S {
631 self.score_director.working_solution()
632 }
633
634 pub fn mutate<T, F>(&mut self, mutate: F) -> T
635 where
636 F: FnOnce(&mut D) -> T,
637 {
638 self.committed_mutation(mutate)
639 }
640
641 pub fn calculate_score(&mut self) -> S::Score {
642 self.record_score_calculation();
643 let score = self.score_director.calculate_score();
644 self.current_score = Some(score);
645 self.assert_score_consistent("calculate_score", score);
646 score
647 }
648
649 pub(crate) fn assert_score_consistent(&self, context: &str, score: S::Score) {
650 if self.environment_mode != EnvironmentMode::FullAssert {
651 return;
652 }
653 let Some(fresh_score) = self.score_director.fresh_score() else {
654 return;
655 };
656 assert_eq!(
657 score, fresh_score,
658 "score director drift after {context}: cached score {score:?} != fresh score {fresh_score:?}"
659 );
660 }
661
662 pub fn initialize_working_solution_as_best(&mut self) -> S::Score {
663 if self.stats.started_at().is_none() {
664 self.start_solving();
665 }
666 let score = self.calculate_score();
667 let solution = self.score_director.clone_working_solution();
668 self.set_best_solution(solution, score);
669 score
670 }
671
672 pub fn replace_working_solution_and_reinitialize(&mut self, solution: S) -> S::Score {
673 self.working_solution_generation += 1;
674 *self.score_director.working_solution_mut() = solution;
675 self.score_director.reset();
676 self.current_score = None;
677 self.best_solution_revision = None;
678 self.solution_revision = 1;
679 self.construction_frontier.reset();
680 self.calculate_score()
681 }
682
683 pub fn best_solution(&self) -> Option<&S> {
684 self.best_solution.as_ref()
685 }
686
687 pub fn best_score(&self) -> Option<&S::Score> {
688 self.best_score.as_ref()
689 }
690
691 pub fn current_score(&self) -> Option<&S::Score> {
692 self.current_score.as_ref()
693 }
694
695 pub(crate) fn is_scalar_slot_completed(&self, slot_id: ConstructionSlotId) -> bool {
696 self.construction_frontier
697 .is_scalar_completed(slot_id, self.solution_revision)
698 }
699
700 pub(crate) fn mark_scalar_slot_completed(&mut self, slot_id: ConstructionSlotId) {
701 self.construction_frontier
702 .mark_scalar_completed(slot_id, self.solution_revision);
703 }
704
705 pub(crate) fn is_group_slot_completed(&self, slot_id: &ConstructionGroupSlotId) -> bool {
706 self.construction_frontier
707 .is_group_completed(slot_id, self.solution_revision)
708 }
709
710 pub(crate) fn mark_group_slot_completed(&mut self, slot_id: ConstructionGroupSlotId) {
711 self.construction_frontier
712 .mark_group_completed(slot_id, self.solution_revision);
713 }
714
715 pub(crate) fn is_list_element_completed(&self, element_id: ConstructionListElementId) -> bool {
716 self.construction_frontier
717 .is_list_completed(element_id, self.solution_revision)
718 }
719
720 pub(crate) fn mark_list_element_completed(&mut self, element_id: ConstructionListElementId) {
721 self.construction_frontier
722 .mark_list_completed(element_id, self.solution_revision);
723 }
724
725 #[cfg(test)]
726 pub(crate) fn solution_revision(&self) -> u64 {
727 self.solution_revision
728 }
729
730 pub(crate) fn working_solution_generation(&self) -> u64 {
731 self.working_solution_generation
732 }
733
734 pub(crate) fn apply_committed_move<M>(&mut self, mov: &M)
735 where
736 M: Move<S>,
737 {
738 self.committed_mutation(|score_director| mov.do_move(score_director));
739 }
740
741 pub(crate) fn apply_committed_change<F>(&mut self, change: F)
742 where
743 F: FnOnce(&mut D),
744 {
745 self.committed_mutation(change);
746 }
747
748}