test(fuzz): add cargo-fuzz harness for Pareto and operator hot paths

This commit is contained in:
2026-05-05 11:28:09 -06:00
parent aba9dbf469
commit c7a8f43a99
11 changed files with 841 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
target
corpus
artifacts
coverage
+254
View File
@@ -0,0 +1,254 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "cc"
version = "1.2.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "heuropt"
version = "0.3.0"
dependencies = [
"rand",
"rand_distr",
]
[[package]]
name = "heuropt-fuzz"
version = "0.0.0"
dependencies = [
"arbitrary",
"heuropt",
"libfuzzer-sys",
]
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom",
"libc",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libfuzzer-sys"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d"
dependencies = [
"arbitrary",
"cc",
]
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
"libm",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom",
]
[[package]]
name = "rand_distr"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463"
dependencies = [
"num-traits",
"rand",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "zerocopy"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
+69
View File
@@ -0,0 +1,69 @@
[package]
name = "heuropt-fuzz"
version = "0.0.0"
publish = false
edition = "2024"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
arbitrary = { version = "1", features = ["derive"] }
heuropt = { path = ".." }
[[bin]]
name = "pareto_compare"
path = "fuzz_targets/pareto_compare.rs"
test = false
doc = false
bench = false
[[bin]]
name = "non_dominated_sort"
path = "fuzz_targets/non_dominated_sort.rs"
test = false
doc = false
bench = false
[[bin]]
name = "hypervolume_2d"
path = "fuzz_targets/hypervolume_2d.rs"
test = false
doc = false
bench = false
[[bin]]
name = "pareto_archive"
path = "fuzz_targets/pareto_archive.rs"
test = false
doc = false
bench = false
[[bin]]
name = "crowding_distance"
path = "fuzz_targets/crowding_distance.rs"
test = false
doc = false
bench = false
[[bin]]
name = "spacing"
path = "fuzz_targets/spacing.rs"
test = false
doc = false
bench = false
[[bin]]
name = "sbx_polymut"
path = "fuzz_targets/sbx_polymut.rs"
test = false
doc = false
bench = false
[[bin]]
name = "clamp_to_bounds"
path = "fuzz_targets/clamp_to_bounds.rs"
test = false
doc = false
bench = false
+88
View File
@@ -0,0 +1,88 @@
#![no_main]
//! Fuzz `ClampToBounds` + `ProjectToSimplex` repair operators for
//! idempotence and target-set membership.
use arbitrary::Arbitrary;
use libfuzzer_sys::fuzz_target;
use heuropt::prelude::*;
#[derive(Arbitrary, Debug)]
struct Input {
bounds: Vec<(f64, f64)>,
x: Vec<f64>,
simplex_total: f64,
}
fuzz_target!(|input: Input| {
if input.bounds.is_empty() || input.bounds.len() > 16 {
return;
}
if input.x.len() != input.bounds.len() {
return;
}
let bounds: Vec<(f64, f64)> = input
.bounds
.iter()
.filter_map(|&(lo, hi)| {
if lo.is_finite() && hi.is_finite() && lo < hi {
Some((lo, hi))
} else {
None
}
})
.collect();
if bounds.len() != input.bounds.len() {
return;
}
// Restrict to a numerically-reasonable magnitude range for repair
// operators — they are invoked downstream of evolutionary search where
// candidate magnitudes are bounded.
if input.x.iter().any(|v| !v.is_finite() || v.abs() > 1e30) {
return;
}
let mut x = input.x.clone();
let mut clamp = ClampToBounds::new(bounds.clone());
clamp.repair(&mut x);
for (j, &v) in x.iter().enumerate() {
let (lo, hi) = bounds[j];
assert!(v >= lo && v <= hi, "clamp out of bounds");
}
let after_one = x.clone();
clamp.repair(&mut x);
assert_eq!(x, after_one, "clamp not idempotent");
// Simplex projection only meaningful when total > 0 and dim >= 1.
// The Duchi/Held-Wolfe projection loses precision when |x| ≫ total
// (τ becomes indistinguishable from max(x) in f64). Restrict to inputs
// within the algorithm's well-conditioned regime, |x_i| ≤ total · 1e6.
let max_abs = input.x.iter().fold(0.0_f64, |a, &b| a.max(b.abs()));
if input.simplex_total.is_finite()
&& input.simplex_total > 1.0
&& input.simplex_total < 1e9
&& max_abs <= input.simplex_total * 1e6
{
let mut y = input.x.clone();
let mut proj = ProjectToSimplex::new(input.simplex_total);
proj.repair(&mut y);
for &v in &y {
assert!(v >= 0.0, "project negative entry");
}
let s: f64 = y.iter().sum();
assert!(
(s - input.simplex_total).abs() < 1e-6 * input.simplex_total.max(1.0),
"project sum {s} != target {}",
input.simplex_total,
);
let after = y.clone();
proj.repair(&mut y);
for (a, b) in after.iter().zip(y.iter()) {
let scale = a.abs().max(b.abs()).max(1.0);
assert!(
(a - b).abs() < 1e-9 * scale,
"project not idempotent: {a} vs {b}",
);
}
}
});
+50
View File
@@ -0,0 +1,50 @@
#![no_main]
//! Fuzz `crowding_distance` for shape and non-negativity.
use arbitrary::Arbitrary;
use libfuzzer_sys::fuzz_target;
use heuropt::core::candidate::Candidate;
use heuropt::core::evaluation::Evaluation;
use heuropt::core::objective::{Objective, ObjectiveSpace};
use heuropt::pareto::crowding::crowding_distance;
#[derive(Arbitrary, Debug)]
struct Input {
points: Vec<(f64, f64)>,
}
fuzz_target!(|input: Input| {
if input.points.len() > 64 {
return;
}
// Bound magnitudes — crowding's `(max - min)` and per-axis gaps can
// both overflow to +∞ when points span ±f64::MAX, yielding inf/inf=NaN.
if input
.points
.iter()
.any(|&(a, b)| !a.is_finite() || !b.is_finite() || a.abs() > 1e150 || b.abs() > 1e150)
{
return;
}
let space = ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]);
let pop: Vec<Candidate<()>> = input
.points
.iter()
.map(|&(a, b)| Candidate::new((), Evaluation::new(vec![a, b])))
.collect();
let front: Vec<usize> = (0..pop.len()).collect();
let d = crowding_distance(&pop, &front, &space);
assert_eq!(d.len(), front.len(), "crowding distance length mismatch");
for (i, &v) in d.iter().enumerate() {
assert!(v >= 0.0 || v.is_infinite(), "negative crowding[{i}] = {v}");
assert!(!v.is_nan(), "NaN crowding[{i}]");
}
// If size <= 2, every entry is +∞.
if pop.len() <= 2 {
for (i, &v) in d.iter().enumerate() {
assert!(v.is_infinite(), "size<=2 crowding[{i}] not inf: {v}");
}
}
});
+47
View File
@@ -0,0 +1,47 @@
#![no_main]
//! Fuzz `hypervolume_2d` for non-negativity and reference-point handling.
use arbitrary::Arbitrary;
use libfuzzer_sys::fuzz_target;
use heuropt::core::candidate::Candidate;
use heuropt::core::evaluation::Evaluation;
use heuropt::core::objective::{Objective, ObjectiveSpace};
use heuropt::metrics::hypervolume::hypervolume_2d;
#[derive(Arbitrary, Debug)]
struct Input {
points: Vec<(f64, f64)>,
ref_point: (f64, f64),
}
fuzz_target!(|input: Input| {
if input.points.len() > 64 {
return;
}
// Non-finite floats are permitted by Evaluation, but HV is undefined
// there — restrict to finite for this property.
if !input.ref_point.0.is_finite() || !input.ref_point.1.is_finite() {
return;
}
if input
.points
.iter()
.any(|&(a, b)| !a.is_finite() || !b.is_finite())
{
return;
}
let space = ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]);
let pop: Vec<Candidate<()>> = input
.points
.iter()
.map(|&(a, b)| Candidate::new((), Evaluation::new(vec![a, b])))
.collect();
let hv = hypervolume_2d(&pop, &space, [input.ref_point.0, input.ref_point.1]);
// HV can be +∞ when the dominated rectangle area overflows f64 (e.g. a
// ref point at f64::MAX with deeply negative front coords). The
// contracted invariants are non-negativity and non-NaN.
assert!(hv >= 0.0, "HV negative: {hv}");
assert!(!hv.is_nan(), "HV is NaN");
});
+62
View File
@@ -0,0 +1,62 @@
#![no_main]
//! Fuzz `non_dominated_sort` for partition correctness.
//!
//! Invariants checked:
//! * Every population index appears in exactly one front.
//! * Earlier fronts dominate later fronts (no backwards domination).
//! * No panics on any vector of finite or non-finite objective values.
use arbitrary::Arbitrary;
use libfuzzer_sys::fuzz_target;
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::sort::non_dominated_sort;
#[derive(Arbitrary, Debug)]
struct Input {
objectives: Vec<(f64, f64)>,
}
fuzz_target!(|input: Input| {
if input.objectives.is_empty() || input.objectives.len() > 32 {
return;
}
let space = ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]);
let pop: Vec<Candidate<()>> = input
.objectives
.iter()
.map(|&(a, b)| Candidate::new((), Evaluation::new(vec![a, b])))
.collect();
let fronts = non_dominated_sort(&pop, &space);
// Partition: every index appears exactly once.
let mut seen = vec![false; pop.len()];
for front in &fronts {
for &idx in front {
assert!(!seen[idx], "index {idx} in multiple fronts");
seen[idx] = true;
}
}
for (i, &was) in seen.iter().enumerate() {
assert!(was, "index {i} missing from all fronts");
}
// Earlier fronts cannot be dominated by later fronts.
for (k, fk) in fronts.iter().enumerate() {
for fl in fronts.iter().skip(k + 1) {
for &i in fk {
for &j in fl {
let r = pareto_compare(&pop[i].evaluation, &pop[j].evaluation, &space);
assert!(
!matches!(r, Dominance::DominatedBy),
"front-{k}/{i} dominated by later front",
);
}
}
}
}
});
+58
View File
@@ -0,0 +1,58 @@
#![no_main]
//! Fuzz `ParetoArchive` for the non-domination invariant under arbitrary
//! insertion/truncation sequences.
use arbitrary::Arbitrary;
use libfuzzer_sys::fuzz_target;
use heuropt::core::candidate::Candidate;
use heuropt::core::evaluation::Evaluation;
use heuropt::core::objective::{Objective, ObjectiveSpace};
use heuropt::pareto::archive::ParetoArchive;
use heuropt::pareto::dominance::{Dominance, pareto_compare};
#[derive(Arbitrary, Debug)]
enum Op {
Insert(f64, f64),
Truncate(u8),
}
#[derive(Arbitrary, Debug)]
struct Input {
ops: Vec<Op>,
}
fuzz_target!(|input: Input| {
if input.ops.len() > 64 {
return;
}
let space = ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]);
let mut archive: ParetoArchive<()> = ParetoArchive::new(space.clone());
for op in input.ops {
match op {
Op::Insert(a, b) => {
let cand = Candidate::new((), Evaluation::new(vec![a, b]));
archive.insert(cand);
}
Op::Truncate(n) => archive.truncate(n as usize),
}
}
// Members must be pairwise non-dominated.
let m = archive.members();
for i in 0..m.len() {
for j in 0..m.len() {
if i == j {
continue;
}
let r = pareto_compare(&m[i].evaluation, &m[j].evaluation, &space);
assert!(
!matches!(r, Dominance::DominatedBy),
"archive member {i} dominated by {j}: {:?} vs {:?}",
m[i].evaluation.objectives,
m[j].evaluation.objectives,
);
}
}
});
+68
View File
@@ -0,0 +1,68 @@
#![no_main]
//! Fuzz `pareto_compare` for anti-symmetry and reflexivity.
//!
//! Invariants checked:
//! * `compare(a, b)` and `compare(b, a)` form an anti-symmetric pair
//! (`Dominates ↔ DominatedBy`, `Equal ↔ Equal`, `NonDominated ↔ NonDominated`).
//! * `compare(a, a) == Equal`.
//! * No panics on any combination of finite/non-finite floats.
use arbitrary::Arbitrary;
use libfuzzer_sys::fuzz_target;
use heuropt::core::evaluation::Evaluation;
use heuropt::core::objective::{Objective, ObjectiveSpace};
use heuropt::pareto::dominance::{Dominance, pareto_compare};
#[derive(Arbitrary, Debug)]
struct Input {
a_objs: Vec<f64>,
b_objs: Vec<f64>,
a_violation: f64,
b_violation: f64,
minimize_mask: u8,
}
fuzz_target!(|input: Input| {
if input.a_objs.is_empty() || input.a_objs.len() != input.b_objs.len() {
return;
}
if input.a_objs.len() > 8 {
return;
}
let m = input.a_objs.len();
let space = ObjectiveSpace::new(
(0..m)
.map(|i| {
if (input.minimize_mask >> i) & 1 == 0 {
Objective::minimize(format!("f{i}"))
} else {
Objective::maximize(format!("f{i}"))
}
})
.collect(),
);
let a = Evaluation::constrained(input.a_objs.clone(), input.a_violation);
let b = Evaluation::constrained(input.b_objs.clone(), input.b_violation);
let ab = pareto_compare(&a, &b, &space);
let ba = pareto_compare(&b, &a, &space);
let aa = pareto_compare(&a, &a, &space);
// Anti-symmetry pairs.
let antisymmetric = matches!(
(ab, ba),
(Dominance::Dominates, Dominance::DominatedBy)
| (Dominance::DominatedBy, Dominance::Dominates)
| (Dominance::Equal, Dominance::Equal)
| (Dominance::NonDominated, Dominance::NonDominated),
);
assert!(antisymmetric, "asymmetric: ab={ab:?}, ba={ba:?}");
// Reflexivity (when objectives are finite — NaNs make equality
// ill-defined, so skip the check there).
if input.a_objs.iter().all(|v| v.is_finite()) && input.a_violation.is_finite() {
assert_eq!(aa, Dominance::Equal);
}
});
+99
View File
@@ -0,0 +1,99 @@
#![no_main]
//! Fuzz SBX + PolynomialMutation: in-bounds parents must produce in-bounds
//! children for any seed and any (η, per-variable-probability) pair.
use arbitrary::Arbitrary;
use libfuzzer_sys::fuzz_target;
use heuropt::core::rng::rng_from_seed;
use heuropt::prelude::*;
#[derive(Arbitrary, Debug)]
struct Input {
bounds: Vec<(f64, f64)>,
eta_sbx: f64,
eta_pm: f64,
pvp_sbx: f64,
pvp_pm: f64,
a_frac: Vec<f64>,
b_frac: Vec<f64>,
seed: u64,
}
fuzz_target!(|input: Input| {
let n = input.bounds.len();
if n == 0 || n > 8 {
return;
}
if !(input.eta_sbx.is_finite() && input.eta_pm.is_finite()) {
return;
}
if !(input.eta_sbx >= 1.0 && input.eta_sbx <= 100.0) {
return;
}
if !(input.eta_pm >= 1.0 && input.eta_pm <= 100.0) {
return;
}
let pvp_sbx = match input.pvp_sbx {
v if v.is_finite() && (0.0..=1.0).contains(&v) => v,
_ => return,
};
let pvp_pm = match input.pvp_pm {
v if v.is_finite() && (0.0..=1.0).contains(&v) => v,
_ => return,
};
// Sanitize bounds: lo < hi, finite.
let bounds: Vec<(f64, f64)> = input
.bounds
.iter()
.filter_map(|&(lo, hi)| {
if lo.is_finite() && hi.is_finite() && hi - lo > 1e-9 {
Some((lo, hi))
} else {
None
}
})
.collect();
if bounds.len() != n {
return;
}
if input.a_frac.len() < n || input.b_frac.len() < n {
return;
}
let p1: Vec<f64> = bounds
.iter()
.zip(&input.a_frac)
.map(|(&(lo, hi), &f)| {
let frac = if f.is_finite() { f.fract().abs() } else { 0.5 };
lo + frac * (hi - lo)
})
.collect();
let p2: Vec<f64> = bounds
.iter()
.zip(&input.b_frac)
.map(|(&(lo, hi), &f)| {
let frac = if f.is_finite() { f.fract().abs() } else { 0.5 };
lo + frac * (hi - lo)
})
.collect();
let mut rng = rng_from_seed(input.seed);
let mut sbx = SimulatedBinaryCrossover::new(bounds.clone(), input.eta_sbx, pvp_sbx);
let kids = sbx.vary(&[p1, p2], &mut rng);
assert_eq!(kids.len(), 2);
for c in &kids {
for (j, &v) in c.iter().enumerate() {
let (lo, hi) = bounds[j];
assert!(v >= lo && v <= hi, "SBX child[{j}] = {v} out of [{lo}, {hi}]");
}
}
let mut pm = PolynomialMutation::new(bounds.clone(), input.eta_pm, pvp_pm);
let mutated = pm.vary(std::slice::from_ref(&kids[0]), &mut rng);
assert_eq!(mutated.len(), 1);
for (j, &v) in mutated[0].iter().enumerate() {
let (lo, hi) = bounds[j];
assert!(v >= lo && v <= hi, "PM child[{j}] = {v} out of [{lo}, {hi}]");
}
});
+42
View File
@@ -0,0 +1,42 @@
#![no_main]
//! Fuzz the `spacing` metric for non-negativity.
use arbitrary::Arbitrary;
use libfuzzer_sys::fuzz_target;
use heuropt::core::candidate::Candidate;
use heuropt::core::evaluation::Evaluation;
use heuropt::core::objective::{Objective, ObjectiveSpace};
use heuropt::metrics::spacing::spacing;
#[derive(Arbitrary, Debug)]
struct Input {
points: Vec<(f64, f64)>,
}
fuzz_target!(|input: Input| {
if input.points.len() > 64 {
return;
}
// Bound magnitudes so distance computations don't overflow to
// inf-inf=NaN — `spacing` is documented to operate on values produced
// by `as_minimization` of problem evaluations, not arbitrary f64s.
if input
.points
.iter()
.any(|&(a, b)| !a.is_finite() || !b.is_finite() || a.abs() > 1e150 || b.abs() > 1e150)
{
return;
}
let space = ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]);
let pop: Vec<Candidate<()>> = input
.points
.iter()
.map(|&(a, b)| Candidate::new((), Evaluation::new(vec![a, b])))
.collect();
let s = spacing(&pop, &space);
// Spacing can overflow to +∞ when point coordinates straddle ±f64::MAX.
// Contract is non-negative + non-NaN.
assert!(s >= 0.0, "spacing negative: {s}");
assert!(!s.is_nan(), "spacing is NaN");
});