use proptest::prelude::*;
use heuropt::core::candidate::Candidate;
use heuropt::core::evaluation::Evaluation;
use heuropt::core::objective::{Objective, ObjectiveSpace};
use heuropt::pareto::dominance::{Dominance, pareto_compare};
use heuropt::pareto::front::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 candidate_2d(lo: f64, hi: f64) -> impl Strategy<Value = Candidate<()>> {
(lo..hi, lo..hi).prop_map(|(a, b)| Candidate::new((), Evaluation::new(vec![a, b])))
}
fn population_2d() -> impl Strategy<Value = Vec<Candidate<()>>> {
prop::collection::vec(candidate_2d(-100.0, 100.0), 1..=15)
}
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()
})
}
proptest! {
#[test]
fn pareto_compare_is_antisymmetric(
a in candidate_2d(-100.0, 100.0),
b in candidate_2d(-100.0, 100.0),
) {
let s = space_2d();
let ab = pareto_compare(&a.evaluation, &b.evaluation, &s);
let ba = pareto_compare(&b.evaluation, &a.evaluation, &s);
match (ab, ba) {
(Dominance::Dominates, Dominance::DominatedBy) => {}
(Dominance::DominatedBy, Dominance::Dominates) => {}
(Dominance::Equal, Dominance::Equal) => {}
(Dominance::NonDominated, Dominance::NonDominated) => {}
(l, r) => prop_assert!(false, "asymmetric result: ab={l:?}, ba={r:?}"),
}
}
#[test]
fn pareto_compare_reflexive(a in candidate_2d(-100.0, 100.0)) {
let s = space_2d();
let r = pareto_compare(&a.evaluation, &a.evaluation, &s);
prop_assert_eq!(r, Dominance::Equal);
}
#[test]
fn pareto_front_is_internally_nondominated(pop in population_2d()) {
let s = space_2d();
let front = pareto_front(&pop, &s);
for i in 0..front.len() {
for j in 0..front.len() {
if i == j {
continue;
}
let r = pareto_compare(&front[i].evaluation, &front[j].evaluation, &s);
prop_assert!(
!matches!(r, Dominance::DominatedBy),
"front member {i} dominated by {j}",
);
}
}
}
#[test]
fn non_dominated_sort_partitions_population(pop in population_2d()) {
let s = space_2d();
let fronts = non_dominated_sort(&pop, &s);
let mut seen = vec![false; pop.len()];
for front in &fronts {
for &idx in front {
prop_assert!(!seen[idx], "index {idx} appears in multiple fronts");
seen[idx] = true;
}
}
for (i, &was_seen) in seen.iter().enumerate() {
prop_assert!(was_seen, "index {i} is missing from all fronts");
}
}
}
proptest! {
#[test]
fn sbx_children_in_bounds_when_parents_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 {
prop_assert_eq!(c.len(), 3);
for (j, &v) in c.iter().enumerate() {
let (lo, hi) = bounds[j];
prop_assert!(v >= lo && v <= hi, "SBX child[{j}] = {v} out of [{lo}, {hi}]");
}
}
}
#[test]
fn polymut_child_in_bounds(
bounds in bounds(4),
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: Vec<f64> = bounds.iter().map(|&(lo, hi)| 0.5 * (lo + hi)).collect();
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 clamp_to_bounds_lands_in_bounds(
bounds in bounds(5),
x_seed in any::<u64>(),
) {
let mut rng = rng_from_seed(x_seed);
use rand::Rng as _;
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 project_to_simplex_lands_in_simplex(
n in 2usize..8,
total in 0.5_f64..10.0,
seed in any::<u64>(),
) {
let mut rng = rng_from_seed(seed);
use rand::Rng as _;
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, "negative entry: {v}");
}
let s: f64 = x.iter().sum();
prop_assert!(
(s - total).abs() < 1e-9,
"sum {s} != total {total}",
);
}
}
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]])
}
}
proptest! {
#[test]
fn de_deterministic_with_seed(seed in any::<u64>()) {
let mut a = DifferentialEvolution::new(
DifferentialEvolutionConfig {
population_size: 10,
generations: 5,
differential_weight: 0.5,
crossover_probability: 0.9,
seed,
},
RealBounds::new(vec![(-3.0, 3.0)]),
);
let mut b = DifferentialEvolution::new(
DifferentialEvolutionConfig {
population_size: 10,
generations: 5,
differential_weight: 0.5,
crossover_probability: 0.9,
seed,
},
RealBounds::new(vec![(-3.0, 3.0)]),
);
let ra = a.run(&Sphere1D);
let rb = b.run(&Sphere1D);
prop_assert_eq!(
ra.best.unwrap().evaluation.objectives,
rb.best.unwrap().evaluation.objectives,
);
}
#[test]
fn cmaes_deterministic_with_seed(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(), RealBounds::new(vec![(-3.0, 3.0)]));
let mut b = CmaEs::new(cfg, RealBounds::new(vec![(-3.0, 3.0)]));
let ra = a.run(&Sphere1D);
let rb = b.run(&Sphere1D);
prop_assert_eq!(
ra.best.unwrap().evaluation.objectives,
rb.best.unwrap().evaluation.objectives,
);
}
}