From b1d339a86999a0a16daf7b2deffc36c2e49c35f9 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 14 May 2026 06:17:49 -0600 Subject: [PATCH] test(bench): cover permutation operators, combinatorial problems, and remaining algorithms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand benches/hot_paths.rs so the instruction-count harness exercises the whole library. Adds four groups — permutation_ops_group (all 10 permutation operators at n=30/100), variation_ops_group (BitFlip, Levy, BoundedGaussian, ClampToBounds, ProjectToSimplex), combinatorial_group (TSP/JSS/knapsack end-to-end plus AntColonyTsp), and multi_fidelity_group (Hyperband) — and folds tabu_search_short and umda_short into single_objective_group. All 33 algorithms and 20 operators are now on the benchmarking surface (69 benches). Co-Authored-By: Claude Opus 4.7 (1M context) --- benches/hot_paths.rs | 665 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 663 insertions(+), 2 deletions(-) diff --git a/benches/hot_paths.rs b/benches/hot_paths.rs index 61bf54e..71f9d5d 100644 --- a/benches/hot_paths.rs +++ b/benches/hot_paths.rs @@ -10,11 +10,14 @@ use std::hint::black_box; use gungraun::prelude::*; +use rand::Rng as _; use heuropt::core::candidate::Candidate; use heuropt::core::evaluation::Evaluation; use heuropt::core::objective::{Objective, ObjectiveSpace}; +use heuropt::core::partial_problem::PartialProblem; use heuropt::core::problem::Problem; +use heuropt::core::rng::{Rng, rng_from_seed}; use heuropt::metrics::hypervolume::{hypervolume_2d, hypervolume_nd}; use heuropt::pareto::crowding::crowding_distance; use heuropt::pareto::sort::non_dominated_sort; @@ -398,6 +401,74 @@ fn ipop_cma_es_short() -> usize { black_box(o.run(black_box(&Sphere1D)).evaluations) } +/// 1-D integer parabola: minimize `(x - 5)^2`. `Vec` decision so it +/// satisfies `TabuSearch`'s `Hash + Eq` decision bound (`f64` is neither). +struct IntParabola; +impl Problem for IntParabola { + type Decision = Vec; + fn objectives(&self) -> ObjectiveSpace { + ObjectiveSpace::new(vec![Objective::minimize("f")]) + } + fn evaluate(&self, x: &Vec) -> Evaluation { + let v = (x[0] - 5) as f64; + Evaluation::new(vec![v * v]) + } +} + +/// Start every 1-D integer decision at 0. +struct IntStartAtZero; +impl Initializer> for IntStartAtZero { + fn initialize(&mut self, size: usize, _rng: &mut Rng) -> Vec> { + (0..size).map(|_| vec![0]).collect() + } +} + +#[library_benchmark] +fn tabu_search_short() -> usize { + let neighbors = |x: &Vec, _rng: &mut Rng| { + vec![ + vec![x[0] - 2], + vec![x[0] - 1], + vec![x[0] + 1], + vec![x[0] + 2], + ] + }; + let mut o = TabuSearch::new( + TabuSearchConfig { + iterations: 50, + tabu_tenure: 8, + seed: 0, + }, + IntStartAtZero, + neighbors, + ); + black_box(o.run(black_box(&IntParabola)).evaluations) +} + +/// OneMax over 16 bits: maximize the count of `true` bits. +struct OneMax16; +impl Problem for OneMax16 { + type Decision = Vec; + fn objectives(&self) -> ObjectiveSpace { + ObjectiveSpace::new(vec![Objective::maximize("ones")]) + } + fn evaluate(&self, x: &Vec) -> Evaluation { + Evaluation::new(vec![x.iter().filter(|b| **b).count() as f64]) + } +} + +#[library_benchmark] +fn umda_short() -> usize { + let mut o = Umda::new(UmdaConfig { + population_size: 20, + selected_size: 8, + generations: 5, + bits: 16, + seed: 0, + }); + black_box(o.run(black_box(&OneMax16)).evaluations) +} + library_benchmark_group!( name = single_objective_group; benchmarks = @@ -405,7 +476,8 @@ library_benchmark_group!( simulated_annealing_short, genetic_algorithm_short, particle_swarm_short, differential_evolution_short, tlbo_short, separable_nes_short, nelder_mead_short, - bayesian_opt_short, tpe_short, ipop_cma_es_short + bayesian_opt_short, tpe_short, ipop_cma_es_short, + tabu_search_short, umda_short ); // ----------------------------------------------------------------------------- @@ -643,9 +715,598 @@ library_benchmark_group!( age_moea_short, grea_short, knea_short, rvea_short, paes_short ); +// ----------------------------------------------------------------------------- +// Permutation operator micro-benchmarks +// ----------------------------------------------------------------------------- + +fn perm_parent(n: usize) -> Vec { + (0..n).collect() +} + +/// Reversed `[0..n)`: same value multiset as `perm_parent`, shares no oriented +/// edges with it — a stress input for the edge-based crossovers. +fn perm_parent_rev(n: usize) -> Vec { + (0..n).rev().collect() +} + +#[library_benchmark] +#[bench::n_30(30)] +#[bench::n_100(100)] +fn shuffled_permutation_init(n: usize) -> Vec> { + let mut rng = rng_from_seed(0); + let mut init = ShuffledPermutation { n }; + black_box(init.initialize(black_box(16), black_box(&mut rng))) +} + +#[library_benchmark] +#[bench::n_30(30)] +#[bench::n_100(100)] +fn shuffled_multiset_permutation_init(n: usize) -> Vec> { + let mut rng = rng_from_seed(0); + let mut init = ShuffledMultisetPermutation::new(vec![5; n]); + black_box(init.initialize(black_box(16), black_box(&mut rng))) +} + +#[library_benchmark] +#[bench::n_30(30)] +#[bench::n_100(100)] +fn swap_mutation_vary(n: usize) -> Vec> { + let parent = perm_parent(n); + let mut rng = rng_from_seed(1); + let mut op = SwapMutation; + black_box(op.vary( + black_box(std::slice::from_ref(&parent)), + black_box(&mut rng), + )) +} + +#[library_benchmark] +#[bench::n_30(30)] +#[bench::n_100(100)] +fn inversion_mutation_vary(n: usize) -> Vec> { + let parent = perm_parent(n); + let mut rng = rng_from_seed(1); + let mut op = InversionMutation; + black_box(op.vary( + black_box(std::slice::from_ref(&parent)), + black_box(&mut rng), + )) +} + +#[library_benchmark] +#[bench::n_30(30)] +#[bench::n_100(100)] +fn insertion_mutation_vary(n: usize) -> Vec> { + let parent = perm_parent(n); + let mut rng = rng_from_seed(1); + let mut op = InsertionMutation; + black_box(op.vary( + black_box(std::slice::from_ref(&parent)), + black_box(&mut rng), + )) +} + +#[library_benchmark] +#[bench::n_30(30)] +#[bench::n_100(100)] +fn scramble_mutation_vary(n: usize) -> Vec> { + let parent = perm_parent(n); + let mut rng = rng_from_seed(1); + let mut op = ScrambleMutation; + black_box(op.vary( + black_box(std::slice::from_ref(&parent)), + black_box(&mut rng), + )) +} + +#[library_benchmark] +#[bench::n_30(30)] +#[bench::n_100(100)] +fn order_crossover_vary(n: usize) -> Vec> { + let parents = [perm_parent(n), perm_parent_rev(n)]; + let mut rng = rng_from_seed(2); + let mut op = OrderCrossover; + black_box(op.vary(black_box(&parents), black_box(&mut rng))) +} + +#[library_benchmark] +#[bench::n_30(30)] +#[bench::n_100(100)] +fn pmx_crossover_vary(n: usize) -> Vec> { + let parents = [perm_parent(n), perm_parent_rev(n)]; + let mut rng = rng_from_seed(2); + let mut op = PartiallyMappedCrossover; + black_box(op.vary(black_box(&parents), black_box(&mut rng))) +} + +#[library_benchmark] +#[bench::n_30(30)] +#[bench::n_100(100)] +fn cycle_crossover_vary(n: usize) -> Vec> { + let parents = [perm_parent(n), perm_parent_rev(n)]; + let mut rng = rng_from_seed(2); + let mut op = CycleCrossover; + black_box(op.vary(black_box(&parents), black_box(&mut rng))) +} + +#[library_benchmark] +#[bench::n_30(30)] +#[bench::n_100(100)] +fn edge_recombination_crossover_vary(n: usize) -> Vec> { + let parents = [perm_parent(n), perm_parent_rev(n)]; + let mut rng = rng_from_seed(2); + let mut op = EdgeRecombinationCrossover; + black_box(op.vary(black_box(&parents), black_box(&mut rng))) +} + +library_benchmark_group!( + name = permutation_ops_group; + benchmarks = + shuffled_permutation_init, shuffled_multiset_permutation_init, + swap_mutation_vary, inversion_mutation_vary, insertion_mutation_vary, + scramble_mutation_vary, order_crossover_vary, pmx_crossover_vary, + cycle_crossover_vary, edge_recombination_crossover_vary +); + +// ----------------------------------------------------------------------------- +// Un-benchmarked operators from the binary / real / repair families +// ----------------------------------------------------------------------------- + +#[library_benchmark] +fn bit_flip_mutation_vary() -> Vec> { + let parent: Vec = (0..64).map(|i| i % 2 == 0).collect(); + let mut rng = rng_from_seed(3); + let mut op = BitFlipMutation { + probability: 1.0 / 64.0, + }; + black_box(op.vary( + black_box(std::slice::from_ref(&parent)), + black_box(&mut rng), + )) +} + +#[library_benchmark] +fn levy_mutation_vary() -> Vec> { + let parent = vec![0.0_f64; 16]; + let mut rng = rng_from_seed(3); + let mut op = LevyMutation::new(1.5, 0.1, vec![(-5.0, 5.0); 16]); + black_box(op.vary( + black_box(std::slice::from_ref(&parent)), + black_box(&mut rng), + )) +} + +#[library_benchmark] +fn bounded_gaussian_mutation_vary() -> Vec> { + let parent = vec![0.0_f64; 16]; + let mut rng = rng_from_seed(3); + let mut op = BoundedGaussianMutation::new(0.3, vec![(-1.0, 1.0); 16]); + black_box(op.vary( + black_box(std::slice::from_ref(&parent)), + black_box(&mut rng), + )) +} + +#[library_benchmark] +fn clamp_to_bounds_repair() -> Vec { + let mut x: Vec = (0..32).map(|i| (i as f64) - 16.0).collect(); + let mut op = ClampToBounds::new(vec![(-1.0, 1.0); 32]); + op.repair(black_box(&mut x)); + black_box(x) +} + +#[library_benchmark] +fn project_to_simplex_repair() -> Vec { + // 32-dim mixed-sign vector; exercises the sort-based projection path. + let mut x: Vec = (0..32).map(|i| ((i * 7 % 13) as f64) - 6.0).collect(); + let mut op = ProjectToSimplex::new(1.0); + op.repair(black_box(&mut x)); + black_box(x) +} + +library_benchmark_group!( + name = variation_ops_group; + benchmarks = + bit_flip_mutation_vary, levy_mutation_vary, bounded_gaussian_mutation_vary, + clamp_to_bounds_repair, project_to_simplex_repair +); + +// ----------------------------------------------------------------------------- +// Combinatorial / sequencing end-to-end benches +// ----------------------------------------------------------------------------- + +const TSP_N: usize = 15; + +/// Deterministic pseudo-scattered city coordinates. The bench only needs a +/// stable distance matrix, not a known optimum. +fn tsp_coords() -> Vec<(f64, f64)> { + (0..TSP_N) + .map(|i| { + let x = ((i * 37) % 100) as f64; + let y = ((i * 53 + 11) % 100) as f64; + (x, y) + }) + .collect() +} + +fn tsp_distance_matrix() -> Vec> { + let c = tsp_coords(); + let n = c.len(); + let mut d = vec![vec![0.0_f64; n]; n]; + for i in 0..n { + for j in 0..n { + if i != j { + let dx = c[i].0 - c[j].0; + let dy = c[i].1 - c[j].1; + d[i][j] = (dx * dx + dy * dy).sqrt(); + } + } + } + d +} + +/// Single-objective TSP over a precomputed distance matrix. +struct TspProblem { + distances: Vec>, +} +impl Problem for TspProblem { + type Decision = Vec; + fn objectives(&self) -> ObjectiveSpace { + ObjectiveSpace::new(vec![Objective::minimize("length")]) + } + fn evaluate(&self, tour: &Vec) -> Evaluation { + let n = tour.len(); + let mut len = 0.0; + for i in 0..n { + len += self.distances[tour[i]][tour[(i + 1) % n]]; + } + Evaluation::new(vec![len]) + } +} + +/// Bi-objective TSP: two distance matrices over the same city set. +struct BiTspProblem { + dist_a: Vec>, + dist_b: Vec>, +} +impl Problem for BiTspProblem { + type Decision = Vec; + fn objectives(&self) -> ObjectiveSpace { + ObjectiveSpace::new(vec![ + Objective::minimize("length_a"), + Objective::minimize("length_b"), + ]) + } + fn evaluate(&self, tour: &Vec) -> Evaluation { + let n = tour.len(); + let (mut la, mut lb) = (0.0, 0.0); + for i in 0..n { + let (u, v) = (tour[i], tour[(i + 1) % n]); + la += self.dist_a[u][v]; + lb += self.dist_b[u][v]; + } + Evaluation::new(vec![la, lb]) + } +} + +#[library_benchmark] +fn tsp_nsga2_short() -> usize { + let dist_a = tsp_distance_matrix(); + // Second objective: a distinct symmetric matrix with a zero diagonal. + let dist_b: Vec> = dist_a + .iter() + .enumerate() + .map(|(i, row)| { + row.iter() + .enumerate() + .map(|(j, &d)| if i == j { 0.0 } else { d * 0.5 + 3.0 }) + .collect() + }) + .collect(); + let problem = BiTspProblem { dist_a, dist_b }; + let mut o = Nsga2::new( + Nsga2Config { + population_size: 20, + generations: 3, + seed: 0, + }, + ShuffledPermutation { n: TSP_N }, + CompositeVariation { + crossover: OrderCrossover, + mutation: InversionMutation, + }, + ); + black_box(o.run(black_box(&problem)).evaluations) +} + +#[library_benchmark] +fn ant_colony_tsp_short() -> usize { + let distances = tsp_distance_matrix(); + let problem = TspProblem { + distances: distances.clone(), + }; + let mut o = AntColonyTsp::new( + AntColonyTspConfig { + ants: 8, + generations: 3, + alpha: 1.0, + beta: 2.0, + evaporation: 0.5, + deposit: 1.0, + initial_pheromone: 1.0, + seed: 0, + }, + distances, + ); + black_box(o.run(black_box(&problem)).evaluations) +} + +const JSS_JOBS: usize = 6; +const JSS_MACHINES: usize = 6; + +/// FT06 (Fisher & Thompson 1963) routing — machine id of the k-th operation +/// of job j. +const FT06_MACHINE: [[usize; JSS_MACHINES]; JSS_JOBS] = [ + [2, 0, 1, 3, 5, 4], + [1, 2, 4, 5, 0, 3], + [2, 3, 5, 0, 1, 4], + [1, 0, 2, 3, 4, 5], + [2, 1, 4, 5, 0, 3], + [1, 3, 5, 0, 4, 2], +]; + +/// FT06 processing times — duration of the k-th operation of job j. +const FT06_TIME: [[f64; JSS_MACHINES]; JSS_JOBS] = [ + [1.0, 3.0, 6.0, 7.0, 3.0, 6.0], + [8.0, 5.0, 10.0, 10.0, 10.0, 4.0], + [5.0, 4.0, 8.0, 9.0, 1.0, 7.0], + [5.0, 5.0, 5.0, 3.0, 8.0, 9.0], + [9.0, 3.0, 5.0, 4.0, 3.0, 1.0], + [3.0, 3.0, 9.0, 10.0, 4.0, 1.0], +]; + +/// Bi-objective FT06 job-shop scheduling: f1 = makespan, f2 = total flow time. +struct Ft06Problem; +impl Problem for Ft06Problem { + type Decision = Vec; + fn objectives(&self) -> ObjectiveSpace { + ObjectiveSpace::new(vec![ + Objective::minimize("makespan"), + Objective::minimize("total_flow_time"), + ]) + } + fn evaluate(&self, schedule: &Vec) -> Evaluation { + let mut job_next = [0_usize; JSS_JOBS]; + let mut job_clock = [0.0_f64; JSS_JOBS]; + let mut machine_clock = [0.0_f64; JSS_MACHINES]; + for &job in schedule { + let k = job_next[job]; + let m = FT06_MACHINE[job][k]; + let t = FT06_TIME[job][k]; + let start = job_clock[job].max(machine_clock[m]); + let end = start + t; + job_clock[job] = end; + machine_clock[m] = end; + job_next[job] = k + 1; + } + let makespan = machine_clock.iter().cloned().fold(0.0_f64, f64::max); + let flow_time: f64 = job_clock.iter().sum(); + Evaluation::new(vec![makespan, flow_time]) + } +} + +/// Precedence-Order Crossover — multiset-preserving crossover for the +/// operation-string JSS encoding. Trimmed from `examples/mo_jss_la01.rs`; +/// the strict-permutation crossovers cannot be used on multiset encodings. +#[derive(Debug, Clone, Copy, Default)] +struct PrecedenceOrderCrossover; +impl Variation> for PrecedenceOrderCrossover { + fn vary(&mut self, parents: &[Vec], rng: &mut Rng) -> Vec> { + assert!(parents.len() >= 2, "POX requires 2 parents"); + let (p1, p2) = (&parents[0], &parents[1]); + let mut in_j1 = [false; JSS_JOBS]; + loop { + for slot in &mut in_j1 { + *slot = rng.random_bool(0.5); + } + let c = in_j1.iter().filter(|&&b| b).count(); + if c > 0 && c < JSS_JOBS { + break; + } + } + vec![pox_child(p1, p2, &in_j1), pox_child(p2, p1, &in_j1)] + } +} +fn pox_child(donor: &[usize], filler: &[usize], in_donor_set: &[bool]) -> Vec { + let n = donor.len(); + let mut child = vec![usize::MAX; n]; + for k in 0..n { + if in_donor_set[donor[k]] { + child[k] = donor[k]; + } + } + let mut fill_idx = 0; + for &v in filler { + if !in_donor_set[v] { + while fill_idx < n && child[fill_idx] != usize::MAX { + fill_idx += 1; + } + child[fill_idx] = v; + fill_idx += 1; + } + } + child +} + +#[library_benchmark] +fn jss_nsga2_short() -> usize { + let mut o = Nsga2::new( + Nsga2Config { + population_size: 20, + generations: 3, + seed: 0, + }, + ShuffledMultisetPermutation::new(vec![JSS_MACHINES; JSS_JOBS]), + CompositeVariation { + crossover: PrecedenceOrderCrossover, + mutation: InsertionMutation, + }, + ); + black_box(o.run(black_box(&Ft06Problem)).evaluations) +} + +const KNAPSACK_N: usize = 20; + +const KP_PROFIT_A: [f64; KNAPSACK_N] = [ + 61.0, 17.0, 92.0, 49.0, 73.0, 28.0, 84.0, 36.0, 55.0, 78.0, 23.0, 91.0, 12.0, 67.0, 45.0, 58.0, + 33.0, 71.0, 14.0, 26.0, +]; +const KP_PROFIT_B: [f64; KNAPSACK_N] = [ + 24.0, 81.0, 16.0, 67.0, 29.0, 73.0, 41.0, 60.0, 52.0, 19.0, 77.0, 34.0, 95.0, 22.0, 71.0, 88.0, + 56.0, 27.0, 64.0, 90.0, +]; +const KP_WEIGHT: [f64; KNAPSACK_N] = [ + 35.0, 58.0, 22.0, 71.0, 14.0, 86.0, 31.0, 53.0, 78.0, 19.0, 44.0, 16.0, 67.0, 88.0, 25.0, 51.0, + 33.0, 74.0, 12.0, 47.0, +]; + +/// Bi-objective 0/1 knapsack with a penalty-based capacity constraint. +struct KnapsackProblem { + capacity: f64, +} +impl Problem for KnapsackProblem { + type Decision = Vec; + fn objectives(&self) -> ObjectiveSpace { + ObjectiveSpace::new(vec![ + Objective::maximize("profit_a"), + Objective::maximize("profit_b"), + ]) + } + fn evaluate(&self, take: &Vec) -> Evaluation { + let (mut pa, mut pb, mut w) = (0.0, 0.0, 0.0); + for (i, &t) in take.iter().enumerate() { + if t { + pa += KP_PROFIT_A[i]; + pb += KP_PROFIT_B[i]; + w += KP_WEIGHT[i]; + } + } + let penalty = 1000.0 * (w - self.capacity).max(0.0); + Evaluation::new(vec![pa - penalty, pb - penalty]) + } +} + +/// Random binary initializer — each bit 50/50 independently. +#[derive(Debug, Clone, Copy)] +struct RandomBinary { + n: usize, +} +impl Initializer> for RandomBinary { + fn initialize(&mut self, size: usize, rng: &mut Rng) -> Vec> { + (0..size) + .map(|_| (0..self.n).map(|_| rng.random_bool(0.5)).collect()) + .collect() + } +} + +/// One-point crossover for binary chromosomes. Trimmed from +/// `examples/mo_knapsack.rs`. +#[derive(Debug, Clone, Copy, Default)] +struct OnePointCrossoverBool; +impl Variation> for OnePointCrossoverBool { + fn vary(&mut self, parents: &[Vec], rng: &mut Rng) -> Vec> { + assert!( + parents.len() >= 2, + "OnePointCrossoverBool requires 2 parents" + ); + let (p1, p2) = (&parents[0], &parents[1]); + let n = p1.len(); + if n < 2 { + return vec![p1.clone(), p2.clone()]; + } + let cut = rng.random_range(1..n); + let mut c1 = Vec::with_capacity(n); + let mut c2 = Vec::with_capacity(n); + c1.extend_from_slice(&p1[..cut]); + c1.extend_from_slice(&p2[cut..]); + c2.extend_from_slice(&p2[..cut]); + c2.extend_from_slice(&p1[cut..]); + vec![c1, c2] + } +} + +#[library_benchmark] +fn knapsack_nsga2_short() -> usize { + let capacity = 0.5 * KP_WEIGHT.iter().sum::(); + let problem = KnapsackProblem { capacity }; + let mut o = Nsga2::new( + Nsga2Config { + population_size: 20, + generations: 3, + seed: 0, + }, + RandomBinary { n: KNAPSACK_N }, + CompositeVariation { + crossover: OnePointCrossoverBool, + mutation: BitFlipMutation { + probability: 1.0 / KNAPSACK_N as f64, + }, + }, + ); + black_box(o.run(black_box(&problem)).evaluations) +} + +library_benchmark_group!( + name = combinatorial_group; + benchmarks = + tsp_nsga2_short, ant_colony_tsp_short, jss_nsga2_short, knapsack_nsga2_short +); + +// ----------------------------------------------------------------------------- +// Multi-fidelity (Hyperband) +// ----------------------------------------------------------------------------- + +/// Multi-fidelity 2-D sphere: higher budget shrinks an additive residual, so +/// the loss is budget-monotone the way Hyperband expects. Deterministic. +struct MultiFidelitySphere; +impl PartialProblem for MultiFidelitySphere { + type Decision = Vec; + fn objectives(&self) -> ObjectiveSpace { + ObjectiveSpace::new(vec![Objective::minimize("loss")]) + } + fn evaluate_at_budget(&self, x: &Vec, budget: f64) -> Evaluation { + let true_f: f64 = x.iter().map(|v| v * v).sum(); + let residual = 1.0 / (budget + 1.0); + Evaluation::new(vec![true_f + residual]) + } +} + +#[library_benchmark] +fn hyperband_short() -> usize { + let mut o = Hyperband::new( + HyperbandConfig { + max_budget: 27.0, + eta: 3.0, + max_brackets: 3, + seed: 0, + }, + RealBounds::new(vec![(-5.0, 5.0); 2]), + ); + black_box(o.run(black_box(&MultiFidelitySphere)).evaluations) +} + +library_benchmark_group!( + name = multi_fidelity_group; + benchmarks = hyperband_short +); + main!( library_benchmark_groups = pareto_group, algorithm_group, single_objective_group, - multi_objective_group + multi_objective_group, + permutation_ops_group, + variation_ops_group, + combinatorial_group, + multi_fidelity_group );