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, 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(&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    let ConfiguredTermination {
212        time_limit: configured_time_limit,
213        criterion,
214    } = parse_configured_termination::<S>(config.termination.as_ref());
215    let fallback_time_limit = Duration::from_secs(default_secs);
216
217    let (termination, effective_time_limit) = match criterion {
218        Some(ConfiguredTerminationCriterion::BestScore(target)) => {
219            let effective_time_limit = configured_time_limit.unwrap_or(fallback_time_limit);
220            let time = TimeTermination::new(effective_time_limit);
221            (
222                AnyTermination::WithBestScore(OrTermination::new((
223                    time,
224                    BestScoreTermination::new(target),
225                ))),
226                Some(effective_time_limit),
227            )
228        }
229        Some(ConfiguredTerminationCriterion::StepCount(step_limit)) => {
230            let effective_time_limit = configured_time_limit.unwrap_or(fallback_time_limit);
231            let time = TimeTermination::new(effective_time_limit);
232            (
233                AnyTermination::WithStepCount(OrTermination::new((
234                    time,
235                    StepCountTermination::new(step_limit),
236                ))),
237                Some(effective_time_limit),
238            )
239        }
240        Some(ConfiguredTerminationCriterion::UnimprovedStepCount(unimproved_step_limit)) => {
241            let effective_time_limit = configured_time_limit.unwrap_or(fallback_time_limit);
242            let time = TimeTermination::new(effective_time_limit);
243            (
244                AnyTermination::WithUnimprovedStep(OrTermination::new((
245                    time,
246                    UnimprovedStepCountTermination::<S>::new(unimproved_step_limit),
247                ))),
248                Some(effective_time_limit),
249            )
250        }
251        Some(ConfiguredTerminationCriterion::UnimprovedTime(unimproved_time)) => {
252            let effective_time_limit = configured_time_limit.unwrap_or(fallback_time_limit);
253            let time = TimeTermination::new(effective_time_limit);
254            (
255                AnyTermination::WithUnimprovedTime(OrTermination::new((
256                    time,
257                    UnimprovedTimeTermination::<S>::new(unimproved_time),
258                ))),
259                Some(effective_time_limit),
260            )
261        }
262        None => configured_time_limit.map_or_else(
263            || (AnyTermination::None(NoTermination), None),
264            |limit| {
265                let time = TimeTermination::new(limit);
266                (
267                    AnyTermination::Default(OrTermination::new((time,))),
268                    Some(limit),
269                )
270            },
271        ),
272    };
273
274    (termination, effective_time_limit)
275}
276
277/// Records the termination policy the configured runtime actually installed.
278///
279/// This deliberately derives its time guard from `build_termination`'s
280/// returned effective limit rather than from the input TOML.  In particular,
281/// a score/work criterion without an explicit time limit gets the configured
282/// entrypoint's fallback guard, and that injected guard is material to both
283/// bounded-work and fixed-budget comparisons.
284pub(crate) fn configured_execution_policy<S>(
285    config: &SolverConfig,
286    default_secs: u64,
287    effective_time_limit: Option<Duration>,
288) -> CandidateTraceExecutionPolicy
289where
290    S: PlanningSolution,
291    S::Score: ParseableScore + std::fmt::Display,
292{
293    let configured = parse_configured_termination::<S>(config.termination.as_ref());
294    let configured_time_limit = configured.time_limit;
295    let criterion = configured.criterion;
296    let fallback_time_limit = Duration::from_secs(default_secs);
297
298    let time_limit_source = match (configured_time_limit, effective_time_limit) {
299        (Some(_), Some(_)) => "configured",
300        (None, Some(_)) if criterion.is_some() => "configured_entrypoint_fallback",
301        (None, Some(_)) => "internal",
302        (_, None) => "not_installed",
303    };
304    let mut attributes = vec![
305        ("entrypoint".to_string(), "configured_runtime".to_string()),
306        (
307            "configured_time_limit_ns".to_string(),
308            configured_time_limit.map_or_else(|| "none".to_string(), duration_nanos),
309        ),
310        (
311            "configured_entrypoint_default_time_limit_ns".to_string(),
312            duration_nanos(fallback_time_limit),
313        ),
314        (
315            "effective_time_limit_ns".to_string(),
316            effective_time_limit.map_or_else(|| "none".to_string(), duration_nanos),
317        ),
318        (
319            "time_limit_source".to_string(),
320            time_limit_source.to_string(),
321        ),
322    ];
323
324    match criterion {
325        Some(ConfiguredTerminationCriterion::BestScore(target)) => {
326            attributes.push(("criterion".to_string(), "best_score".to_string()));
327            attributes.push(("criterion_target".to_string(), target.to_string()));
328            attributes.push((
329                "termination_composition".to_string(),
330                "time_or_best_score".to_string(),
331            ));
332        }
333        Some(ConfiguredTerminationCriterion::StepCount(limit)) => {
334            attributes.push(("criterion".to_string(), "step_count".to_string()));
335            attributes.push(("criterion_target".to_string(), limit.to_string()));
336            attributes.push((
337                "termination_composition".to_string(),
338                "time_or_step_count".to_string(),
339            ));
340        }
341        Some(ConfiguredTerminationCriterion::UnimprovedStepCount(limit)) => {
342            attributes.push(("criterion".to_string(), "unimproved_step_count".to_string()));
343            attributes.push(("criterion_target".to_string(), limit.to_string()));
344            attributes.push((
345                "termination_composition".to_string(),
346                "time_or_unimproved_step_count".to_string(),
347            ));
348        }
349        Some(ConfiguredTerminationCriterion::UnimprovedTime(limit)) => {
350            attributes.push(("criterion".to_string(), "unimproved_time".to_string()));
351            attributes.push(("criterion_target_ns".to_string(), duration_nanos(limit)));
352            attributes.push((
353                "termination_composition".to_string(),
354                "time_or_unimproved_time".to_string(),
355            ));
356        }
357        None if effective_time_limit.is_some() => {
358            attributes.push(("criterion".to_string(), "none".to_string()));
359            attributes.push((
360                "termination_composition".to_string(),
361                "time_only".to_string(),
362            ));
363        }
364        None => {
365            attributes.push(("criterion".to_string(), "none".to_string()));
366            attributes.push((
367                "termination_composition".to_string(),
368                "unbounded".to_string(),
369            ));
370        }
371    }
372
373    CandidateTraceExecutionPolicy::known("solverforge.execution_policy", attributes)
374}
375
376fn duration_nanos(duration: Duration) -> String {
377    duration.as_nanos().to_string()
378}
379
380pub fn log_solve_start(
381    entity_count: usize,
382    element_count: Option<usize>,
383    candidate_count: Option<usize>,
384) {
385    match (element_count, candidate_count) {
386        (Some(element_count), None) => {
387            info!(
388                event = "solve_start",
389                entity_count = entity_count,
390                element_count = element_count,
391                solve_shape = "list",
392            );
393        }
394        (None, Some(candidate_count)) => {
395            info!(
396                event = "solve_start",
397                entity_count = entity_count,
398                candidate_count = candidate_count,
399                solve_shape = "scalar",
400            );
401        }
402        _ => {
403            panic!(
404                "log_solve_start requires exactly one solve scale: list elements or scalar candidates"
405            );
406        }
407    }
408}
409
410#[cfg(test)]
411fn load_solver_config_from(path: impl AsRef<Path>) -> SolverConfig {
412    SolverConfig::load(path).unwrap_or_default()
413}
414
415/// Runs one configured model through the immutable runtime graph compiler and
416/// retained compiled runner.
417///
418/// `build_search` creates the one descriptor-resolved declaration consumed by
419/// the compiled graph. Every model, including a zero-work model, follows this
420/// one lifecycle; the public API never exposes a graph, prepared source
421/// catalog, or phase-builder fallback.
422#[allow(clippy::too_many_arguments)]
423pub fn try_run_solver_with_config_and_search<S, C, V, DM, IDM, Declaration, BuildSearch>(
424    solution: S,
425    constraints: C,
426    descriptor: SolutionDescriptor,
427    entity_count_by_descriptor: fn(&S, usize) -> usize,
428    runtime: SolverRuntime<S>,
429    config: SolverConfig,
430    default_time_limit_secs: u64,
431    log_scale: fn(&S),
432    qualified_candidate_trace_provenance: Option<QualifiedCandidateTraceRunProvenance>,
433    build_search: BuildSearch,
434) -> RuntimeBuildResult<S>
435where
436    S: PlanningSolution + Clone + Send + Sync + 'static,
437    S::Score: Score + Copy + Ord + ParseableScore,
438    C: ConstraintSet<S, S::Score>,
439    V: Clone + Copy + PartialEq + Eq + Hash + Into<usize> + Send + Sync + fmt::Debug + 'static,
440    DM: crate::heuristic::selector::nearby_list_change::CrossEntityDistanceMeter<S>
441        + Clone
442        + Send
443        + Sync
444        + fmt::Debug
445        + 'static,
446    IDM: crate::heuristic::selector::nearby_list_change::CrossEntityDistanceMeter<S>
447        + Clone
448        + Send
449        + Sync
450        + fmt::Debug
451        + 'static,
452    Declaration: Search<S, V, DM, IDM>,
453    Declaration::Extensions: RuntimeExtensionRegistry<S, V, DM, IDM>,
454    BuildSearch: FnOnce(&SolverConfig, SolutionDescriptor) -> RuntimeBuildResult<Declaration>,
455{
456    try_run_solver_with_config_and_search_request(
457        solution,
458        constraints,
459        descriptor,
460        entity_count_by_descriptor,
461        runtime,
462        config,
463        default_time_limit_secs,
464        log_scale,
465        qualified_candidate_trace_provenance,
466        build_search,
467    )
468}
469
470#[allow(clippy::too_many_arguments)]
471fn try_run_solver_with_config_and_search_request<S, C, V, DM, IDM, Declaration, BuildSearch>(
472    solution: S,
473    constraints: C,
474    descriptor: SolutionDescriptor,
475    entity_count_by_descriptor: fn(&S, usize) -> usize,
476    runtime: SolverRuntime<S>,
477    config: SolverConfig,
478    default_time_limit_secs: u64,
479    log_scale: fn(&S),
480    qualified_candidate_trace_provenance: Option<QualifiedCandidateTraceRunProvenance>,
481    build_search: BuildSearch,
482) -> RuntimeBuildResult<S>
483where
484    S: PlanningSolution + Clone + Send + Sync + 'static,
485    S::Score: Score + Copy + Ord + ParseableScore,
486    C: ConstraintSet<S, S::Score>,
487    V: Clone + Copy + PartialEq + Eq + Hash + Into<usize> + Send + Sync + fmt::Debug + 'static,
488    DM: crate::heuristic::selector::nearby_list_change::CrossEntityDistanceMeter<S>
489        + Clone
490        + Send
491        + Sync
492        + fmt::Debug
493        + 'static,
494    IDM: crate::heuristic::selector::nearby_list_change::CrossEntityDistanceMeter<S>
495        + Clone
496        + Send
497        + Sync
498        + fmt::Debug
499        + 'static,
500    Declaration: Search<S, V, DM, IDM>,
501    Declaration::Extensions: RuntimeExtensionRegistry<S, V, DM, IDM>,
502    BuildSearch: FnOnce(&SolverConfig, SolutionDescriptor) -> RuntimeBuildResult<Declaration>,
503{
504    try_run_solver_with_candidate_trace_request(
505        solution,
506        constraints,
507        descriptor,
508        entity_count_by_descriptor,
509        runtime,
510        config,
511        default_time_limit_secs,
512        log_scale,
513        qualified_candidate_trace_provenance,
514        move |config, descriptor| {
515            let declaration = build_search(config, descriptor.clone())?;
516            let (context, extensions) = declaration.into_runtime_parts();
517            let graph = compile_runtime_graph(config, RuntimeGraphInput::new(context, extensions))
518                .map_err(|error| {
519                    let message = error.to_string();
520                    RuntimeBuildError::Compilation {
521                        path: error.path,
522                        message,
523                    }
524                })?;
525            let executor = CompiledRuntimeExecutor::new(graph);
526            CompiledRuntimePhaseRunner::try_new(&executor)
527        },
528    )
529}
530
531#[allow(clippy::too_many_arguments)]
532fn try_run_solver_with_candidate_trace_request<S, C, Runner, BuildRunner>(
533    solution: S,
534    constraints: C,
535    descriptor: SolutionDescriptor,
536    entity_count_by_descriptor: fn(&S, usize) -> usize,
537    runtime: SolverRuntime<S>,
538    config: SolverConfig,
539    default_time_limit_secs: u64,
540    log_scale: fn(&S),
541    qualified_candidate_trace_provenance: Option<QualifiedCandidateTraceRunProvenance>,
542    build_runner: BuildRunner,
543) -> RuntimeBuildResult<S>
544where
545    S: PlanningSolution,
546    S::Score: Score + ParseableScore,
547    C: ConstraintSet<S, S::Score>,
548    Runner: Phase<S, ScoreDirector<S, C>, ChannelProgressCallback<S>> + Send + std::fmt::Debug,
549    BuildRunner: FnOnce(&SolverConfig, &SolutionDescriptor) -> RuntimeBuildResult<Runner>,
550{
551    log_scale(&solution);
552    let director = ScoreDirector::with_descriptor(
553        solution,
554        constraints,
555        descriptor.clone(),
556        entity_count_by_descriptor,
557    );
558
559    let (termination, time_limit) = build_termination::<S, C>(&config, default_time_limit_secs);
560    let execution_policy =
561        configured_execution_policy::<S>(&config, default_time_limit_secs, time_limit);
562
563    let callback = ChannelProgressCallback::new(runtime);
564
565    let runner = match build_runner(&config, &descriptor) {
566        Ok(runner) => runner,
567        Err(error) => {
568            runtime.emit_failed(error.to_string());
569            return Err(error);
570        }
571    };
572    let mut solver = Solver::new((runner,))
573        .with_config(config.clone())
574        .with_candidate_trace_execution_policy(execution_policy)
575        .with_termination(termination)
576        .with_runtime(runtime)
577        .with_progress_callback(callback);
578    if let Some(provenance) = qualified_candidate_trace_provenance {
579        solver = solver.with_qualified_candidate_trace_run_provenance(provenance);
580    }
581    if let Some(time_limit) = time_limit {
582        solver = solver.with_time_limit(time_limit);
583    }
584
585    let result = match catch_unwind(AssertUnwindSafe(|| {
586        solver.with_terminate(runtime.cancel_flag()).solve(director)
587    })) {
588        Ok(result) => result,
589        Err(payload) => match take_runtime_execution_failure(payload) {
590            Ok(error) => {
591                runtime.emit_failed(error.to_string());
592                return Err(error);
593            }
594            Err(payload) => resume_unwind(payload),
595        },
596    };
597
598    let crate::solver::SolveResult {
599        solution,
600        current_score,
601        best_score: final_score,
602        terminal_reason,
603        stats,
604    } = result;
605    let final_telemetry = stats.snapshot();
606    let final_move_speed = whole_units_per_second(stats.moves_evaluated, stats.elapsed());
607    match terminal_reason {
608        SolverTerminalReason::Completed | SolverTerminalReason::TerminatedByConfig => {
609            runtime.emit_completed(
610                solution.clone(),
611                current_score,
612                final_score,
613                final_telemetry,
614                terminal_reason,
615            );
616        }
617        SolverTerminalReason::Cancelled => {
618            runtime.emit_cancelled(current_score, Some(final_score), final_telemetry);
619        }
620        SolverTerminalReason::Failed => unreachable!("solver completion cannot report failure"),
621    }
622
623    info!(
624        event = "solve_end",
625        score = %final_score,
626        steps = stats.step_count,
627        moves_generated = stats.moves_generated,
628        moves_evaluated = stats.moves_evaluated,
629        moves_accepted = stats.moves_accepted,
630        score_calculations = stats.score_calculations,
631        generation_time = %format_duration(stats.generation_time()),
632        evaluation_time = %format_duration(stats.evaluation_time()),
633        moves_speed = final_move_speed,
634        acceptance_rate = format!("{:.1}%", stats.acceptance_rate() * 100.0),
635    );
636    Ok(solution)
637}
638
639#[cfg(test)]
640#[path = "run_tests.rs"]
641mod tests;