//! `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(feature = "async")] impl TabuSearch where D: Clone + Hash + Eq, I: Initializer, N: FnMut(&D, &mut Rng) -> Vec, { /// Async version of [`Optimizer::run`] — drives evaluations through /// the user-chosen async runtime. Available only with the `async` /// feature. /// /// Each iteration evaluates the K neighbors of the current /// incumbent concurrently (bounded by `concurrency`), then picks /// the best non-tabu (or aspiration-passing) move. pub async fn run_async

(&mut self, problem: &P, concurrency: usize) -> OptimizationResult where P: crate::core::async_problem::AsyncProblem, D: Send + Sync, { use crate::algorithms::parallel_eval_async::evaluate_batch_async; 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_async(¤t_decision).await; 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; } let cand_results = evaluate_batch_async(problem, candidates.clone(), concurrency).await; let mut cand_evals: Vec = cand_results.into_iter().map(|c| c.evaluation).collect(); evaluations += candidates.len(); let mut best_idx: Option = None; let mut best_cand_eval: Option = None; 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 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(); } 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, ) } } impl crate::traits::AlgorithmInfo for TabuSearch where D: Clone + Hash + Eq, I: Initializer, N: FnMut(&D, &mut Rng) -> Vec, { fn name(&self) -> &'static str { "Tabu Search" } fn seed(&self) -> Option { Some(self.config.seed) } } #[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, ); } // ---- Mutation-test pinned helpers -------------------------------------- #[test] fn better_than_feasibility_first_and_direction() { use crate::core::objective::Direction; let feasible = Evaluation::new(vec![100.0]); let infeasible = Evaluation::constrained(vec![0.0], 1.0); assert!(better_than(&feasible, &infeasible, Direction::Minimize)); assert!(!better_than(&infeasible, &feasible, Direction::Minimize)); let lo = Evaluation::new(vec![1.0]); let hi = Evaluation::new(vec![2.0]); assert!(better_than(&lo, &hi, Direction::Minimize)); assert!(better_than(&hi, &lo, Direction::Maximize)); let eq = Evaluation::new(vec![1.0]); assert!(!better_than(&lo, &eq, Direction::Minimize)); let v_lo = Evaluation::constrained(vec![0.0], 0.2); let v_hi = Evaluation::constrained(vec![0.0], 0.8); assert!(better_than(&v_lo, &v_hi, Direction::Minimize)); } }