From 66f6cf6e86abca999548d84a1872a7a847b6b176 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 5 May 2026 09:55:15 -0600 Subject: [PATCH] feat(selection): add stochastic_ranking_select for constrained problems Runarsson & Yao 2000 stochastic ranking: a probabilistic alternative to feasibility-first tournament selection. Each pairwise comparison during a bubble-sort pass uses the *objective* value with probability `pf` even when one or both candidates are infeasible. The classic recommendation `pf = 0.45` reliably outperforms strict feasibility-first on heavily-constrained problems where occasionally exploring the infeasible region helps cross narrow feasible corridors. New helper: `stochastic_ranking_select` lives next to `tournament_select_single_objective` in `selection::tournament`. Single-objective only; same signature pattern (population, objectives, count, rng, plus the new `pf` knob). --- src/selection/tournament.rs | 135 ++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/src/selection/tournament.rs b/src/selection/tournament.rs index f3b0bc2..3ba7e03 100644 --- a/src/selection/tournament.rs +++ b/src/selection/tournament.rs @@ -73,6 +73,105 @@ fn challenger_wins(c: &Candidate, b: &Candidate, dir: Direction) -> boo } } +/// Stochastic-ranking selection (Runarsson & Yao 2000) for single-objective +/// constrained problems. +/// +/// Performs a probabilistic bubble-sort pass on the population — each +/// pairwise comparison uses the *objective* value with probability `pf`, +/// otherwise it uses the standard feasibility-then-violation-then-objective +/// rule. The classic value is `pf = 0.45`; values close to `0.5` weight +/// objective improvement against constraint satisfaction. +/// +/// Returns `count` decisions cloned from the top of the ranked +/// population. Useful when constraint satisfaction is hard and strict +/// feasibility-first selection traps the search outside the feasible +/// region. +/// +/// # Panics +/// If `objectives` does not contain exactly one objective, if `pf` is +/// outside `[0.0, 1.0]`, or if the population is empty when `count > 0`. +pub fn stochastic_ranking_select( + population: &[Candidate], + objectives: &ObjectiveSpace, + pf: f64, + count: usize, + rng: &mut Rng, +) -> Vec { + assert!( + objectives.is_single_objective(), + "stochastic_ranking_select requires exactly one objective", + ); + assert!( + (0.0..=1.0).contains(&pf), + "stochastic_ranking_select pf must be in [0.0, 1.0]", + ); + if count == 0 { + return Vec::new(); + } + assert!( + !population.is_empty(), + "stochastic_ranking_select called on empty population with count > 0", + ); + + let direction = objectives.objectives[0].direction; + let n = population.len(); + let mut order: Vec = (0..n).collect(); + + // Bubble-sort with at most n full sweeps (Runarsson & Yao §3). + for _ in 0..n { + let mut swapped = false; + for i in 0..n - 1 { + let a = &population[order[i]].evaluation; + let b = &population[order[i + 1]].evaluation; + let use_objective = rng.random::() < pf; + let a_first = if use_objective || (a.is_feasible() && b.is_feasible()) { + better_by_objective(a, b, direction) + } else { + better_by_feasibility(a, b, direction) + }; + if !a_first { + order.swap(i, i + 1); + swapped = true; + } + } + if !swapped { + break; + } + } + + let mut out = Vec::with_capacity(count); + for k in 0..count { + out.push(population[order[k % n]].decision.clone()); + } + out +} + +fn better_by_objective( + a: &crate::core::evaluation::Evaluation, + b: &crate::core::evaluation::Evaluation, + direction: Direction, +) -> bool { + let av = a.objectives.first().copied().unwrap_or(f64::INFINITY); + let bv = b.objectives.first().copied().unwrap_or(f64::INFINITY); + match direction { + Direction::Minimize => av < bv, + Direction::Maximize => av > bv, + } +} + +fn better_by_feasibility( + 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) => better_by_objective(a, b, direction), + } +} + #[cfg(test)] mod tests { use super::*; @@ -127,4 +226,40 @@ mod tests { let mut rng = rng_from_seed(0); let _ = tournament_select_single_objective(&pop, &s, 2, 1, &mut rng); } + + #[test] + fn stochastic_ranking_returns_count_decisions() { + let s = ObjectiveSpace::new(vec![Objective::minimize("f")]); + let pop = [cand_min(1, 5.0), cand_min(2, 1.0), cand_min(3, 9.0)]; + let mut rng = rng_from_seed(7); + let picks = stochastic_ranking_select(&pop, &s, 0.45, 4, &mut rng); + assert_eq!(picks.len(), 4); + for p in &picks { + assert!([1, 2, 3].contains(p)); + } + } + + #[test] + fn stochastic_ranking_pf_zero_is_feasibility_first() { + // With pf = 0, the algorithm reduces to strict feasibility-first + // ordering, so the best feasible candidate should top the rank. + let s = ObjectiveSpace::new(vec![Objective::minimize("f")]); + let pop = [ + Candidate::new(1u32, Evaluation::constrained(vec![0.0], 5.0)), // infeasible + Candidate::new(2u32, Evaluation::new(vec![10.0])), // feasible, big f + Candidate::new(3u32, Evaluation::new(vec![3.0])), // feasible, small f + ]; + let mut rng = rng_from_seed(0); + let picks = stochastic_ranking_select(&pop, &s, 0.0, 3, &mut rng); + assert_eq!(picks[0], 3); // best feasible first + } + + #[test] + #[should_panic(expected = "pf must be in [0.0, 1.0]")] + fn stochastic_ranking_pf_out_of_range_panics() { + let s = ObjectiveSpace::new(vec![Objective::minimize("f")]); + let pop = [cand_min(1, 1.0)]; + let mut rng = rng_from_seed(0); + let _ = stochastic_ranking_select(&pop, &s, 1.5, 1, &mut rng); + } }