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, 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
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(&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#[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 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
289pub(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#[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;