diff --git a/src/operators/real.rs b/src/operators/real.rs index c3d7665..62fac68 100644 --- a/src/operators/real.rs +++ b/src/operators/real.rs @@ -720,4 +720,216 @@ mod tests { let mut rng = rng_from_seed(0); m.vary(&[vec![0.5; 2]], &mut rng); } + + // ---- Pinned numerical snapshots ---------------------------------------- + // + // Mutation testing surfaced ~120 arithmetic-flip mutants surviving in + // this file (`+= → *=`, `*` ↔ `+`, `−` ↔ `/`, etc.). The existing + // shape/bounds tests pass with most of those flips because they only + // check ranges. The snapshots below pin the *exact* output of each + // operator at a fixed seed so any arithmetic flip changes a value and + // fails the assertion. Snapshots come from running the un-mutated + // implementation; updating an operator's math requires updating its + // snapshot, by design. + + fn assert_close_slice(got: &[f64], want: &[f64], tol: f64) { + assert_eq!(got.len(), want.len(), "length mismatch: got {got:?} want {want:?}"); + for (g, w) in got.iter().zip(want.iter()) { + assert!((g - w).abs() < tol, "got {g}, want {w}; full got = {got:?}"); + } + } + + #[test] + fn gaussian_mutation_seed_42_pinned() { + let mut m = GaussianMutation { sigma: 0.5 }; + let mut rng = rng_from_seed(42); + let parent = vec![1.0_f64, 2.0, 3.0]; + let children = m.vary(std::slice::from_ref(&parent), &mut rng); + assert_close_slice( + &children[0], + &[1.034_713_959_180_981_7, 2.066_469_060_997_062_6, 3.131_288_178_686_977], + 1e-12, + ); + } + + #[test] + fn bounded_gaussian_mutation_seed_7_pinned() { + let mut m = BoundedGaussianMutation::new(0.3, vec![(-1.0, 1.0); 3]); + let mut rng = rng_from_seed(7); + let parent = vec![0.0_f64, 0.5, -0.5]; + let children = m.vary(std::slice::from_ref(&parent), &mut rng); + assert_close_slice( + &children[0], + &[ + -0.313_072_988_018_995_14, + 0.326_975_666_440_741_83, + -0.713_376_295_479_132, + ], + 1e-12, + ); + } + + #[test] + fn sbx_seed_42_pinned_pair_of_children() { + let bounds = vec![(-1.0, 1.0); 3]; + let mut sbx = SimulatedBinaryCrossover::new(bounds, 15.0, 1.0); + let mut rng = rng_from_seed(42); + let p1 = vec![-0.5, 0.0, 0.5]; + let p2 = vec![0.5, 0.5, -0.5]; + let children = sbx.vary(&[p1, p2], &mut rng); + assert_eq!(children.len(), 2); + assert_close_slice( + &children[0], + &[ + -0.501_708_457_102_519_2, + -0.001_399_584_314_974_167_1, + 0.510_060_271_407_340_6, + ], + 1e-12, + ); + assert_close_slice( + &children[1], + &[ + 0.501_708_457_102_519_2, + 0.501_399_584_314_974_1, + -0.510_060_271_407_340_6, + ], + 1e-12, + ); + } + + /// SBX has the algebraic identity `c1 + c2 = p1 + p2` for any β (before + /// clamping). Pinning this directly catches arithmetic flips in the + /// `(1+β) * p1 + (1-β) * p2` formula that would break the identity. + #[test] + fn sbx_sum_of_children_equals_sum_of_parents_when_unclamped() { + let bounds = vec![(-100.0, 100.0); 3]; // wide so no clamping fires + let mut sbx = SimulatedBinaryCrossover::new(bounds, 15.0, 1.0); + let p1 = vec![-0.5, 0.2, 0.9]; + let p2 = vec![0.3, -0.7, 0.1]; + for seed in 0..20 { + let mut rng = rng_from_seed(seed); + let kids = sbx.vary(&[p1.clone(), p2.clone()], &mut rng); + for j in 0..p1.len() { + let lhs = kids[0][j] + kids[1][j]; + let rhs = p1[j] + p2[j]; + assert!((lhs - rhs).abs() < 1e-12, "seed={seed} j={j} {lhs} ≠ {rhs}"); + } + } + } + + #[test] + fn polynomial_mutation_seed_42_pinned() { + let bounds = vec![(-1.0, 1.0); 3]; + let mut pm = PolynomialMutation::new(bounds, 20.0, 1.0); + let mut rng = rng_from_seed(42); + let parent = vec![0.0_f64, 0.5, -0.5]; + let children = pm.vary(std::slice::from_ref(&parent), &mut rng); + assert_close_slice( + &children[0], + &[ + 0.005_191_102_584_008_567, + 0.508_488_942_560_315, + -0.469_873_699_029_174_75, + ], + 1e-12, + ); + } + + /// PolynomialMutation's δ should scale by `(hi - lo)`. If the + /// `delta * (hi - lo)` arithmetic gets mutated (e.g., `*` → `+`), the + /// per-axis perturbation scale drops out and a 10× bound range no + /// longer produces a 10× larger step. Tests with two different bound + /// widths at the same seed and asserts the perturbation ratio is ≈ 10. + #[test] + fn polynomial_mutation_step_scales_with_bound_width() { + let parent = vec![0.0_f64]; + let probe = |bounds: Vec<(f64, f64)>| -> f64 { + let mut pm = PolynomialMutation::new(bounds, 20.0, 1.0); + let mut rng = rng_from_seed(123); + pm.vary(std::slice::from_ref(&parent), &mut rng)[0][0] + }; + let narrow = probe(vec![(-1.0_f64, 1.0)]); // hi - lo = 2 + let wide = probe(vec![(-10.0_f64, 10.0)]); // hi - lo = 20 + // Same seed → same δ; the only difference is the (hi-lo) factor. + // Ratio must be ≈ 10. + let ratio = wide / narrow; + assert!((ratio - 10.0).abs() < 1e-12, "ratio = {ratio}, narrow={narrow}, wide={wide}"); + } + + #[test] + fn levy_mutation_seed_42_pinned() { + let mut m = LevyMutation::new(1.5, 0.1, vec![(-100.0, 100.0); 3]); + let mut rng = rng_from_seed(42); + let parent = vec![0.0_f64; 3]; + let children = m.vary(std::slice::from_ref(&parent), &mut rng); + assert_close_slice( + &children[0], + &[ + 0.018_566_727_273_339_814, + 0.049_398_595_670_997_11, + -0.128_765_264_276_263_75, + ], + 1e-12, + ); + } + + /// The `mantegna_sigma_u` helper computes `σᵤ` for Mantegna's Lévy + /// algorithm. Pinning a non-degenerate alpha catches arithmetic flips + /// in both the outer formula and the inner `gamma()` Lanczos series. + #[test] + fn mantegna_sigma_u_alpha_1_5_pinned() { + let got = mantegna_sigma_u(1.5); + assert!( + (got - 0.696_574_502_557_698).abs() < 1e-12, + "mantegna_sigma_u(1.5) = {got}", + ); + } + + #[test] + fn mantegna_sigma_u_alpha_1_0_pinned() { + // alpha = 1.0: sin(π/2) = 1, gamma(2) = 1, gamma(1) = 1 → σᵤ ≈ 1. + let got = mantegna_sigma_u(1.0); + assert!( + (got - 1.0).abs() < 1e-12, + "mantegna_sigma_u(1.0) = {got}", + ); + } + + #[test] + fn mantegna_sigma_u_alpha_2_0_pinned() { + // alpha = 2.0 (Normal limit): sin(π) = 0 numerically → σᵤ → 0. + // Specifically about 1e-8 due to the FP error in sin(π). + let got = mantegna_sigma_u(2.0); + assert!((0.0..1e-7).contains(&got), "mantegna_sigma_u(2.0) = {got}"); + } + + /// `gamma(z)` at exact integer arguments hits known recurrence values. + /// We probe it indirectly via `mantegna_sigma_u` since gamma is a + /// private inner fn. Pin `σᵤ` at alpha = 1.5 — under any arithmetic + /// mutation inside gamma() the value shifts well beyond f64 precision. + /// (Already covered by the alpha-1.5 test above; left here as docs.) + #[test] + fn mantegna_sigma_u_changes_monotonically_with_alpha() { + // For alpha ∈ [0.5, 1.5], σᵤ is a monotone function of α + // (Mantegna 1994, fig 1). This is a property test that breaks + // under structural changes to the formula even if the snapshot + // values are wrong. + let a = mantegna_sigma_u(0.5); + let b = mantegna_sigma_u(0.8); + let c = mantegna_sigma_u(1.2); + let d = mantegna_sigma_u(1.5); + // Verify (a, b, c, d) all positive and the sequence is monotone + // — direction depends on implementation, just assert non-trivial. + for v in [a, b, c, d] { + assert!(v > 0.0 && v.is_finite(), "non-positive sigma_u: {v}"); + } + // a > d (decreasing) or a < d (increasing) — both are valid; just + // require the values aren't all identical (which would happen + // under a `gamma -> const` mutant). + assert!( + (a - d).abs() > 0.01, + "sigma_u barely changes with alpha: a={a}, d={d}", + ); + } }