diff --git a/crates/solverforge-solver/src/builder/acceptor/tests.rs b/crates/solverforge-solver/src/builder/acceptor/tests.rs index 5527b853..1198663a 100644 --- a/crates/solverforge-solver/src/builder/acceptor/tests.rs +++ b/crates/solverforge-solver/src/builder/acceptor/tests.rs @@ -341,3 +341,94 @@ fn simulated_annealing_rejects_calibration_beside_level_temperatures() { let error = build_acceptor::(&config).expect_err("exclusive keys"); assert_eq!(error.field(), "calibration"); } + +/// Whether `acceptor` takes a move from `last_step` to `candidate`. +fn accepts( + acceptor: &mut AnyAcceptor, + last_step: S::Score, + candidate: S::Score, +) -> bool +where + S::Score: Score, +{ + Acceptor::::is_accepted(acceptor, &last_step, &candidate, None) +} + +#[test] +fn step_counting_hill_climbing_accepts_worse_moves_for_step_count_limit_steps() { + let config = AcceptorConfig::StepCountingHillClimbing(StepCountingHillClimbingConfig { + step_count_limit: Some(3), + }); + let mut acceptor: AnyAcceptor = build_acceptor(&config).unwrap(); + let zero = SoftScore::of(0); + Acceptor::::phase_started(&mut acceptor, &zero); + for _ in 0..2 { + Acceptor::::step_ended(&mut acceptor, &zero, None); + } + assert!(accepts(&mut acceptor, zero, SoftScore::of(-1))); + + Acceptor::::step_ended(&mut acceptor, &zero, None); + assert!(!accepts(&mut acceptor, zero, SoftScore::of(-1))); + assert!(accepts(&mut acceptor, zero, SoftScore::of(1))); +} + +#[test] +fn great_deluge_raises_the_water_level_by_the_configured_ratio() { + // From -100 at a ratio of 0.5, one step raises the water level to -50. + let config = AcceptorConfig::GreatDeluge(GreatDelugeConfig { + water_level_increase_ratio: Some(0.5), + }); + let mut acceptor: AnyAcceptor = build_acceptor(&config).unwrap(); + Acceptor::::phase_started(&mut acceptor, &SoftScore::of(-100)); + Acceptor::::step_ended(&mut acceptor, &SoftScore::of(-40), None); + + assert!(accepts( + &mut acceptor, + SoftScore::of(-40), + SoftScore::of(-50) + )); + assert!(!accepts( + &mut acceptor, + SoftScore::of(-40), + SoftScore::of(-51) + )); +} + +#[test] +fn simulated_annealing_hard_regression_policy_decides_hard_regressions() { + // At these temperatures a one-point regression is accepted with + // probability exp(-1e-12). + let hot = |policy| { + AcceptorConfig::SimulatedAnnealing(SimulatedAnnealingConfig { + level_temperatures: Some(vec![1.0e12, 1.0e12]), + hard_regression_policy: policy, + ..Default::default() + }) + }; + let regresses = |config: &AcceptorConfig| { + let mut acceptor: AnyAcceptor = + CompiledAcceptor::::compile(config) + .unwrap() + .build(Some(7)); + let zero = HardSoftScore::of(0, 0); + Acceptor::::phase_started(&mut acceptor, &zero); + ( + accepts(&mut acceptor, zero, HardSoftScore::of(-1, 0)), + accepts(&mut acceptor, zero, HardSoftScore::of(0, -1)), + ) + }; + + assert_eq!(regresses(&hot(None)), (true, true)); + assert_eq!( + regresses(&hot(Some( + HardRegressionPolicyConfig::TemperatureControlled + ))), + (true, true) + ); + assert_eq!( + regresses(&hot(Some( + HardRegressionPolicyConfig::NeverAcceptHardRegression + ))), + (false, true) + ); +} diff --git a/crates/solverforge-solver/src/builder/forager.rs b/crates/solverforge-solver/src/builder/forager.rs index 3bd1a19d..fe6cae30 100644 --- a/crates/solverforge-solver/src/builder/forager.rs +++ b/crates/solverforge-solver/src/builder/forager.rs @@ -113,6 +113,9 @@ where } } +#[cfg(test)] +mod tests; + /// Builds a forager from a configuration that passed [`CompiledForager::compile`]. pub(super) fn build_validated_forager( config: &ForagerConfig, diff --git a/crates/solverforge-solver/src/builder/forager/tests.rs b/crates/solverforge-solver/src/builder/forager/tests.rs new file mode 100644 index 00000000..dbaae9c7 --- /dev/null +++ b/crates/solverforge-solver/src/builder/forager/tests.rs @@ -0,0 +1,106 @@ +use solverforge_config::{ForagerConfig, ScoreTieBreak}; +use solverforge_core::domain::PlanningSolution; +use solverforge_core::score::SoftScore; + +use crate::builder::{AnyForager, CompiledForager}; +use crate::heuristic::r#move::ChangeMove; +use crate::heuristic::selector::move_selector::CandidateId; +use crate::phase::localsearch::LocalSearchForager; + +#[derive(Clone, Debug)] +struct Plan { + score: Option, +} + +impl PlanningSolution for Plan { + type Score = SoftScore; + + fn score(&self) -> Option { + self.score + } + + fn set_score(&mut self, score: Option) { + self.score = score; + } +} + +type TestMove = ChangeMove; + +/// Starts a step at `best` and `last_step`, offers `scores` in order until the +/// forager quits, and returns the picked candidate and how many were offered. +fn forage( + forager: &mut AnyForager, + best: i64, + last_step: i64, + step_seed: u64, + scores: &[i64], +) -> (usize, usize) { + LocalSearchForager::::step_started( + forager, + SoftScore::of(best), + SoftScore::of(last_step), + step_seed, + ); + let mut offered = 0; + for (index, score) in scores.iter().enumerate() { + offered += 1; + LocalSearchForager::::add_move_index( + forager, + CandidateId::new(index), + SoftScore::of(*score), + ); + if LocalSearchForager::::is_quit_early(forager) { + break; + } + } + let (picked, _) = LocalSearchForager::::pick_move_index(forager) + .expect("an offered candidate is picked"); + (picked.index(), offered) +} + +/// The candidates a forager picks among four equally scored ones over 32 steps. +fn tie_picks(forager: &mut AnyForager) -> std::collections::BTreeSet { + (0..32) + .map(|seed| forage(forager, -1, -1, seed, &[-1, -1, -1, -1]).0) + .collect() +} + +fn build(config: ForagerConfig, score_tie_break: ScoreTieBreak) -> AnyForager { + CompiledForager::compile(&config) + .expect("the forager config is valid") + .build(score_tie_break) +} + +#[test] +fn first_accepted_takes_the_first_candidate() { + let mut forager = build(ForagerConfig::FirstAccepted {}, ScoreTieBreak::First); + assert_eq!(forage(&mut forager, -10, -10, 0, &[-12, -5, -1]), (0, 1)); +} + +#[test] +fn first_best_score_improving_waits_for_a_new_best() { + // -15 improves the last step (-20) but not the best (-10); -5 improves both. + let mut forager = build( + ForagerConfig::FirstBestScoreImproving {}, + ScoreTieBreak::First, + ); + assert_eq!(forage(&mut forager, -10, -20, 0, &[-15, -5, -1]), (1, 2)); +} + +#[test] +fn first_last_step_score_improving_stops_on_beating_the_last_step() { + let mut forager = build( + ForagerConfig::FirstLastStepScoreImproving {}, + ScoreTieBreak::First, + ); + assert_eq!(forage(&mut forager, -10, -20, 0, &[-15, -5, -1]), (0, 1)); +} + +#[test] +fn a_random_score_tie_break_spreads_picks_over_equal_candidates() { + let mut random = build(ForagerConfig::BestScore {}, ScoreTieBreak::Random); + assert!(tie_picks(&mut random).len() > 1); + + let mut first = build(ForagerConfig::BestScore {}, ScoreTieBreak::First); + assert_eq!(tie_picks(&mut first), [0].into()); +} diff --git a/crates/solverforge-solver/src/runtime/compiler/executor/runner/local_search.rs b/crates/solverforge-solver/src/runtime/compiler/executor/runner/local_search.rs index 404e51f5..a63363b9 100644 --- a/crates/solverforge-solver/src/runtime/compiler/executor/runner/local_search.rs +++ b/crates/solverforge-solver/src/runtime/compiler/executor/runner/local_search.rs @@ -137,6 +137,9 @@ where } } +#[cfg(test)] +mod tests; + pub(super) fn build_acceptor_and_forager( components: DefaultLocalSearchComponents, configured_acceptor: Option<&CompiledAcceptor>, diff --git a/crates/solverforge-solver/src/runtime/compiler/executor/runner/local_search/tests.rs b/crates/solverforge-solver/src/runtime/compiler/executor/runner/local_search/tests.rs new file mode 100644 index 00000000..eeaa6212 --- /dev/null +++ b/crates/solverforge-solver/src/runtime/compiler/executor/runner/local_search/tests.rs @@ -0,0 +1,111 @@ +use solverforge_config::{AcceptorConfig, ScoreTieBreak, TabuSearchConfig}; +use solverforge_core::domain::PlanningSolution; +use solverforge_core::score::SoftScore; + +use super::build_acceptor_and_forager; +use crate::builder::{AnyForager, CompiledAcceptor}; +use crate::heuristic::r#move::ChangeMove; +use crate::heuristic::selector::move_selector::CandidateId; +use crate::phase::localsearch::LocalSearchForager; +use crate::runtime::compiler::default_local_search::{ + DefaultLocalSearchAcceptorPolicy, DefaultLocalSearchComponents, DefaultLocalSearchForagerPolicy, +}; + +#[derive(Clone, Debug)] +struct Plan { + score: Option, +} + +impl PlanningSolution for Plan { + type Score = SoftScore; + + fn score(&self) -> Option { + self.score + } + + fn set_score(&mut self, score: Option) { + self.score = score; + } +} + +type TestMove = ChangeMove; + +/// Starts a step at `best` and `last_step`, offers `scores` in order until the +/// forager quits, and returns the picked candidate and how many were offered. +fn forage( + forager: &mut AnyForager, + best: i64, + last_step: i64, + step_seed: u64, + scores: &[i64], +) -> (usize, usize) { + LocalSearchForager::::step_started( + forager, + SoftScore::of(best), + SoftScore::of(last_step), + step_seed, + ); + let mut offered = 0; + for (index, score) in scores.iter().enumerate() { + offered += 1; + LocalSearchForager::::add_move_index( + forager, + CandidateId::new(index), + SoftScore::of(*score), + ); + if LocalSearchForager::::is_quit_early(forager) { + break; + } + } + let (picked, _) = LocalSearchForager::::pick_move_index(forager) + .expect("an offered candidate is picked"); + (picked.index(), offered) +} + +/// The candidates a forager picks among four equally scored ones over 32 steps. +fn tie_picks(forager: &mut AnyForager) -> std::collections::BTreeSet { + (0..32) + .map(|seed| forage(forager, -1, -1, seed, &[-1, -1, -1, -1]).0) + .collect() +} + +const COMPONENTS: DefaultLocalSearchComponents = DefaultLocalSearchComponents { + acceptor: DefaultLocalSearchAcceptorPolicy::LateAcceptance { history_size: 400 }, + forager: DefaultLocalSearchForagerPolicy::AcceptedCount { limit: 4 }, +}; + +#[test] +fn the_default_forager_follows_the_score_tie_break() { + let (_, mut random) = + build_acceptor_and_forager::(COMPONENTS, None, None, ScoreTieBreak::Random, Some(7)); + assert!(tie_picks(&mut random).len() > 1); + + let (_, mut first) = + build_acceptor_and_forager::(COMPONENTS, None, None, ScoreTieBreak::First, Some(7)); + assert_eq!(tie_picks(&mut first), [0].into()); +} + +#[test] +fn the_tabu_search_default_forager_follows_the_score_tie_break() { + let tabu = + CompiledAcceptor::::compile(&AcceptorConfig::TabuSearch(TabuSearchConfig::default())) + .expect("the default tabu search config is valid"); + + let (_, mut random) = build_acceptor_and_forager::( + COMPONENTS, + Some(&tabu), + None, + ScoreTieBreak::Random, + Some(7), + ); + assert!(tie_picks(&mut random).len() > 1); + + let (_, mut first) = build_acceptor_and_forager::( + COMPONENTS, + Some(&tabu), + None, + ScoreTieBreak::First, + Some(7), + ); + assert_eq!(tie_picks(&mut first), [0].into()); +} diff --git a/crates/solverforge/tests/local_search_config.rs b/crates/solverforge/tests/local_search_config.rs new file mode 100644 index 00000000..7b6a21a1 --- /dev/null +++ b/crates/solverforge/tests/local_search_config.rs @@ -0,0 +1,158 @@ +//! Local search settings that only show in a solve: the recreate heuristic of +//! ruin-and-recreate moves, and that each random part of a search +//! (simulated annealing, random tie breaks, ruin choice) follows +//! `random_seed`. + +use solverforge::prelude::*; +use solverforge::SolverConfig; + +#[path = "local_search_config/domain/mod.rs"] +mod domain; + +use domain::Plan; + +fn solve(plan: Plan, toml: &str) -> Plan { + plan.solve_with_config(SolverConfig::from_toml_str(toml).expect("the test config parses")) + .expect("solves") +} + +/// First fit construction, then hill climbing over ruin-and-recreate moves +/// that rebuild with `recreate`. +fn ruin_recreate(recreate: &str) -> String { + format!( + r#"random_seed = 7 + +[[phases]] +type = "construction_heuristic" +construction_heuristic_type = "first_fit" + +[[phases]] +type = "local_search" +[phases.termination] +step_count_limit = 40 +[phases.acceptor] +type = "hill_climbing" +[phases.move_selector] +type = "ruin_recreate_move_selector" +min_ruin_count = 1 +max_ruin_count = 2 +recreate_heuristic_type = "{recreate}" +"# + ) +} + +#[test] +fn cheapest_insertion_recreates_on_the_cheapest_free_worker() { + // Two tasks, workers costing 3, 2, 1, sharing a worker costing 10. First + // fit construction puts both tasks on worker 0 (cost 16); cheapest + // insertion rebuilds on the cheapest free workers, reaching cost 3. + let solved = solve( + Plan::new(&[1, 1], &[3, 2, 1]), + &ruin_recreate("cheapest_insertion"), + ); + assert_eq!(solved.score, Some(SoftScore::of(-3))); + let mut workers = solved.workers_by_task(); + workers.sort(); + assert_eq!(workers, [Some(1), Some(2)]); +} + +#[test] +fn first_fit_recreate_puts_ruined_tasks_back_on_the_first_worker() { + // Every rebuild lands on worker 0 again, so no move improves on + // construction. + let solved = solve(Plan::new(&[1, 1], &[3, 2, 1]), &ruin_recreate("first_fit")); + assert_eq!(solved.score, Some(SoftScore::of(-16))); + assert_eq!(solved.workers_by_task(), [Some(0), Some(0)]); +} + +/// First fit construction, then local search configured by `local_search`, on +/// twelve tasks weighing 0 to 8 and four workers costing 1 to 4: many local +/// optima and equally scored moves, so a random part decides where a bounded +/// search ends. +fn stochastic(seed: u64, local_search: &str) -> Vec> { + let toml = format!( + r#"random_seed = {seed} + +[[phases]] +type = "construction_heuristic" +construction_heuristic_type = "first_fit" + +[[phases]] +type = "local_search" +{local_search} +"# + ); + let weights: Vec = (0..12).map(|id| (id * 5 + 2) % 9).collect(); + solve(Plan::new(&weights, &[1, 2, 3, 4]), &toml).workers_by_task() +} + +/// Repeated solves with one seed agree, and some other seed disagrees, so +/// the agreement is not a search that ignores its seed. +fn assert_seeded(local_search: &str) { + let first = stochastic(7, local_search); + for _ in 0..3 { + assert_eq!(stochastic(7, local_search), first); + } + assert!( + (1..10).any(|seed| stochastic(seed, local_search) != first), + "every seed gave the same assignment" + ); +} + +#[test] +fn simulated_annealing_follows_the_random_seed() { + // Moves in original order and the first accepted one taken: only + // annealing draws random numbers. + assert_seeded( + r#"score_tie_break = "first" +[phases.termination] +step_count_limit = 200 +[phases.acceptor] +type = "simulated_annealing" +level_temperatures = [20.0] +decay_rate = 0.999 +[phases.forager] +type = "first_accepted" +[phases.move_selector] +type = "change_move_selector" +selection_order = "original""#, + ); +} + +#[test] +fn random_tie_breaks_follow_the_random_seed() { + // Hill climbing over moves in original order: only tie breaks among + // equally scored moves are random. + assert_seeded( + r#"score_tie_break = "random" +[phases.termination] +step_count_limit = 200 +[phases.acceptor] +type = "hill_climbing" +[phases.forager] +type = "best_score" +[phases.move_selector] +type = "change_move_selector" +selection_order = "original""#, + ); +} + +#[test] +fn ruin_choice_follows_the_random_seed() { + // Hill climbing taking the first accepted move: only which tasks a + // ruin-and-recreate move unassigns is random. + assert_seeded( + r#"score_tie_break = "first" +[phases.termination] +step_count_limit = 200 +[phases.acceptor] +type = "hill_climbing" +[phases.forager] +type = "first_accepted" +[phases.move_selector] +type = "ruin_recreate_move_selector" +min_ruin_count = 1 +max_ruin_count = 3 +recreate_heuristic_type = "cheapest_insertion""#, + ); +} diff --git a/crates/solverforge/tests/local_search_config/domain/mod.rs b/crates/solverforge/tests/local_search_config/domain/mod.rs new file mode 100644 index 00000000..9020853f --- /dev/null +++ b/crates/solverforge/tests/local_search_config/domain/mod.rs @@ -0,0 +1,11 @@ +solverforge::planning_model! { + root = "crates/solverforge/tests/local_search_config/domain"; + + mod plan; + mod task; + mod worker; + + pub use plan::Plan; + pub use task::Task; + pub use worker::Worker; +} diff --git a/crates/solverforge/tests/local_search_config/domain/plan.rs b/crates/solverforge/tests/local_search_config/domain/plan.rs new file mode 100644 index 00000000..60970256 --- /dev/null +++ b/crates/solverforge/tests/local_search_config/domain/plan.rs @@ -0,0 +1,67 @@ +use solverforge::prelude::*; +use solverforge::stream::joiner::{equal, equal_bi}; +use solverforge::stream::ConstraintFactory; + +use super::{Task, Worker}; + +#[planning_solution(constraints = "constraints")] +pub struct Plan { + #[problem_fact_collection] + pub workers: Vec, + + #[planning_entity_collection] + pub tasks: Vec, + + #[planning_score] + pub score: Option, +} + +impl Plan { + /// One unassigned task per entry of `weights` and one worker per entry + /// of `costs`. + pub fn new(weights: &[i64], costs: &[i64]) -> Self { + Self { + workers: costs + .iter() + .enumerate() + .map(|(id, &cost)| Worker { id, cost }) + .collect(), + tasks: weights + .iter() + .enumerate() + .map(|(id, &weight)| Task { + id, + weight, + worker: None, + }) + .collect(), + score: None, + } + } + + pub fn workers_by_task(&self) -> Vec> { + self.tasks.iter().map(|task| task.worker).collect() + } +} + +/// Each task costs its weight times its worker's cost; two tasks sharing a +/// worker cost 10. +fn constraints() -> impl ConstraintSet { + ( + ConstraintFactory::::new() + .for_each(Plan::tasks()) + .join(equal(|task: &Task| task.worker)) + .penalize(|left: &Task, _right: &Task| { + SoftScore::of(if left.worker.is_some() { 10 } else { 0 }) + }) + .named("shared worker"), + ConstraintFactory::::new() + .for_each(Plan::tasks()) + .join(( + Plan::workers(), + equal_bi(|task: &Task| task.worker, |worker: &Worker| Some(worker.id)), + )) + .penalize(|task: &Task, worker: &Worker| SoftScore::of(task.weight * worker.cost)) + .named("worker cost"), + ) +} diff --git a/crates/solverforge/tests/local_search_config/domain/task.rs b/crates/solverforge/tests/local_search_config/domain/task.rs new file mode 100644 index 00000000..3a56cdc0 --- /dev/null +++ b/crates/solverforge/tests/local_search_config/domain/task.rs @@ -0,0 +1,13 @@ +use solverforge::prelude::*; + +#[planning_entity] +pub struct Task { + #[planning_id] + pub id: usize, + + /// Multiplies the worker's cost. + pub weight: i64, + + #[planning_variable(value_range_provider = "workers")] + pub worker: Option, +} diff --git a/crates/solverforge/tests/local_search_config/domain/worker.rs b/crates/solverforge/tests/local_search_config/domain/worker.rs new file mode 100644 index 00000000..517af67b --- /dev/null +++ b/crates/solverforge/tests/local_search_config/domain/worker.rs @@ -0,0 +1,10 @@ +use solverforge::prelude::*; + +#[problem_fact] +pub struct Worker { + #[planning_id] + pub id: usize, + + /// Soft cost of a task on this worker, per unit of task weight. + pub cost: i64, +}