From 8a8c32f125a228332046a165b2db72c7d7900406 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 5 May 2026 10:58:13 -0600 Subject: [PATCH] test(proptest): massive property-test expansion for every algorithm and operator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/algorithm_properties.rs | 733 ++++++++++++++++++++++++++++++++++ tests/metric_properties.rs | 121 ++++++ tests/numerical_stability.rs | 279 +++++++++++++ tests/operator_properties.rs | 273 +++++++++++++ 4 files changed, 1406 insertions(+) create mode 100644 tests/algorithm_properties.rs create mode 100644 tests/metric_properties.rs create mode 100644 tests/numerical_stability.rs create mode 100644 tests/operator_properties.rs diff --git a/tests/algorithm_properties.rs b/tests/algorithm_properties.rs new file mode 100644 index 0000000..fdaf29c --- /dev/null +++ b/tests/algorithm_properties.rs @@ -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; + fn objectives(&self) -> ObjectiveSpace { + ObjectiveSpace::new(vec![Objective::minimize("f")]) + } + fn evaluate(&self, x: &Vec) -> Evaluation { + Evaluation::new(vec![x[0] * x[0]]) + } +} + +struct SchafferN1; +impl Problem for SchafferN1 { + type Decision = Vec; + fn objectives(&self) -> ObjectiveSpace { + ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]) + } + fn evaluate(&self, x: &Vec) -> 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; + fn objectives(&self) -> ObjectiveSpace { + ObjectiveSpace::new(vec![Objective::maximize("count")]) + } + fn evaluate(&self, x: &Vec) -> 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 { + 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::()) { + 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::()) { + 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::()) { + 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::()) { + 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::()) { + 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::()) { + 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::()) { + 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::()) { + 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::()) { + 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::()) { + 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::()) { + 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::()) { + // 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::()) { + 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::()) { + 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::()) { + 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> = r1.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + let ob: Vec> = 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::()) { + 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> = r1.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + let ob: Vec> = 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::()) { + 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> = r1.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + let ob: Vec> = r2.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + prop_assert_eq!(oa, ob); + } + + #[test] + fn moead_deterministic(seed in any::()) { + 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> = r1.population.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + let ob: Vec> = r2.population.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + prop_assert_eq!(oa, ob); + } + + #[test] + fn mopso_deterministic(seed in any::()) { + 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> = r1.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + let ob: Vec> = r2.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + prop_assert_eq!(oa, ob); + } + + #[test] + fn ibea_deterministic(seed in any::()) { + 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> = r1.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + let ob: Vec> = r2.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + prop_assert_eq!(oa, ob); + } + + #[test] + fn sms_emoa_deterministic(seed in any::()) { + 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> = r1.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + let ob: Vec> = r2.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + prop_assert_eq!(oa, ob); + } + + #[test] + fn hype_deterministic(seed in any::()) { + 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> = r1.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + let ob: Vec> = r2.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + prop_assert_eq!(oa, ob); + } + + #[test] + fn pesa2_deterministic(seed in any::()) { + 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> = r1.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + let ob: Vec> = r2.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + prop_assert_eq!(oa, ob); + } + + #[test] + fn epsilon_moea_deterministic(seed in any::()) { + 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> = r1.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + let ob: Vec> = r2.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + prop_assert_eq!(oa, ob); + } + + #[test] + fn age_moea_deterministic(seed in any::()) { + 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> = r1.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + let ob: Vec> = r2.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + prop_assert_eq!(oa, ob); + } + + #[test] + fn grea_deterministic(seed in any::()) { + 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> = r1.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + let ob: Vec> = r2.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + prop_assert_eq!(oa, ob); + } + + #[test] + fn knea_deterministic(seed in any::()) { + 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> = r1.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + let ob: Vec> = r2.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + prop_assert_eq!(oa, ob); + } + + #[test] + fn rvea_deterministic(seed in any::()) { + 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> = r1.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + let ob: Vec> = r2.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + prop_assert_eq!(oa, ob); + } + + #[test] + fn paes_deterministic(seed in any::()) { + 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> = r1.pareto_front.iter() + .map(|c| c.evaluation.objectives.clone()).collect(); + let ob: Vec> = 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::(), 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::(), + ) { + 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::()) { + // 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}", + ); + } +} diff --git a/tests/metric_properties.rs b/tests/metric_properties.rs new file mode 100644 index 0000000..555bfaf --- /dev/null +++ b/tests/metric_properties.rs @@ -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> = 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> = 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> = 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> = 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> = 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); + } +} diff --git a/tests/numerical_stability.rs b/tests/numerical_stability.rs new file mode 100644 index 0000000..b7c6a83 --- /dev/null +++ b/tests/numerical_stability.rs @@ -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> = 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> = 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> = 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> = 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> = 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; + fn objectives(&self) -> ObjectiveSpace { + ObjectiveSpace::new(vec![Objective::minimize("f")]) + } + fn evaluate(&self, _: &Vec) -> 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; +} diff --git a/tests/operator_properties.rs b/tests/operator_properties.rs new file mode 100644 index 0000000..624105f --- /dev/null +++ b/tests/operator_properties.rs @@ -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> { + 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 { + 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::(), + ) { + 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::(), + ) { + 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::(), + ) { + let mut rng = rng_from_seed(seed); + let parent: Vec = 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::(), + ) { + 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::(), + ) { + let mut rng = rng_from_seed(seed); + let parent: Vec = (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::(), + ) { + let mut rng = rng_from_seed(seed); + let parent: Vec = (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 = (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::(), + ) { + let mut rng = rng_from_seed(seed); + let p1: Vec = bounds.iter().map(|&(lo, hi)| lo + a_frac * (hi - lo)).collect(); + let p2: Vec = 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::(), + ) { + 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::(), + ) { + 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::(), + ) { + let mut rng = rng_from_seed(seed); + let p1: Vec = bounds.iter().map(|&(lo, hi)| lo + a_frac * (hi - lo)).collect(); + let p2: Vec = 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::(), + ) { + use rand::Rng as _; + let mut rng = rng_from_seed(seed); + let mut x: Vec = (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::(), + ) { + use rand::Rng as _; + let mut rng = rng_from_seed(seed); + let mut x: Vec = (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::(), + ) { + use rand::Rng as _; + let mut rng = rng_from_seed(seed); + let mut x: Vec = (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::(), + ) { + use rand::Rng as _; + let mut rng = rng_from_seed(seed); + let mut x: Vec = (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); + } + } +}