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 needs_clock(&self) -> bool {
248        self.time_limit.is_some() || self.unimproved_time_limit.is_some()
249    }
250
251    fn is_reached(
252        &self,
253        total_step_count: u64,
254        elapsed: Duration,
255    ) -> bool {
256        let phase_steps = total_step_count.saturating_sub(self.start_step_count);
257        let phase_elapsed = elapsed.saturating_sub(self.start_elapsed);
258        self.time_limit.is_some_and(|limit| phase_elapsed >= limit)
259            || self.step_count_limit.is_some_and(|limit| phase_steps >= limit)
260            || self
261                .best_score_limit
262                .is_some_and(|limit| self.best_score.is_some_and(|score| score >= limit))
263            || self.unimproved_step_count_limit.is_some_and(|limit| {
264                total_step_count.saturating_sub(self.last_improvement_step_count) >= limit
265            })
266            || self.unimproved_time_limit.is_some_and(|limit| {
267                elapsed.saturating_sub(self.last_improvement_elapsed) >= limit
268            })
269    }
270
271    fn record_improvement(&mut self, total_step_count: u64, elapsed: Duration) {
272        self.last_improvement_step_count = total_step_count;
273        self.last_improvement_elapsed = elapsed;
274    }
275
276    fn observe_score(
277        &mut self,
278        score: S::Score,
279        completed_step_count: u64,
280        elapsed: Duration,
281    ) {
282        if self.best_score.is_none_or(|best| score > best) {
283            self.best_score = Some(score);
284        }
285        if self.improvement_score.is_none_or(|best| score > best) {
286            self.improvement_score = Some(score);
287            self.record_improvement(completed_step_count, elapsed);
288        }
289    }
290
291    fn needs_score_observation(&self) -> bool {
292        self.best_score_limit.is_some()
293            || self.unimproved_step_count_limit.is_some()
294            || self.unimproved_time_limit.is_some()
295    }
296}
297
298fn remaining_limit(limit: Option<u64>, used: u64) -> Option<u64> {
299    limit.map(|limit| limit.saturating_sub(used))
300}
301
302fn limit_reached(limit: Option<u64>, used: u64) -> bool {
303    limit.is_some_and(|limit| used >= limit)
304}
305
306#[derive(Clone, Copy)]
307pub(crate) struct SolverScopeChildConfig<'t, S: PlanningSolution> {
308    terminate: Option<&'t AtomicBool>,
309    runtime: Option<SolverRuntime<S>>,
310    environment_mode: EnvironmentMode,
311    time_deadline: Option<Instant>,
312    phase_budget: Option<&'t PhaseBudget>,
313    inphase_step_count_limit: Option<u64>,
314    inphase_move_count_limit: Option<u64>,
315    inphase_score_calc_count_limit: Option<u64>,
316    inphase_best_score_limit: Option<S::Score>,
317}
318
319impl<'t, S: PlanningSolution> SolverScopeChildConfig<'t, S> {
320    pub(crate) fn build_scope<PD>(&self, score_director: PD, seed: u64) -> SolverScope<'t, S, PD>
321    where
322        PD: Director<S>,
323    {
324        let terminate = self
325            .terminate
326            .or_else(|| self.runtime.map(|runtime| runtime.cancel_flag()));
327        let mut scope = SolverScope::new(score_director)
328            .with_terminate(terminate)
329            .with_runtime(self.runtime)
330            .without_publication()
331            .with_environment_mode(self.environment_mode)
332            .with_seed(seed);
333        scope.time_deadline = self.time_deadline;
334        scope.phase_budget = self.phase_budget;
335        if self.phase_budget.is_none() {
336            scope.inphase_step_count_limit = self.inphase_step_count_limit;
337            scope.inphase_move_count_limit = self.inphase_move_count_limit;
338            scope.inphase_score_calc_count_limit = self.inphase_score_calc_count_limit;
339        }
340        scope.inphase_best_score_limit = self.inphase_best_score_limit;
341        scope
342    }
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346pub(crate) enum PendingControl {
347    Continue,
348    PauseRequested,
349    CancelRequested,
350    ConfigTerminationRequested,
351}
352
353impl<'t, S: PlanningSolution, D: Director<S>> SolverScope<'t, S, D, ()> {
354    pub fn new(score_director: D) -> Self {
355        let construction_frontier = ConstructionFrontier::new();
356        Self {
357            score_director,
358            best_solution: None,
359            current_score: None,
360            best_score: None,
361            rng: StdRng::from_rng(&mut rand::rng()),
362            start_time: None,
363            paused_at: None,
364            total_step_count: 0,
365            terminate: None,
366            runtime: None,
367            publication: Publication::Enabled,
368            best_solution_publication_enabled: true,
369            yielded_to_parent: false,
370            environment_mode: EnvironmentMode::default(),
371            stats: SolverStats::default(),
372            time_limit: None,
373            time_deadline: None,
374            progress_callback: (),
375            progress_pulse: None,
376            terminal_reason: None,
377            last_best_elapsed: None,
378            best_solution_revision: None,
379            solution_revision: 1,
380            construction_frontier,
381            phase_budget: None,
382            inphase_step_count_limit: None,
383            inphase_move_count_limit: None,
384            inphase_score_calc_count_limit: None,
385            inphase_best_score_limit: None,
386            phase_termination: None,
387        }
388    }
389}
390
391impl<'t, S: PlanningSolution, D: Director<S>, ProgressCb: ProgressCallback<S>>
392    SolverScope<'t, S, D, ProgressCb>
393{
394    pub fn new_with_callback(
395        score_director: D,
396        callback: ProgressCb,
397        terminate: Option<&'t AtomicBool>,
398        runtime: Option<SolverRuntime<S>>,
399    ) -> Self {
400        let construction_frontier = ConstructionFrontier::new();
401        Self {
402            score_director,
403            best_solution: None,
404            current_score: None,
405            best_score: None,
406            rng: StdRng::from_rng(&mut rand::rng()),
407            start_time: None,
408            paused_at: None,
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            construction_frontier,
426            phase_budget: None,
427            inphase_step_count_limit: None,
428            inphase_move_count_limit: None,
429            inphase_score_calc_count_limit: None,
430            inphase_best_score_limit: None,
431            phase_termination: None,
432        }
433    }
434
435    pub fn with_terminate(mut self, terminate: Option<&'t AtomicBool>) -> Self {
436        self.terminate = terminate;
437        self
438    }
439
440    pub fn with_runtime(mut self, runtime: Option<SolverRuntime<S>>) -> Self {
441        self.runtime = runtime;
442        self
443    }
444
445    pub(crate) fn without_publication(mut self) -> Self {
446        self.publication = Publication::Disabled;
447        self
448    }
449
450    pub(crate) fn defer_best_solution_publication(&mut self) {
451        self.best_solution_publication_enabled = false;
452    }
453
454    pub(crate) fn best_solution_publication_enabled(&self) -> bool {
455        self.best_solution_publication_enabled
456    }
457
458    pub(crate) fn yielded_to_parent(&self) -> bool {
459        self.yielded_to_parent
460    }
461
462    pub fn with_environment_mode(mut self, environment_mode: EnvironmentMode) -> Self {
463        self.environment_mode = environment_mode;
464        self
465    }
466
467    pub fn with_seed(mut self, seed: u64) -> Self {
468        self.rng = StdRng::seed_from_u64(seed);
469        self
470    }
471
472    pub(crate) fn child_phase_budget(&self) -> PhaseBudget {
473        PhaseBudget::from_scope(self)
474    }
475
476    pub(crate) fn child_config<'a>(
477        &'a self,
478        phase_budget: Option<&'a PhaseBudget>,
479    ) -> SolverScopeChildConfig<'a, S> {
480        let phase_budget = self
481            .phase_budget
482            .or_else(|| phase_budget.filter(|budget| budget.has_limits()));
483        SolverScopeChildConfig {
484            terminate: self.terminate,
485            runtime: self.runtime,
486            environment_mode: self.environment_mode,
487            time_deadline: self.child_time_deadline(),
488            phase_budget,
489            inphase_step_count_limit: self.inphase_step_count_limit,
490            inphase_move_count_limit: self.inphase_move_count_limit,
491            inphase_score_calc_count_limit: self.inphase_score_calc_count_limit,
492            inphase_best_score_limit: self.inphase_best_score_limit,
493        }
494    }
495
496    /// Runs one configured phase with a phase-relative termination overlay.
497    ///
498    /// The overlay is intentionally independent from solver-wide termination:
499    /// it starts at this phase boundary, applies every supported
500    /// `TerminationConfig` limit, and is restored before the next phase runs.
501    /// Mandatory default construction deliberately does not install this
502    /// overlay, so its required-completion pass remains interruptible only by
503    /// lifecycle control.
504    pub(crate) fn with_phase_termination<T>(
505        &mut self,
506        config: Option<&TerminationConfig>,
507        work: impl FnOnce(&mut Self) -> T,
508    ) -> T
509    where
510        S::Score: ParseableScore,
511    {
512        let previous = self.phase_termination.take();
513        self.phase_termination =
514            config.and_then(|config| ScopedPhaseTermination::from_config(self, config));
515        let result = work(self);
516        self.phase_termination = previous;
517        result
518    }
519
520    fn phase_termination_reached(&self) -> bool {
521        self.phase_termination.as_ref().is_some_and(|termination| {
522            // Only scoped terminations with clock-based limits need the
523            // wall clock; step/budget/score limits run on counters and the
524            // control poll calls this per candidate.
525            let elapsed = if termination.needs_clock() {
526                self.elapsed().unwrap_or_default()
527            } else {
528                Duration::ZERO
529            };
530            termination.is_reached(self.total_step_count, elapsed)
531        })
532    }
533
534    pub(crate) fn phase_termination_requires_score_observation(&self) -> bool {
535        self.phase_termination
536            .as_ref()
537            .is_some_and(ScopedPhaseTermination::needs_score_observation)
538    }
539
540    fn observe_phase_score(&mut self, score: S::Score, completed_step_count: u64) {
541        let elapsed = self.elapsed().unwrap_or_default();
542        if let Some(termination) = &mut self.phase_termination {
543            termination.observe_score(score, completed_step_count, elapsed);
544        }
545    }
546
547    pub(crate) fn observe_phase_step_score(&mut self, score: S::Score) {
548        self.observe_phase_score(score, self.total_step_count.saturating_add(1));
549    }
550
551    fn child_time_deadline(&self) -> Option<Instant> {
552        self.time_deadline.or_else(|| {
553            self.time_limit.map(|limit| {
554                self.start_time
555                    .map(|start| start + limit)
556                    .unwrap_or_else(|| Instant::now() + limit)
557            })
558        })
559    }
560
561    pub fn with_progress_callback<F: ProgressCallback<S>>(
562        self,
563        callback: F,
564    ) -> SolverScope<'t, S, D, F> {
565        SolverScope {
566            score_director: self.score_director,
567            best_solution: self.best_solution,
568            current_score: self.current_score,
569            best_score: self.best_score,
570            rng: self.rng,
571            start_time: self.start_time,
572            paused_at: self.paused_at,
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            construction_frontier: self.construction_frontier,
590            phase_budget: self.phase_budget,
591            inphase_step_count_limit: self.inphase_step_count_limit,
592            inphase_move_count_limit: self.inphase_move_count_limit,
593            inphase_score_calc_count_limit: self.inphase_score_calc_count_limit,
594            inphase_best_score_limit: self.inphase_best_score_limit,
595            phase_termination: self.phase_termination,
596        }
597    }
598
599    pub fn start_solving(&mut self) {
600        self.start_time = Some(Instant::now());
601        self.paused_at = None;
602        self.total_step_count = 0;
603        self.terminal_reason = None;
604        self.last_best_elapsed = None;
605        self.yielded_to_parent = false;
606        self.best_solution_revision = None;
607        self.solution_revision = 1;
608        self.progress_pulse = None;
609        self.construction_frontier.reset();
610        self.stats.start();
611    }
612
613    pub fn elapsed(&self) -> Option<Duration> {
614        match (self.start_time, self.paused_at) {
615            (Some(start), Some(paused_at)) => Some(paused_at.duration_since(start)),
616            (Some(start), None) => Some(start.elapsed()),
617            _ => None,
618        }
619    }
620
621    pub fn time_since_last_improvement(&self) -> Option<Duration> {
622        let elapsed = self.elapsed()?;
623        let last_best_elapsed = self.last_best_elapsed?;
624        Some(elapsed.saturating_sub(last_best_elapsed))
625    }
626
627    pub fn score_director(&self) -> &D {
628        &self.score_director
629    }
630
631    pub(crate) fn score_director_mut(&mut self) -> &mut D {
632        &mut self.score_director
633    }
634
635    pub fn working_solution(&self) -> &S {
636        self.score_director.working_solution()
637    }
638
639    pub fn mutate<T, F>(&mut self, mutate: F) -> T
640    where
641        F: FnOnce(&mut D) -> T,
642    {
643        self.committed_mutation(mutate)
644    }
645
646    pub fn calculate_score(&mut self) -> S::Score {
647        self.record_score_calculation();
648        let score = self.score_director.calculate_score();
649        self.current_score = Some(score);
650        self.assert_score_consistent("calculate_score", score);
651        score
652    }
653
654    pub(crate) fn assert_score_consistent(&self, context: &str, score: S::Score) {
655        if self.environment_mode != EnvironmentMode::FullAssert {
656            return;
657        }
658        let Some(fresh_score) = self.score_director.fresh_score() else {
659            return;
660        };
661        assert_eq!(
662            score, fresh_score,
663            "score director drift after {context}: cached score {score:?} != fresh score {fresh_score:?}"
664        );
665    }
666
667    pub fn initialize_working_solution_as_best(&mut self) -> S::Score {
668        if self.start_time.is_none() {
669            self.start_solving();
670        }
671        let score = self.calculate_score();
672        let solution = self.score_director.clone_working_solution();
673        self.set_best_solution(solution, score);
674        score
675    }
676
677    pub fn replace_working_solution_and_reinitialize(&mut self, solution: S) -> S::Score {
678        *self.score_director.working_solution_mut() = solution;
679        self.score_director.reset();
680        self.current_score = None;
681        self.best_solution_revision = None;
682        self.solution_revision = 1;
683        self.construction_frontier.reset();
684        self.calculate_score()
685    }
686
687    pub fn best_solution(&self) -> Option<&S> {
688        self.best_solution.as_ref()
689    }
690
691    pub fn best_score(&self) -> Option<&S::Score> {
692        self.best_score.as_ref()
693    }
694
695    pub fn current_score(&self) -> Option<&S::Score> {
696        self.current_score.as_ref()
697    }
698
699    pub(crate) fn is_scalar_slot_completed(&self, slot_id: ConstructionSlotId) -> bool {
700        self.construction_frontier
701            .is_scalar_completed(slot_id, self.solution_revision)
702    }
703
704    pub(crate) fn mark_scalar_slot_completed(&mut self, slot_id: ConstructionSlotId) {
705        self.construction_frontier
706            .mark_scalar_completed(slot_id, self.solution_revision);
707    }
708
709    pub(crate) fn is_group_slot_completed(&self, slot_id: &ConstructionGroupSlotId) -> bool {
710        self.construction_frontier
711            .is_group_completed(slot_id, self.solution_revision)
712    }
713
714    pub(crate) fn mark_group_slot_completed(&mut self, slot_id: ConstructionGroupSlotId) {
715        self.construction_frontier
716            .mark_group_completed(slot_id, self.solution_revision);
717    }
718
719    pub(crate) fn is_list_element_completed(&self, element_id: ConstructionListElementId) -> bool {
720        self.construction_frontier
721            .is_list_completed(element_id, self.solution_revision)
722    }
723
724    pub(crate) fn mark_list_element_completed(&mut self, element_id: ConstructionListElementId) {
725        self.construction_frontier
726            .mark_list_completed(element_id, self.solution_revision);
727    }
728
729    #[cfg(test)]
730    pub(crate) fn solution_revision(&self) -> u64 {
731        self.solution_revision
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}