Skip to main content

solverforge_solver/scope/solver/
scope_core.rs

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