From 7004a572c86e99e034f01639317e5000227d38ae Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 14 May 2026 08:34:26 -0600 Subject: [PATCH] test(permutation): add ERX edge-preservation tests ERX had five tests, all checking `is_strict_perm` validity -- none verified the *point* of edge recombination: that children actually inherit parent edges. A "valid permutation but edge-ignoring" ERX would have passed every existing test. Adds: - erx_identical_parents_inherit_every_edge: with identical parents the child's edge set must equal the parent's exactly (zero foreign edges). - erx_preserves_parent_edges_better_than_order_crossover: ERX must strand fewer non-parent edges than Order Crossover -- a direct test of ERX's reason to exist. - erx_pinned_output: locks the adjacency-walk + min-degree tie-break. Investigation result: ERX is correct and effective. It wins the tsp_operators_compare showdown on KroAB-25 (hypervolume 638M vs OX 622M, PMX 609M, CX 593M) and produces the most diverse front. The compare TSP table's GA underperformance is an Order-Crossover-plus-generational-GA artifact on a convex-position instance, not an ERX bug. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/operators/permutation.rs | 84 ++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/src/operators/permutation.rs b/src/operators/permutation.rs index 9a261d1..066c290 100644 --- a/src/operators/permutation.rs +++ b/src/operators/permutation.rs @@ -1239,4 +1239,88 @@ mod tests { "ERX never produced distinct children across 30 seeds" ); } + + /// Undirected edge set of a closed tour, each edge normalised to + /// `(min, max)` so direction and rotation don't matter. + fn tour_edges(tour: &[usize]) -> std::collections::HashSet<(usize, usize)> { + let n = tour.len(); + (0..n) + .map(|i| { + let (a, b) = (tour[i], tour[(i + 1) % n]); + if a <= b { (a, b) } else { (b, a) } + }) + .collect() + } + + /// ERX's whole purpose: with identical parents the adjacency table is + /// exactly that tour's edge set, so the child must inherit *every* + /// edge — zero foreign edges. (The existing identical-parents test + /// only checks the result is *a* permutation, not that it reuses the + /// parent's edges.) + #[test] + fn erx_identical_parents_inherit_every_edge() { + let mut erx = EdgeRecombinationCrossover; + let p: Vec = vec![3, 0, 4, 1, 5, 2, 6]; + let p_edges = tour_edges(&p); + for seed in 0..30 { + let mut rng = rng_from_seed(seed); + for child in erx.vary(&[p.clone(), p.clone()], &mut rng) { + assert_eq!( + tour_edges(&child), + p_edges, + "identical-parent child must reuse exactly the parent's edges", + ); + } + } + } + + /// ERX exists to *preserve parent edges*. It walks the parents' joint + /// adjacency table, so a child's only non-parent ("foreign") edges + /// come from dead-end jumps. Order Crossover keeps just one contiguous + /// segment and re-threads the rest, stranding far more edges that + /// exist in neither parent. Pinning `ERX foreign < OX foreign` + /// directly verifies ERX is doing its job — a hypothetical + /// "valid-permutation-but-edge-ignoring" ERX would fail here while + /// still passing every `is_strict_perm` test. + #[test] + fn erx_preserves_parent_edges_better_than_order_crossover() { + let p1: Vec = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + let p2: Vec = vec![9, 7, 5, 3, 1, 8, 6, 4, 2, 0]; + let parent_edges: std::collections::HashSet<(usize, usize)> = + tour_edges(&p1).union(&tour_edges(&p2)).copied().collect(); + let foreign = |child: &[usize]| tour_edges(child).difference(&parent_edges).count(); + + let (mut erx_foreign, mut ox_foreign) = (0usize, 0usize); + let mut erx = EdgeRecombinationCrossover; + let mut ox = OrderCrossover; + for seed in 0..40 { + let mut rng = rng_from_seed(seed); + for child in erx.vary(&[p1.clone(), p2.clone()], &mut rng) { + erx_foreign += foreign(&child); + } + let mut rng = rng_from_seed(seed); + for child in ox.vary(&[p1.clone(), p2.clone()], &mut rng) { + ox_foreign += foreign(&child); + } + } + assert!( + erx_foreign < ox_foreign, + "ERX should strand fewer foreign edges than OX \ + (ERX={erx_foreign}, OX={ox_foreign})", + ); + } + + /// Pinned exact output — locks the adjacency-walk and min-degree + /// tie-break logic so a subtle regression in `erx_child` is caught + /// even when the result is still a valid permutation. + #[test] + fn erx_pinned_output() { + let mut erx = EdgeRecombinationCrossover; + let p1: Vec = vec![0, 1, 2, 3, 4, 5, 6, 7]; + let p2: Vec = vec![2, 4, 6, 0, 7, 5, 3, 1]; + let mut rng = rng_from_seed(123); + let children = erx.vary(&[p1, p2], &mut rng); + assert_eq!(children[0], vec![0, 1, 2, 3, 4, 5, 7, 6]); + assert_eq!(children[1], vec![2, 1, 0, 7, 6, 5, 3, 4]); + } }