From 2bb7c8160b7a71efdded07a1d9e3d647a9a984de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:42:44 +0000 Subject: [PATCH 1/4] Initial plan From 747da09942d9ca55312a2d1e8266a3e120171f22 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:53:52 +0000 Subject: [PATCH 2/4] Fix FitnessObserver inputs to be sorted by raw fitness in SpeciatedFitnessEliminator Co-authored-by: HyperCodec <72839119+HyperCodec@users.noreply.github.com> --- genetic-rs-common/src/builtin/eliminator.rs | 49 +++++++++++++++------ genetic-rs/tests/speciation.rs | 48 ++++++++++++++++++++ 2 files changed, 83 insertions(+), 14 deletions(-) diff --git a/genetic-rs-common/src/builtin/eliminator.rs b/genetic-rs-common/src/builtin/eliminator.rs index 24b15cd..03d0b8d 100644 --- a/genetic-rs-common/src/builtin/eliminator.rs +++ b/genetic-rs-common/src/builtin/eliminator.rs @@ -35,6 +35,7 @@ impl + Send + Sync> FeatureBoundedFitne /// A trait for observing fitness scores. This can be used to implement things like logging or statistics collection. pub trait FitnessObserver { /// Observes the fitness scores of a generation of genomes. + /// The input slice is always sorted in descending order by fitness (highest fitness first). fn observe(&mut self, fitnesses: &[(G, f32)]); /// Layers this observer with another, calling both in sequence. @@ -732,42 +733,62 @@ mod speciation { fn eliminate(&mut self, genomes: Vec) -> Vec { let (raw, divided) = self.calculate_fitnesses(&genomes); - let mut sorted: Vec<(G, f32, f32)> = genomes + let mut data: Vec<(G, f32, f32)> = genomes .into_iter() .enumerate() .map(|(i, g)| (g, raw[i], divided[i])) .collect(); - sorted.sort_by(|(_, _, a), (_, _, b)| b.partial_cmp(a).unwrap()); - let median_index = (sorted.len() as f32) * self.inner.threshold; + let median_index = (data.len() as f32) * self.inner.threshold; + + // Sort by raw fitness so observer inputs are ordered by fitness descending. + data.sort_by(|(_, a, _), (_, b, _)| b.partial_cmp(a).unwrap()); + + // Split raw-sorted pairs for the observer while retaining divided values. + let (observer_pairs, divided_vals): (Vec<(G, f32)>, Vec) = data + .into_iter() + .map(|(g, raw, div)| ((g, raw), div)) + .unzip(); - let mut observer_pairs: Vec<(G, f32)> = - sorted.into_iter().map(|(g, raw, _)| (g, raw)).collect(); self.inner.observer.observe(&observer_pairs); - observer_pairs.truncate(median_index as usize + 1); - observer_pairs.into_iter().map(|(g, _)| g).collect() + // Re-sort by divided fitness and truncate for speciation-aware elimination. + let mut with_divided: Vec<_> = + observer_pairs.into_iter().zip(divided_vals).collect(); + with_divided.sort_by(|(_, a), (_, b)| b.partial_cmp(a).unwrap()); + with_divided.truncate(median_index as usize + 1); + with_divided.into_iter().map(|((g, _), _)| g).collect() } #[cfg(feature = "rayon")] fn eliminate(&mut self, genomes: Vec) -> Vec { let (raw, divided) = self.calculate_fitnesses(&genomes); - let mut sorted: Vec<(G, f32, f32)> = genomes + let mut data: Vec<(G, f32, f32)> = genomes .into_iter() .enumerate() .map(|(i, g)| (g, raw[i], divided[i])) .collect(); - sorted.sort_by(|(_, _, a), (_, _, b)| b.partial_cmp(a).unwrap()); - let median_index = (sorted.len() as f32) * self.inner.threshold; + let median_index = (data.len() as f32) * self.inner.threshold; + + // Sort by raw fitness so observer inputs are ordered by fitness descending. + data.sort_by(|(_, a, _), (_, b, _)| b.partial_cmp(a).unwrap()); + + // Split raw-sorted pairs for the observer while retaining divided values. + let (observer_pairs, divided_vals): (Vec<(G, f32)>, Vec) = data + .into_iter() + .map(|(g, raw, div)| ((g, raw), div)) + .unzip(); - let mut observer_pairs: Vec<(G, f32)> = - sorted.into_iter().map(|(g, raw, _)| (g, raw)).collect(); self.inner.observer.observe(&observer_pairs); - observer_pairs.truncate(median_index as usize + 1); - observer_pairs.into_par_iter().map(|(g, _)| g).collect() + // Re-sort by divided fitness and truncate for speciation-aware elimination. + let mut with_divided: Vec<_> = + observer_pairs.into_iter().zip(divided_vals).collect(); + with_divided.sort_by(|(_, a), (_, b)| b.partial_cmp(a).unwrap()); + with_divided.truncate(median_index as usize + 1); + with_divided.into_par_iter().map(|((g, _), _)| g).collect() } } } diff --git a/genetic-rs/tests/speciation.rs b/genetic-rs/tests/speciation.rs index 89a2562..6aadac8 100644 --- a/genetic-rs/tests/speciation.rs +++ b/genetic-rs/tests/speciation.rs @@ -266,6 +266,54 @@ fn speciation_protects_rare_species() { ); } +/// The fitness observer on [`SpeciatedFitnessEliminator`] must receive fitness scores +/// sorted in descending order by raw (pre-division) fitness. +/// +/// Setup: +/// - 4 genomes of class 0 with val = 1.0 → raw fitness = 1.0, divided = 0.25 +/// - 1 genome of class 1 with val = 0.5 → raw fitness = 0.5, divided = 0.5 +/// +/// If sorted by divided fitness, the class-1 genome (divided = 0.5) would come first. +/// If sorted by raw fitness, the four class-0 genomes (raw = 1.0) come first. +#[test] +fn observer_receives_fitness_sorted_by_raw_descending() { + use std::sync::{Arc, Mutex}; + + let observed: Arc>> = Arc::new(Mutex::new(Vec::new())); + let observed_clone = Arc::clone(&observed); + + let observer = move |fitnesses: &[(Genome, f32)]| { + let mut v = observed_clone.lock().unwrap(); + v.extend(fitnesses.iter().map(|(_, f)| *f)); + }; + + let mut genomes: Vec = (0..4).map(|_| Genome { class: 0, val: 1.0 }).collect(); + genomes.push(Genome { class: 1, val: 0.5 }); + + let mut eliminator = SpeciatedFitnessEliminator::new(fitness, 0.5, 0.5, observer, ()); + eliminator.eliminate(genomes); + + let scores = observed.lock().unwrap(); + assert_eq!(scores.len(), 5, "observer must receive all genomes"); + + // Scores must be in non-increasing order (sorted descending by raw fitness). + for window in scores.windows(2) { + assert!( + window[0] >= window[1], + "observer inputs must be sorted descending by fitness, but got: {:?}", + *scores + ); + } + + // If sorted by divided fitness, the class-1 genome (divided = 0.5) would come first + // with score 0.5. Sorted by raw fitness, the highest score must be 1.0. + assert!( + (scores[0] - 1.0_f32).abs() < 1e-6, + "first fitness must be the highest raw fitness (1.0), but got: {:?}", + *scores + ); +} + /// The fitness observer on [`SpeciatedFitnessEliminator`] must receive the raw /// (pre-division) fitness values, not the values after they have been divided by /// the number of genomes in the species. From 54045c8d09184c5c8826ce02ea474f2a7f3aac84 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:55:45 +0000 Subject: [PATCH 3/4] cargo fmt --- genetic-rs-common/src/builtin/eliminator.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/genetic-rs-common/src/builtin/eliminator.rs b/genetic-rs-common/src/builtin/eliminator.rs index 03d0b8d..27fea10 100644 --- a/genetic-rs-common/src/builtin/eliminator.rs +++ b/genetic-rs-common/src/builtin/eliminator.rs @@ -753,8 +753,7 @@ mod speciation { self.inner.observer.observe(&observer_pairs); // Re-sort by divided fitness and truncate for speciation-aware elimination. - let mut with_divided: Vec<_> = - observer_pairs.into_iter().zip(divided_vals).collect(); + let mut with_divided: Vec<_> = observer_pairs.into_iter().zip(divided_vals).collect(); with_divided.sort_by(|(_, a), (_, b)| b.partial_cmp(a).unwrap()); with_divided.truncate(median_index as usize + 1); with_divided.into_iter().map(|((g, _), _)| g).collect() @@ -784,8 +783,7 @@ mod speciation { self.inner.observer.observe(&observer_pairs); // Re-sort by divided fitness and truncate for speciation-aware elimination. - let mut with_divided: Vec<_> = - observer_pairs.into_iter().zip(divided_vals).collect(); + let mut with_divided: Vec<_> = observer_pairs.into_iter().zip(divided_vals).collect(); with_divided.sort_by(|(_, a), (_, b)| b.partial_cmp(a).unwrap()); with_divided.truncate(median_index as usize + 1); with_divided.into_par_iter().map(|((g, _), _)| g).collect() From c6feb71de82bf503db7da00dbbde4cf8f5d81ab1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:10:34 +0000 Subject: [PATCH 4/4] Fix test: put low-fitness genome first so input is deliberately unsorted Co-authored-by: HyperCodec <72839119+HyperCodec@users.noreply.github.com> --- genetic-rs/tests/speciation.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/genetic-rs/tests/speciation.rs b/genetic-rs/tests/speciation.rs index 6aadac8..a18af19 100644 --- a/genetic-rs/tests/speciation.rs +++ b/genetic-rs/tests/speciation.rs @@ -269,12 +269,14 @@ fn speciation_protects_rare_species() { /// The fitness observer on [`SpeciatedFitnessEliminator`] must receive fitness scores /// sorted in descending order by raw (pre-division) fitness. /// -/// Setup: -/// - 4 genomes of class 0 with val = 1.0 → raw fitness = 1.0, divided = 0.25 +/// Setup (deliberately unsorted input — low fitness genome placed first): /// - 1 genome of class 1 with val = 0.5 → raw fitness = 0.5, divided = 0.5 +/// - 4 genomes of class 0 with val = 1.0 → raw fitness = 1.0, divided = 0.25 /// -/// If sorted by divided fitness, the class-1 genome (divided = 0.5) would come first. -/// If sorted by raw fitness, the four class-0 genomes (raw = 1.0) come first. +/// If the eliminator forwards the input order unchanged, the observer would see +/// `[0.5, 1.0, 1.0, 1.0, 1.0]` (not sorted). If sorted by divided fitness the +/// class-1 genome (divided = 0.5) would come first, yielding `[0.5, 1.0, …]`. +/// Only when sorted by raw fitness does the observer see `[1.0, 1.0, 1.0, 1.0, 0.5]`. #[test] fn observer_receives_fitness_sorted_by_raw_descending() { use std::sync::{Arc, Mutex}; @@ -287,8 +289,9 @@ fn observer_receives_fitness_sorted_by_raw_descending() { v.extend(fitnesses.iter().map(|(_, f)| *f)); }; - let mut genomes: Vec = (0..4).map(|_| Genome { class: 0, val: 1.0 }).collect(); - genomes.push(Genome { class: 1, val: 0.5 }); + // Put the low-fitness genome first so the input is intentionally unsorted. + let mut genomes = vec![Genome { class: 1, val: 0.5 }]; + genomes.extend((0..4).map(|_| Genome { class: 0, val: 1.0 })); let mut eliminator = SpeciatedFitnessEliminator::new(fitness, 0.5, 0.5, observer, ()); eliminator.eliminate(genomes); @@ -305,13 +308,17 @@ fn observer_receives_fitness_sorted_by_raw_descending() { ); } - // If sorted by divided fitness, the class-1 genome (divided = 0.5) would come first - // with score 0.5. Sorted by raw fitness, the highest score must be 1.0. + // The full expected sequence is [1.0, 1.0, 1.0, 1.0, 0.5]. assert!( (scores[0] - 1.0_f32).abs() < 1e-6, "first fitness must be the highest raw fitness (1.0), but got: {:?}", *scores ); + assert!( + (scores[4] - 0.5_f32).abs() < 1e-6, + "last fitness must be the lowest raw fitness (0.5), but got: {:?}", + *scores + ); } /// The fitness observer on [`SpeciatedFitnessEliminator`] must receive the raw