From c7a8f43a99c32533a92f527717a59c4a8b4bbc94 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 5 May 2026 11:03:12 -0600 Subject: [PATCH] test(fuzz): add cargo-fuzz harness for Pareto and operator hot paths --- fuzz/.gitignore | 4 + fuzz/Cargo.lock | 254 ++++++++++++++++++++++++ fuzz/Cargo.toml | 69 +++++++ fuzz/fuzz_targets/clamp_to_bounds.rs | 88 ++++++++ fuzz/fuzz_targets/crowding_distance.rs | 50 +++++ fuzz/fuzz_targets/hypervolume_2d.rs | 47 +++++ fuzz/fuzz_targets/non_dominated_sort.rs | 62 ++++++ fuzz/fuzz_targets/pareto_archive.rs | 58 ++++++ fuzz/fuzz_targets/pareto_compare.rs | 68 +++++++ fuzz/fuzz_targets/sbx_polymut.rs | 99 +++++++++ fuzz/fuzz_targets/spacing.rs | 42 ++++ 11 files changed, 841 insertions(+) create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Cargo.lock create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/clamp_to_bounds.rs create mode 100644 fuzz/fuzz_targets/crowding_distance.rs create mode 100644 fuzz/fuzz_targets/hypervolume_2d.rs create mode 100644 fuzz/fuzz_targets/non_dominated_sort.rs create mode 100644 fuzz/fuzz_targets/pareto_archive.rs create mode 100644 fuzz/fuzz_targets/pareto_compare.rs create mode 100644 fuzz/fuzz_targets/sbx_polymut.rs create mode 100644 fuzz/fuzz_targets/spacing.rs diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..1a45eee --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,4 @@ +target +corpus +artifacts +coverage diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 0000000..b8479fb --- /dev/null +++ b/fuzz/Cargo.lock @@ -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", +] diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..baf728c --- /dev/null +++ b/fuzz/Cargo.toml @@ -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 diff --git a/fuzz/fuzz_targets/clamp_to_bounds.rs b/fuzz/fuzz_targets/clamp_to_bounds.rs new file mode 100644 index 0000000..9dcadfd --- /dev/null +++ b/fuzz/fuzz_targets/clamp_to_bounds.rs @@ -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, + 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}", + ); + } + } +}); diff --git a/fuzz/fuzz_targets/crowding_distance.rs b/fuzz/fuzz_targets/crowding_distance.rs new file mode 100644 index 0000000..a4d3af3 --- /dev/null +++ b/fuzz/fuzz_targets/crowding_distance.rs @@ -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> = input + .points + .iter() + .map(|&(a, b)| Candidate::new((), Evaluation::new(vec![a, b]))) + .collect(); + + let front: Vec = (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}"); + } + } +}); diff --git a/fuzz/fuzz_targets/hypervolume_2d.rs b/fuzz/fuzz_targets/hypervolume_2d.rs new file mode 100644 index 0000000..4a30f83 --- /dev/null +++ b/fuzz/fuzz_targets/hypervolume_2d.rs @@ -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> = 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"); +}); diff --git a/fuzz/fuzz_targets/non_dominated_sort.rs b/fuzz/fuzz_targets/non_dominated_sort.rs new file mode 100644 index 0000000..3294f65 --- /dev/null +++ b/fuzz/fuzz_targets/non_dominated_sort.rs @@ -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> = 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", + ); + } + } + } + } +}); diff --git a/fuzz/fuzz_targets/pareto_archive.rs b/fuzz/fuzz_targets/pareto_archive.rs new file mode 100644 index 0000000..872f2e6 --- /dev/null +++ b/fuzz/fuzz_targets/pareto_archive.rs @@ -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, +} + +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, + ); + } + } +}); diff --git a/fuzz/fuzz_targets/pareto_compare.rs b/fuzz/fuzz_targets/pareto_compare.rs new file mode 100644 index 0000000..eaec2ec --- /dev/null +++ b/fuzz/fuzz_targets/pareto_compare.rs @@ -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, + b_objs: Vec, + 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); + } +}); diff --git a/fuzz/fuzz_targets/sbx_polymut.rs b/fuzz/fuzz_targets/sbx_polymut.rs new file mode 100644 index 0000000..d162196 --- /dev/null +++ b/fuzz/fuzz_targets/sbx_polymut.rs @@ -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, + b_frac: Vec, + 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 = 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 = 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}]"); + } +}); diff --git a/fuzz/fuzz_targets/spacing.rs b/fuzz/fuzz_targets/spacing.rs new file mode 100644 index 0000000..85697e6 --- /dev/null +++ b/fuzz/fuzz_targets/spacing.rs @@ -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> = 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"); +});