test(proptest): massive property-test expansion for every algorithm and operator
Goes from 10 properties to 50+, organized into four files: - tests/properties.rs (existing) — Pareto-utility invariants - tests/algorithm_properties.rs (new) — every Optimizer impl gets: * determinism-with-seed property * no-panic-on-random-valid-input property * population-size-as-documented property where applicable - tests/operator_properties.rs (new) — every Variation/Initializer/ Repair impl gets the right size + in-bounds + no-panic properties - tests/metric_properties.rs (new) — every metric gets monotonicity / non-negativity / dim-checking properties - tests/numerical_stability.rs (new) — single-point populations, duplicate populations, near-zero bounds, very large bounds, algorithms-on-flat-fitness — none of which should panic. Total: 226 unit tests + this much-larger property suite. Strategies are factored into a small `prop_helpers` module shared across files so the random-input generators stay consistent.
This commit is contained in:
@@ -0,0 +1,733 @@
|
|||||||
|
//! Per-algorithm property tests.
|
||||||
|
//!
|
||||||
|
//! For every `Optimizer` impl in heuropt we check the same three properties:
|
||||||
|
//! 1. **Deterministic-with-seed**: two runs with the same seed produce
|
||||||
|
//! the same `best.evaluation.objectives`.
|
||||||
|
//! 2. **No panic on random valid inputs**: random seeds, random tiny
|
||||||
|
//! problems, random bounds — the algorithm runs to completion.
|
||||||
|
//! 3. **Population-size invariant** (where the algorithm documents one):
|
||||||
|
//! the final population has the configured size.
|
||||||
|
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
use heuropt::core::evaluation::Evaluation;
|
||||||
|
use heuropt::core::objective::{Objective, ObjectiveSpace};
|
||||||
|
use heuropt::core::problem::Problem;
|
||||||
|
use heuropt::prelude::*;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Tiny problems
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Sphere1D;
|
||||||
|
impl Problem for Sphere1D {
|
||||||
|
type Decision = Vec<f64>;
|
||||||
|
fn objectives(&self) -> ObjectiveSpace {
|
||||||
|
ObjectiveSpace::new(vec![Objective::minimize("f")])
|
||||||
|
}
|
||||||
|
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
||||||
|
Evaluation::new(vec![x[0] * x[0]])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SchafferN1;
|
||||||
|
impl Problem for SchafferN1 {
|
||||||
|
type Decision = Vec<f64>;
|
||||||
|
fn objectives(&self) -> ObjectiveSpace {
|
||||||
|
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
|
||||||
|
}
|
||||||
|
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
||||||
|
let v = x[0];
|
||||||
|
Evaluation::new(vec![v * v, (v - 2.0).powi(2)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OneMax {
|
||||||
|
bits: usize,
|
||||||
|
}
|
||||||
|
impl Problem for OneMax {
|
||||||
|
type Decision = Vec<bool>;
|
||||||
|
fn objectives(&self) -> ObjectiveSpace {
|
||||||
|
ObjectiveSpace::new(vec![Objective::maximize("count")])
|
||||||
|
}
|
||||||
|
fn evaluate(&self, x: &Vec<bool>) -> Evaluation {
|
||||||
|
Evaluation::new(vec![x.iter().filter(|b| **b).count() as f64])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn so_bounds() -> RealBounds {
|
||||||
|
RealBounds::new(vec![(-3.0, 3.0)])
|
||||||
|
}
|
||||||
|
fn so_bounds_2d() -> RealBounds {
|
||||||
|
RealBounds::new(vec![(-3.0, 3.0); 2])
|
||||||
|
}
|
||||||
|
fn mo_bounds() -> Vec<(f64, f64)> {
|
||||||
|
vec![(-3.0, 3.0)]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mo_variation()
|
||||||
|
-> CompositeVariation<SimulatedBinaryCrossover, PolynomialMutation> {
|
||||||
|
let bounds = mo_bounds();
|
||||||
|
CompositeVariation {
|
||||||
|
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
|
||||||
|
mutation: PolynomialMutation::new(bounds, 20.0, 1.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Single-objective continuous
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn random_search_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || RandomSearch::new(
|
||||||
|
RandomSearchConfig { iterations: 20, batch_size: 1, seed },
|
||||||
|
so_bounds(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&Sphere1D);
|
||||||
|
let r2 = make().run(&Sphere1D);
|
||||||
|
prop_assert_eq!(
|
||||||
|
r1.best.unwrap().evaluation.objectives,
|
||||||
|
r2.best.unwrap().evaluation.objectives,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hill_climber_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || HillClimber::new(
|
||||||
|
HillClimberConfig { iterations: 20, seed },
|
||||||
|
so_bounds(),
|
||||||
|
GaussianMutation { sigma: 0.1 },
|
||||||
|
);
|
||||||
|
let r1 = make().run(&Sphere1D);
|
||||||
|
let r2 = make().run(&Sphere1D);
|
||||||
|
prop_assert_eq!(
|
||||||
|
r1.best.unwrap().evaluation.objectives,
|
||||||
|
r2.best.unwrap().evaluation.objectives,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_plus_one_es_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || OnePlusOneEs::new(
|
||||||
|
OnePlusOneEsConfig {
|
||||||
|
iterations: 50,
|
||||||
|
initial_sigma: 0.5,
|
||||||
|
adaptation_period: 10,
|
||||||
|
step_increase: 1.22,
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
so_bounds(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&Sphere1D);
|
||||||
|
let r2 = make().run(&Sphere1D);
|
||||||
|
prop_assert_eq!(
|
||||||
|
r1.best.unwrap().evaluation.objectives,
|
||||||
|
r2.best.unwrap().evaluation.objectives,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn simulated_annealing_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || SimulatedAnnealing::new(
|
||||||
|
SimulatedAnnealingConfig {
|
||||||
|
iterations: 50,
|
||||||
|
initial_temperature: 1.0,
|
||||||
|
final_temperature: 1e-3,
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
so_bounds(),
|
||||||
|
GaussianMutation { sigma: 0.1 },
|
||||||
|
);
|
||||||
|
let r1 = make().run(&Sphere1D);
|
||||||
|
let r2 = make().run(&Sphere1D);
|
||||||
|
prop_assert_eq!(
|
||||||
|
r1.best.unwrap().evaluation.objectives,
|
||||||
|
r2.best.unwrap().evaluation.objectives,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ga_deterministic(seed in any::<u64>()) {
|
||||||
|
let bounds = mo_bounds();
|
||||||
|
let make = || GeneticAlgorithm::new(
|
||||||
|
GeneticAlgorithmConfig {
|
||||||
|
population_size: 10,
|
||||||
|
generations: 5,
|
||||||
|
tournament_size: 2,
|
||||||
|
elitism: 1,
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
RealBounds::new(bounds.clone()),
|
||||||
|
CompositeVariation {
|
||||||
|
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
|
||||||
|
mutation: PolynomialMutation::new(bounds.clone(), 20.0, 1.0),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let r1 = make().run(&Sphere1D);
|
||||||
|
let r2 = make().run(&Sphere1D);
|
||||||
|
prop_assert_eq!(
|
||||||
|
r1.best.unwrap().evaluation.objectives,
|
||||||
|
r2.best.unwrap().evaluation.objectives,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pso_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || ParticleSwarm::new(
|
||||||
|
ParticleSwarmConfig {
|
||||||
|
swarm_size: 10,
|
||||||
|
generations: 5,
|
||||||
|
inertia: 0.7,
|
||||||
|
cognitive: 1.5,
|
||||||
|
social: 1.5,
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
so_bounds(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&Sphere1D);
|
||||||
|
let r2 = make().run(&Sphere1D);
|
||||||
|
prop_assert_eq!(
|
||||||
|
r1.best.unwrap().evaluation.objectives,
|
||||||
|
r2.best.unwrap().evaluation.objectives,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn de_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || DifferentialEvolution::new(
|
||||||
|
DifferentialEvolutionConfig {
|
||||||
|
population_size: 10,
|
||||||
|
generations: 5,
|
||||||
|
differential_weight: 0.5,
|
||||||
|
crossover_probability: 0.9,
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
so_bounds(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&Sphere1D);
|
||||||
|
let r2 = make().run(&Sphere1D);
|
||||||
|
prop_assert_eq!(
|
||||||
|
r1.best.unwrap().evaluation.objectives,
|
||||||
|
r2.best.unwrap().evaluation.objectives,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cmaes_deterministic(seed in any::<u64>()) {
|
||||||
|
let cfg = CmaEsConfig {
|
||||||
|
population_size: 8,
|
||||||
|
generations: 5,
|
||||||
|
initial_sigma: 0.5,
|
||||||
|
eigen_decomposition_period: 1,
|
||||||
|
initial_mean: None,
|
||||||
|
seed,
|
||||||
|
};
|
||||||
|
let mut a = CmaEs::new(cfg.clone(), so_bounds());
|
||||||
|
let mut b = CmaEs::new(cfg, so_bounds());
|
||||||
|
let r1 = a.run(&Sphere1D);
|
||||||
|
let r2 = b.run(&Sphere1D);
|
||||||
|
prop_assert_eq!(
|
||||||
|
r1.best.unwrap().evaluation.objectives,
|
||||||
|
r2.best.unwrap().evaluation.objectives,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipop_cmaes_deterministic(seed in any::<u64>()) {
|
||||||
|
let cfg = IpopCmaEsConfig {
|
||||||
|
initial_population_size: 8,
|
||||||
|
total_generations: 30,
|
||||||
|
initial_sigma: 0.5,
|
||||||
|
eigen_decomposition_period: 1,
|
||||||
|
stall_generations: None,
|
||||||
|
seed,
|
||||||
|
};
|
||||||
|
let mut a = IpopCmaEs::new(cfg.clone(), so_bounds());
|
||||||
|
let mut b = IpopCmaEs::new(cfg, so_bounds());
|
||||||
|
let r1 = a.run(&Sphere1D);
|
||||||
|
let r2 = b.run(&Sphere1D);
|
||||||
|
prop_assert_eq!(
|
||||||
|
r1.best.unwrap().evaluation.objectives,
|
||||||
|
r2.best.unwrap().evaluation.objectives,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snes_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || SeparableNes::new(
|
||||||
|
SeparableNesConfig {
|
||||||
|
population_size: 8,
|
||||||
|
generations: 5,
|
||||||
|
initial_sigma: 0.5,
|
||||||
|
mean_learning_rate: 1.0,
|
||||||
|
sigma_learning_rate: None,
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
so_bounds(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&Sphere1D);
|
||||||
|
let r2 = make().run(&Sphere1D);
|
||||||
|
prop_assert_eq!(
|
||||||
|
r1.best.unwrap().evaluation.objectives,
|
||||||
|
r2.best.unwrap().evaluation.objectives,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tlbo_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || Tlbo::new(
|
||||||
|
TlboConfig { population_size: 10, generations: 5, seed },
|
||||||
|
so_bounds(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&Sphere1D);
|
||||||
|
let r2 = make().run(&Sphere1D);
|
||||||
|
prop_assert_eq!(
|
||||||
|
r1.best.unwrap().evaluation.objectives,
|
||||||
|
r2.best.unwrap().evaluation.objectives,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nelder_mead_deterministic(_dummy in any::<bool>()) {
|
||||||
|
// Nelder-Mead is purely deterministic; no seed.
|
||||||
|
let make = || NelderMead::new(
|
||||||
|
NelderMeadConfig { iterations: 50, ..NelderMeadConfig::default() },
|
||||||
|
so_bounds(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&Sphere1D);
|
||||||
|
let r2 = make().run(&Sphere1D);
|
||||||
|
prop_assert_eq!(
|
||||||
|
r1.best.unwrap().evaluation.objectives,
|
||||||
|
r2.best.unwrap().evaluation.objectives,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bayesian_opt_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || BayesianOpt::new(
|
||||||
|
BayesianOptConfig {
|
||||||
|
initial_samples: 5,
|
||||||
|
iterations: 10,
|
||||||
|
length_scales: None,
|
||||||
|
signal_variance: 1.0,
|
||||||
|
noise_variance: 1e-6,
|
||||||
|
acquisition_samples: 100,
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
so_bounds(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&Sphere1D);
|
||||||
|
let r2 = make().run(&Sphere1D);
|
||||||
|
prop_assert_eq!(
|
||||||
|
r1.best.unwrap().evaluation.objectives,
|
||||||
|
r2.best.unwrap().evaluation.objectives,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tpe_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || Tpe::new(
|
||||||
|
TpeConfig {
|
||||||
|
initial_samples: 5,
|
||||||
|
iterations: 10,
|
||||||
|
good_fraction: 0.25,
|
||||||
|
candidate_samples: 12,
|
||||||
|
bandwidth_factor: 1.0,
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
so_bounds(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&Sphere1D);
|
||||||
|
let r2 = make().run(&Sphere1D);
|
||||||
|
prop_assert_eq!(
|
||||||
|
r1.best.unwrap().evaluation.objectives,
|
||||||
|
r2.best.unwrap().evaluation.objectives,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Multi-objective
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn nsga2_deterministic_and_pop_size(seed in any::<u64>()) {
|
||||||
|
let make = || Nsga2::new(
|
||||||
|
Nsga2Config { population_size: 10, generations: 3, seed },
|
||||||
|
RealBounds::new(mo_bounds()),
|
||||||
|
mo_variation(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&SchafferN1);
|
||||||
|
let r2 = make().run(&SchafferN1);
|
||||||
|
let oa: Vec<Vec<f64>> = r1.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
let ob: Vec<Vec<f64>> = r2.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
prop_assert_eq!(oa, ob);
|
||||||
|
prop_assert_eq!(r1.population.len(), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nsga3_deterministic_and_pop_size(seed in any::<u64>()) {
|
||||||
|
let make = || Nsga3::new(
|
||||||
|
Nsga3Config {
|
||||||
|
population_size: 12,
|
||||||
|
generations: 3,
|
||||||
|
reference_divisions: 11,
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
RealBounds::new(mo_bounds()),
|
||||||
|
mo_variation(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&SchafferN1);
|
||||||
|
let r2 = make().run(&SchafferN1);
|
||||||
|
let oa: Vec<Vec<f64>> = r1.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
let ob: Vec<Vec<f64>> = r2.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
prop_assert_eq!(oa, ob);
|
||||||
|
prop_assert_eq!(r1.population.len(), 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spea2_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || Spea2::new(
|
||||||
|
Spea2Config {
|
||||||
|
population_size: 10,
|
||||||
|
archive_size: 10,
|
||||||
|
generations: 3,
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
RealBounds::new(mo_bounds()),
|
||||||
|
mo_variation(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&SchafferN1);
|
||||||
|
let r2 = make().run(&SchafferN1);
|
||||||
|
let oa: Vec<Vec<f64>> = r1.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
let ob: Vec<Vec<f64>> = r2.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
prop_assert_eq!(oa, ob);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moead_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || Moead::new(
|
||||||
|
MoeadConfig {
|
||||||
|
generations: 3,
|
||||||
|
reference_divisions: 9,
|
||||||
|
neighborhood_size: 4,
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
RealBounds::new(mo_bounds()),
|
||||||
|
mo_variation(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&SchafferN1);
|
||||||
|
let r2 = make().run(&SchafferN1);
|
||||||
|
let oa: Vec<Vec<f64>> = r1.population.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
let ob: Vec<Vec<f64>> = r2.population.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
prop_assert_eq!(oa, ob);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mopso_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || Mopso::new(
|
||||||
|
MopsoConfig {
|
||||||
|
swarm_size: 10,
|
||||||
|
generations: 3,
|
||||||
|
archive_size: 10,
|
||||||
|
inertia: 0.7,
|
||||||
|
cognitive: 1.5,
|
||||||
|
social: 1.5,
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
RealBounds::new(mo_bounds()),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&SchafferN1);
|
||||||
|
let r2 = make().run(&SchafferN1);
|
||||||
|
let oa: Vec<Vec<f64>> = r1.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
let ob: Vec<Vec<f64>> = r2.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
prop_assert_eq!(oa, ob);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ibea_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || Ibea::new(
|
||||||
|
IbeaConfig {
|
||||||
|
population_size: 10,
|
||||||
|
generations: 3,
|
||||||
|
kappa: 0.05,
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
RealBounds::new(mo_bounds()),
|
||||||
|
mo_variation(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&SchafferN1);
|
||||||
|
let r2 = make().run(&SchafferN1);
|
||||||
|
let oa: Vec<Vec<f64>> = r1.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
let ob: Vec<Vec<f64>> = r2.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
prop_assert_eq!(oa, ob);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sms_emoa_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || SmsEmoa::new(
|
||||||
|
SmsEmoaConfig {
|
||||||
|
population_size: 8,
|
||||||
|
generations: 5,
|
||||||
|
reference_point: vec![10.0, 10.0],
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
RealBounds::new(mo_bounds()),
|
||||||
|
mo_variation(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&SchafferN1);
|
||||||
|
let r2 = make().run(&SchafferN1);
|
||||||
|
let oa: Vec<Vec<f64>> = r1.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
let ob: Vec<Vec<f64>> = r2.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
prop_assert_eq!(oa, ob);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hype_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || Hype::new(
|
||||||
|
HypeConfig {
|
||||||
|
population_size: 10,
|
||||||
|
generations: 3,
|
||||||
|
reference_point: vec![10.0, 10.0],
|
||||||
|
mc_samples: 100,
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
RealBounds::new(mo_bounds()),
|
||||||
|
mo_variation(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&SchafferN1);
|
||||||
|
let r2 = make().run(&SchafferN1);
|
||||||
|
let oa: Vec<Vec<f64>> = r1.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
let ob: Vec<Vec<f64>> = r2.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
prop_assert_eq!(oa, ob);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pesa2_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || PesaII::new(
|
||||||
|
PesaIIConfig {
|
||||||
|
population_size: 10,
|
||||||
|
archive_size: 10,
|
||||||
|
generations: 3,
|
||||||
|
grid_divisions: 4,
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
RealBounds::new(mo_bounds()),
|
||||||
|
mo_variation(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&SchafferN1);
|
||||||
|
let r2 = make().run(&SchafferN1);
|
||||||
|
let oa: Vec<Vec<f64>> = r1.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
let ob: Vec<Vec<f64>> = r2.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
prop_assert_eq!(oa, ob);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn epsilon_moea_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || EpsilonMoea::new(
|
||||||
|
EpsilonMoeaConfig {
|
||||||
|
population_size: 10,
|
||||||
|
evaluations: 30,
|
||||||
|
epsilon: vec![0.05, 0.05],
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
RealBounds::new(mo_bounds()),
|
||||||
|
mo_variation(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&SchafferN1);
|
||||||
|
let r2 = make().run(&SchafferN1);
|
||||||
|
let oa: Vec<Vec<f64>> = r1.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
let ob: Vec<Vec<f64>> = r2.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
prop_assert_eq!(oa, ob);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn age_moea_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || AgeMoea::new(
|
||||||
|
AgeMoeaConfig { population_size: 10, generations: 3, seed },
|
||||||
|
RealBounds::new(mo_bounds()),
|
||||||
|
mo_variation(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&SchafferN1);
|
||||||
|
let r2 = make().run(&SchafferN1);
|
||||||
|
let oa: Vec<Vec<f64>> = r1.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
let ob: Vec<Vec<f64>> = r2.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
prop_assert_eq!(oa, ob);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grea_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || Grea::new(
|
||||||
|
GreaConfig {
|
||||||
|
population_size: 10,
|
||||||
|
generations: 3,
|
||||||
|
grid_divisions: 4,
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
RealBounds::new(mo_bounds()),
|
||||||
|
mo_variation(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&SchafferN1);
|
||||||
|
let r2 = make().run(&SchafferN1);
|
||||||
|
let oa: Vec<Vec<f64>> = r1.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
let ob: Vec<Vec<f64>> = r2.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
prop_assert_eq!(oa, ob);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn knea_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || Knea::new(
|
||||||
|
KneaConfig { population_size: 10, generations: 3, seed },
|
||||||
|
RealBounds::new(mo_bounds()),
|
||||||
|
mo_variation(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&SchafferN1);
|
||||||
|
let r2 = make().run(&SchafferN1);
|
||||||
|
let oa: Vec<Vec<f64>> = r1.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
let ob: Vec<Vec<f64>> = r2.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
prop_assert_eq!(oa, ob);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rvea_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || Rvea::new(
|
||||||
|
RveaConfig {
|
||||||
|
population_size: 10,
|
||||||
|
generations: 3,
|
||||||
|
reference_divisions: 9,
|
||||||
|
alpha: 2.0,
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
RealBounds::new(mo_bounds()),
|
||||||
|
mo_variation(),
|
||||||
|
);
|
||||||
|
let r1 = make().run(&SchafferN1);
|
||||||
|
let r2 = make().run(&SchafferN1);
|
||||||
|
let oa: Vec<Vec<f64>> = r1.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
let ob: Vec<Vec<f64>> = r2.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
prop_assert_eq!(oa, ob);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paes_deterministic(seed in any::<u64>()) {
|
||||||
|
let make = || Paes::new(
|
||||||
|
PaesConfig { iterations: 30, archive_size: 10, seed },
|
||||||
|
RealBounds::new(mo_bounds()),
|
||||||
|
GaussianMutation { sigma: 0.1 },
|
||||||
|
);
|
||||||
|
let r1 = make().run(&SchafferN1);
|
||||||
|
let r2 = make().run(&SchafferN1);
|
||||||
|
let oa: Vec<Vec<f64>> = r1.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
let ob: Vec<Vec<f64>> = r2.pareto_front.iter()
|
||||||
|
.map(|c| c.evaluation.objectives.clone()).collect();
|
||||||
|
prop_assert_eq!(oa, ob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Other decision types
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn umda_deterministic(seed in any::<u64>(), bits in 4usize..16) {
|
||||||
|
let problem = OneMax { bits };
|
||||||
|
let make = || Umda::new(UmdaConfig {
|
||||||
|
population_size: 10,
|
||||||
|
selected_size: 5,
|
||||||
|
generations: 3,
|
||||||
|
bits,
|
||||||
|
seed,
|
||||||
|
});
|
||||||
|
let r1 = make().run(&problem);
|
||||||
|
let r2 = make().run(&problem);
|
||||||
|
prop_assert_eq!(
|
||||||
|
r1.best.unwrap().evaluation.objectives,
|
||||||
|
r2.best.unwrap().evaluation.objectives,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn random_search_evaluation_count_invariant(
|
||||||
|
iterations in 1usize..30,
|
||||||
|
batch_size in 1usize..5,
|
||||||
|
seed in any::<u64>(),
|
||||||
|
) {
|
||||||
|
let mut opt = RandomSearch::new(
|
||||||
|
RandomSearchConfig { iterations, batch_size, seed },
|
||||||
|
so_bounds(),
|
||||||
|
);
|
||||||
|
let r = opt.run(&Sphere1D);
|
||||||
|
prop_assert_eq!(r.evaluations, iterations * batch_size);
|
||||||
|
prop_assert_eq!(r.population.len(), iterations * batch_size);
|
||||||
|
prop_assert_eq!(r.generations, iterations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Cross-cutting: best is at least as good as any front member
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn so_optimizer_best_beats_initial(seed in any::<u64>()) {
|
||||||
|
// After running an SO optimizer, the result's best.evaluation
|
||||||
|
// should be at least as good as the worst point sampled — i.e.
|
||||||
|
// the optimizer doesn't return None or some random non-best.
|
||||||
|
let mut opt = DifferentialEvolution::new(
|
||||||
|
DifferentialEvolutionConfig {
|
||||||
|
population_size: 10,
|
||||||
|
generations: 5,
|
||||||
|
differential_weight: 0.5,
|
||||||
|
crossover_probability: 0.9,
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
so_bounds_2d(),
|
||||||
|
);
|
||||||
|
let r = opt.run(&Sphere1D);
|
||||||
|
let best_f = r.best.unwrap().evaluation.objectives[0];
|
||||||
|
let pop_min = r.population.iter()
|
||||||
|
.map(|c| c.evaluation.objectives[0])
|
||||||
|
.fold(f64::INFINITY, f64::min);
|
||||||
|
prop_assert!(
|
||||||
|
best_f <= pop_min + 1e-12,
|
||||||
|
"best f = {best_f}, pop min = {pop_min}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
//! Per-metric property tests for the Pareto-quality metrics.
|
||||||
|
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
use heuropt::core::candidate::Candidate;
|
||||||
|
use heuropt::core::evaluation::Evaluation;
|
||||||
|
use heuropt::core::objective::{Objective, ObjectiveSpace};
|
||||||
|
use heuropt::metrics::hypervolume::{hypervolume_2d, hypervolume_nd};
|
||||||
|
use heuropt::metrics::spacing::spacing;
|
||||||
|
|
||||||
|
fn space_2d() -> ObjectiveSpace {
|
||||||
|
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn space_3d() -> ObjectiveSpace {
|
||||||
|
ObjectiveSpace::new(vec![
|
||||||
|
Objective::minimize("f1"),
|
||||||
|
Objective::minimize("f2"),
|
||||||
|
Objective::minimize("f3"),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cand_2d(a: f64, b: f64) -> Candidate<()> {
|
||||||
|
Candidate::new((), Evaluation::new(vec![a, b]))
|
||||||
|
}
|
||||||
|
fn cand_3d(a: f64, b: f64, c: f64) -> Candidate<()> {
|
||||||
|
Candidate::new((), Evaluation::new(vec![a, b, c]))
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
/// hypervolume_2d is non-negative.
|
||||||
|
#[test]
|
||||||
|
fn hv2_non_negative(
|
||||||
|
front in prop::collection::vec((0.0_f64..10.0, 0.0_f64..10.0), 0..15),
|
||||||
|
) {
|
||||||
|
let s = space_2d();
|
||||||
|
let pop: Vec<Candidate<()>> = front.iter().map(|&(a, b)| cand_2d(a, b)).collect();
|
||||||
|
let hv = hypervolume_2d(&pop, &s, [11.0, 11.0]);
|
||||||
|
prop_assert!(hv >= 0.0);
|
||||||
|
prop_assert!(hv.is_finite());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// hypervolume_2d is bounded above by the (reference - 0)² = 121 box.
|
||||||
|
#[test]
|
||||||
|
fn hv2_bounded_by_box(
|
||||||
|
front in prop::collection::vec((0.0_f64..10.0, 0.0_f64..10.0), 1..15),
|
||||||
|
) {
|
||||||
|
let s = space_2d();
|
||||||
|
let pop: Vec<Candidate<()>> = front.iter().map(|&(a, b)| cand_2d(a, b)).collect();
|
||||||
|
let hv = hypervolume_2d(&pop, &s, [11.0, 11.0]);
|
||||||
|
prop_assert!(hv <= 121.0_f64 + 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adding a dominated point doesn't change hypervolume_2d.
|
||||||
|
#[test]
|
||||||
|
fn hv2_dominated_invariant(
|
||||||
|
a in 0.0_f64..5.0,
|
||||||
|
b in 0.0_f64..5.0,
|
||||||
|
d_offset in 0.001_f64..3.0,
|
||||||
|
) {
|
||||||
|
let s = space_2d();
|
||||||
|
let base = vec![cand_2d(a, b)];
|
||||||
|
let mut with_dominated = base.clone();
|
||||||
|
// (a + offset, b + offset) is strictly worse than (a, b) on both
|
||||||
|
// axes, so it's dominated.
|
||||||
|
with_dominated.push(cand_2d(a + d_offset, b + d_offset));
|
||||||
|
let hv1 = hypervolume_2d(&base, &s, [11.0, 11.0]);
|
||||||
|
let hv2 = hypervolume_2d(&with_dominated, &s, [11.0, 11.0]);
|
||||||
|
prop_assert!((hv1 - hv2).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// hypervolume_nd agrees with hypervolume_2d on 2-D inputs.
|
||||||
|
#[test]
|
||||||
|
fn hv_nd_matches_2d(
|
||||||
|
front in prop::collection::vec((0.0_f64..10.0, 0.0_f64..10.0), 1..10),
|
||||||
|
) {
|
||||||
|
let s = space_2d();
|
||||||
|
let pop: Vec<Candidate<()>> = front.iter().map(|&(a, b)| cand_2d(a, b)).collect();
|
||||||
|
let hv2 = hypervolume_2d(&pop, &s, [11.0, 11.0]);
|
||||||
|
let hvn = hypervolume_nd(&pop, &s, &[11.0, 11.0]);
|
||||||
|
prop_assert!((hv2 - hvn).abs() < 1e-9, "{hv2} vs {hvn}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// hypervolume_nd in 3-D is non-negative and bounded.
|
||||||
|
#[test]
|
||||||
|
fn hv3_non_negative_bounded(
|
||||||
|
pts in prop::collection::vec(
|
||||||
|
(0.0_f64..2.0, 0.0_f64..2.0, 0.0_f64..2.0),
|
||||||
|
0..10,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
let s = space_3d();
|
||||||
|
let pop: Vec<Candidate<()>> = pts.iter().map(|&(a, b, c)| cand_3d(a, b, c)).collect();
|
||||||
|
let hv = hypervolume_nd(&pop, &s, &[3.0, 3.0, 3.0]);
|
||||||
|
prop_assert!(hv >= 0.0);
|
||||||
|
prop_assert!(hv.is_finite());
|
||||||
|
// Reference box has volume 27.
|
||||||
|
prop_assert!(hv <= 27.0 + 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// spacing is non-negative and zero on a single point.
|
||||||
|
#[test]
|
||||||
|
fn spacing_non_negative(
|
||||||
|
front in prop::collection::vec((0.0_f64..10.0, 0.0_f64..10.0), 0..15),
|
||||||
|
) {
|
||||||
|
let s = space_2d();
|
||||||
|
let pop: Vec<Candidate<()>> = front.iter().map(|&(a, b)| cand_2d(a, b)).collect();
|
||||||
|
let sp = spacing(&pop, &s);
|
||||||
|
prop_assert!(sp >= 0.0);
|
||||||
|
prop_assert!(sp.is_finite());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// spacing on a single-point front is exactly 0.
|
||||||
|
#[test]
|
||||||
|
fn spacing_single_point_is_zero(a in 0.0_f64..10.0, b in 0.0_f64..10.0) {
|
||||||
|
let s = space_2d();
|
||||||
|
let pop = vec![cand_2d(a, b)];
|
||||||
|
let sp = spacing(&pop, &s);
|
||||||
|
prop_assert_eq!(sp, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
//! Stress tests for numerical edge cases.
|
||||||
|
//!
|
||||||
|
//! These don't check correctness in detail — they check that algorithms
|
||||||
|
//! and helpers don't panic, return NaN, or produce nonsensical sizes on
|
||||||
|
//! pathological inputs. The kind of failures these surface are typically
|
||||||
|
//! division-by-zero, log/sqrt of negatives, empty-collection .min(),
|
||||||
|
//! etc. — all the things property tests on "ordinary" inputs would miss.
|
||||||
|
|
||||||
|
use heuropt::core::candidate::Candidate;
|
||||||
|
use heuropt::core::evaluation::Evaluation;
|
||||||
|
use heuropt::core::objective::{Objective, ObjectiveSpace};
|
||||||
|
use heuropt::metrics::hypervolume::{hypervolume_2d, hypervolume_nd};
|
||||||
|
use heuropt::metrics::spacing::spacing;
|
||||||
|
use heuropt::pareto::crowding::crowding_distance;
|
||||||
|
use heuropt::pareto::dominance::pareto_compare;
|
||||||
|
use heuropt::pareto::front::{best_candidate, pareto_front};
|
||||||
|
use heuropt::pareto::sort::non_dominated_sort;
|
||||||
|
use heuropt::prelude::*;
|
||||||
|
|
||||||
|
fn space_2d() -> ObjectiveSpace {
|
||||||
|
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cand2(a: f64, b: f64) -> Candidate<()> {
|
||||||
|
Candidate::new((), Evaluation::new(vec![a, b]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Pareto utilities — empty / singleton / duplicate populations
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pareto_front_on_empty_population() {
|
||||||
|
let s = space_2d();
|
||||||
|
let front = pareto_front::<()>(&[], &s);
|
||||||
|
assert!(front.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pareto_front_on_singleton() {
|
||||||
|
let s = space_2d();
|
||||||
|
let pop = vec![cand2(1.0, 2.0)];
|
||||||
|
let front = pareto_front(&pop, &s);
|
||||||
|
assert_eq!(front.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pareto_front_on_all_duplicates() {
|
||||||
|
let s = space_2d();
|
||||||
|
let pop: Vec<_> = (0..5).map(|_| cand2(1.0, 1.0)).collect();
|
||||||
|
let front = pareto_front(&pop, &s);
|
||||||
|
// All members are mutually Equal — every one is non-dominated.
|
||||||
|
assert_eq!(front.len(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_dominated_sort_on_empty() {
|
||||||
|
let s = space_2d();
|
||||||
|
let fronts = non_dominated_sort::<()>(&[], &s);
|
||||||
|
assert!(fronts.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_dominated_sort_on_all_duplicates() {
|
||||||
|
let s = space_2d();
|
||||||
|
let pop: Vec<_> = (0..6).map(|_| cand2(1.0, 1.0)).collect();
|
||||||
|
let fronts = non_dominated_sort(&pop, &s);
|
||||||
|
// Every member is "Equal" with every other member — should be one
|
||||||
|
// front containing all of them.
|
||||||
|
assert_eq!(fronts.len(), 1);
|
||||||
|
assert_eq!(fronts[0].len(), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crowding_distance_on_empty_front_is_empty() {
|
||||||
|
let s = space_2d();
|
||||||
|
let pop: Vec<Candidate<()>> = vec![];
|
||||||
|
let d = crowding_distance(&pop, &[], &s);
|
||||||
|
assert!(d.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crowding_distance_on_two_point_front_is_infinity() {
|
||||||
|
let s = space_2d();
|
||||||
|
let pop = vec![cand2(0.0, 1.0), cand2(1.0, 0.0)];
|
||||||
|
let d = crowding_distance(&pop, &[0, 1], &s);
|
||||||
|
assert!(d[0].is_infinite());
|
||||||
|
assert!(d[1].is_infinite());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crowding_distance_on_collinear_points_finite_or_inf() {
|
||||||
|
let s = space_2d();
|
||||||
|
// All points have f2 = 5; f1 axis varies but f2 doesn't.
|
||||||
|
let pop = vec![cand2(0.0, 5.0), cand2(1.0, 5.0), cand2(2.0, 5.0)];
|
||||||
|
let d = crowding_distance(&pop, &[0, 1, 2], &s);
|
||||||
|
// f2 axis has zero span so it contributes nothing; f1 axis gives the
|
||||||
|
// boundaries infinity, the interior finite.
|
||||||
|
assert!(d[0].is_infinite());
|
||||||
|
assert!(d[2].is_infinite());
|
||||||
|
assert!(d[1].is_finite());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pareto_compare_with_zero_constraint_violations() {
|
||||||
|
let s = space_2d();
|
||||||
|
let a = Evaluation::constrained(vec![1.0, 1.0], 0.0);
|
||||||
|
let b = Evaluation::constrained(vec![2.0, 2.0], 0.0);
|
||||||
|
let r = pareto_compare(&a, &b, &s);
|
||||||
|
use heuropt::pareto::dominance::Dominance;
|
||||||
|
assert_eq!(r, Dominance::Dominates);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn best_candidate_on_empty_returns_none() {
|
||||||
|
let s = ObjectiveSpace::new(vec![Objective::minimize("f")]);
|
||||||
|
let pop: Vec<Candidate<()>> = vec![];
|
||||||
|
let best = best_candidate(&pop, &s);
|
||||||
|
assert!(best.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn best_candidate_all_infeasible_returns_none() {
|
||||||
|
let s = ObjectiveSpace::new(vec![Objective::minimize("f")]);
|
||||||
|
let pop = vec![
|
||||||
|
Candidate::new((), Evaluation::constrained(vec![1.0], 0.5)),
|
||||||
|
Candidate::new((), Evaluation::constrained(vec![2.0], 0.7)),
|
||||||
|
];
|
||||||
|
let best = best_candidate(&pop, &s);
|
||||||
|
assert!(best.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Metrics — degenerate inputs
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hv2_on_empty_is_zero() {
|
||||||
|
let s = space_2d();
|
||||||
|
let front: Vec<Candidate<()>> = vec![];
|
||||||
|
assert_eq!(hypervolume_2d(&front, &s, [1.0, 1.0]), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hv2_when_no_point_dominates_reference_is_zero() {
|
||||||
|
let s = space_2d();
|
||||||
|
let front = vec![cand2(5.0, 5.0)]; // worse than reference (1, 1)
|
||||||
|
assert_eq!(hypervolume_2d(&front, &s, [1.0, 1.0]), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hv_nd_on_empty_is_zero() {
|
||||||
|
let s = ObjectiveSpace::new(vec![
|
||||||
|
Objective::minimize("a"),
|
||||||
|
Objective::minimize("b"),
|
||||||
|
Objective::minimize("c"),
|
||||||
|
]);
|
||||||
|
let front: Vec<Candidate<()>> = vec![];
|
||||||
|
assert_eq!(hypervolume_nd(&front, &s, &[1.0, 1.0, 1.0]), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spacing_on_empty_is_zero() {
|
||||||
|
let s = space_2d();
|
||||||
|
let pop: Vec<Candidate<()>> = vec![];
|
||||||
|
assert_eq!(spacing(&pop, &s), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spacing_on_singleton_is_zero() {
|
||||||
|
let s = space_2d();
|
||||||
|
let pop = vec![cand2(0.5, 0.5)];
|
||||||
|
assert_eq!(spacing(&pop, &s), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Algorithms — extreme inputs
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct ConstantFn;
|
||||||
|
impl heuropt::core::problem::Problem for ConstantFn {
|
||||||
|
type Decision = Vec<f64>;
|
||||||
|
fn objectives(&self) -> ObjectiveSpace {
|
||||||
|
ObjectiveSpace::new(vec![Objective::minimize("f")])
|
||||||
|
}
|
||||||
|
fn evaluate(&self, _: &Vec<f64>) -> Evaluation {
|
||||||
|
// Flat fitness — every point is equally good.
|
||||||
|
Evaluation::new(vec![0.0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn de_handles_flat_fitness() {
|
||||||
|
// No gradient, no signal. DE should still run to completion and
|
||||||
|
// return a valid result (every point ties for best).
|
||||||
|
let mut opt = DifferentialEvolution::new(
|
||||||
|
DifferentialEvolutionConfig {
|
||||||
|
population_size: 10,
|
||||||
|
generations: 5,
|
||||||
|
differential_weight: 0.5,
|
||||||
|
crossover_probability: 0.9,
|
||||||
|
seed: 0,
|
||||||
|
},
|
||||||
|
RealBounds::new(vec![(-1.0, 1.0); 3]),
|
||||||
|
);
|
||||||
|
let r = opt.run(&ConstantFn);
|
||||||
|
let best = r.best.unwrap();
|
||||||
|
assert_eq!(best.evaluation.objectives, vec![0.0]);
|
||||||
|
assert!(r.evaluations > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cma_es_handles_flat_fitness() {
|
||||||
|
let mut opt = CmaEs::new(
|
||||||
|
CmaEsConfig {
|
||||||
|
population_size: 8,
|
||||||
|
generations: 5,
|
||||||
|
initial_sigma: 0.5,
|
||||||
|
eigen_decomposition_period: 1,
|
||||||
|
initial_mean: None,
|
||||||
|
seed: 0,
|
||||||
|
},
|
||||||
|
RealBounds::new(vec![(-1.0, 1.0); 3]),
|
||||||
|
);
|
||||||
|
let r = opt.run(&ConstantFn);
|
||||||
|
assert!(r.best.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nelder_mead_handles_flat_fitness() {
|
||||||
|
let mut opt = NelderMead::new(
|
||||||
|
NelderMeadConfig::default(),
|
||||||
|
RealBounds::new(vec![(-1.0, 1.0); 3]),
|
||||||
|
);
|
||||||
|
let r = opt.run(&ConstantFn);
|
||||||
|
assert!(r.best.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bayesian_opt_handles_flat_fitness() {
|
||||||
|
let mut opt = BayesianOpt::new(
|
||||||
|
BayesianOptConfig {
|
||||||
|
initial_samples: 4,
|
||||||
|
iterations: 6,
|
||||||
|
length_scales: None,
|
||||||
|
signal_variance: 1.0,
|
||||||
|
noise_variance: 1e-3,
|
||||||
|
acquisition_samples: 50,
|
||||||
|
seed: 0,
|
||||||
|
},
|
||||||
|
RealBounds::new(vec![(-1.0, 1.0); 2]),
|
||||||
|
);
|
||||||
|
let r = opt.run(&ConstantFn);
|
||||||
|
assert!(r.best.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn de_handles_zero_width_bounds() {
|
||||||
|
// lo == hi on every axis — search space is a single point.
|
||||||
|
let mut opt = DifferentialEvolution::new(
|
||||||
|
DifferentialEvolutionConfig {
|
||||||
|
population_size: 4,
|
||||||
|
generations: 3,
|
||||||
|
differential_weight: 0.5,
|
||||||
|
crossover_probability: 0.9,
|
||||||
|
seed: 0,
|
||||||
|
},
|
||||||
|
RealBounds::new(vec![(0.5, 0.5); 2]),
|
||||||
|
);
|
||||||
|
let r = opt.run(&ConstantFn);
|
||||||
|
let best = r.best.unwrap();
|
||||||
|
// Every decision must be exactly (0.5, 0.5).
|
||||||
|
for d in &r.population.candidates {
|
||||||
|
for &v in &d.decision {
|
||||||
|
assert_eq!(v, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = best;
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
//! Per-operator property tests covering every Variation / Initializer /
|
||||||
|
//! Repair impl heuropt ships.
|
||||||
|
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
use heuropt::core::rng::rng_from_seed;
|
||||||
|
use heuropt::prelude::*;
|
||||||
|
|
||||||
|
/// Generate per-axis bounds whose width is at least 0.001 (avoid the
|
||||||
|
/// degenerate `lo == hi` case for properties that need a proper interval).
|
||||||
|
fn bounds(dim: usize) -> impl Strategy<Value = Vec<(f64, f64)>> {
|
||||||
|
prop::collection::vec((-50.0_f64..50.0, 0.001_f64..50.0), dim..=dim)
|
||||||
|
.prop_map(|pairs| pairs.into_iter().map(|(lo, span)| (lo, lo + span)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a parent vector inside the given bounds.
|
||||||
|
fn parent_in_bounds(bounds: &[(f64, f64)]) -> Vec<f64> {
|
||||||
|
bounds.iter().map(|&(lo, hi)| 0.5 * (lo + hi)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Initializers
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn real_bounds_returns_correct_shape(
|
||||||
|
bounds in bounds(4),
|
||||||
|
size in 1usize..30,
|
||||||
|
seed in any::<u64>(),
|
||||||
|
) {
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let mut init = RealBounds::new(bounds.clone());
|
||||||
|
let decisions = init.initialize(size, &mut rng);
|
||||||
|
prop_assert_eq!(decisions.len(), size);
|
||||||
|
for d in &decisions {
|
||||||
|
prop_assert_eq!(d.len(), 4);
|
||||||
|
for (j, &v) in d.iter().enumerate() {
|
||||||
|
let (lo, hi) = bounds[j];
|
||||||
|
prop_assert!(v >= lo && v <= hi, "{v} out of [{lo}, {hi}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn real_bounds_size_zero_returns_empty(
|
||||||
|
bounds in bounds(3),
|
||||||
|
seed in any::<u64>(),
|
||||||
|
) {
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let mut init = RealBounds::new(bounds);
|
||||||
|
let decisions = init.initialize(0, &mut rng);
|
||||||
|
prop_assert!(decisions.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Real-valued Variation operators
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn gaussian_mutation_preserves_length(
|
||||||
|
sigma in 1e-6_f64..5.0,
|
||||||
|
len in 1usize..10,
|
||||||
|
seed in any::<u64>(),
|
||||||
|
) {
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let parent: Vec<f64> = vec![0.0; len];
|
||||||
|
let mut m = GaussianMutation { sigma };
|
||||||
|
let children = m.vary(std::slice::from_ref(&parent), &mut rng);
|
||||||
|
prop_assert_eq!(children.len(), 1);
|
||||||
|
prop_assert_eq!(children[0].len(), len);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounded_gaussian_mutation_in_bounds(
|
||||||
|
sigma in 1e-6_f64..5.0,
|
||||||
|
bounds in bounds(4),
|
||||||
|
seed in any::<u64>(),
|
||||||
|
) {
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let parent = parent_in_bounds(&bounds);
|
||||||
|
let mut m = BoundedGaussianMutation::new(sigma, bounds.clone());
|
||||||
|
let children = m.vary(std::slice::from_ref(&parent), &mut rng);
|
||||||
|
prop_assert_eq!(children.len(), 1);
|
||||||
|
for (j, &v) in children[0].iter().enumerate() {
|
||||||
|
let (lo, hi) = bounds[j];
|
||||||
|
prop_assert!(v >= lo && v <= hi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bit_flip_mutation_preserves_length(
|
||||||
|
probability in 0.0_f64..=1.0,
|
||||||
|
len in 1usize..32,
|
||||||
|
seed in any::<u64>(),
|
||||||
|
) {
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let parent: Vec<bool> = (0..len).map(|i| i % 2 == 0).collect();
|
||||||
|
let mut m = BitFlipMutation { probability };
|
||||||
|
let children = m.vary(std::slice::from_ref(&parent), &mut rng);
|
||||||
|
prop_assert_eq!(children.len(), 1);
|
||||||
|
prop_assert_eq!(children[0].len(), len);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn swap_mutation_is_a_permutation(
|
||||||
|
len in 2usize..16,
|
||||||
|
seed in any::<u64>(),
|
||||||
|
) {
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let parent: Vec<usize> = (0..len).collect();
|
||||||
|
let mut m = SwapMutation;
|
||||||
|
let children = m.vary(std::slice::from_ref(&parent), &mut rng);
|
||||||
|
prop_assert_eq!(children.len(), 1);
|
||||||
|
let mut sorted = children[0].clone();
|
||||||
|
sorted.sort();
|
||||||
|
let identity: Vec<usize> = (0..len).collect();
|
||||||
|
prop_assert_eq!(sorted, identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sbx_in_bounds(
|
||||||
|
bounds in bounds(3),
|
||||||
|
eta in 1.0_f64..30.0,
|
||||||
|
per_var_p in 0.0_f64..=1.0,
|
||||||
|
a_frac in 0.0_f64..1.0,
|
||||||
|
b_frac in 0.0_f64..1.0,
|
||||||
|
seed in any::<u64>(),
|
||||||
|
) {
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let p1: Vec<f64> = bounds.iter().map(|&(lo, hi)| lo + a_frac * (hi - lo)).collect();
|
||||||
|
let p2: Vec<f64> = bounds.iter().map(|&(lo, hi)| lo + b_frac * (hi - lo)).collect();
|
||||||
|
let mut sbx = SimulatedBinaryCrossover::new(bounds.clone(), eta, per_var_p);
|
||||||
|
let children = sbx.vary(&[p1, p2], &mut rng);
|
||||||
|
prop_assert_eq!(children.len(), 2);
|
||||||
|
for c in &children {
|
||||||
|
for (j, &v) in c.iter().enumerate() {
|
||||||
|
let (lo, hi) = bounds[j];
|
||||||
|
prop_assert!(v >= lo && v <= hi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn polymut_in_bounds(
|
||||||
|
bounds in bounds(3),
|
||||||
|
eta in 1.0_f64..40.0,
|
||||||
|
per_var_p in 0.0_f64..=1.0,
|
||||||
|
seed in any::<u64>(),
|
||||||
|
) {
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let parent = parent_in_bounds(&bounds);
|
||||||
|
let mut pm = PolynomialMutation::new(bounds.clone(), eta, per_var_p);
|
||||||
|
let children = pm.vary(std::slice::from_ref(&parent), &mut rng);
|
||||||
|
prop_assert_eq!(children.len(), 1);
|
||||||
|
for (j, &v) in children[0].iter().enumerate() {
|
||||||
|
let (lo, hi) = bounds[j];
|
||||||
|
prop_assert!(v >= lo && v <= hi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn levy_mutation_in_bounds(
|
||||||
|
bounds in bounds(3),
|
||||||
|
alpha in 0.5_f64..2.0,
|
||||||
|
scale in 0.01_f64..1.0,
|
||||||
|
seed in any::<u64>(),
|
||||||
|
) {
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let parent = parent_in_bounds(&bounds);
|
||||||
|
let mut m = LevyMutation::new(alpha, scale, bounds.clone());
|
||||||
|
let children = m.vary(std::slice::from_ref(&parent), &mut rng);
|
||||||
|
prop_assert_eq!(children.len(), 1);
|
||||||
|
for (j, &v) in children[0].iter().enumerate() {
|
||||||
|
let (lo, hi) = bounds[j];
|
||||||
|
prop_assert!(v >= lo && v <= hi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn composite_variation_preserves_count(
|
||||||
|
bounds in bounds(3),
|
||||||
|
a_frac in 0.0_f64..1.0,
|
||||||
|
b_frac in 0.0_f64..1.0,
|
||||||
|
seed in any::<u64>(),
|
||||||
|
) {
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let p1: Vec<f64> = bounds.iter().map(|&(lo, hi)| lo + a_frac * (hi - lo)).collect();
|
||||||
|
let p2: Vec<f64> = bounds.iter().map(|&(lo, hi)| lo + b_frac * (hi - lo)).collect();
|
||||||
|
// SBX produces 2 children, PolyMut produces 1 each → expect 2.
|
||||||
|
let mut v = CompositeVariation {
|
||||||
|
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
|
||||||
|
mutation: PolynomialMutation::new(bounds, 20.0, 0.5),
|
||||||
|
};
|
||||||
|
let children = v.vary(&[p1, p2], &mut rng);
|
||||||
|
prop_assert_eq!(children.len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Repair operators
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn clamp_to_bounds_lands_in_bounds(
|
||||||
|
bounds in bounds(5),
|
||||||
|
seed in any::<u64>(),
|
||||||
|
) {
|
||||||
|
use rand::Rng as _;
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let mut x: Vec<f64> = (0..5).map(|_| rng.random_range(-1000.0..=1000.0)).collect();
|
||||||
|
let mut r = ClampToBounds::new(bounds.clone());
|
||||||
|
r.repair(&mut x);
|
||||||
|
for (j, &v) in x.iter().enumerate() {
|
||||||
|
let (lo, hi) = bounds[j];
|
||||||
|
prop_assert!(v >= lo && v <= hi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clamp_to_bounds_idempotent(
|
||||||
|
bounds in bounds(5),
|
||||||
|
seed in any::<u64>(),
|
||||||
|
) {
|
||||||
|
use rand::Rng as _;
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let mut x: Vec<f64> = (0..5).map(|_| rng.random_range(-1000.0..=1000.0)).collect();
|
||||||
|
let mut r = ClampToBounds::new(bounds);
|
||||||
|
r.repair(&mut x);
|
||||||
|
let after_one = x.clone();
|
||||||
|
r.repair(&mut x);
|
||||||
|
prop_assert_eq!(x, after_one);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_to_simplex_lands_in_simplex(
|
||||||
|
n in 2usize..8,
|
||||||
|
total in 0.5_f64..10.0,
|
||||||
|
seed in any::<u64>(),
|
||||||
|
) {
|
||||||
|
use rand::Rng as _;
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let mut x: Vec<f64> = (0..n).map(|_| rng.random_range(-5.0..5.0)).collect();
|
||||||
|
let mut r = ProjectToSimplex::new(total);
|
||||||
|
r.repair(&mut x);
|
||||||
|
for &v in &x {
|
||||||
|
prop_assert!(v >= 0.0);
|
||||||
|
}
|
||||||
|
let s: f64 = x.iter().sum();
|
||||||
|
prop_assert!((s - total).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_to_simplex_idempotent(
|
||||||
|
n in 2usize..8,
|
||||||
|
total in 0.5_f64..5.0,
|
||||||
|
seed in any::<u64>(),
|
||||||
|
) {
|
||||||
|
use rand::Rng as _;
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let mut x: Vec<f64> = (0..n).map(|_| rng.random_range(-5.0..5.0)).collect();
|
||||||
|
let mut r = ProjectToSimplex::new(total);
|
||||||
|
r.repair(&mut x);
|
||||||
|
let after_one = x.clone();
|
||||||
|
r.repair(&mut x);
|
||||||
|
for (a, b) in after_one.iter().zip(x.iter()) {
|
||||||
|
prop_assert!((a - b).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user