use std::any::TypeId;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use solverforge_config::{
CustomPhaseConfig, MoveThreadCount, PartitionedSearchConfig, PhaseConfig, SolverConfig,
};
use solverforge_core::domain::{PlanningSolution, SolutionDescriptor};
use solverforge_core::score::SoftScore;
use super::{
compile_runtime_graph, CompiledRuntimeExecutor, CompiledRuntimeExtension, CompiledRuntimePhase,
PreparedRuntimePhase, RuntimeCompileErrorKind, RuntimeExtensionKind, RuntimeGraphInput,
};
use crate::builder::{
CustomSearchPhase, NoDynamicExtensions, NoTypedExtensions, RuntimeModel, SearchContext,
};
use crate::heuristic::selector::nearby_list_change::CrossEntityDistanceMeter;
use crate::scope::{ProgressCallback, SolverScope};
static EXTENSION_BUILD_CALLS: AtomicUsize = AtomicUsize::new(0);
static EXTENSION_BUILD_LOCK: Mutex<()> = Mutex::new(());
#[derive(Clone, Debug)]
struct Plan {
score: Option<SoftScore>,
}
impl PlanningSolution for Plan {
type Score = SoftScore;
fn score(&self) -> Option<Self::Score> {
self.score
}
fn set_score(&mut self, score: Option<Self::Score>) {
self.score = score;
}
}
#[derive(Debug)]
struct MarkerExtension;
impl CustomSearchPhase<Plan> for MarkerExtension {
fn solve<D, ProgressCb>(&mut self, _solver_scope: &mut SolverScope<'_, Plan, D, ProgressCb>)
where
D: solverforge_scoring::Director<Plan>,
ProgressCb: ProgressCallback<Plan>,
{
}
}
fn context(seed: Option<u64>) -> SearchContext<Plan> {
SearchContext::new(
SolutionDescriptor::new("Plan", TypeId::of::<Plan>()),
RuntimeModel::new(Vec::new()),
seed,
)
}
#[derive(Clone, Debug)]
struct ExtensionMeter;
impl CrossEntityDistanceMeter<Plan> for ExtensionMeter {
fn distance(&self, _: &Plan, _: usize, _: usize, _: usize, _: usize) -> f64 {
0.0
}
}
fn extension_context(
seed: Option<u64>,
) -> SearchContext<Plan, usize, ExtensionMeter, ExtensionMeter> {
SearchContext::new(
SolutionDescriptor::new("Plan", TypeId::of::<Plan>()),
RuntimeModel::new(Vec::new()),
seed,
)
}
fn custom(name: &str) -> PhaseConfig {
PhaseConfig::Custom(CustomPhaseConfig {
name: name.to_string(),
})
}
fn partitioned(name: Option<&str>) -> PhaseConfig {
PhaseConfig::PartitionedSearch(PartitionedSearchConfig {
partitioner: name.map(str::to_string),
thread_count: MoveThreadCount::Count(3),
log_progress: true,
child_phases: vec![custom("child_extension")],
termination: None,
})
}
#[test]
fn typed_extensions_lower_names_without_invoking_builders_and_freeze_config() {
let _guard = EXTENSION_BUILD_LOCK.lock().expect("test lock");
EXTENSION_BUILD_CALLS.store(0, Ordering::SeqCst);
let mut config = SolverConfig {
random_seed: Some(41),
phases: vec![custom("repair"), partitioned(Some("by_task"))],
..SolverConfig::default()
};
let declaration = context(config.random_seed)
.defaults()
.phase("repair", |_| {
EXTENSION_BUILD_CALLS.fetch_add(1, Ordering::SeqCst);
MarkerExtension
})
.partitioned_phase("by_task", |_context, _config| {
EXTENSION_BUILD_CALLS.fetch_add(1, Ordering::SeqCst);
MarkerExtension
});
let (context, extensions) = declaration.into_runtime_parts();
let input = RuntimeGraphInput::new(context, extensions);
let graph = compile_runtime_graph(&config, input)
.expect("registered typed extensions compile into graph declarations");
assert_eq!(EXTENSION_BUILD_CALLS.load(Ordering::SeqCst), 0);
assert_eq!(graph.context().seed(), Some(41));
assert_eq!(graph.phases().len(), 2);
assert!(matches!(
&graph.phases()[0],
CompiledRuntimePhase::Extension(CompiledRuntimeExtension::Custom { name })
if name == "repair"
));
let CompiledRuntimePhase::Extension(CompiledRuntimeExtension::Partitioned {
name,
config: frozen,
}) = &graph.phases()[1]
else {
panic!("partitioned extension should be lowered as one immutable graph node");
};
assert_eq!(name, "by_task");
assert_eq!(frozen.partitioner.as_deref(), Some("by_task"));
assert_eq!(frozen.thread_count, MoveThreadCount::Count(3));
assert!(frozen.log_progress);
assert_eq!(frozen.child_phases.len(), 1);
let PhaseConfig::PartitionedSearch(live) = &mut config.phases[1] else {
panic!("test configuration should retain its partitioned phase");
};
live.partitioner = Some("mutated_after_compile".to_string());
live.thread_count = MoveThreadCount::None;
assert_eq!(frozen.partitioner.as_deref(), Some("by_task"));
assert_eq!(frozen.thread_count, MoveThreadCount::Count(3));
}
#[test]
fn typed_extensions_instantiate_fresh_once_per_solve_after_graph_compilation() {
let _guard = EXTENSION_BUILD_LOCK.lock().expect("test lock");
EXTENSION_BUILD_CALLS.store(0, Ordering::SeqCst);
let config = SolverConfig {
phases: vec![custom("repair")],
..SolverConfig::default()
};
let declaration = extension_context(config.random_seed)
.defaults()
.phase("repair", |_| {
EXTENSION_BUILD_CALLS.fetch_add(1, Ordering::SeqCst);
MarkerExtension
});
let (context, extensions) = declaration.into_runtime_parts();
let input = RuntimeGraphInput::new(context, extensions);
let graph = compile_runtime_graph(&config, input)
.expect("registered typed extension compiles without builder work");
assert_eq!(EXTENSION_BUILD_CALLS.load(Ordering::SeqCst), 0);
let executor = CompiledRuntimeExecutor::new(graph);
let first = executor
.instantiate()
.expect("first solve instantiates the declared extension");
assert_eq!(EXTENSION_BUILD_CALLS.load(Ordering::SeqCst), 1);
assert!(matches!(
first.phases.as_slice(),
[PreparedRuntimePhase::Extension(_)]
));
let second = executor
.instantiate()
.expect("each solve gets one fresh declared extension instance");
assert_eq!(EXTENSION_BUILD_CALLS.load(Ordering::SeqCst), 2);
assert!(matches!(
second.phases.as_slice(),
[PreparedRuntimePhase::Extension(_)]
));
}
#[test]
fn typed_extension_name_errors_are_precise() {
let missing_custom = SolverConfig {
phases: vec![custom("")],
..SolverConfig::default()
};
let error = compile_runtime_graph(
&missing_custom,
RuntimeGraphInput::new(context(missing_custom.random_seed), NoTypedExtensions),
)
.expect_err("an empty custom extension name is invalid");
assert!(matches!(
error.kind,
RuntimeCompileErrorKind::MissingCustomExtensionName
));
let unknown_custom = SolverConfig {
phases: vec![custom("missing")],
..SolverConfig::default()
};
let error = compile_runtime_graph(
&unknown_custom,
RuntimeGraphInput::new(context(unknown_custom.random_seed), NoTypedExtensions),
)
.expect_err("unregistered typed custom name is invalid");
assert!(matches!(
error.kind,
RuntimeCompileErrorKind::UnregisteredTypedCustomExtension { ref name }
if name == "missing"
));
let missing_partitioner = SolverConfig {
phases: vec![partitioned(None)],
..SolverConfig::default()
};
let error = compile_runtime_graph(
&missing_partitioner,
RuntimeGraphInput::new(context(missing_partitioner.random_seed), NoTypedExtensions),
)
.expect_err("a partitioned extension needs an explicit partitioner name");
assert!(matches!(
error.kind,
RuntimeCompileErrorKind::MissingPartitionerName
));
let unknown_partitioner = SolverConfig {
phases: vec![partitioned(Some("missing"))],
..SolverConfig::default()
};
let error = compile_runtime_graph(
&unknown_partitioner,
RuntimeGraphInput::new(context(unknown_partitioner.random_seed), NoTypedExtensions),
)
.expect_err("unregistered typed partitioner is invalid");
assert!(matches!(
error.kind,
RuntimeCompileErrorKind::UnregisteredTypedPartitioner { ref name }
if name == "missing"
));
}
#[test]
fn dynamic_extensions_reject_without_any_extension_builder_work() {
let custom_config = SolverConfig {
phases: vec![custom("repair")],
..SolverConfig::default()
};
let error = compile_runtime_graph(
&custom_config,
RuntimeGraphInput::new(context(custom_config.random_seed), NoDynamicExtensions),
)
.expect_err("dynamic custom extensions are not emulated");
assert!(matches!(
error.kind,
RuntimeCompileErrorKind::UnsupportedDynamicExtension {
extension: RuntimeExtensionKind::Custom
}
));
let partitioned_config = SolverConfig {
phases: vec![partitioned(Some("by_task"))],
..SolverConfig::default()
};
let error = compile_runtime_graph(
&partitioned_config,
RuntimeGraphInput::new(context(partitioned_config.random_seed), NoDynamicExtensions),
)
.expect_err("dynamic partitioned extensions are not emulated");
assert!(matches!(
error.kind,
RuntimeCompileErrorKind::UnsupportedDynamicExtension {
extension: RuntimeExtensionKind::Partitioned
}
));
}
#[test]
fn compiler_rejects_a_context_seed_that_diverges_from_config() {
let config = SolverConfig {
random_seed: Some(41),
..SolverConfig::default()
};
let error = compile_runtime_graph(
&config,
RuntimeGraphInput::new(context(Some(42)), NoTypedExtensions),
)
.expect_err("config remains authoritative for extension context seed");
assert!(matches!(
error.kind,
RuntimeCompileErrorKind::ContextSeedMismatch {
config_seed: Some(41),
context_seed: Some(42),
}
));
}
#[test]
#[should_panic(expected = "custom phase `repair` was registered more than once")]
fn typed_extension_registry_rejects_duplicate_names_before_compilation() {
let _ = context(None)
.defaults()
.phase("repair", |_| MarkerExtension)
.phase("repair", |_| MarkerExtension);
}