1use 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#[derive(Clone, Copy, Debug, Default)]
45pub enum RepresentativePolicy {
46 #[default]
49 Best,
50 Random,
53 Archive {
55 capacity: usize,
57 },
58}
59
60#[derive(Debug, Clone)]
67pub struct CooperativeCoEAParams<PA, PB> {
68 pub params_a: PA,
71 pub params_b: PB,
74 pub dims_a: Vec<usize>,
77 pub total_dims: usize,
79 pub representative_policy: RepresentativePolicy,
81 pub evaluations_per_generation: usize,
85}
86
87impl<PA, PB> CooperativeCoEAParams<PA, PB> {
88 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
115impl<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#[derive(Debug, Clone)]
173pub struct CooperativeState<StA, StB, B: Backend> {
174 pub base: CoEAState<StA, StB>,
176 dims_b: Vec<usize>,
181 rep_archive_a: Option<Tensor<B, 2>>,
183 rep_archive_b: Option<Tensor<B, 2>>,
185}
186
187pub 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 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 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 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(¶ms.params_a, rng, device);
287 let state_b = self.strategy_b.init(¶ms.params_b, rng, device);
288 CooperativeState {
289 base: CoEAState::new(state_a, state_b),
290 dims_b: complement(¶ms.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 = ¶ms.dims_a;
304 let dims_b = &state.dims_b;
306 let generation = state.base.generation;
307
308 let (pop_a, asked_a) =
310 self.strategy_a
311 .ask(¶ms.params_a, &state.base.state_a, rng, device);
312 let (pop_b, asked_b) =
313 self.strategy_b
314 .ask(¶ms.params_b, &state.base.state_b, rng, device);
315
316 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 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 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 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 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 let (next_a, metrics_a) =
373 self.strategy_a
374 .tell(¶ms.params_a, pop_a, fit_a, asked_a, rng);
375 let (next_b, metrics_b) =
376 self.strategy_b
377 .tell(¶ms.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
396fn 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
402fn 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
410fn 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 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 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
464fn 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 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 let pop_a = make(&[10.0, 20.0, 11.0, 21.0], 2, 2); let rep_b = make(&[5.0, 7.0], 1, 2); 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 assert_eq!(&v[0..4], &[10.0, 5.0, 20.0, 7.0]);
552 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 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 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 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 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 #[test]
712 fn cooperative_nan_is_sanitized_in_metrics() {
713 let device = Default::default();
714 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(¶ms, &mut rng, &device);
731 let (_next, m) = algo.step(¶ms, 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 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(¶ms, &mut rng, &device);
795 let (_next, m) = algo.step(¶ms, state, &mut rng, &device);
796
797 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 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}