From 4882e1865de6ab99f65d5618b6b86cabc8e73df6 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 4 May 2026 19:22:15 -0600 Subject: [PATCH] feat(selection): add random and single-objective tournament selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `select_random` samples `count` decisions with replacement and clones them out of the population (spec §10.1). `tournament_select_single_objective` runs binary-or-larger tournaments with the spec's tiebreak order: feasible beats infeasible, lower violation among infeasibles, and direction-correct objective comparison among feasibles. Panics if not exactly one objective (spec §10.2). Selection helpers stay under `heuropt::selection` and are not part of the prelude (spec §15). --- src/lib.rs | 1 + src/selection/mod.rs | 7 ++ src/selection/random.rs | 67 +++++++++++++++++++ src/selection/tournament.rs | 130 ++++++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+) create mode 100644 src/selection/mod.rs create mode 100644 src/selection/random.rs create mode 100644 src/selection/tournament.rs diff --git a/src/lib.rs b/src/lib.rs index 483060d..bc00fd2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,4 +6,5 @@ pub mod core; pub mod operators; pub mod pareto; pub mod prelude; +pub mod selection; pub mod traits; diff --git a/src/selection/mod.rs b/src/selection/mod.rs new file mode 100644 index 0000000..34f66f0 --- /dev/null +++ b/src/selection/mod.rs @@ -0,0 +1,7 @@ +//! Reusable selection helpers used by built-in algorithms. + +pub mod random; +pub mod tournament; + +pub use random::*; +pub use tournament::*; diff --git a/src/selection/random.rs b/src/selection/random.rs new file mode 100644 index 0000000..9d8613a --- /dev/null +++ b/src/selection/random.rs @@ -0,0 +1,67 @@ +//! Uniform random selection (with replacement). + +use rand::Rng as _; + +use crate::core::candidate::Candidate; +use crate::core::rng::Rng; + +/// Select `count` decisions uniformly at random with replacement. +/// +/// Returns cloned decisions. Panics if `population` is empty and `count > 0` +/// (spec §10.1). +pub fn select_random( + population: &[Candidate], + count: usize, + rng: &mut Rng, +) -> Vec { + if count == 0 { + return Vec::new(); + } + assert!( + !population.is_empty(), + "select_random called on empty population with count > 0", + ); + let mut out = Vec::with_capacity(count); + for _ in 0..count { + let idx = rng.random_range(0..population.len()); + out.push(population[idx].decision.clone()); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::evaluation::Evaluation; + use crate::core::rng::rng_from_seed; + + fn cand(d: u32) -> Candidate { + Candidate::new(d, Evaluation::new(vec![d as f64])) + } + + #[test] + fn returns_count_decisions_from_population() { + let pop = [cand(1), cand(2), cand(3)]; + let mut rng = rng_from_seed(42); + let picks = select_random(&pop, 5, &mut rng); + assert_eq!(picks.len(), 5); + for p in &picks { + assert!([1, 2, 3].contains(p)); + } + } + + #[test] + fn count_zero_returns_empty() { + let pop = [cand(1)]; + let mut rng = rng_from_seed(0); + assert!(select_random(&pop, 0, &mut rng).is_empty()); + } + + #[test] + #[should_panic(expected = "empty population")] + fn empty_population_panics() { + let pop: Vec> = Vec::new(); + let mut rng = rng_from_seed(0); + let _ = select_random(&pop, 1, &mut rng); + } +} diff --git a/src/selection/tournament.rs b/src/selection/tournament.rs new file mode 100644 index 0000000..f3b0bc2 --- /dev/null +++ b/src/selection/tournament.rs @@ -0,0 +1,130 @@ +//! Single-objective tournament selection. + +use rand::Rng as _; + +use crate::core::candidate::Candidate; +use crate::core::objective::{Direction, ObjectiveSpace}; +use crate::core::rng::Rng; + +/// Tournament selection for single-objective problems. +/// +/// Each tournament samples `tournament_size` candidates uniformly with +/// replacement; the best one's decision is cloned into the output. Tiebreak +/// rules (spec §10.2): +/// +/// 1. Feasible candidates beat infeasible candidates. +/// 2. Among infeasibles, smaller `constraint_violation` wins. +/// 3. Among feasibles, the direction-correct best objective wins. +/// +/// # Panics +/// If `objectives` does not contain exactly one objective, or if `population` +/// is empty when `count > 0`, or if `tournament_size == 0`. +pub fn tournament_select_single_objective( + population: &[Candidate], + objectives: &ObjectiveSpace, + tournament_size: usize, + count: usize, + rng: &mut Rng, +) -> Vec { + assert!( + objectives.is_single_objective(), + "tournament_select_single_objective requires exactly one objective", + ); + assert!( + tournament_size > 0, + "tournament_size must be greater than 0", + ); + if count == 0 { + return Vec::new(); + } + assert!( + !population.is_empty(), + "tournament_select_single_objective called on empty population with count > 0", + ); + + let direction = objectives.objectives[0].direction; + let mut out = Vec::with_capacity(count); + for _ in 0..count { + let mut best_idx = rng.random_range(0..population.len()); + for _ in 1..tournament_size { + let challenger = rng.random_range(0..population.len()); + if challenger_wins(&population[challenger], &population[best_idx], direction) { + best_idx = challenger; + } + } + out.push(population[best_idx].decision.clone()); + } + out +} + +fn challenger_wins(c: &Candidate, b: &Candidate, dir: Direction) -> bool { + match (c.evaluation.is_feasible(), b.evaluation.is_feasible()) { + (true, false) => true, + (false, true) => false, + (false, false) => c.evaluation.constraint_violation < b.evaluation.constraint_violation, + (true, true) => { + let cv = c.evaluation.objectives.first().copied().unwrap_or(f64::INFINITY); + let bv = b.evaluation.objectives.first().copied().unwrap_or(f64::INFINITY); + match dir { + Direction::Minimize => cv < bv, + Direction::Maximize => cv > bv, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::evaluation::Evaluation; + use crate::core::objective::Objective; + use crate::core::rng::rng_from_seed; + + fn cand_min(d: u32, v: f64) -> Candidate { + Candidate::new(d, Evaluation::new(vec![v])) + } + + #[test] + fn large_tournament_picks_best_minimize() { + let s = ObjectiveSpace::new(vec![Objective::minimize("f")]); + let pop = [cand_min(1, 10.0), cand_min(2, 1.0), cand_min(3, 5.0)]; + let mut rng = rng_from_seed(1); + // Tournament size equal to population almost always returns the best. + let picks = tournament_select_single_objective(&pop, &s, 100, 10, &mut rng); + assert!(picks.iter().all(|&d| d == 2)); + } + + #[test] + fn large_tournament_picks_best_maximize() { + let s = ObjectiveSpace::new(vec![Objective::maximize("score")]); + let pop = [cand_min(1, 10.0), cand_min(2, 1.0), cand_min(3, 5.0)]; + let mut rng = rng_from_seed(2); + let picks = tournament_select_single_objective(&pop, &s, 100, 10, &mut rng); + assert!(picks.iter().all(|&d| d == 1)); + } + + #[test] + fn feasible_beats_infeasible() { + let s = ObjectiveSpace::new(vec![Objective::minimize("f")]); + let pop = [ + Candidate::new(1u32, Evaluation::constrained(vec![0.0], 5.0)), + Candidate::new(2u32, Evaluation::new(vec![100.0])), + ]; + let mut rng = rng_from_seed(3); + let picks = tournament_select_single_objective(&pop, &s, 50, 20, &mut rng); + // Feasible candidate (decision 2) wins regardless of objective value. + assert!(picks.iter().all(|&d| d == 2)); + } + + #[test] + #[should_panic(expected = "exactly one objective")] + fn multi_objective_panics() { + let s = ObjectiveSpace::new(vec![ + Objective::minimize("f1"), + Objective::minimize("f2"), + ]); + let pop = [cand_min(1, 1.0)]; + let mut rng = rng_from_seed(0); + let _ = tournament_select_single_objective(&pop, &s, 2, 1, &mut rng); + } +}