From d82fcfc6581246d02bd37bf02c85706e9e8605e4 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 5 May 2026 07:55:37 -0600 Subject: [PATCH] feat(algorithms): add TabuSearch with a configurable neighbor generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Glover 1986 tabu search for single-objective problems. Generic over decision type — the user supplies a neighbor-generator closure that produces a finite list of candidate moves from the current incumbent (e.g. all 2-swaps for a permutation, or N Gaussian-perturbed copies of a real vector). Each iteration picks the best non-tabu neighbor (with an aspiration override that lets a tabu move through if it beats the best-seen-ever incumbent) and adds the chosen move's decision to a fixed-size FIFO tabu list. Single-objective only. Tracks the best-seen-ever incumbent across the run, returned as the result. Generic over the decision `D: Hash + Eq` so the tabu list can match by full decision (simple and correct; move-based tabu is left for users to implement themselves via a custom decision wrapper). --- src/algorithms/mod.rs | 2 + src/algorithms/tabu_search.rs | 277 ++++++++++++++++++++++++++++++++++ src/prelude.rs | 2 +- 3 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 src/algorithms/tabu_search.rs diff --git a/src/algorithms/mod.rs b/src/algorithms/mod.rs index c545731..43eb134 100644 --- a/src/algorithms/mod.rs +++ b/src/algorithms/mod.rs @@ -12,6 +12,7 @@ pub mod particle_swarm; pub mod random_search; pub mod simulated_annealing; pub mod spea2; +pub mod tabu_search; pub use differential_evolution::*; pub use genetic_algorithm::*; @@ -24,3 +25,4 @@ pub use particle_swarm::*; pub use random_search::*; pub use simulated_annealing::*; pub use spea2::*; +pub use tabu_search::*; diff --git a/src/algorithms/tabu_search.rs b/src/algorithms/tabu_search.rs new file mode 100644 index 0000000..35332b9 --- /dev/null +++ b/src/algorithms/tabu_search.rs @@ -0,0 +1,277 @@ +//! `TabuSearch` — Glover 1986 tabu search with a user-supplied neighbor +//! generator and decision-level FIFO tabu list. + +use std::collections::{HashSet, VecDeque}; +use std::hash::Hash; + +use crate::core::candidate::Candidate; +use crate::core::objective::Direction; +use crate::core::population::Population; +use crate::core::problem::Problem; +use crate::core::result::OptimizationResult; +use crate::core::rng::{Rng, rng_from_seed}; +use crate::traits::{Initializer, Optimizer}; + +/// Configuration for [`TabuSearch`]. +#[derive(Debug, Clone)] +pub struct TabuSearchConfig { + /// Number of iterations. + pub iterations: usize, + /// Maximum size of the FIFO tabu list (older entries are evicted). + pub tabu_tenure: usize, + /// Seed for the deterministic RNG used by the neighbor generator. + pub seed: u64, +} + +impl Default for TabuSearchConfig { + fn default() -> Self { + Self { iterations: 500, tabu_tenure: 16, seed: 42 } + } +} + +/// Single-objective tabu search. +/// +/// Each iteration the user-supplied `neighbors` closure produces a finite +/// list of candidate moves from the current incumbent. The best non-tabu +/// neighbor (or any tabu neighbor that improves the best-seen-ever +/// incumbent — the standard "aspiration" override) is accepted as the new +/// incumbent and its decision is appended to a FIFO tabu list of size +/// `tabu_tenure`. Tabu matches the full decision; users wanting move-based +/// tabu can wrap moves into a custom decision type. +pub struct TabuSearch +where + D: Clone + Hash + Eq, + I: Initializer, + N: FnMut(&D, &mut Rng) -> Vec, +{ + /// Algorithm configuration. + pub config: TabuSearchConfig, + /// Initial-decision sampler. + pub initializer: I, + /// Neighbor generator: produces a finite list of candidate moves from + /// the current incumbent. + pub neighbors: N, + _marker: std::marker::PhantomData, +} + +impl TabuSearch +where + D: Clone + Hash + Eq, + I: Initializer, + N: FnMut(&D, &mut Rng) -> Vec, +{ + /// Construct a `TabuSearch`. + pub fn new(config: TabuSearchConfig, initializer: I, neighbors: N) -> Self { + Self { config, initializer, neighbors, _marker: std::marker::PhantomData } + } +} + +impl Optimizer

for TabuSearch +where + P: Problem + Sync, + P::Decision: Clone + Hash + Eq + Send, + I: Initializer, + N: FnMut(&P::Decision, &mut Rng) -> Vec, +{ + fn run(&mut self, problem: &P) -> OptimizationResult { + let objectives = problem.objectives(); + assert!( + objectives.is_single_objective(), + "TabuSearch requires exactly one objective", + ); + assert!( + self.config.tabu_tenure >= 1, + "TabuSearch tabu_tenure must be >= 1", + ); + let direction = objectives.objectives[0].direction; + let mut rng = rng_from_seed(self.config.seed); + + let mut initial = self.initializer.initialize(1, &mut rng); + assert!(!initial.is_empty(), "TabuSearch initializer returned no decisions"); + let mut current_decision = initial.remove(0); + let mut current_eval = problem.evaluate(¤t_decision); + let mut best_decision = current_decision.clone(); + let mut best_eval = current_eval.clone(); + let mut evaluations = 1usize; + + let mut tabu_queue: VecDeque = VecDeque::with_capacity(self.config.tabu_tenure); + let mut tabu_set: HashSet = HashSet::new(); + + for _ in 0..self.config.iterations { + let candidates = (self.neighbors)(¤t_decision, &mut rng); + if candidates.is_empty() { + break; + } + + // Best non-tabu candidate, OR best tabu candidate that beats the + // best-seen-ever (aspiration). + let mut best_idx: Option = None; + let mut best_cand_eval: Option = None; + let evaluations_before = evaluations; + let mut cand_evals: Vec = + Vec::with_capacity(candidates.len()); + for c in &candidates { + cand_evals.push(problem.evaluate(c)); + } + evaluations += candidates.len(); + let _ = evaluations_before; + + for (i, c) in candidates.iter().enumerate() { + let is_tabu = tabu_set.contains(c); + let aspires = is_tabu + && better_than(&cand_evals[i], &best_eval, direction); + if is_tabu && !aspires { + continue; + } + let eligible = match &best_cand_eval { + None => true, + Some(b) => better_than(&cand_evals[i], b, direction), + }; + if eligible { + best_idx = Some(i); + best_cand_eval = Some(cand_evals[i].clone()); + } + } + + // If everything is tabu and nothing aspires, fall back to the + // best tabu candidate (avoid getting stuck). + if best_idx.is_none() { + for (i, _) in candidates.iter().enumerate() { + let eligible = match &best_cand_eval { + None => true, + Some(b) => better_than(&cand_evals[i], b, direction), + }; + if eligible { + best_idx = Some(i); + best_cand_eval = Some(cand_evals[i].clone()); + } + } + } + + let chosen_idx = best_idx.expect("non-empty candidate list"); + let chosen_decision = candidates[chosen_idx].clone(); + current_eval = cand_evals.remove(chosen_idx); + current_decision = chosen_decision.clone(); + + if better_than(¤t_eval, &best_eval, direction) { + best_decision = current_decision.clone(); + best_eval = current_eval.clone(); + } + + // Update FIFO tabu list. + tabu_queue.push_back(chosen_decision.clone()); + tabu_set.insert(chosen_decision); + if tabu_queue.len() > self.config.tabu_tenure { + if let Some(old) = tabu_queue.pop_front() { + tabu_set.remove(&old); + } + } + } + + let best = Candidate::new(best_decision, best_eval); + let population = Population::new(vec![best.clone()]); + let front = vec![best.clone()]; + OptimizationResult::new( + population, + front, + Some(best), + evaluations, + self.config.iterations, + ) + } +} + +fn better_than( + a: &crate::core::evaluation::Evaluation, + b: &crate::core::evaluation::Evaluation, + direction: Direction, +) -> bool { + match (a.is_feasible(), b.is_feasible()) { + (true, false) => true, + (false, true) => false, + (false, false) => a.constraint_violation < b.constraint_violation, + (true, true) => match direction { + Direction::Minimize => a.objectives[0] < b.objectives[0], + Direction::Maximize => a.objectives[0] > b.objectives[0], + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::evaluation::Evaluation; + use crate::core::objective::{Objective, ObjectiveSpace}; + use rand::Rng as _; + + /// Trivial integer-grid problem: minimize `(x - 7)^2`. + struct GridProblem; + impl Problem for GridProblem { + type Decision = Vec; + + fn objectives(&self) -> ObjectiveSpace { + ObjectiveSpace::new(vec![Objective::minimize("f")]) + } + + fn evaluate(&self, x: &Vec) -> Evaluation { + let v = (x[0] - 7) as f64; + Evaluation::new(vec![v * v]) + } + } + + /// Initialize a single 1-D integer at 0. + struct StartAtZero; + impl Initializer> for StartAtZero { + fn initialize(&mut self, size: usize, _rng: &mut Rng) -> Vec> { + (0..size).map(|_| vec![0]).collect() + } + } + + fn make_optimizer( + seed: u64, + neighbors: F, + ) -> TabuSearch, StartAtZero, F> + where + F: FnMut(&Vec, &mut Rng) -> Vec>, + { + TabuSearch::new( + TabuSearchConfig { iterations: 50, tabu_tenure: 4, seed }, + StartAtZero, + neighbors, + ) + } + + #[test] + fn finds_optimum_on_grid() { + // Neighbors: ±1 of current value. + let neighbors = |x: &Vec, _rng: &mut Rng| { + vec![vec![x[0] - 1], vec![x[0] + 1]] + }; + let mut opt = make_optimizer(1, neighbors); + let r = opt.run(&GridProblem); + let best = r.best.unwrap(); + assert_eq!(best.decision, vec![7]); + assert_eq!(best.evaluation.objectives, vec![0.0]); + } + + #[test] + fn deterministic_with_same_seed() { + let neighbors = |x: &Vec, rng: &mut Rng| { + (0..5) + .map(|_| vec![x[0] + rng.random_range(-3..=3)]) + .collect::>() + }; + let mut a = make_optimizer(99, neighbors); + let mut b = make_optimizer(99, |x: &Vec, rng: &mut Rng| { + (0..5) + .map(|_| vec![x[0] + rng.random_range(-3..=3)]) + .collect::>() + }); + let ra = a.run(&GridProblem); + let rb = b.run(&GridProblem); + assert_eq!( + ra.best.unwrap().evaluation.objectives, + rb.best.unwrap().evaluation.objectives, + ); + } +} diff --git a/src/prelude.rs b/src/prelude.rs index 521a095..cebc420 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -26,5 +26,5 @@ pub use crate::algorithms::{ GeneticAlgorithmConfig, HillClimber, HillClimberConfig, Moead, MoeadConfig, Nsga2, Nsga2Config, Nsga3, Nsga3Config, Paes, PaesConfig, ParticleSwarm, ParticleSwarmConfig, RandomSearch, RandomSearchConfig, SimulatedAnnealing, SimulatedAnnealingConfig, Spea2, - Spea2Config, + Spea2Config, TabuSearch, TabuSearchConfig, };