1use 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
37pub 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#[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
201pub 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
277pub(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#[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;