diff --git a/crates/solverforge-config/WIREFRAME.md b/crates/solverforge-config/WIREFRAME.md index 31585482..a81b7c31 100644 --- a/crates/solverforge-config/WIREFRAME.md +++ b/crates/solverforge-config/WIREFRAME.md @@ -220,7 +220,8 @@ profile. Explicit `acceptor`, `forager`, `move_selector`, and VND `score_tie_break` defaults to `ScoreTieBreak::Random`, which uses the seeded step stream for reservoir tie selection. `ScoreTieBreak::First` keeps the first -equally scored accepted candidate. +equally scored accepted candidate. Variable neighborhood descent applies the +same rule to equally scored best improving moves of a neighborhood. ### `VariableTargetConfig` diff --git a/crates/solverforge-config/src/phase.rs b/crates/solverforge-config/src/phase.rs index e5d709cb..58e6d8b0 100644 --- a/crates/solverforge-config/src/phase.rs +++ b/crates/solverforge-config/src/phase.rs @@ -168,7 +168,9 @@ pub struct LocalSearchConfig { /// Forager configuration. pub forager: Option, - /// Selection among equally scored accepted candidates. + /// Selection among equally scored accepted candidates; under + /// `variable_neighborhood_descent`, among equally scored best improving + /// moves of a neighborhood. #[serde(default)] pub score_tie_break: ScoreTieBreak, diff --git a/crates/solverforge-solver/src/phase/hard_delta_phase_tests.rs b/crates/solverforge-solver/src/phase/hard_delta_phase_tests.rs index 0e64e384..39d90faa 100644 --- a/crates/solverforge-solver/src/phase/hard_delta_phase_tests.rs +++ b/crates/solverforge-solver/src/phase/hard_delta_phase_tests.rs @@ -31,6 +31,7 @@ fn vnd_accepts_required_repair_that_improves_later_hard_level() { &mut neighborhoods, &mut resources, None, + solverforge_config::ScoreTieBreak::First, &mut solver_scope, ); diff --git a/crates/solverforge-solver/src/phase/localsearch/forager.rs b/crates/solverforge-solver/src/phase/localsearch/forager.rs index 8ef9e8dc..968611e7 100644 --- a/crates/solverforge-solver/src/phase/localsearch/forager.rs +++ b/crates/solverforge-solver/src/phase/localsearch/forager.rs @@ -138,7 +138,10 @@ where } } -fn reservoir_pick(step_seed: u64, equal_count: u64) -> bool { +/// Seeded reservoir selection: whether the `equal_count`-th equally scored +/// candidate of a step replaces the current one, so every tie is kept with +/// probability `1 / equal_count`. +pub(crate) fn reservoir_pick(step_seed: u64, equal_count: u64) -> bool { let mixed = splitmix64( step_seed ^ equal_count.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0xF04A_63E2_39B7_4D11, ); diff --git a/crates/solverforge-solver/src/phase/localsearch/vnd/phase.rs b/crates/solverforge-solver/src/phase/localsearch/vnd/phase.rs index 1ba7ad42..f7709607 100644 --- a/crates/solverforge-solver/src/phase/localsearch/vnd/phase.rs +++ b/crates/solverforge-solver/src/phase/localsearch/vnd/phase.rs @@ -2,6 +2,7 @@ use std::fmt::Debug; use std::time::Instant; use rand::RngExt; +use solverforge_config::ScoreTieBreak; use solverforge_core::domain::PlanningSolution; use solverforge_scoring::Director; use tracing::info; @@ -17,6 +18,7 @@ use crate::phase::control::{ use crate::phase::localsearch::evaluation::{ evaluate_candidate, record_evaluated_move, CandidateEvaluation, }; +use crate::phase::localsearch::forager::reservoir_pick; use crate::phase::localsearch::vnd::telemetry::{candidate_selector_label, VndProgress}; use crate::phase::localsearch::{CursorResources, MoveCursorSource}; use crate::scope::{PhaseScope, ProgressCallback, SolverScope, StepScope}; @@ -28,10 +30,15 @@ use crate::stats::{ /// Executes the one VND loop while lending the caller-owned resource only at /// cursor opening and candidate pull boundaries. The compiled runner passes /// its one solve-owned provider resource directly. +/// +/// `score_tie_break` chooses among equally scored best improving moves of a +/// neighborhood: `Random` by seeded reservoir selection from the step's seed, +/// `First` the first one. pub(crate) fn solve_vnd_with_resources( neighborhoods: &mut [Source], resources: &mut Source::Resources, step_limit: Option, + score_tie_break: ScoreTieBreak, solver_scope: &mut SolverScope<'_, S, D, ProgressCb>, ) where S: PlanningSolution, @@ -67,15 +74,12 @@ pub(crate) fn solve_vnd_with_resources( } let mut step_scope = StepScope::new(&mut phase_scope); - let stream_context = MoveStreamContext::new( - step_scope.step_index(), - step_scope - .phase_scope_mut() - .solver_scope_mut() - .rng() - .random::(), - None, - ); + let step_seed = step_scope + .phase_scope_mut() + .solver_scope_mut() + .rng() + .random::(); + let stream_context = MoveStreamContext::new(step_scope.step_index(), step_seed, None); let mut cursor = neighborhoods[k].open_cursor(resources, step_scope.score_director(), stream_context); @@ -85,6 +89,7 @@ pub(crate) fn solve_vnd_with_resources( &mut step_scope, ¤t_score, &mut progress, + TieBreak::new(score_tie_break, step_seed), ); resources.flush_step_telemetry(step_scope.phase_scope_mut().stats_mut()); match search { @@ -186,6 +191,7 @@ fn find_best_improving_move( step_scope: &mut StepScope<'_, '_, '_, S, D, ProgressCb>, current_score: &S::Score, progress: &mut VndProgress, + mut ties: TieBreak, ) -> MoveSearchResult where S: PlanningSolution, @@ -345,8 +351,15 @@ where progress.maybe_report(step_scope); if move_score > *current_score { + let replaces_best = match &best { + Some((_, best_score, _)) => ties.replaces(move_score.cmp(best_score)), + None => { + ties.first(); + false + } + }; match &best { - Some((_, best_score, _)) if move_score > *best_score => { + Some(_) if replaces_best => { let (previous_best, _, previous_token) = best .take() .expect("VND best candidate must exist before replacement"); @@ -411,6 +424,46 @@ where } } +/// Tie state of one VND step: how many equally scored best improving moves +/// were seen, and whether a later one may replace the current best. +struct TieBreak { + random: bool, + step_seed: u64, + equal_count: u64, +} + +impl TieBreak { + fn new(score_tie_break: ScoreTieBreak, step_seed: u64) -> Self { + Self { + random: matches!(score_tie_break, ScoreTieBreak::Random), + step_seed, + equal_count: 0, + } + } + + /// Records the first improving move of the step. + fn first(&mut self) { + self.equal_count = 1; + } + + /// Whether an improving move comparing `ordering` to the current best + /// replaces it: a better one always does, an equal one by seeded + /// reservoir selection under `Random`. + fn replaces(&mut self, ordering: std::cmp::Ordering) -> bool { + match ordering { + std::cmp::Ordering::Greater => { + self.equal_count = 1; + true + } + std::cmp::Ordering::Equal => { + self.equal_count += 1; + self.random && reservoir_pick(self.step_seed, self.equal_count) + } + std::cmp::Ordering::Less => false, + } + } +} + #[cfg(test)] #[path = "tests.rs"] mod tests; diff --git a/crates/solverforge-solver/src/phase/localsearch/vnd/tests.rs b/crates/solverforge-solver/src/phase/localsearch/vnd/tests.rs index fa87b336..c811c372 100644 --- a/crates/solverforge-solver/src/phase/localsearch/vnd/tests.rs +++ b/crates/solverforge-solver/src/phase/localsearch/vnd/tests.rs @@ -21,6 +21,9 @@ use crate::scope::SolverScope; #[path = "tests/repair.rs"] mod repair; +#[path = "tests/ties.rs"] +mod ties; + #[derive(Clone, Debug)] struct InterruptPlan { value: i64, @@ -244,6 +247,7 @@ fn vnd_single_neighborhood_applies_best_improving_move() { &mut neighborhoods, &mut resources, None, + solverforge_config::ScoreTieBreak::First, &mut solver_scope, ); @@ -280,6 +284,7 @@ fn vnd_cancel_mid_neighborhood_does_not_commit_partial_best() { &mut neighborhoods, &mut resources, None, + solverforge_config::ScoreTieBreak::First, &mut solver_scope, ); @@ -305,6 +310,7 @@ fn vnd_polling_advances_through_non_doable_tail_after_cancel_request() { &mut neighborhoods, &mut resources, None, + solverforge_config::ScoreTieBreak::First, &mut solver_scope, ); diff --git a/crates/solverforge-solver/src/phase/localsearch/vnd/tests/repair.rs b/crates/solverforge-solver/src/phase/localsearch/vnd/tests/repair.rs index fb1e9c48..72d6dcfa 100644 --- a/crates/solverforge-solver/src/phase/localsearch/vnd/tests/repair.rs +++ b/crates/solverforge-solver/src/phase/localsearch/vnd/tests/repair.rs @@ -16,6 +16,7 @@ fn vnd_rejects_hard_neutral_repair_move_when_hard_improvement_required() { &mut neighborhoods, &mut resources, None, + solverforge_config::ScoreTieBreak::First, &mut solver_scope, ); diff --git a/crates/solverforge-solver/src/phase/localsearch/vnd/tests/ties.rs b/crates/solverforge-solver/src/phase/localsearch/vnd/tests/ties.rs new file mode 100644 index 00000000..f6a06cab --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/vnd/tests/ties.rs @@ -0,0 +1,218 @@ +// `score_tie_break` among equally scored best improving VND moves. + +use std::any::TypeId; +use std::borrow::Cow; + +use solverforge_config::ScoreTieBreak; +use solverforge_core::domain::{PlanningSolution, SolutionDescriptor}; +use solverforge_core::score::SoftScore; +use solverforge_scoring::Director; + +use super::super::{solve_vnd_with_resources, TieBreak}; +use crate::heuristic::r#move::metadata::{hash_str, MoveTabuScope}; +use crate::heuristic::r#move::{Move, MoveTabuSignature}; +use crate::heuristic::selector::move_selector::ArenaMoveCursor; +use crate::heuristic::selector::MoveSelector; +use crate::phase::localsearch::SelectorCursorSource; +use crate::scope::SolverScope; + +#[derive(Clone, Debug)] +struct TiePlan { + value: i64, + tag: usize, + score: Option, +} + +impl PlanningSolution for TiePlan { + type Score = SoftScore; + + fn score(&self) -> Option { + self.score + } + + fn set_score(&mut self, score: Option) { + self.score = score; + } +} + +struct TieDirector { + plan: TiePlan, + descriptor: SolutionDescriptor, +} + +impl Director for TieDirector { + fn working_solution(&self) -> &TiePlan { + &self.plan + } + + fn working_solution_mut(&mut self) -> &mut TiePlan { + &mut self.plan + } + + fn calculate_score(&mut self) -> SoftScore { + let score = SoftScore::of(self.plan.value); + self.plan.set_score(Some(score)); + score + } + + fn solution_descriptor(&self) -> &SolutionDescriptor { + &self.descriptor + } + + fn clone_working_solution(&self) -> TiePlan { + self.plan.clone() + } + + fn before_variable_changed(&mut self, _descriptor_index: usize, _entity_index: usize) {} + + fn after_variable_changed(&mut self, _descriptor_index: usize, _entity_index: usize) {} + + fn entity_count(&self, _descriptor_index: usize) -> Option { + Some(0) + } + + fn total_entity_count(&self) -> Option { + Some(0) + } + + fn constraint_metadata(&self) -> Vec> { + Vec::new() + } +} + +/// Sets the plan's value (its score) and tags the plan with the move. +#[derive(Clone, Debug)] +struct TieMove { + value: i64, + tag: usize, +} + +impl Move for TieMove { + type Undo = (i64, usize); + + fn is_doable>(&self, _score_director: &D) -> bool { + true + } + + fn do_move>(&self, score_director: &mut D) -> Self::Undo { + let plan = score_director.working_solution_mut(); + let previous = (plan.value, plan.tag); + plan.value = self.value; + plan.tag = self.tag; + previous + } + + fn undo_move>(&self, score_director: &mut D, undo: Self::Undo) { + let plan = score_director.working_solution_mut(); + (plan.value, plan.tag) = undo; + } + + fn descriptor_index(&self) -> usize { + 0 + } + + fn entity_indices(&self) -> &[usize] { + &[] + } + + fn variable_name(&self) -> &str { + "tie_move" + } + + fn tabu_signature>( + &self, + _score_director: &D, + ) -> Cow<'_, MoveTabuSignature> { + Cow::Owned(MoveTabuSignature::new( + MoveTabuScope::new(0, "tie_move"), + &[hash_str("vnd_tie_move")], + &[hash_str("vnd_tie_move")], + )) + } +} + +#[derive(Clone, Debug)] +struct TieSelector { + moves: Vec, +} + +impl MoveSelector for TieSelector { + type Cursor<'a> + = ArenaMoveCursor + where + Self: 'a; + + fn open_cursor<'a, D: Director>(&'a self, _score_director: &D) -> Self::Cursor<'a> { + ArenaMoveCursor::from_moves(self.moves.iter().cloned()) + } + + fn size>(&self, _score_director: &D) -> usize { + self.moves.len() + } +} + +/// One VND step over eight moves that all improve to the same score, with +/// `seed`; returns the tag of the applied move. +fn applied_tag(score_tie_break: ScoreTieBreak, seed: u64) -> usize { + let director = TieDirector { + plan: TiePlan { + value: 0, + tag: usize::MAX, + score: None, + }, + descriptor: SolutionDescriptor::new("TiePlan", TypeId::of::()), + }; + let mut solver_scope = SolverScope::new(director).with_seed(seed); + let selector = TieSelector { + moves: (0..8).map(|tag| TieMove { value: 5, tag }).collect(), + }; + let mut neighborhoods = vec![SelectorCursorSource::new(selector)]; + solve_vnd_with_resources::<_, _, _, TieMove, _>( + &mut neighborhoods, + &mut (), + Some(1), + score_tie_break, + &mut solver_scope, + ); + assert_eq!(solver_scope.working_solution().value, 5); + solver_scope.working_solution().tag +} + +#[test] +fn first_keeps_the_first_tied_move() { + for seed in 0..32 { + assert_eq!(applied_tag(ScoreTieBreak::First, seed), 0); + } +} + +#[test] +fn random_picks_among_tied_moves_reproducibly() { + let tags: Vec = (0..32) + .map(|seed| applied_tag(ScoreTieBreak::Random, seed)) + .collect(); + assert!( + tags.iter().any(|&tag| tag != 0), + "random tie-breaking never left the first move: {tags:?}" + ); + let distinct: std::collections::BTreeSet<_> = tags.iter().collect(); + assert!(distinct.len() > 2, "{tags:?}"); + for seed in 0..32 { + assert_eq!( + applied_tag(ScoreTieBreak::Random, seed), + tags[seed as usize] + ); + } +} + +#[test] +fn a_better_move_always_wins_over_ties() { + let mut ties = TieBreak::new(ScoreTieBreak::Random, 7); + ties.first(); + assert!(ties.replaces(std::cmp::Ordering::Greater)); + assert!(!ties.replaces(std::cmp::Ordering::Less)); + let mut first = TieBreak::new(ScoreTieBreak::First, 7); + first.first(); + for _ in 0..16 { + assert!(!first.replaces(std::cmp::Ordering::Equal)); + } +} 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 75880fd1..404e51f5 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 @@ -51,6 +51,7 @@ where VariableNeighborhoodDescent { phase_termination: Option, neighborhoods: Vec>, + score_tie_break: ScoreTieBreak, }, } @@ -104,13 +105,20 @@ where Self::VariableNeighborhoodDescent { phase_termination, neighborhoods, + score_tie_break, } => { let mut sources = neighborhoods .iter_mut() .map(RuntimeNeighborhoodState::borrowed_source) .collect::>(); solver_scope.with_phase_termination(phase_termination.as_ref(), |solver_scope| { - solve_vnd_with_resources(&mut sources, resources, None, solver_scope); + solve_vnd_with_resources( + &mut sources, + resources, + None, + *score_tie_break, + solver_scope, + ); }); } } diff --git a/crates/solverforge-solver/src/runtime/compiler/executor/runner/lowering.rs b/crates/solverforge-solver/src/runtime/compiler/executor/runner/lowering.rs index 1a2ad518..77830ed2 100644 --- a/crates/solverforge-solver/src/runtime/compiler/executor/runner/lowering.rs +++ b/crates/solverforge-solver/src/runtime/compiler/executor/runner/lowering.rs @@ -199,6 +199,7 @@ where RuntimeLocalSearch::VariableNeighborhoodDescent { phase_termination: config.termination, neighborhoods, + score_tie_break: config.score_tie_break, }, declaration, ))