277 lines
9.9 KiB
Rust
277 lines
9.9 KiB
Rust
//! Property-based tests for heuropt invariants.
|
|
//!
|
|
//! Where the unit-test suite checks specific cases, this suite checks
|
|
//! invariants that should hold for *any* well-formed input. proptest
|
|
//! generates random instances and shrinks failures.
|
|
|
|
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::*;
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Strategies
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/// Generate a 2-objective minimize ObjectiveSpace.
|
|
fn space_2d() -> ObjectiveSpace {
|
|
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
|
|
}
|
|
|
|
/// Generate a candidate with a 2-D objective vector in `[lo, hi]`.
|
|
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])))
|
|
}
|
|
|
|
/// Generate a small 2-D population.
|
|
fn population_2d() -> impl Strategy<Value = Vec<Candidate<()>>> {
|
|
prop::collection::vec(candidate_2d(-100.0, 100.0), 1..=15)
|
|
}
|
|
|
|
/// Generate per-axis bounds.
|
|
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()
|
|
})
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Pareto invariants
|
|
// -----------------------------------------------------------------------------
|
|
|
|
proptest! {
|
|
/// `pareto_compare` is anti-symmetric on Dominates / DominatedBy.
|
|
#[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:?}"),
|
|
}
|
|
}
|
|
|
|
/// Comparing a candidate with itself returns Equal.
|
|
#[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);
|
|
}
|
|
|
|
/// `pareto_front` output members are pairwise non-dominated.
|
|
#[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}",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `non_dominated_sort` partitions every population member into exactly
|
|
/// one front (no missing or duplicate indices).
|
|
#[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");
|
|
}
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Operator invariants
|
|
// -----------------------------------------------------------------------------
|
|
|
|
proptest! {
|
|
/// SBX returns exactly 2 children, both in bounds when parents are in
|
|
/// bounds. (SBX only clamps variables it actually mixes — when
|
|
/// `per_variable_probability < 1` the rest pass through from the
|
|
/// parents, so out-of-bounds parents would yield out-of-bounds children
|
|
/// by design. We're checking the in-bounds-parent contract.)
|
|
#[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);
|
|
// Parents are convex combinations of bounds — strictly in box.
|
|
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}]");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// PolynomialMutation returns 1 child in bounds.
|
|
#[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);
|
|
}
|
|
}
|
|
|
|
/// ClampToBounds always lands every variable in bounds.
|
|
#[test]
|
|
fn clamp_to_bounds_lands_in_bounds(
|
|
bounds in bounds(5),
|
|
x_seed in any::<u64>(),
|
|
) {
|
|
// Sample a "before-repair" vector that may be wildly out of bounds.
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// ProjectToSimplex always lands in the simplex { x ≥ 0, Σ x = total }.
|
|
#[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}",
|
|
);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Optimizer determinism
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/// Tiny single-objective sphere problem reused across determinism props.
|
|
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,
|
|
);
|
|
}
|
|
}
|