Skip to main content

rlevo_evolution/coevolution/
cooperative.rs

1//! Cooperative co-evolution — CCGA (Potter & De Jong 1994).
2//!
3//! A high-dimensional problem is decomposed by assigning disjoint subsets of
4//! dimensions to two populations. Neither population holds a complete solution
5//! on its own: to score a member of population A, it is combined with a
6//! *representative* drawn from population B (and vice versa) to assemble a
7//! full-dimensional candidate, which the [`CoupledFitness`] then evaluates
8//! row-wise.
9//!
10//! # Where the coupling lives
11//!
12//! Representative selection and full-genome assembly live in
13//! [`CooperativeCoEA::step`] — the *algorithm* owns the coupling, alongside
14//! [`RepresentativePolicy`] (in [`CooperativeCoEAParams`]). The
15//! [`CoupledFitness`] stays a pure, stateless
16//! row-wise objective (e.g. Rastrigin): it receives already-assembled
17//! full-dimensional populations and never performs selection or holds a lock.
18
19use std::collections::HashSet;
20use std::fmt::Debug;
21use std::marker::PhantomData;
22
23use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
24use rand::rngs::StdRng;
25use rand::{Rng, RngExt};
26use rlevo_core::config::{ConfigError, ConstraintKind, Validate};
27use rlevo_core::objective::ObjectiveSense;
28
29use crate::fitness::sanitize_fitness_tensor;
30use crate::rng::{SeedPurpose, seed_stream};
31use crate::strategy::Strategy;
32
33use super::fitness::CoupledFitness;
34use super::harness::CoEAMetrics;
35use super::{CoEAState, CoEvolutionaryAlgorithm};
36
37/// Which member of the opposing population completes a partial candidate
38/// during fitness evaluation.
39///
40/// These map onto the standard CCGA coupling strategies: `Best` is the
41/// canonical single representative; `Random` is sampled coupling with a
42/// fresh draw per generation; `Archive` keeps a bounded ring of past
43/// champions and cycles through it.
44#[derive(Clone, Copy, Debug, Default)]
45pub enum RepresentativePolicy {
46    /// Always pair against the best individual seen so far (previous
47    /// generation's best). Canonical CCGA.
48    #[default]
49    Best,
50    /// Pair against a uniformly random member of the opposing population,
51    /// re-drawn each generation.
52    Random,
53    /// Maintain a bounded archive of past champions and cycle through it.
54    Archive {
55        /// Maximum number of past champions retained.
56        capacity: usize,
57    },
58}
59
60/// Static parameters for [`CooperativeCoEA`].
61///
62/// Population A evolves the genes named by `dims_a`; population B evolves the
63/// complement within `0..total_dims`. The split is validated by the
64/// [`Validate`] impl (called by [`new`](Self::new), and asserted in
65/// [`CooperativeCoEA`]'s `init` in debug builds).
66#[derive(Debug, Clone)]
67pub struct CooperativeCoEAParams<PA, PB> {
68    /// Params for population A's inner strategy (genome width must equal
69    /// `dims_a.len()`).
70    pub params_a: PA,
71    /// Params for population B's inner strategy (genome width must equal
72    /// `total_dims - dims_a.len()`).
73    pub params_b: PB,
74    /// Global dimension indices assigned to population A. Population B
75    /// receives the complement within `0..total_dims`.
76    pub dims_a: Vec<usize>,
77    /// Total problem dimensionality.
78    pub total_dims: usize,
79    /// Representative-selection policy used to complete partial candidates.
80    pub representative_policy: RepresentativePolicy,
81    /// Advisory fitness-evaluation budget per generation (informational in
82    /// v1; the simultaneous loop evaluates each population once per
83    /// generation).
84    pub evaluations_per_generation: usize,
85}
86
87impl<PA, PB> CooperativeCoEAParams<PA, PB> {
88    /// Build and eagerly [`validate`](Validate::validate) the parameters.
89    ///
90    /// # Errors
91    ///
92    /// Returns a [`ConfigError`] if the dimension split is invalid — see the
93    /// [`Validate`] impl.
94    pub fn new(
95        params_a: PA,
96        params_b: PB,
97        dims_a: Vec<usize>,
98        total_dims: usize,
99        representative_policy: RepresentativePolicy,
100        evaluations_per_generation: usize,
101    ) -> Result<Self, ConfigError> {
102        let params = Self {
103            params_a,
104            params_b,
105            dims_a,
106            total_dims,
107            representative_policy,
108            evaluations_per_generation,
109        };
110        params.validate()?;
111        Ok(params)
112    }
113}
114
115/// Validates that `dims_a` is a proper subset of `0..total_dims`, so that
116/// `dims_a.len() + dims_b_count == total_dims` with a non-empty B.
117///
118/// The inner `params_a` / `params_b` are validated when their respective
119/// strategies consume them; this impl checks only the dimension split (it has
120/// no `PA: Validate` / `PB: Validate` bound so it stays usable with unit-typed
121/// params in tests).
122impl<PA, PB> Validate for CooperativeCoEAParams<PA, PB> {
123    fn validate(&self) -> Result<(), ConfigError> {
124        const C: &str = "CooperativeCoEAParams";
125        if self.total_dims == 0 {
126            return Err(ConfigError {
127                config: C,
128                field: "total_dims",
129                kind: ConstraintKind::Zero,
130            });
131        }
132        if self.dims_a.is_empty() {
133            return Err(ConfigError {
134                config: C,
135                field: "dims_a",
136                kind: ConstraintKind::Custom("dims_a must be non-empty"),
137            });
138        }
139        for &d in &self.dims_a {
140            if d >= self.total_dims {
141                return Err(ConfigError {
142                    config: C,
143                    field: "dims_a",
144                    kind: ConstraintKind::Custom("dims_a index is out of range for total_dims"),
145                });
146            }
147        }
148        let unique: HashSet<usize> = self.dims_a.iter().copied().collect();
149        if unique.len() != self.dims_a.len() {
150            return Err(ConfigError {
151                config: C,
152                field: "dims_a",
153                kind: ConstraintKind::Custom("dims_a contains duplicate indices"),
154            });
155        }
156        if self.total_dims - self.dims_a.len() == 0 {
157            return Err(ConfigError {
158                config: C,
159                field: "dims_a",
160                kind: ConstraintKind::Custom(
161                    "dims_a covers every dimension, leaving population B empty",
162                ),
163            });
164        }
165        Ok(())
166    }
167}
168
169/// Joint state for [`CooperativeCoEA`]: the shared [`CoEAState`] plus the
170/// per-population representative archives used by
171/// [`RepresentativePolicy::Archive`].
172#[derive(Debug, Clone)]
173pub struct CooperativeState<StA, StB, B: Backend> {
174    /// Shared best/mean/generation trackers and inner strategy states.
175    pub base: CoEAState<StA, StB>,
176    /// Complement of `params.dims_a` within `0..total_dims`, ascending; frozen
177    /// at [`init`](CooperativeCoEA::init). Cached here so `step` never
178    /// re-derives (and re-allocates) the split each generation. `Vec<usize>`
179    /// keeps the `Clone + Debug + Send` derives valid.
180    dims_b: Vec<usize>,
181    /// Bounded archive of population A's past champions (`Archive` policy only).
182    rep_archive_a: Option<Tensor<B, 2>>,
183    /// Bounded archive of population B's past champions (`Archive` policy only).
184    rep_archive_b: Option<Tensor<B, 2>>,
185}
186
187/// Cooperative (CCGA-style) co-evolutionary algorithm.
188///
189/// Generic over the backend `B`, the two inner strategies `SA`/`SB` (each
190/// producing `Tensor<B, 2>` sub-genomes), and a stateless row-wise
191/// [`CoupledFitness`] `F` evaluating assembled full-dimensional candidates.
192pub struct CooperativeCoEA<B, SA, SB, F>
193where
194    B: Backend,
195    SA: Strategy<B, Genome = Tensor<B, 2>>,
196    SB: Strategy<B, Genome = Tensor<B, 2>>,
197    F: CoupledFitness<B>,
198{
199    strategy_a: SA,
200    strategy_b: SB,
201    fitness: F,
202    _backend: PhantomData<fn() -> B>,
203}
204
205impl<B, SA, SB, F> Debug for CooperativeCoEA<B, SA, SB, F>
206where
207    B: Backend,
208    SA: Strategy<B, Genome = Tensor<B, 2>>,
209    SB: Strategy<B, Genome = Tensor<B, 2>>,
210    F: CoupledFitness<B>,
211{
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        f.debug_struct("CooperativeCoEA").finish_non_exhaustive()
214    }
215}
216
217impl<B, SA, SB, F> CooperativeCoEA<B, SA, SB, F>
218where
219    B: Backend,
220    SA: Strategy<B, Genome = Tensor<B, 2>>,
221    SB: Strategy<B, Genome = Tensor<B, 2>>,
222    F: CoupledFitness<B>,
223{
224    /// Build a cooperative co-evolution from two inner strategies and a
225    /// (stateless, row-wise) coupled fitness.
226    pub fn new(strategy_a: SA, strategy_b: SB, fitness: F) -> Self {
227        Self {
228            strategy_a,
229            strategy_b,
230            fitness,
231            _backend: PhantomData,
232        }
233    }
234
235    /// Project the joint state into public [`CoEAMetrics`].
236    ///
237    /// The `best`/`mean` trackers on `state.base` are **canonical**
238    /// (maximise-native): `step` sources them from the per-population `tell`
239    /// metrics computed over the canonicalised, **sanitized** assembled-candidate
240    /// fitness (see the chokepoint in [`step`](Self::step) and ADR 0023 / 0034).
241    /// This projection maps the four display fields back into the objective's
242    /// **natural** sense via [`from_canonical`](ObjectiveSense::from_canonical),
243    /// while `binding_fitness` — the harness reward — is kept in canonical space.
244    /// Means are averaged over the finite members only, so a broken individual
245    /// cannot emit a `NaN` mean.
246    fn snapshot(&self, state: &CooperativeState<SA::State, SB::State, B>) -> CoEAMetrics {
247        let sizes = self.fitness.archive_sizes();
248        let sense = self.fitness.sense();
249        // `binding_fitness` (harness reward) stays in canonical space — compute
250        // it before mapping the display fields to natural sense.
251        let binding = state.base.best_a.min(state.base.best_b);
252        CoEAMetrics {
253            generation: state.base.generation,
254            best_fitness_a: sense.from_canonical(state.base.best_a),
255            best_fitness_b: sense.from_canonical(state.base.best_b),
256            mean_fitness_a: sense.from_canonical(state.base.mean_a),
257            mean_fitness_b: sense.from_canonical(state.base.mean_b),
258            binding_fitness: binding,
259            hof_size_a: sizes.first().copied().unwrap_or(0),
260            hof_size_b: sizes.get(1).copied().unwrap_or(0),
261        }
262    }
263}
264
265impl<B, SA, SB, F> CoEvolutionaryAlgorithm<B> for CooperativeCoEA<B, SA, SB, F>
266where
267    B: Backend,
268    SA: Strategy<B, Genome = Tensor<B, 2>>,
269    SB: Strategy<B, Genome = Tensor<B, 2>>,
270    F: CoupledFitness<B>,
271{
272    type Params = CooperativeCoEAParams<SA::Params, SB::Params>;
273    type State = CooperativeState<SA::State, SB::State, B>;
274
275    fn init(
276        &self,
277        params: &Self::Params,
278        rng: &mut dyn Rng,
279        device: &<B as burn::tensor::backend::BackendTypes>::Device,
280    ) -> Self::State {
281        debug_assert!(
282            params.validate().is_ok(),
283            "invalid CooperativeCoEAParams reached init: {:?}",
284            params.validate().err()
285        );
286        let state_a = self.strategy_a.init(&params.params_a, rng, device);
287        let state_b = self.strategy_b.init(&params.params_b, rng, device);
288        CooperativeState {
289            base: CoEAState::new(state_a, state_b),
290            dims_b: complement(&params.dims_a, params.total_dims),
291            rep_archive_a: None,
292            rep_archive_b: None,
293        }
294    }
295
296    fn step(
297        &self,
298        params: &Self::Params,
299        mut state: Self::State,
300        rng: &mut dyn Rng,
301        device: &<B as burn::tensor::backend::BackendTypes>::Device,
302    ) -> (Self::State, CoEAMetrics) {
303        let dims_a = &params.dims_a;
304        // Complement cached on the state at `init` — no per-generation alloc.
305        let dims_b = &state.dims_b;
306        let generation = state.base.generation;
307
308        // Both populations propose sub-genomes simultaneously.
309        let (pop_a, asked_a) =
310            self.strategy_a
311                .ask(&params.params_a, &state.base.state_a, rng, device);
312        let (pop_b, asked_b) =
313            self.strategy_b
314                .ask(&params.params_b, &state.base.state_b, rng, device);
315
316        // Previous-generation bests (the canonical CCGA representatives).
317        let prev_best_a = self.strategy_a.best(&state.base.state_a).map(|(g, _)| g);
318        let prev_best_b = self.strategy_b.best(&state.base.state_b).map(|(g, _)| g);
319
320        // One representative stream per generation (host-RNG convention).
321        let mut rep_rng = seed_stream(rng.next_u64(), generation, SeedPurpose::Representative);
322        let rep_a = select_representative(
323            &pop_a,
324            prev_best_a.as_ref(),
325            &mut state.rep_archive_a,
326            params.representative_policy,
327            &mut rep_rng,
328            generation,
329            device,
330        );
331        let rep_b = select_representative(
332            &pop_b,
333            prev_best_b.as_ref(),
334            &mut state.rep_archive_b,
335            params.representative_policy,
336            &mut rep_rng,
337            generation,
338            device,
339        );
340
341        // Assemble full-dimensional candidates: each varying sub-population is
342        // completed by the opposing fixed representative.
343        let full_a = assemble(&pop_a, dims_a, &rep_b, dims_b, params.total_dims, device);
344        let full_b = assemble(&pop_b, dims_b, &rep_a, dims_a, params.total_dims, device);
345
346        // `sense` is the single source of truth (ADR 0023); `evaluate_coupled`
347        // returns NATURAL fitness over the assembled candidates.
348        let sense = self.fitness.sense();
349        let fits = self.fitness.evaluate_coupled(&[full_a, full_b]);
350        debug_assert_eq!(fits.len(), 2, "cooperative co-evolution is bi-population");
351
352        // Canonicalise-then-sanitize chokepoint for the coupled-fitness path
353        // (ADR 0023 / 0034), mirroring `EvolutionaryHarness::step`. First map
354        // NATURAL → canonical (maximise-native: negate iff `Minimize`), THEN
355        // sanitize (`NaN → −∞` worst, `+∞ → f32::MAX`). The ordering is
356        // load-bearing: "NaN = worst" is only well-defined in maximise space, so
357        // sanitizing before `neg()` would flip a `NaN` cost to `+∞` = canonical
358        // *best* under `Minimize`. After this the per-population `tell`, the
359        // `snapshot` best/mean written into `CoEAState`, and any hall-of-fame all
360        // see canonical, finite-or-`−∞` fitness.
361        let canon = |t: Tensor<B, 1>| {
362            let c = match sense {
363                ObjectiveSense::Maximize => t,
364                ObjectiveSense::Minimize => t.neg(),
365            };
366            sanitize_fitness_tensor(c)
367        };
368        let fit_a = canon(fits[0].clone());
369        let fit_b = canon(fits[1].clone());
370
371        // Each strategy consumes its own sub-population with the assembled fitness.
372        let (next_a, metrics_a) =
373            self.strategy_a
374                .tell(&params.params_a, pop_a, fit_a, asked_a, rng);
375        let (next_b, metrics_b) =
376            self.strategy_b
377                .tell(&params.params_b, pop_b, fit_b, asked_b, rng);
378
379        state.base.state_a = next_a;
380        state.base.state_b = next_b;
381        state.base.generation += 1;
382        state.base.best_a = metrics_a.best_fitness_ever();
383        state.base.best_b = metrics_b.best_fitness_ever();
384        state.base.mean_a = metrics_a.mean_fitness();
385        state.base.mean_b = metrics_b.mean_fitness();
386
387        let metrics = self.snapshot(&state);
388        (state, metrics)
389    }
390
391    fn metrics(&self, state: &Self::State) -> CoEAMetrics {
392        self.snapshot(state)
393    }
394}
395
396/// The complement of `dims_a` within `0..total_dims`, ascending.
397fn complement(dims_a: &[usize], total_dims: usize) -> Vec<usize> {
398    let set: HashSet<usize> = dims_a.iter().copied().collect();
399    (0..total_dims).filter(|d| !set.contains(d)).collect()
400}
401
402/// Extract row `idx` of `pop` as a `(1, genome_dim)` tensor.
403fn row<B: Backend>(pop: &Tensor<B, 2>, idx: usize) -> Tensor<B, 2> {
404    let device = pop.device();
405    #[allow(clippy::cast_possible_wrap)]
406    let i = Tensor::<B, 1, Int>::from_data(TensorData::new(vec![idx as i64], [1]), &device);
407    pop.clone().select(0, i)
408}
409
410/// Pick the opposing-population representative under `policy`.
411///
412/// `prev_best` is the opposing strategy's best-so-far (`None` at generation 0,
413/// before the first `tell`). The `archive` is mutated in place for the
414/// `Archive` policy. Returns a `(1, genome_dim)` representative.
415fn select_representative<B: Backend>(
416    pop: &Tensor<B, 2>,
417    prev_best: Option<&Tensor<B, 2>>,
418    archive: &mut Option<Tensor<B, 2>>,
419    policy: RepresentativePolicy,
420    rng: &mut StdRng,
421    generation: u64,
422    _device: &<B as burn::tensor::backend::BackendTypes>::Device,
423) -> Tensor<B, 2> {
424    let n = pop.dims()[0];
425    match policy {
426        RepresentativePolicy::Best => match prev_best {
427            Some(best) => best.clone(),
428            None => row(pop, 0),
429        },
430        RepresentativePolicy::Random => {
431            let idx = rng.random_range(0..n.max(1));
432            row(pop, idx)
433        }
434        RepresentativePolicy::Archive { capacity } => {
435            if let Some(best) = prev_best {
436                let updated = match archive.take() {
437                    None => best.clone(),
438                    Some(existing) => {
439                        let cat = Tensor::cat(vec![existing, best.clone()], 0);
440                        let rows = cat.dims()[0];
441                        if capacity > 0 && rows > capacity {
442                            // Keep the most recent `capacity` rows (FIFO window).
443                            cat.narrow(0, rows - capacity, capacity)
444                        } else {
445                            cat
446                        }
447                    }
448                };
449                *archive = Some(updated);
450            }
451            match archive.as_ref() {
452                Some(a) if a.dims()[0] > 0 => {
453                    let rows = a.dims()[0];
454                    // `generation % rows < rows`, so the conversion never fails.
455                    let pick = usize::try_from(generation % rows as u64).unwrap_or(0);
456                    row(a, pick)
457                }
458                _ => row(pop, 0),
459            }
460        }
461    }
462}
463
464/// Scatter a sub-population and a fixed opposing representative into
465/// full-dimensional rows.
466///
467/// `sub_pop` is `(n, my_dims.len())`; its column `j` maps to global dimension
468/// `my_dims[j]`. `rep_other` is `(1, other_dims.len())`; its column `j` maps
469/// to global dimension `other_dims[j]` and is broadcast across all `n` rows.
470/// Returns an `(n, total_dims)` tensor. Assembly is host-side, which is also
471/// where the row-wise objective evaluates, so no extra device round-trip is
472/// incurred.
473fn assemble<B: Backend>(
474    sub_pop: &Tensor<B, 2>,
475    my_dims: &[usize],
476    rep_other: &Tensor<B, 2>,
477    other_dims: &[usize],
478    total_dims: usize,
479    device: &<B as burn::tensor::backend::BackendTypes>::Device,
480) -> Tensor<B, 2> {
481    let dims = sub_pop.dims();
482    let n = dims[0];
483    let sub_w = dims[1];
484    debug_assert_eq!(
485        sub_w,
486        my_dims.len(),
487        "sub-population width must match my_dims"
488    );
489    // Host-side scatter; a device→host transfer failure is a genuine fault, so
490    // surface it honestly rather than silently assembling from empty vecs.
491    let sub_flat = sub_pop
492        .clone()
493        .into_data()
494        .into_vec::<f32>()
495        .expect("sub-population genome tensor must be readable as f32");
496    let rep_flat = rep_other
497        .clone()
498        .into_data()
499        .into_vec::<f32>()
500        .expect("representative genome tensor must be readable as f32");
501    debug_assert_eq!(
502        rep_flat.len(),
503        other_dims.len(),
504        "representative width must match other_dims"
505    );
506
507    let mut full = vec![0.0_f32; n * total_dims];
508    for i in 0..n {
509        let base = i * total_dims;
510        for (j, &d) in my_dims.iter().enumerate() {
511            full[base + d] = sub_flat[i * sub_w + j];
512        }
513        for (j, &d) in other_dims.iter().enumerate() {
514            full[base + d] = rep_flat[j];
515        }
516    }
517    Tensor::<B, 2>::from_data(TensorData::new(full, [n, total_dims]), device)
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use burn::backend::Flex;
524
525    type B = Flex;
526
527    fn make(rows: &[f32], n: usize, d: usize) -> Tensor<B, 2> {
528        let device = Default::default();
529        Tensor::<B, 2>::from_data(TensorData::new(rows.to_vec(), [n, d]), &device)
530    }
531
532    #[test]
533    fn complement_is_ascending_set_difference() {
534        assert_eq!(complement(&[0, 2], 4), vec![1, 3]);
535        assert_eq!(complement(&[3, 1], 4), vec![0, 2]);
536        assert_eq!(complement(&[0, 1], 2), Vec::<usize>::new());
537    }
538
539    #[test]
540    fn assemble_scatters_into_global_positions() {
541        let device = Default::default();
542        // dims_a = [0, 2], dims_b = [1, 3], total = 4.
543        let pop_a = make(&[10.0, 20.0, 11.0, 21.0], 2, 2); // rows: (10,20),(11,21)
544        let rep_b = make(&[5.0, 7.0], 1, 2); // global dims 1,3 -> 5,7
545        let full = assemble(&pop_a, &[0, 2], &rep_b, &[1, 3], 4, &device);
546        let v = full
547            .into_data()
548            .into_vec::<f32>()
549            .expect("genome host-read of a tensor this test just built");
550        // row0: dim0=10, dim1=5, dim2=20, dim3=7
551        assert_eq!(&v[0..4], &[10.0, 5.0, 20.0, 7.0]);
552        // row1: dim0=11, dim1=5, dim2=21, dim3=7
553        assert_eq!(&v[4..8], &[11.0, 5.0, 21.0, 7.0]);
554    }
555
556    #[test]
557    fn representative_best_uses_prev_best_else_row_zero() {
558        let device = Default::default();
559        let pop = make(&[1.0, 2.0, 3.0, 4.0], 2, 2);
560        let mut rng = seed_stream(0, 0, SeedPurpose::Representative);
561        let mut archive = None;
562        // No prev best -> row 0.
563        let r0 = select_representative(
564            &pop,
565            None,
566            &mut archive,
567            RepresentativePolicy::Best,
568            &mut rng,
569            0,
570            &device,
571        );
572        assert_eq!(
573            r0.into_data()
574                .into_vec::<f32>()
575                .expect("genome host-read of a tensor this test just built"),
576            vec![1.0, 2.0]
577        );
578        // With prev best -> that genome.
579        let best = make(&[9.0, 9.0], 1, 2);
580        let r1 = select_representative(
581            &pop,
582            Some(&best),
583            &mut archive,
584            RepresentativePolicy::Best,
585            &mut rng,
586            1,
587            &device,
588        );
589        assert_eq!(
590            r1.into_data()
591                .into_vec::<f32>()
592                .expect("genome host-read of a tensor this test just built"),
593            vec![9.0, 9.0]
594        );
595    }
596
597    #[test]
598    fn archive_policy_bounds_archive_size() {
599        let device = Default::default();
600        let pop = make(&[0.0, 0.0], 1, 2);
601        let mut rng = seed_stream(0, 0, SeedPurpose::Representative);
602        let mut archive = None;
603        for g in 0..5_u64 {
604            #[allow(clippy::cast_precision_loss)]
605            let best = make(&[g as f32, g as f32], 1, 2);
606            let _ = select_representative(
607                &pop,
608                Some(&best),
609                &mut archive,
610                RepresentativePolicy::Archive { capacity: 2 },
611                &mut rng,
612                g,
613                &device,
614            );
615            if let Some(a) = archive.as_ref() {
616                assert!(a.dims()[0] <= 2, "archive exceeded capacity at gen {g}");
617            }
618        }
619        assert_eq!(archive.unwrap().dims()[0], 2);
620    }
621
622    #[test]
623    fn params_new_rejects_out_of_range_dim() {
624        let err =
625            CooperativeCoEAParams::new((), (), vec![0, 1, 4], 4, RepresentativePolicy::Best, 0)
626                .unwrap_err();
627        assert_eq!(err.field, "dims_a");
628        assert!(err.to_string().contains("out of range"));
629    }
630
631    #[test]
632    fn params_new_rejects_when_a_covers_everything() {
633        let err =
634            CooperativeCoEAParams::new((), (), vec![0, 1, 2, 3], 4, RepresentativePolicy::Best, 0)
635                .unwrap_err();
636        assert!(err.to_string().contains("leaving population B empty"));
637    }
638
639    #[test]
640    fn params_new_rejects_duplicate_dims() {
641        let err =
642            CooperativeCoEAParams::new((), (), vec![0, 0, 1], 4, RepresentativePolicy::Best, 0)
643                .unwrap_err();
644        assert!(err.to_string().contains("duplicate"));
645    }
646
647    #[test]
648    fn params_new_accepts_equal_split() {
649        let p = CooperativeCoEAParams::new((), (), vec![0, 1], 4, RepresentativePolicy::Best, 16)
650            .unwrap();
651        assert_eq!(complement(&p.dims_a, p.total_dims), vec![2, 3]);
652    }
653
654    // --- Fitness-hygiene chokepoint regression (issue #134 / ADR 0034) ---
655
656    use rand::SeedableRng;
657
658    use rlevo_core::bounds::Bounds;
659    use rlevo_core::probability::Probability;
660    use rlevo_core::rate::NonNegativeRate;
661
662    use crate::algorithms::ga::{
663        GaConfig, GaCrossover, GaReplacement, GaSelection, GeneticAlgorithm,
664    };
665
666    const COOP_POP: usize = 4;
667
668    fn ga_config_dim(dim: usize) -> GaConfig {
669        GaConfig {
670            pop_size: COOP_POP,
671            genome_dim: dim,
672            bounds: Bounds::new(0.0, 1.0),
673            mutation_sigma: NonNegativeRate::new(0.1),
674            selection: GaSelection::Tournament { size: 2 },
675            crossover: GaCrossover::Uniform {
676                p: Probability::new(0.5),
677            },
678            replacement: GaReplacement::Elitist { elitism_k: 1 },
679        }
680    }
681
682    /// Coupled fitness over assembled full-dimensional candidates: row 0 is
683    /// `NaN`, the rest a finite ramp `1, 2, …`. The chokepoint must sanitize
684    /// `NaN → −∞` so the finite maximum (`COOP_POP - 1`) is the champion.
685    struct PoisonRow0Nan;
686
687    impl CoupledFitness<B> for PoisonRow0Nan {
688        fn evaluate_coupled(&self, populations: &[Tensor<B, 2>]) -> Vec<Tensor<B, 1>> {
689            populations
690                .iter()
691                .map(|p| {
692                    let n = p.dims()[0];
693                    let device = p.device();
694                    #[allow(clippy::cast_precision_loss)]
695                    let v: Vec<f32> = (0..n)
696                        .map(|i| if i == 0 { f32::NAN } else { i as f32 })
697                        .collect();
698                    Tensor::<B, 1>::from_data(TensorData::new(v, [n]), &device)
699                })
700                .collect()
701        }
702        fn sense(&self) -> ObjectiveSense {
703            ObjectiveSense::Maximize
704        }
705    }
706
707    /// A `NaN` from a cooperative [`CoupledFitness`] is sanitized at the
708    /// assembled-candidate chokepoint, so it never becomes the champion nor
709    /// blanks a mean: `best` is the finite ramp maximum and the mean is finite.
710    /// Regression for issue #134 (cooperative §1.1).
711    #[test]
712    fn cooperative_nan_is_sanitized_in_metrics() {
713        let device = Default::default();
714        // total_dims = 2, dims_a = [0] → each sub-genome is width 1.
715        let algo = CooperativeCoEA::new(
716            GeneticAlgorithm::<B>::new(),
717            GeneticAlgorithm::<B>::new(),
718            PoisonRow0Nan,
719        );
720        let params = CooperativeCoEAParams::new(
721            ga_config_dim(1),
722            ga_config_dim(1),
723            vec![0],
724            2,
725            RepresentativePolicy::Best,
726            0,
727        )
728        .unwrap();
729        let mut rng = StdRng::seed_from_u64(7);
730        let state = algo.init(&params, &mut rng, &device);
731        let (_next, m) = algo.step(&params, state, &mut rng, &device);
732
733        #[allow(clippy::cast_precision_loss)]
734        let expected_best = (COOP_POP - 1) as f32;
735        approx::assert_relative_eq!(m.best_fitness_a, expected_best, epsilon = 1e-6);
736        assert!(
737            m.mean_fitness_a.is_finite(),
738            "cooperative mean must stay finite when a NaN individual is present, got {}",
739            m.mean_fitness_a
740        );
741        assert!(
742            !m.best_fitness_b.is_nan(),
743            "best_fitness_b must never be NaN"
744        );
745        assert!(
746            !m.mean_fitness_b.is_nan(),
747            "mean_fitness_b must never be NaN"
748        );
749    }
750
751    /// Row-wise cost landscape over assembled candidates declaring
752    /// [`ObjectiveSense::Minimize`]: natural cost = row index, so row 0 is the
753    /// best (cost `0.0`). Regression proving the cooperative canonicalisation
754    /// chokepoint (ADR 0023) maximises a `Minimize` objective and reports the
755    /// natural cost.
756    struct RowCost;
757
758    impl CoupledFitness<B> for RowCost {
759        fn evaluate_coupled(&self, populations: &[Tensor<B, 2>]) -> Vec<Tensor<B, 1>> {
760            populations
761                .iter()
762                .map(|p| {
763                    let n = p.dims()[0];
764                    let device = p.device();
765                    #[allow(clippy::cast_precision_loss)]
766                    let v: Vec<f32> = (0..n).map(|i| i as f32).collect();
767                    Tensor::<B, 1>::from_data(TensorData::new(v, [n]), &device)
768                })
769                .collect()
770        }
771        fn sense(&self) -> ObjectiveSense {
772            ObjectiveSense::Minimize
773        }
774    }
775
776    #[test]
777    fn cooperative_minimize_is_maximized_and_reported_natural() {
778        let device = Default::default();
779        let algo = CooperativeCoEA::new(
780            GeneticAlgorithm::<B>::new(),
781            GeneticAlgorithm::<B>::new(),
782            RowCost,
783        );
784        let params = CooperativeCoEAParams::new(
785            ga_config_dim(1),
786            ga_config_dim(1),
787            vec![0],
788            2,
789            RepresentativePolicy::Best,
790            0,
791        )
792        .unwrap();
793        let mut rng = StdRng::seed_from_u64(7);
794        let state = algo.init(&params, &mut rng, &device);
795        let (_next, m) = algo.step(&params, state, &mut rng, &device);
796
797        // The engine optimises `−cost`; the best natural cost is 0.0 (row 0),
798        // reported back in natural sense.
799        approx::assert_relative_eq!(m.best_fitness_a, 0.0, epsilon = 1e-6);
800        approx::assert_relative_eq!(m.best_fitness_b, 0.0, epsilon = 1e-6);
801        // Mean is a natural cost, finite and positive (rows 0..COOP_POP).
802        assert!(
803            m.mean_fitness_a.is_finite() && m.mean_fitness_a > 0.0,
804            "mean natural cost should be finite positive, got {}",
805            m.mean_fitness_a
806        );
807        assert!(
808            m.binding_fitness.is_finite(),
809            "binding_fitness must be finite, got {}",
810            m.binding_fitness
811        );
812    }
813}