makepad/platform/script/tests/math_aot_fuzz.rs
Admin 3b4d6a4ff7 platform+widgets: sploded 3D inspect view, the tweaker design-feedback suite, script math-AOT and docs channel, pointer-pin and pixel-probe, modal layout, map warp
Squashed from work; the fine-grained history is under tag archive/work-2026-08-29:
- mp* wave: mpwm window manager + the mp app family, WM API, theme bridge, PDF engine fix
- mpwm polish wave: terminal key focus, focus-history close order, pop-back-to-origin, occupied-workspace cycling, demo
- mpwm: warm-instance pool, flat-luminance opens, flicker-free CEF resize
- work: land the sources the last commits reference
- kenney: catalogue all 50 free 3D kits; Modal dismissed() never fired
- platform: windows check green again — SetWindowTextW binding
- map: exact warp-aware inverse projections — pointer ops work folded
- mpwm: quick-look gap fixes; image cache eviction on preview unload
- tweaker: material thumbnails + vibecode popup + ctrl-space notes, undo/redo over the edit ledger, capture-semantics pi
- tweaker: vibe popup card chrome + dispatch order, ctrl-space notes verified, sploded design v2 chapter
- tweaker: tabbed side panel (Props/Shader/Tree) - shader tab with checkerboard material well + prompt, complete widget-
- sploded v2: nesting-depth z, hairline scope frames, body pass
- sploded: pin the depth convention with a test, kill the draw_depth residue
- sploded: real body-pass split (scene-pass capture, panel flat) + y-convention source of truth with anti-flip gate test
- sploded: hollow outlines, flat-band input, ray-pick unprojection
- tweaker: shader tab defaults to the selection's first draw layer, stale hint trimmed
- sploded: outlines become clipped, antialiased strips; tighter deck
- sploded: merge the lane's v2 (nesting-depth z, clipped AA strip outlines, flat-band input, unproject, SplodedStack bod
- sploded: the exploded view is a LIVE view — pointer events route through the inverse explode transform (ray -> plane -
- tweaker: tabs are real widgets (uid, tree node under the dock, own plane in 3D) and pickable; navigation-class clicks
- sploded: pinned/hover outlines render on the widget's own plane in 3D — per-widget nesting depth lives on the platform
- tweaker: the material well renders the pinned widget's actual shader — the swatch byte-copies the widget's live draw c
- tweaker: the Shader tab shows the shader as written — the layer's pixel/vertex fn source (nearest definition up the co
- sploded: I = true isometric preset (yaw 45°, pitch atan(1/√2))
- tweaker: eyedropper — the colour popover's pick button arms a pixel probe; the next press in the app samples that devi
- tweaker: the shader loop closes — /tweak/apply resolves the pinned widget by uid (anonymous path segments never round-
- vj: responsive DJ mixer + Windows drag-and-drop, cherry-picked from PR #1199 (vjroger)
- tweaker: the material well is a magnifier — the mirrored instance draws at the widget's native size in the well's own
- tweaker: per-layer material thumbnails — the Widget derive emits WidgetNode::layer_areas() (every #[live] Draw… field
- tweaker: the shader source view is the real CodeView (syntax highlighting, selection, editing) when the app registers
- tweaker: Ctrl+Enter sends on every platform (TextInput treated only Cmd as primary on macOS, so Ctrl+Enter inserted a
- Modal claims no layout slot: the DJ page fills its window again
- tweaker: every fn apply recompiles (eval_chunk ran every chunk under ONE synthetic callsite, so the script body — and
- tweaker: an apply whose draw shader fails to compile is rejected — the layer goes back (last live fns / the fn as writ
- tweaker: the Shader tab's source view owns its scrolling (the ScrollYView around the CodeView double-scrolled the care
- widgets: set_visible belongs to every widget, not just View (#1194)
- script: a dead heap's resource handles must not outlive it (#1195)
- Resources: search the executable's directory, not only the working directory (#1196)
- Windows: fit a restored window to the displays that are actually attached (#1197)
- d3d11: a failing GPU call reports the loss instead of killing the process (#1198)

Co-authored-by: Kevin Boos <1139460+kevinaboos@users.noreply.github.com>
2026-08-29 09:26:24 +02:00

346 lines
13 KiB
Rust

//! Layer: fuzz (differential).
//!
//! Generates random pure-math splash expressions (scalars + vec3s over
//! the whole taught intrinsic set), then requires, for every random
//! input tuple:
//!
//! splash interpreter == StitchBackend == VirInterpBackend
//!
//! bit-for-bit (NaN outputs compare as NaN-class: the interpreter boxes
//! NaN results with a source-trace payload). The batch entry is also
//! checked against the single-call entry per expression.
//!
//! The committed run is deterministic (seeds 1..=FUZZ_EXPRS). For a
//! larger sweep set MATH_AOT_FUZZ_EXPRS, e.g.:
//! MATH_AOT_FUZZ_EXPRS=5000 cargo test --release --test math_aot_fuzz
//! (a 5000-expression run is recorded in the mathaot report).
use makepad_script::math_aot::vir;
use makepad_script::math_aot::{
MathAot, MathAotParam, MathAotValue, MathBackend, StitchBackend, VirInterpBackend,
};
use makepad_script::makepad_math::Vec3f;
use makepad_script::*;
const FUZZ_EXPRS: u64 = 600;
const INPUTS_PER_EXPR: usize = 6;
fn test_vm() -> ScriptVm<'static> {
let host = Box::leak(Box::new(0i32));
let std = Box::leak(Box::new(0i32));
ScriptVm {
host,
std,
bx: Box::new(ScriptVmBase::new()),
}
}
/// xorshift64* — deterministic, dependency-free.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545F4914F6CDD1D)
}
fn below(&mut self, n: u64) -> u64 {
self.next() % n
}
fn f64(&mut self) -> f64 {
// Mix of magnitudes and specials.
match self.below(12) {
0 => 0.0,
1 => -0.0,
2 => 1.0,
3 => -1.0,
4 => (self.below(2000) as f64 - 1000.0) / 64.0,
5 => (self.below(2000) as f64 - 1000.0) * 64.0,
_ => (self.below(1_000_000) as f64 - 500_000.0) / 32768.0,
}
}
}
/// Generates a scalar-valued expression over params `x`, `y` (scalars)
/// and `p`, `q` (vec3s).
fn gen_scalar(rng: &mut Rng, depth: u32) -> String {
if depth == 0 {
return match rng.below(7) {
0 => "x".into(),
1 => "y".into(),
6 => "u".into(),
2 => format!("{:.4}", (rng.below(4000) as f64 - 2000.0) / 256.0),
3 => format!("{}", rng.below(9)), // integer: parser inline-fusion path
4 => "p.x".into(),
5 => ["p.y", "p.z", "q.x", "q.y", "q.z"][rng.below(5) as usize].into(),
_ => unreachable!(),
};
}
let d = depth - 1;
match rng.below(22) {
0 => format!("({} + {})", gen_scalar(rng, d), gen_scalar(rng, d)),
1 => format!("({} - {})", gen_scalar(rng, d), gen_scalar(rng, d)),
2 => format!("({} * {})", gen_scalar(rng, d), gen_scalar(rng, d)),
3 => format!("({} / {})", gen_scalar(rng, d), gen_scalar(rng, d)),
4 => format!("({} % {})", gen_scalar(rng, d), gen_scalar(rng, d)),
5 => format!("(-{})", gen_scalar(rng, d)),
6 => {
let f = ["sin", "cos", "tan", "asin", "acos", "atan", "exp", "log", "sqrt",
"abs", "floor", "ceil", "fract"][rng.below(13) as usize];
format!("{}({})", f, gen_scalar(rng, d))
}
7 => {
let f = ["min", "max", "pow", "atan2", "step", "modf"][rng.below(6) as usize];
format!("{}({}, {})", f, gen_scalar(rng, d), gen_scalar(rng, d))
}
8 => format!(
"clamp({}, {}, {})",
gen_scalar(rng, d),
gen_scalar(rng, d),
gen_scalar(rng, d)
),
9 => format!(
"mix({}, {}, {})",
gen_scalar(rng, d),
gen_scalar(rng, d),
gen_scalar(rng, d)
),
10 => format!(
"smoothstep({}, {}, {})",
gen_scalar(rng, d),
gen_scalar(rng, d),
gen_scalar(rng, d)
),
11 => format!("dot({}, {})", gen_vec(rng, d), gen_vec(rng, d)),
12 => format!("length({})", gen_vec(rng, d)),
13 => format!("distance({}, {})", gen_vec(rng, d), gen_vec(rng, d)),
14 => {
let cc = ["<", ">", "<=", ">="][rng.below(4) as usize];
format!(
"if {} {} {} {{ {} }} else {{ {} }}",
gen_scalar(rng, d),
cc,
gen_scalar(rng, d),
gen_scalar(rng, d),
gen_scalar(rng, d)
)
}
15 => format!("({} && {})", gen_scalar(rng, d), gen_scalar(rng, d)),
16 => format!("({} || {})", gen_scalar(rng, d), gen_scalar(rng, d)),
17 => format!("{}.length()", gen_vec(rng, d)),
18 => format!("{}.dot({})", gen_vec(rng, d), gen_vec(rng, d)),
19 => {
let sw = ["x", "y", "z"][rng.below(3) as usize];
format!("{}.{}", gen_vec(rng, d), sw)
}
20 => format!("lerp({}, {}, {})", gen_scalar(rng, d), gen_scalar(rng, d), gen_scalar(rng, d)),
21 => format!("length(cross({}, {}))", gen_vec(rng, d), gen_vec(rng, d)),
_ => unreachable!(),
}
}
/// Generates a vec3-valued expression.
fn gen_vec(rng: &mut Rng, depth: u32) -> String {
if depth == 0 {
return if rng.below(2) == 0 { "p".into() } else { "q".into() };
}
let d = depth - 1;
match rng.below(15) {
0 => format!("({} + {})", gen_vec(rng, d), gen_vec(rng, d)),
1 => format!("({} - {})", gen_vec(rng, d), gen_vec(rng, d)),
2 => format!("({} * {})", gen_vec(rng, d), gen_vec(rng, d)),
3 => format!("({} / {})", gen_vec(rng, d), gen_vec(rng, d)),
4 => format!("({} * {})", gen_vec(rng, d), gen_scalar(rng, d)),
5 => format!("({} * {})", gen_scalar(rng, d), gen_vec(rng, d)),
6 => format!("(-{})", gen_vec(rng, d)),
7 => {
let f = ["sin", "cos", "abs", "floor", "ceil", "fract"][rng.below(6) as usize];
format!("{}({})", f, gen_vec(rng, d))
}
8 => format!("normalize({})", gen_vec(rng, d)),
9 => format!("cross({}, {})", gen_vec(rng, d), gen_vec(rng, d)),
10 => {
let f = ["min", "max"][rng.below(2) as usize];
format!("{}({}, {})", f, gen_vec(rng, d), gen_vec(rng, d))
}
11 => format!(
"mix({}, {}, {})",
gen_vec(rng, d),
gen_vec(rng, d),
gen_scalar(rng, d)
),
12 => {
let sw = ["zyx", "xxy", "yzx", "zzz", "xyz", "yx"][rng.below(6) as usize];
if sw.len() == 2 {
// A vec2 swizzle immediately widened again is not valid
// vec3 math; use a 3-lane swizzle instead.
format!("{}.zxy", gen_vec(rng, d))
} else {
format!("{}.{}", gen_vec(rng, d), sw)
}
}
13 => format!(
"vec3({}, {}, {})",
gen_scalar(rng, d),
gen_scalar(rng, d),
gen_scalar(rng, d)
),
14 => format!(
"clamp({}, {}, {})",
gen_vec(rng, d),
gen_scalar(rng, d),
gen_scalar(rng, d)
),
_ => unreachable!(),
}
}
fn bits_match(a: f64, b: f64) -> bool {
(a.is_nan() && b.is_nan()) || a.to_bits() == b.to_bits()
}
#[test]
fn differential_fuzz() {
let exprs: u64 = std::env::var("MATH_AOT_FUZZ_EXPRS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(FUZZ_EXPRS);
let mut vm = test_vm();
let aot = MathAot::new(&mut vm);
let stitch = StitchBackend::new();
let vir_interp = VirInterpBackend;
let params = [
MathAotParam::Scalar,
MathAotParam::Scalar,
MathAotParam::Vec3,
MathAotParam::Vec3,
];
let mut accepted = 0u64;
let mut rejected = 0u64;
for seed in 1..=exprs {
let mut rng = Rng(seed.wrapping_mul(0x9E3779B97F4A7C15) | 1);
let depth = 2 + (seed % 3) as u32;
let expr = gen_scalar(&mut rng, depth);
let code = format!("use mod.math.*\nuse mod.pod.*\nlet f = |x, y, p, q, u| {expr}\n(f)");
vm.bx.captured_errors = Some(Vec::new());
let fn_value = vm.eval(ScriptMod {
cargo_manifest_path: String::new(),
module_path: String::new(),
file: format!("fuzz_{seed}"),
line: 0,
column: 0,
code: code.clone(),
values: vec![],
});
let errors = vm.take_errors();
assert!(errors.is_empty(), "seed {seed} script errors: {errors:?}\n{code}");
assert!(fn_value.as_object().is_some(), "seed {seed}: no fn\n{code}");
let Some(virf) = aot.to_vir(&vm, fn_value, &params, &[MathAotParam::Scalar]) else {
rejected += 1;
continue;
};
accepted += 1;
let compiled_stitch = stitch.compile(&virf).expect("stitch backend");
let compiled_ref = vir_interp.compile(&virf).expect("vir interp backend");
for _ in 0..INPUTS_PER_EXPR {
let x = rng.f64();
let y = rng.f64();
let p = [rng.f64() as f32, rng.f64() as f32, rng.f64() as f32];
let q = [rng.f64() as f32, rng.f64() as f32, rng.f64() as f32];
let u = rng.f64() as f32;
let args = [
MathAotValue::Scalar(x),
MathAotValue::Scalar(y),
MathAotValue::Vec3(p),
MathAotValue::Vec3(q),
MathAotValue::Scalar(u as f64),
];
let script_args: Vec<ScriptValue> = vec![
x.into(),
y.into(),
Vec3f {
x: p[0],
y: p[1],
z: p[2],
}
.script_to_value(&mut vm),
Vec3f {
x: q[0],
y: q[1],
z: q[2],
}
.script_to_value(&mut vm),
(u as f64).into(),
];
let interp = vm.call(fn_value, &script_args);
let interp = interp.as_number().unwrap_or_else(|| {
panic!("seed {seed}: interpreter returned non-number\n{code}")
});
let aot_stitch = compiled_stitch.call(&args).expect("stitch call");
let aot_ref = compiled_ref.call(&args).expect("ref call");
assert!(
bits_match(aot_stitch, interp),
"seed {seed} STITCH mismatch\n{code}\nargs x={x:?} y={y:?} p={p:?} q={q:?}\ninterp {interp:?} ({:#x}) stitch {aot_stitch:?} ({:#x})",
interp.to_bits(),
aot_stitch.to_bits()
);
assert!(
bits_match(aot_ref, interp),
"seed {seed} VIR-INTERP mismatch\n{code}\nargs x={x:?} y={y:?} p={p:?} q={q:?}\ninterp {interp:?} ({:#x}) vir {aot_ref:?} ({:#x})",
interp.to_bits(),
aot_ref.to_bits()
);
}
// Batch-vs-call parity on a few points (both backends).
let n = 5;
let mut input = Vec::new();
let mut rng2 = Rng(seed.wrapping_mul(0xD1342543DE82EF95) | 1);
for _ in 0..n {
for _ in 0..virf.stride() {
input.push(rng2.f64() as f32);
}
}
let ub = [rng2.f64() as f32];
let mut out_stitch = vec![0f32; n];
let mut out_ref = vec![0f32; n];
compiled_stitch.eval_batch(&input, &ub, &mut out_stitch);
compiled_ref.eval_batch(&input, &ub, &mut out_ref);
for i in 0..n {
let s = i * virf.stride();
let args = [
MathAotValue::Scalar(input[s] as f64),
MathAotValue::Scalar(input[s + 1] as f64),
MathAotValue::Vec3([input[s + 2], input[s + 3], input[s + 4]]),
MathAotValue::Vec3([input[s + 5], input[s + 6], input[s + 7]]),
MathAotValue::Scalar(ub[0] as f64),
];
let expected = compiled_stitch.call(&args).unwrap() as f32;
let sb = out_stitch[i];
let rb = out_ref[i];
let ok_s = (sb.is_nan() && expected.is_nan()) || sb.to_bits() == expected.to_bits();
let ok_r = (rb.is_nan() && expected.is_nan()) || rb.to_bits() == expected.to_bits();
assert!(ok_s, "seed {seed} batch/stitch point {i}\n{code}");
assert!(ok_r, "seed {seed} batch/vir point {i}\n{code}");
}
}
println!("differential fuzz: {accepted} accepted, {rejected} rejected");
// The generator emits only subset constructs; a high rejection rate
// would mean the fuzz has stopped covering the compiler.
assert!(
accepted * 5 >= (accepted + rejected) * 4,
"acceptance too low: {accepted} accepted, {rejected} rejected"
);
// Silence unused-warning when the vir module is only used via to_vir.
let _ = vir::VirTy::F64;
}