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.
280 lines
8.3 KiB
Rust
280 lines
8.3 KiB
Rust
//! 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;
|
|
}
|