test(bench): cover permutation operators, combinatorial problems, and remaining algorithms

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 06:21:59 -06:00
co-authored by Claude Opus 4.7
parent 54f9db5dc9
commit b1d339a869
+663 -2
View File
@@ -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<i32>` decision so it
/// satisfies `TabuSearch`'s `Hash + Eq` decision bound (`f64` is neither).
struct IntParabola;
impl Problem for IntParabola {
type Decision = Vec<i32>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("f")])
}
fn evaluate(&self, x: &Vec<i32>) -> 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<Vec<i32>> for IntStartAtZero {
fn initialize(&mut self, size: usize, _rng: &mut Rng) -> Vec<Vec<i32>> {
(0..size).map(|_| vec![0]).collect()
}
}
#[library_benchmark]
fn tabu_search_short() -> usize {
let neighbors = |x: &Vec<i32>, _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<bool>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::maximize("ones")])
}
fn evaluate(&self, x: &Vec<bool>) -> 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<usize> {
(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<usize> {
(0..n).rev().collect()
}
#[library_benchmark]
#[bench::n_30(30)]
#[bench::n_100(100)]
fn shuffled_permutation_init(n: usize) -> Vec<Vec<usize>> {
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<Vec<usize>> {
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<Vec<usize>> {
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<Vec<usize>> {
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<Vec<usize>> {
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<Vec<usize>> {
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<Vec<usize>> {
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<Vec<usize>> {
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<Vec<usize>> {
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<Vec<usize>> {
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<Vec<bool>> {
let parent: Vec<bool> = (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<Vec<f64>> {
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<Vec<f64>> {
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<f64> {
let mut x: Vec<f64> = (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<f64> {
// 32-dim mixed-sign vector; exercises the sort-based projection path.
let mut x: Vec<f64> = (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<Vec<f64>> {
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<Vec<f64>>,
}
impl Problem for TspProblem {
type Decision = Vec<usize>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("length")])
}
fn evaluate(&self, tour: &Vec<usize>) -> 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<Vec<f64>>,
dist_b: Vec<Vec<f64>>,
}
impl Problem for BiTspProblem {
type Decision = Vec<usize>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::minimize("length_a"),
Objective::minimize("length_b"),
])
}
fn evaluate(&self, tour: &Vec<usize>) -> 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<Vec<f64>> = 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<usize>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::minimize("makespan"),
Objective::minimize("total_flow_time"),
])
}
fn evaluate(&self, schedule: &Vec<usize>) -> 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<Vec<usize>> for PrecedenceOrderCrossover {
fn vary(&mut self, parents: &[Vec<usize>], rng: &mut Rng) -> Vec<Vec<usize>> {
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<usize> {
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<bool>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::maximize("profit_a"),
Objective::maximize("profit_b"),
])
}
fn evaluate(&self, take: &Vec<bool>) -> 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<Vec<bool>> for RandomBinary {
fn initialize(&mut self, size: usize, rng: &mut Rng) -> Vec<Vec<bool>> {
(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<Vec<bool>> for OnePointCrossoverBool {
fn vary(&mut self, parents: &[Vec<bool>], rng: &mut Rng) -> Vec<Vec<bool>> {
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::<f64>();
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<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("loss")])
}
fn evaluate_at_budget(&self, x: &Vec<f64>, 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
);