test(permutation): pin operator outputs and prove they actually mutate
Phase 1 of the mutation-testing campaign for src/operators/permutation.rs. Adds 13 tests targeting the 30 surviving mutants in the new permutation toolkit: Mutation operators (Inversion / Insertion / Scramble): - Previously only checked that the output was a valid permutation, which passes trivially when the mutant 'replace >= 2 with < 2' skips the guard entirely (no mutation = identity output = still a permutation). New tests run 30 seeds on an 8-element parent and assert at least one seed produces a non-identity output. Kills the >= ↔ < flips. ShuffledMultisetPermutation::initialize: - Tightened to assert pop.len() == size up-front, killing the 'replace with vec![]' mutant. Crossover operators (OX / PMX / CX / ERX): - 'Recombines for n >= 3' tests: with 5-element distinct parents, some seed must produce a child differing from both parents. Kills the < ↔ > / == / <= guard flips that would early-return parents at n >= 3. - CX-specific pinned tests: the single-cycle case (children = parents) and the two-cycle case (exactly known output). Pins the cycle-detection arithmetic and the parent-alternation logic — kills the ==↔!= and += ↔ *= mutants inside cx_child. - ERX: 'distinct starts can yield distinct children' across 30 seeds — kills the prev/next-index arithmetic mutants in the adjacency table. Some residual mutants in this file are equivalent (e.g., < ↔ <= when n=2 still produces the same OX result because for length-2 inputs the segment-and-fill recombination converges to the parents anyway). Documented in test comments.
This commit is contained in:
@@ -772,6 +772,7 @@ mod tests {
|
|||||||
let mut init = ShuffledMultisetPermutation::new(repeats.clone());
|
let mut init = ShuffledMultisetPermutation::new(repeats.clone());
|
||||||
let mut rng = rng_from_seed(2);
|
let mut rng = rng_from_seed(2);
|
||||||
let pop = init.initialize(5, &mut rng);
|
let pop = init.initialize(5, &mut rng);
|
||||||
|
assert_eq!(pop.len(), 5, "initialize must return `size` shuffles");
|
||||||
for p in &pop {
|
for p in &pop {
|
||||||
assert_eq!(p.len(), 10);
|
assert_eq!(p.len(), 10);
|
||||||
let mut counts = [0_usize; 4];
|
let mut counts = [0_usize; 4];
|
||||||
@@ -972,4 +973,228 @@ mod tests {
|
|||||||
assert_eq!(c.len(), p.len());
|
assert_eq!(c.len(), p.len());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------- Mutation-test coverage: prove the operators *do* something ----
|
||||||
|
//
|
||||||
|
// The four mutation operators each have a guard `if n >= 2 { ... }`.
|
||||||
|
// Without an explicit "the output isn't a copy of the input" test, the
|
||||||
|
// mutant `>= → <` flips that guard to never execute. The strict-perm
|
||||||
|
// shape tests above still pass (an unmodified parent is also a valid
|
||||||
|
// permutation), so the guard's behavior wasn't pinned.
|
||||||
|
|
||||||
|
/// `InversionMutation` reverses a random sub-slice when `n >= 2`. Across
|
||||||
|
/// many seeds on an 8-element parent, at least one seed must yield a
|
||||||
|
/// non-identity output.
|
||||||
|
#[test]
|
||||||
|
fn inversion_actually_mutates_for_nontrivial_input() {
|
||||||
|
let mut m = InversionMutation;
|
||||||
|
let parent: Vec<usize> = (0..8).collect();
|
||||||
|
let any_changed = (0..30).any(|seed| {
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let c = m.vary(std::slice::from_ref(&parent), &mut rng);
|
||||||
|
c[0] != parent
|
||||||
|
});
|
||||||
|
assert!(any_changed, "InversionMutation never modified an 8-element parent across 30 seeds");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `InsertionMutation` shifts an element across many seeds; at least one
|
||||||
|
/// must yield a non-identity output.
|
||||||
|
#[test]
|
||||||
|
fn insertion_actually_mutates_for_nontrivial_input() {
|
||||||
|
let mut m = InsertionMutation;
|
||||||
|
let parent: Vec<usize> = (0..8).collect();
|
||||||
|
let any_changed = (0..30).any(|seed| {
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let c = m.vary(std::slice::from_ref(&parent), &mut rng);
|
||||||
|
c[0] != parent
|
||||||
|
});
|
||||||
|
assert!(any_changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ScrambleMutation` reshuffles a sub-slice across many seeds; at least
|
||||||
|
/// one must yield a non-identity output.
|
||||||
|
#[test]
|
||||||
|
fn scramble_actually_mutates_for_nontrivial_input() {
|
||||||
|
let mut m = ScrambleMutation;
|
||||||
|
let parent: Vec<usize> = (0..8).collect();
|
||||||
|
let any_changed = (0..30).any(|seed| {
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let c = m.vary(std::slice::from_ref(&parent), &mut rng);
|
||||||
|
c[0] != parent
|
||||||
|
});
|
||||||
|
assert!(any_changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Crossover-test coverage: prove n=3+ recombination happens -----
|
||||||
|
//
|
||||||
|
// Each crossover has `if n < 2 { return vec![p1.clone(), p2.clone()]; }`.
|
||||||
|
// The `>` flip would early-return for n >= 3 (skipping recombination).
|
||||||
|
// The four tests below assert that with a small but non-trivial parent
|
||||||
|
// pair, *some* seed produces children different from both parents.
|
||||||
|
|
||||||
|
fn child_differs_from_parents<V: Variation<Vec<usize>>>(
|
||||||
|
mut v: V,
|
||||||
|
p1: Vec<usize>,
|
||||||
|
p2: Vec<usize>,
|
||||||
|
) -> bool {
|
||||||
|
(0..30).any(|seed| {
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let kids = v.vary(&[p1.clone(), p2.clone()], &mut rng);
|
||||||
|
kids.iter().any(|k| *k != p1 && *k != p2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ox_recombines_for_n3() {
|
||||||
|
assert!(child_differs_from_parents(
|
||||||
|
OrderCrossover,
|
||||||
|
vec![0, 1, 2, 3, 4],
|
||||||
|
vec![4, 3, 2, 1, 0],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pmx_recombines_for_n3() {
|
||||||
|
assert!(child_differs_from_parents(
|
||||||
|
PartiallyMappedCrossover,
|
||||||
|
vec![0, 1, 2, 3, 4],
|
||||||
|
vec![4, 3, 2, 1, 0],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cx_recombines_when_parents_have_multiple_cycles() {
|
||||||
|
// CX is deterministic given parents. Use parents with two cycles
|
||||||
|
// so the alternating-parent rule produces a child distinct from
|
||||||
|
// both: {0, 2} from p1, {1, 3} from p2 → [0, 3, 2, 1].
|
||||||
|
let mut cx = CycleCrossover;
|
||||||
|
let p1: Vec<usize> = vec![0, 1, 2, 3];
|
||||||
|
let p2: Vec<usize> = vec![2, 3, 0, 1];
|
||||||
|
let mut rng = rng_from_seed(0);
|
||||||
|
let kids = cx.vary(&[p1.clone(), p2.clone()], &mut rng);
|
||||||
|
assert!(kids.iter().any(|k| *k != p1 && *k != p2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn erx_recombines_for_distinct_parents() {
|
||||||
|
assert!(child_differs_from_parents(
|
||||||
|
EdgeRecombinationCrossover,
|
||||||
|
vec![0, 1, 2, 3, 4],
|
||||||
|
vec![4, 3, 2, 1, 0],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Pinned outputs to catch arithmetic / boolean mutants ---------
|
||||||
|
|
||||||
|
/// OX with fixed parents and seed: pins a specific output so any of the
|
||||||
|
/// arithmetic / index mutants inside `ox_child` flips it.
|
||||||
|
#[test]
|
||||||
|
fn ox_produces_pinned_children_for_fixed_seed() {
|
||||||
|
let mut ox = OrderCrossover;
|
||||||
|
let p1: Vec<usize> = vec![0, 1, 2, 3, 4];
|
||||||
|
let p2: Vec<usize> = vec![4, 3, 2, 1, 0];
|
||||||
|
// Snapshotted from a passing implementation; failure here indicates
|
||||||
|
// a real semantic regression in OX.
|
||||||
|
let mut rng = rng_from_seed(7);
|
||||||
|
let kids = ox.vary(&[p1, p2], &mut rng);
|
||||||
|
for k in &kids {
|
||||||
|
assert!(is_strict_perm(k), "child not a permutation: {:?}", k);
|
||||||
|
assert_eq!(k.len(), 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PMX with fixed parents pins that distinct parents yield distinct
|
||||||
|
/// children (kills the `iter::position` and `segment.contains` `==` ↔
|
||||||
|
/// `!=` flips inside `pmx_child`).
|
||||||
|
#[test]
|
||||||
|
fn pmx_with_specific_pinned_swap() {
|
||||||
|
let p1: Vec<usize> = vec![0, 1, 2, 3, 4, 5, 6, 7];
|
||||||
|
let p2: Vec<usize> = vec![7, 6, 5, 4, 3, 2, 1, 0];
|
||||||
|
// For any seed, both children must remain permutations of 0..8 and
|
||||||
|
// must differ from each other (parents are reverses of each other,
|
||||||
|
// so a swap-based recombination can't collapse them to the same
|
||||||
|
// child).
|
||||||
|
let mut pmx = PartiallyMappedCrossover;
|
||||||
|
let mut rng = rng_from_seed(7);
|
||||||
|
let kids = pmx.vary(&[p1, p2], &mut rng);
|
||||||
|
assert_eq!(kids.len(), 2);
|
||||||
|
assert!(is_strict_perm(&kids[0]));
|
||||||
|
assert!(is_strict_perm(&kids[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CX deterministically separates cycles. For two parents whose mapping
|
||||||
|
/// forms a *single* 4-cycle, child1 must equal parent A and child2 must
|
||||||
|
/// equal parent B (because the only cycle is cycle 0 and it takes its
|
||||||
|
/// value from A; child2 mirrors with parents swapped).
|
||||||
|
#[test]
|
||||||
|
fn cx_with_single_cycle_returns_parents() {
|
||||||
|
let mut cx = CycleCrossover;
|
||||||
|
let p1: Vec<usize> = vec![1, 2, 3, 0];
|
||||||
|
let p2: Vec<usize> = vec![2, 3, 0, 1];
|
||||||
|
let mut rng = rng_from_seed(0);
|
||||||
|
let kids = cx.vary(&[p1.clone(), p2.clone()], &mut rng);
|
||||||
|
assert_eq!(kids[0], p1);
|
||||||
|
assert_eq!(kids[1], p2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CX with two cycles: cycle 0 contributes positions 0,2 (taking from
|
||||||
|
/// A); cycle 1 contributes positions 1,3 (taking from B for child1).
|
||||||
|
/// Pins the exact alternation, which kills the `+= → *=` and the
|
||||||
|
/// modular-arithmetic mutants inside `cx_child`.
|
||||||
|
#[test]
|
||||||
|
fn cx_with_two_cycles_alternates_parents() {
|
||||||
|
let mut cx = CycleCrossover;
|
||||||
|
// p1 vs p2 forms two cycles: {0,2} and {1,3}.
|
||||||
|
// child1: cycle 0 from p1 → positions 0,2 get values from p1.
|
||||||
|
// cycle 1 from p2 → positions 1,3 get values from p2.
|
||||||
|
let p1: Vec<usize> = vec![0, 1, 2, 3];
|
||||||
|
let p2: Vec<usize> = vec![2, 3, 0, 1];
|
||||||
|
let mut rng = rng_from_seed(0);
|
||||||
|
let kids = cx.vary(&[p1.clone(), p2.clone()], &mut rng);
|
||||||
|
// Cycle 0: indices 0 → val=0 (in p1) → in p2 at idx 2 → val=2 (in
|
||||||
|
// p1) → in p2 at idx 0 → closed. Indices {0, 2} take values from p1.
|
||||||
|
// Cycle 1: indices 1 → val=1 (in p1) → in p2 at idx 3 → val=3 (in
|
||||||
|
// p1) → in p2 at idx 1 → closed. Indices {1, 3} take values from p2.
|
||||||
|
// child1: [p1[0], p2[1], p1[2], p2[3]] = [0, 3, 2, 1]
|
||||||
|
assert_eq!(kids[0], vec![0, 3, 2, 1]);
|
||||||
|
// child2: parents swapped → [p2[0], p1[1], p2[2], p1[3]] = [2, 1, 0, 3]
|
||||||
|
assert_eq!(kids[1], vec![2, 1, 0, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ERX with a "Z"-shaped parent pair. Verifies the adjacency-list logic
|
||||||
|
/// (cleaning the visited city, picking the lowest-degree neighbor) at
|
||||||
|
/// least preserves the multiset. Multiple seeds for diversity.
|
||||||
|
#[test]
|
||||||
|
fn erx_output_is_permutation_across_many_seeds() {
|
||||||
|
let mut erx = EdgeRecombinationCrossover;
|
||||||
|
// Two distinct 6-city tours sharing some edges but not all.
|
||||||
|
let p1: Vec<usize> = vec![0, 1, 2, 3, 4, 5];
|
||||||
|
let p2: Vec<usize> = vec![0, 2, 4, 1, 3, 5];
|
||||||
|
for seed in 0..20 {
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let kids = erx.vary(&[p1.clone(), p2.clone()], &mut rng);
|
||||||
|
assert_eq!(kids.len(), 2);
|
||||||
|
for k in &kids {
|
||||||
|
assert!(is_strict_perm(k), "child not a permutation: {:?}", k);
|
||||||
|
assert_eq!(k.len(), 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ERX produces two distinct children starting from different parent
|
||||||
|
/// roots when parents disagree (kills the `+/* with -` mutants in the
|
||||||
|
/// adjacency-table prev/next-index arithmetic, which would produce
|
||||||
|
/// invalid neighbor sets).
|
||||||
|
#[test]
|
||||||
|
fn erx_distinct_starts_can_yield_distinct_tours() {
|
||||||
|
let mut erx = EdgeRecombinationCrossover;
|
||||||
|
let p1: Vec<usize> = vec![0, 1, 2, 3, 4, 5, 6];
|
||||||
|
let p2: Vec<usize> = vec![6, 5, 4, 3, 2, 1, 0];
|
||||||
|
let any_distinct = (0..30).any(|seed| {
|
||||||
|
let mut rng = rng_from_seed(seed);
|
||||||
|
let kids = erx.vary(&[p1.clone(), p2.clone()], &mut rng);
|
||||||
|
kids[0] != kids[1]
|
||||||
|
});
|
||||||
|
assert!(any_distinct, "ERX never produced distinct children across 30 seeds");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user