Skip to main content

solverforge_solver/
run.rs

1/* Solver entry point. */
2
3use std::fmt;
4use std::hash::Hash;
5use std::marker::PhantomData;
6use std::panic::{catch_unwind, resume_unwind, AssertUnwindSafe};
7use std::time::Duration;
8
9#[cfg(test)]
10use std::path::Path;
11
12use solverforge_config::{SolverConfig, TerminationConfig};
13use solverforge_core::domain::{PlanningSolution, SolutionDescriptor};
14use solverforge_core::score::{ParseableScore, Score};
15use solverforge_scoring::{ConstraintSet, Director, ReplicaDescriptor, ScoreDirector};
16use tracing::info;
17
18use crate::builder::{RuntimeExtensionRegistry, Search};
19use crate::manager::{SolverRuntime, SolverTerminalReason};
20use crate::phase::Phase;
21use crate::runtime::compiler::executor::{
22    take_runtime_execution_failure, CompiledRuntimePhaseRunner,
23};
24use crate::runtime::compiler::{compile_runtime_graph, CompiledRuntimeExecutor, RuntimeGraphInput};
25use crate::runtime_build_error::{RuntimeBuildError, RuntimeBuildResult};
26use crate::scope::{ProgressCallback, SolverProgressKind, SolverProgressRef, SolverScope};
27use crate::solver::{NoTermination, Solver};
28use crate::stats::{
29    format_duration, whole_units_per_second, CandidateTraceExecutionPolicy,
30    QualifiedCandidateTraceRunProvenance,
31};
32use crate::termination::{
33    BestScoreTermination, OrTermination, StepCountTermination, Termination, TimeTermination,
34    UnimprovedStepCountTermination, UnimprovedTimeTermination,
35};
36
37/// Monomorphized termination enum for config-driven solver configurations.
38///
39/// Avoids repeated branching across termination overloads by capturing the
40/// selected termination variant upfront.
41pub enum AnyTermination<S: PlanningSolution, D: Director<S>> {
42    None(NoTermination),
43    Default(OrTermination<(TimeTermination,), S, D>),
44    WithBestScore(OrTermination<(TimeTermination, BestScoreTermination<S::Score>), S, D>),
45    WithStepCount(OrTermination<(TimeTermination, StepCountTermination), S, D>),
46    WithUnimprovedStep(OrTermination<(TimeTermination, UnimprovedStepCountTermination<S>), S, D>),
47    WithUnimprovedTime(OrTermination<(TimeTermination, UnimprovedTimeTermination<S>), S, D>),
48}
49
50#[derive(Clone)]
51pub struct ChannelProgressCallback<S: PlanningSolution> {
52    runtime: SolverRuntime<S>,
53    _phantom: PhantomData<fn() -> S>,
54}
55
56impl<S: PlanningSolution> ChannelProgressCallback<S> {
57    fn new(runtime: SolverRuntime<S>) -> Self {
58        Self {
59            runtime,
60            _phantom: PhantomData,
61        }
62    }
63}
64
65impl<S: PlanningSolution> fmt::Debug for ChannelProgressCallback<S> {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        f.debug_struct("ChannelProgressCallback").finish()
68    }
69}
70
71impl<S: PlanningSolution> ProgressCallback<S> for ChannelProgressCallback<S> {
72    fn invoke(&self, progress: SolverProgressRef<'_, S>) {
73        match progress.kind {
74            SolverProgressKind::Progress => {
75                self.runtime.emit_progress(
76                    progress.current_score.copied(),
77                    progress.best_score.copied(),
78                    progress.telemetry.clone(),
79                );
80            }
81            SolverProgressKind::BestSolution => {
82                if let (Some(solution), Some(score)) = (progress.solution, progress.best_score) {
83                    self.runtime.emit_best_solution(
84                        (*solution).clone(),
85                        progress.current_score.copied(),
86                        *score,
87                        progress.telemetry.clone(),
88                    );
89                }
90            }
91        }
92    }
93}
94
95impl<S: PlanningSolution, D: Director<S>> fmt::Debug for AnyTermination<S, D> {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        match self {
98            Self::None(_) => write!(f, "AnyTermination::None"),
99            Self::Default(_) => write!(f, "AnyTermination::Default"),
100            Self::WithBestScore(_) => write!(f, "AnyTermination::WithBestScore"),
101            Self::WithStepCount(_) => write!(f, "AnyTermination::WithStepCount"),
102            Self::WithUnimprovedStep(_) => write!(f, "AnyTermination::WithUnimprovedStep"),
103            Self::WithUnimprovedTime(_) => write!(f, "AnyTermination::WithUnimprovedTime"),
104        }
105    }
106}
107
108impl<S: PlanningSolution, D: Director<S>, ProgressCb: ProgressCallback<S>>
109    Termination<S, D, ProgressCb> for AnyTermination<S, D>
110where
111    S::Score: Score,
112{
113    fn is_terminated(&mut self, solver_scope: &SolverScope<S, D, ProgressCb>) -> bool {
114        match self {
115            Self::None(t) => t.is_terminated(solver_scope),
116            Self::Default(t) => t.is_terminated(solver_scope),
117            Self::WithBestScore(t) => t.is_terminated(solver_scope),
118            Self::WithStepCount(t) => t.is_terminated(solver_scope),
119            Self::WithUnimprovedStep(t) => t.is_terminated(solver_scope),
120            Self::WithUnimprovedTime(t) => t.is_terminated(solver_scope),
121        }
122    }
123
124    fn install_inphase_limits(&self, solver_scope: &mut SolverScope<S, D, ProgressCb>) {
125        match self {
126            Self::None(t) => t.install_inphase_limits(solver_scope),
127            Self::Default(t) => t.install_inphase_limits(solver_scope),
128            Self::WithBestScore(t) => t.install_inphase_limits(solver_scope),
129            Self::WithStepCount(t) => t.install_inphase_limits(solver_scope),
130            Self::WithUnimprovedStep(t) => t.install_inphase_limits(solver_scope),
131            Self::WithUnimprovedTime(t) => t.install_inphase_limits(solver_scope),
132        }
133    }
134}
135
136/// Parsed solver termination policy shared by runtime phase assembly and the
137/// top-level termination builder.
138///
139/// `TerminationConfig` historically chooses the first configured score/work
140/// criterion in this order: best score, step count, unimproved steps,
141/// unimproved time. A configured time limit is paired with that criterion, or
142/// is the policy itself when no other criterion is present. Keeping that
143/// precedence here prevents phase assembly from treating an empty or
144/// unparsable configuration as a finite solver boundary.
145#[derive(Clone, Copy)]
146pub(crate) struct ConfiguredTermination<Sc> {
147    time_limit: Option<Duration>,
148    criterion: Option<ConfiguredTerminationCriterion<Sc>>,
149}
150
151#[derive(Clone, Copy)]
152enum ConfiguredTerminationCriterion<Sc> {
153    BestScore(Sc),
154    StepCount(u64),
155    UnimprovedStepCount(u64),
156    UnimprovedTime(Duration),
157}
158
159impl<Sc> ConfiguredTermination<Sc> {
160    pub(crate) fn has_effective_limit(&self) -> bool {
161        self.time_limit.is_some() || self.criterion.is_some()
162    }
163}
164
165pub(crate) fn parse_configured_termination<S>(
166    config: Option<&TerminationConfig>,
167) -> ConfiguredTermination<S::Score>
168where
169    S: PlanningSolution,
170    S::Score: ParseableScore,
171{
172    let time_limit = config.and_then(TerminationConfig::time_limit);
173    let criterion = config.and_then(|config| {
174        config
175            .best_score_limit
176            .as_deref()
177            .and_then(|score| S::Score::parse(score).ok())
178            .map(ConfiguredTerminationCriterion::BestScore)
179            .or_else(|| {
180                config
181                    .step_count_limit
182                    .map(ConfiguredTerminationCriterion::StepCount)
183            })
184            .or_else(|| {
185                config
186                    .unimproved_step_count_limit
187                    .map(ConfiguredTerminationCriterion::UnimprovedStepCount)
188            })
189            .or_else(|| {
190                config
191                    .unimproved_time_limit()
192                    .map(ConfiguredTerminationCriterion::UnimprovedTime)
193            })
194    });
195    ConfiguredTermination {
196        time_limit,
197        criterion,
198    }
199}
200
201/// Builds a termination from config, returning both the termination and the time limit.
202pub fn build_termination<S, C>(
203    config: &SolverConfig,
204    default_secs: u64,
205) -> (AnyTermination<S, ScoreDirector<S, C>>, Option<Duration>)
206where
207    S: PlanningSolution,
208    S::Score: Score + ParseableScore,
209    C: ConstraintSet<S, S::Score>,
210{
211    build_director_termination(config, default_secs)
212}
213
214fn build_director_termination<S, D>(
215    config: &SolverConfig,
216    default_secs: u64,
217) -> (AnyTermination<S, D>, Option<Duration>)
218where
219    S: PlanningSolution,
220    S::Score: Score + ParseableScore,
221    D: Director<S>,
222{
223    let ConfiguredTermination {
224        time_limit: configured_time_limit,
225        criterion,
226    } = parse_configured_termination::<S>(config.termination.as_ref());
227    let fallback_time_limit = Duration::from_secs(default_secs);
228
229    let (termination, effective_time_limit) = match criterion {
230        Some(ConfiguredTerminationCriterion::BestScore(target)) => {
231            let effective_time_limit = configured_time_limit.unwrap_or(fallback_time_limit);
232            let time = TimeTermination::new(effective_time_limit);
233            (
234                AnyTermination::WithBestScore(OrTermination::new((
235                    time,
236                    BestScoreTermination::new(target),
237                ))),
238                Some(effective_time_limit),
239            )
240        }
241        Some(ConfiguredTerminationCriterion::StepCount(step_limit)) => {
242            let effective_time_limit = configured_time_limit.unwrap_or(fallback_time_limit);
243            let time = TimeTermination::new(effective_time_limit);
244            (
245                AnyTermination::WithStepCount(OrTermination::new((
246                    time,
247                    StepCountTermination::new(step_limit),
248                ))),
249                Some(effective_time_limit),
250            )
251        }
252        Some(ConfiguredTerminationCriterion::UnimprovedStepCount(unimproved_step_limit)) => {
253            let effective_time_limit = configured_time_limit.unwrap_or(fallback_time_limit);
254            let time = TimeTermination::new(effective_time_limit);
255            (
256                AnyTermination::WithUnimprovedStep(OrTermination::new((
257                    time,
258                    UnimprovedStepCountTermination::<S>::new(unimproved_step_limit),
259                ))),
260                Some(effective_time_limit),
261            )
262        }
263        Some(ConfiguredTerminationCriterion::UnimprovedTime(unimproved_time)) => {
264            let effective_time_limit = configured_time_limit.unwrap_or(fallback_time_limit);
265            let time = TimeTermination::new(effective_time_limit);
266            (
267                AnyTermination::WithUnimprovedTime(OrTermination::new((
268                    time,
269                    UnimprovedTimeTermination::<S>::new(unimproved_time),
270                ))),
271                Some(effective_time_limit),
272            )
273        }
274        None => configured_time_limit.map_or_else(
275            || (AnyTermination::None(NoTermination), None),
276            |limit| {
277                let time = TimeTermination::new(limit);
278                (
279                    AnyTermination::Default(OrTermination::new((time,))),
280                    Some(limit),
281                )
282            },
283        ),
284    };
285
286    (termination, effective_time_limit)
287}
288
289/// Records the termination policy the configured runtime actually installed.
290///
291/// This deliberately derives its time guard from `build_termination`'s
292/// returned effective limit rather than from the input TOML.  In particular,
293/// a score/work criterion without an explicit time limit gets the configured
294/// entrypoint's fallback guard, and that injected guard is material to both
295/// bounded-work and fixed-budget comparisons.
296pub(crate) fn configured_execution_policy<S>(
297    config: &SolverConfig,
298    default_secs: u64,
299    effective_time_limit: Option<Duration>,
300) -> CandidateTraceExecutionPolicy
301where
302    S: PlanningSolution,
303    S::Score: ParseableScore + std::fmt::Display,
304{
305    let configured = parse_configured_termination::<S>(config.termination.as_ref());
306    let configured_time_limit = configured.time_limit;
307    let criterion = configured.criterion;
308    let fallback_time_limit = Duration::from_secs(default_secs);
309
310    let time_limit_source = match (configured_time_limit, effective_time_limit) {
311        (Some(_), Some(_)) => "configured",
312        (None, Some(_)) if criterion.is_some() => "configured_entrypoint_fallback",
313        (None, Some(_)) => "internal",
314        (_, None) => "not_installed",
315    };
316    let mut attributes = vec![
317        ("entrypoint".to_string(), "configured_runtime".to_string()),
318        (
319            "configured_time_limit_ns".to_string(),
320            configured_time_limit.map_or_else(|| "none".to_string(), duration_nanos),
321        ),
322        (
323            "configured_entrypoint_default_time_limit_ns".to_string(),
324            duration_nanos(fallback_time_limit),
325        ),
326        (
327            "effective_time_limit_ns".to_string(),
328            effective_time_limit.map_or_else(|| "none".to_string(), duration_nanos),
329        ),
330        (
331            "time_limit_source".to_string(),
332            time_limit_source.to_string(),
333        ),
334    ];
335
336    match criterion {
337        Some(ConfiguredTerminationCriterion::BestScore(target)) => {
338            attributes.push(("criterion".to_string(), "best_score".to_string()));
339            attributes.push(("criterion_target".to_string(), target.to_string()));
340            attributes.push((
341                "termination_composition".to_string(),
342                "time_or_best_score".to_string(),
343            ));
344        }
345        Some(ConfiguredTerminationCriterion::StepCount(limit)) => {
346            attributes.push(("criterion".to_string(), "step_count".to_string()));
347            attributes.push(("criterion_target".to_string(), limit.to_string()));
348            attributes.push((
349                "termination_composition".to_string(),
350                "time_or_step_count".to_string(),
351            ));
352        }
353        Some(ConfiguredTerminationCriterion::UnimprovedStepCount(limit)) => {
354            attributes.push(("criterion".to_string(), "unimproved_step_count".to_string()));
355            attributes.push(("criterion_target".to_string(), limit.to_string()));
356            attributes.push((
357                "termination_composition".to_string(),
358                "time_or_unimproved_step_count".to_string(),
359            ));
360        }
361        Some(ConfiguredTerminationCriterion::UnimprovedTime(limit)) => {
362            attributes.push(("criterion".to_string(), "unimproved_time".to_string()));
363            attributes.push(("criterion_target_ns".to_string(), duration_nanos(limit)));
364            attributes.push((
365                "termination_composition".to_string(),
366                "time_or_unimproved_time".to_string(),
367            ));
368        }
369        None if effective_time_limit.is_some() => {
370            attributes.push(("criterion".to_string(), "none".to_string()));
371            attributes.push((
372                "termination_composition".to_string(),
373                "time_only".to_string(),
374            ));
375        }
376        None => {
377            attributes.push(("criterion".to_string(), "none".to_string()));
378            attributes.push((
379                "termination_composition".to_string(),
380                "unbounded".to_string(),
381            ));
382        }
383    }
384
385    CandidateTraceExecutionPolicy::known("solverforge.execution_policy", attributes)
386}
387
388fn duration_nanos(duration: Duration) -> String {
389    duration.as_nanos().to_string()
390}
391
392pub fn log_solve_start(
393    entity_count: usize,
394    element_count: Option<usize>,
395    candidate_count: Option<usize>,
396) {
397    match (element_count, candidate_count) {
398        (Some(element_count), None) => {
399            info!(
400                event = "solve_start",
401                entity_count = entity_count,
402                element_count = element_count,
403                solve_shape = "list",
404            );
405        }
406        (None, Some(candidate_count)) => {
407            info!(
408                event = "solve_start",
409                entity_count = entity_count,
410                candidate_count = candidate_count,
411                solve_shape = "scalar",
412            );
413        }
414        _ => {
415            panic!(
416                "log_solve_start requires exactly one solve scale: list elements or scalar candidates"
417            );
418        }
419    }
420}
421
422#[cfg(test)]
423fn load_solver_config_from(path: impl AsRef<Path>) -> SolverConfig {
424    SolverConfig::load(path).unwrap_or_default()
425}
426
427/// Runs one configured model through the immutable runtime graph compiler and
428/// retained compiled runner.
429///
430/// `build_search` creates the one descriptor-resolved declaration consumed by
431/// the compiled graph. Every model, including a zero-work model, follows this
432/// one lifecycle; the public API never exposes a graph, prepared source
433/// catalog, or phase-builder fallback.
434///
435/// `constraints` builds a constraint set for a given solution. It builds the
436/// working solution's constraint set and every evaluator replica's, so parallel
437/// local-search evaluation can fork replicas for any model.
438#[allow(clippy::too_many_arguments)]
439pub fn try_run_solver_with_config_and_search<S, C, V, DM, IDM, Declaration, BuildSearch>(
440    solution: S,
441    constraints: fn(&S) -> C,
442    descriptor: SolutionDescriptor,
443    entity_count_by_descriptor: fn(&S, usize) -> usize,
444    runtime: SolverRuntime<S>,
445    config: SolverConfig,
446    default_time_limit_secs: u64,
447    log_scale: fn(&S),
448    qualified_candidate_trace_provenance: Option<QualifiedCandidateTraceRunProvenance>,
449    build_search: BuildSearch,
450) -> RuntimeBuildResult<S>
451where
452    S: PlanningSolution + Clone + Send + Sync + 'static,
453    S::Score: Score + Copy + Ord + ParseableScore,
454    C: ConstraintSet<S, S::Score>,
455    V: Clone + Copy + PartialEq + Eq + Hash + Into<usize> + Send + Sync + fmt::Debug + 'static,
456    DM: crate::heuristic::selector::nearby_list_change::CrossEntityDistanceMeter<S>
457        + Clone
458        + Send
459        + Sync
460        + fmt::Debug
461        + 'static,
462    IDM: crate::heuristic::selector::nearby_list_change::CrossEntityDistanceMeter<S>
463        + Clone
464        + Send
465        + Sync
466        + fmt::Debug
467        + 'static,
468    Declaration: Search<S, V, DM, IDM>,
469    Declaration::Extensions: RuntimeExtensionRegistry<S, V, DM, IDM>,
470    BuildSearch: FnOnce(&SolverConfig, SolutionDescriptor) -> RuntimeBuildResult<Declaration>,
471{
472    try_run_solver_with_config_and_search_request(
473        solution,
474        constraints,
475        descriptor,
476        entity_count_by_descriptor,
477        runtime,
478        config,
479        default_time_limit_secs,
480        log_scale,
481        qualified_candidate_trace_provenance,
482        build_search,
483    )
484}
485
486#[allow(clippy::too_many_arguments)]
487fn try_run_solver_with_config_and_search_request<S, C, V, DM, IDM, Declaration, BuildSearch>(
488    solution: S,
489    constraints: fn(&S) -> C,
490    descriptor: SolutionDescriptor,
491    entity_count_by_descriptor: fn(&S, usize) -> usize,
492    runtime: SolverRuntime<S>,
493    config: SolverConfig,
494    default_time_limit_secs: u64,
495    log_scale: fn(&S),
496    qualified_candidate_trace_provenance: Option<QualifiedCandidateTraceRunProvenance>,
497    build_search: BuildSearch,
498) -> RuntimeBuildResult<S>
499where
500    S: PlanningSolution + Clone + Send + Sync + 'static,
501    S::Score: Score + Copy + Ord + ParseableScore,
502    C: ConstraintSet<S, S::Score>,
503    V: Clone + Copy + PartialEq + Eq + Hash + Into<usize> + Send + Sync + fmt::Debug + 'static,
504    DM: crate::heuristic::selector::nearby_list_change::CrossEntityDistanceMeter<S>
505        + Clone
506        + Send
507        + Sync
508        + fmt::Debug
509        + 'static,
510    IDM: crate::heuristic::selector::nearby_list_change::CrossEntityDistanceMeter<S>
511        + Clone
512        + Send
513        + Sync
514        + fmt::Debug
515        + 'static,
516    Declaration: Search<S, V, DM, IDM>,
517    Declaration::Extensions: RuntimeExtensionRegistry<S, V, DM, IDM>,
518    BuildSearch: FnOnce(&SolverConfig, SolutionDescriptor) -> RuntimeBuildResult<Declaration>,
519{
520    try_run_solver_with_candidate_trace_request(
521        solution,
522        constraints,
523        descriptor,
524        entity_count_by_descriptor,
525        runtime,
526        config,
527        default_time_limit_secs,
528        log_scale,
529        qualified_candidate_trace_provenance,
530        move |config, descriptor| {
531            let declaration = build_search(config, descriptor.clone())?;
532            let (context, extensions) = declaration.into_runtime_parts();
533            let graph = compile_runtime_graph(config, RuntimeGraphInput::new(context, extensions))
534                .map_err(|error| {
535                    let message = error.to_string();
536                    RuntimeBuildError::Compilation {
537                        path: error.path,
538                        message,
539                    }
540                })?;
541            let executor = CompiledRuntimeExecutor::new(graph);
542            CompiledRuntimePhaseRunner::try_new(executor)
543        },
544    )
545}
546
547#[allow(clippy::too_many_arguments)]
548fn try_run_solver_with_candidate_trace_request<S, C, Runner, BuildRunner>(
549    solution: S,
550    constraints: fn(&S) -> C,
551    descriptor: SolutionDescriptor,
552    entity_count_by_descriptor: fn(&S, usize) -> usize,
553    runtime: SolverRuntime<S>,
554    config: SolverConfig,
555    default_time_limit_secs: u64,
556    log_scale: fn(&S),
557    qualified_candidate_trace_provenance: Option<QualifiedCandidateTraceRunProvenance>,
558    build_runner: BuildRunner,
559) -> RuntimeBuildResult<S>
560where
561    S: PlanningSolution,
562    S::Score: Score + ParseableScore,
563    C: ConstraintSet<S, S::Score>,
564    Runner: for<'d> Phase<
565            S,
566            ScoreDirector<S, C, ReplicaDescriptor<'d, S, C>>,
567            ChannelProgressCallback<S>,
568        > + Send
569        + std::fmt::Debug,
570    BuildRunner: FnOnce(&SolverConfig, &SolutionDescriptor) -> RuntimeBuildResult<Runner>,
571{
572    log_scale(&solution);
573    let main_constraints = constraints(&solution);
574    let director = ScoreDirector::with_descriptor(
575        solution,
576        main_constraints,
577        ReplicaDescriptor::new(&descriptor, constraints),
578        entity_count_by_descriptor,
579    );
580
581    let (termination, time_limit) = build_director_termination(&config, default_time_limit_secs);
582    let execution_policy =
583        configured_execution_policy::<S>(&config, default_time_limit_secs, time_limit);
584
585    let callback = ChannelProgressCallback::new(runtime);
586
587    let runner = match build_runner(&config, &descriptor) {
588        Ok(runner) => runner,
589        Err(error) => {
590            runtime.emit_failed(error.to_string());
591            return Err(error);
592        }
593    };
594    let mut solver = Solver::new((runner,))
595        .with_config(config.clone())
596        .with_candidate_trace_execution_policy(execution_policy)
597        .with_termination(termination)
598        .with_runtime(runtime)
599        .with_progress_callback(callback);
600    if let Some(provenance) = qualified_candidate_trace_provenance {
601        solver = solver.with_qualified_candidate_trace_run_provenance(provenance);
602    }
603    if let Some(time_limit) = time_limit {
604        solver = solver.with_time_limit(time_limit);
605    }
606
607    let result = match catch_unwind(AssertUnwindSafe(|| {
608        solver.with_terminate(runtime.cancel_flag()).solve(director)
609    })) {
610        Ok(result) => result,
611        Err(payload) => match take_runtime_execution_failure(payload) {
612            Ok(error) => {
613                runtime.emit_failed(error.to_string());
614                return Err(error);
615            }
616            Err(payload) => resume_unwind(payload),
617        },
618    };
619
620    let crate::solver::SolveResult {
621        solution,
622        current_score,
623        best_score: final_score,
624        terminal_reason,
625        stats,
626    } = result;
627    let final_telemetry = stats.snapshot();
628    let final_move_speed = whole_units_per_second(stats.moves_evaluated(), stats.elapsed());
629    match terminal_reason {
630        SolverTerminalReason::Completed | SolverTerminalReason::TerminatedByConfig => {
631            runtime.emit_completed(
632                solution.clone(),
633                current_score,
634                final_score,
635                final_telemetry,
636                terminal_reason,
637            );
638        }
639        SolverTerminalReason::Cancelled => {
640            runtime.emit_cancelled(current_score, Some(final_score), final_telemetry);
641        }
642        SolverTerminalReason::Failed => {
643            let error = RuntimeBuildError::Execution {
644                phase_index: 0,
645                message: "configured solver reported a failed terminal state".to_string(),
646            };
647            runtime.emit_failed(error.to_string());
648            return Err(error);
649        }
650    }
651
652    info!(
653        event = "solve_end",
654        score = %final_score,
655        steps = stats.step_count(),
656        moves_generated = stats.moves_generated(),
657        moves_evaluated = stats.moves_evaluated(),
658        moves_accepted = stats.moves_accepted(),
659        score_calculations = stats.score_calculations(),
660        generation_time = %format_duration(stats.generation_time()),
661        evaluation_time = %format_duration(stats.evaluation_time()),
662        moves_speed = final_move_speed,
663        acceptance_rate = format!("{:.1}%", stats.acceptance_rate() * 100.0),
664    );
665    Ok(solution)
666}
667
668#[cfg(test)]
669#[path = "run_tests.rs"]
670mod tests;