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 rng = rng_from_seed(2);
|
||||
let pop = init.initialize(5, &mut rng);
|
||||
assert_eq!(pop.len(), 5, "initialize must return `size` shuffles");
|
||||
for p in &pop {
|
||||
assert_eq!(p.len(), 10);
|
||||
let mut counts = [0_usize; 4];
|
||||
@@ -972,4 +973,228 @@ mod tests {
|
||||
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