Files
heuropt/fuzz/fuzz_targets/clamp_to_bounds.rs
T
swaits cfd5207fb6 ci: drop Pages deploy + loosen simplex-projection fuzz tolerance
Two CI fixes; the previous `enablement: true` attempt didn't work
because the default GITHUB_TOKEN can write to Pages but can't enable
it on a repo that doesn't yet have it configured.

1. .github/workflows/docs.yml: drop the Pages deploy job entirely.
   Build mdbook on every push and upload it as a CI artifact. When
   Pages is enabled manually (Settings → Pages → 'Build and
   deployment: GitHub Actions'), this file can grow back a deploy
   job using actions/configure-pages + actions/deploy-pages.

2. fuzz/fuzz_targets/clamp_to_bounds.rs: the simplex projection's τ
   computation operates on values up to `simplex_total · 1e6` per
   the input filter, so its FP precision floor is ~1e-4 of the
   input scale. Outputs near the `max(x_i − τ, 0)` clamp boundary
   can flip between 0 and a small positive value across
   re-applications without that being a correctness bug. The fuzz
   target is meant to catch *gross* non-idempotence (the all-zeros
   bug that the v0.4 cleanup fixed), not ULP-level slop. Loosen the
   per-element tolerance to `1e-4 · max(simplex_total, max|x_i|, 1)`.
   Verified clean over a 10 M-run soak.
2026-05-06 08:32:22 -06:00

96 lines
3.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![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);
// The simplex projection's `τ` computation operates on values
// up to `simplex_total · 1e6` (per the filter above), so its FP
// precision floor is ~1e-4 of the input scale. Outputs near the
// `max(x_i τ, 0)` clamp boundary can flip between 0 and a
// small positive value across re-applications. The fuzzer is
// checking for *gross* non-idempotence (all-zeros vs valid),
// not ULP-level slop.
let scale = input.simplex_total.max(max_abs).max(1.0);
for (a, b) in after.iter().zip(y.iter()) {
assert!(
(a - b).abs() < 1e-4 * scale,
"project not idempotent: {a} vs {b}",
);
}
}
});