Skip to main content

fcmaes_core/
mapelites.rs

1// Numeric kernels index parallel arrays by a shared counter.
2#![allow(clippy::needless_range_loop)]
3
4//! Quality-Diversity search with CVT-MAP-Elites and the Diversifier.
5//!
6//! A CVT (centroidal Voronoi tessellation) archive partitions the behavior
7//! space into `capacity` niches (via k-means over uniform samples); each niche
8//! keeps the best solution found for it. MAP-Elites fills the archive with an
9//! SBX / Iso+LineDD emitter driven by a quality-diversity fitness
10//! `x -> (fitness, behavior descriptor)`. The Diversifier generalizes CMA-ME:
11//! it drives an ask/tell optimizer (here Rust CMA-ES) whose objective is the
12//! per-niche *improvement*, so the search fills and improves niches.
13//!
14//! Archive mutation remains serial and deterministic. Objective evaluation can
15//! be supplied either point-by-point through [`QdFitness`] or in parallel
16//! batches through [`QdBatchFitness`].
17//!
18//! # References
19//!
20//! - J.-B. Mouret and J. Clune, [“Illuminating Search Spaces by Mapping
21//!   Elites”](https://arxiv.org/abs/1504.04909) (2015).
22//! - V. Vassiliades, K. Chatzilygeroudis, and J.-B. Mouret, [“Using
23//!   Centroidal Voronoi Tessellations to Scale Up the Multi-dimensional
24//!   Archive of Phenotypic Elites
25//!   Algorithm”](https://arxiv.org/abs/1610.05729) (2016).
26//!
27//! # Example
28//!
29//! ```
30//! use fcmaes_core::{map_elites, Archive, MapElitesParams, Rng};
31//!
32//! let mut rng = Rng::new(42);
33//! let mut archive = Archive::new(2, &[0.0, 0.0], &[1.0, 1.0], 16, 0, &mut rng);
34//! archive.seed_uniform(&[0.0; 2], &[1.0; 2], &mut rng);
35//! let mut fitness = |x: &[f64]| {
36//!     let quality = x.iter().map(|v| (v - 0.5).powi(2)).sum();
37//!     (quality, x.to_vec())
38//! };
39//! let params = MapElitesParams {
40//!     generations: 4,
41//!     chunk_size: 8,
42//!     ..Default::default()
43//! };
44//! map_elites(
45//!     &mut archive,
46//!     &mut fitness,
47//!     &[0.0; 2],
48//!     &[1.0; 2],
49//!     &params,
50//!     &mut rng,
51//! );
52//! assert!(archive.occupied() > 0);
53//! ```
54
55use crate::cmaes::{Cmaes, CmaesParams};
56use crate::fitness::Fitness;
57use crate::rng::Rng;
58use rayon::prelude::*;
59
60/// Quality-diversity fitness: maps a solution to `(fitness, behavior)`.
61pub trait QdFitness {
62    /// Evaluate one decoded solution, returning minimized quality and behavior descriptors.
63    fn eval(&mut self, x: &[f64]) -> (f64, Vec<f64>);
64}
65
66impl<F> QdFitness for F
67where
68    F: FnMut(&[f64]) -> (f64, Vec<f64>),
69{
70    fn eval(&mut self, x: &[f64]) -> (f64, Vec<f64>) {
71        self(x)
72    }
73}
74
75/// Batch quality-diversity fitness. Implementations may evaluate `xs` in
76/// parallel, but must return one result per input in the same order.
77pub trait QdBatchFitness {
78    /// Evaluate decoded solutions in order.
79    fn eval_batch(&mut self, xs: &[Vec<f64>]) -> Vec<(f64, Vec<f64>)>;
80}
81
82impl<F> QdBatchFitness for F
83where
84    F: FnMut(&[Vec<f64>]) -> Vec<(f64, Vec<f64>)>,
85{
86    fn eval_batch(&mut self, xs: &[Vec<f64>]) -> Vec<(f64, Vec<f64>)> {
87        self(xs)
88    }
89}
90
91struct SerialQdBatchFitness<'a> {
92    fitness: &'a mut dyn QdFitness,
93}
94
95impl QdBatchFitness for SerialQdBatchFitness<'_> {
96    fn eval_batch(&mut self, xs: &[Vec<f64>]) -> Vec<(f64, Vec<f64>)> {
97        xs.iter().map(|x| self.fitness.eval(x)).collect()
98    }
99}
100
101// ---------------------------------------------------------------------------
102// k-means++ (CVT niche centers)
103// ---------------------------------------------------------------------------
104
105fn dist2(a: &[f64], b: &[f64]) -> f64 {
106    a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum()
107}
108
109/// Uniform two-dimensional grid with exactly `k` centers. Rows differ by at
110/// most one column when `k` is not a square.
111fn grid_centers_2d(k: usize) -> Vec<Vec<f64>> {
112    let rows = (k as f64).sqrt().floor().max(1.0) as usize;
113    let base_columns = k / rows;
114    let extra_columns = k % rows;
115    let mut centers = Vec::with_capacity(k);
116    for row in 0..rows {
117        let columns = base_columns + usize::from(row < extra_columns);
118        for column in 0..columns {
119            centers.push(vec![
120                (column as f64 + 0.5) / columns as f64,
121                (row as f64 + 0.5) / rows as f64,
122            ]);
123        }
124    }
125    centers
126}
127
128fn grid_index_2d(k: usize, descriptor: &[f64]) -> usize {
129    let rows = (k as f64).sqrt().floor().max(1.0) as usize;
130    let base_columns = k / rows;
131    let extra_columns = k % rows;
132    let y = descriptor[1].clamp(0.0, 1.0 - f64::EPSILON);
133    let row = (y * rows as f64) as usize;
134    let columns = base_columns + usize::from(row < extra_columns);
135    let x = descriptor[0].clamp(0.0, 1.0 - f64::EPSILON);
136    let column = (x * columns as f64) as usize;
137    row * base_columns + row.min(extra_columns) + column
138}
139
140/// Compute `k` niche centers in `[0,1]^dim` via k-means++ over
141/// `k * samples_per_niche` uniform samples (Lloyd iterations).
142fn cvt_centers(k: usize, dim: usize, samples_per_niche: usize, rng: &mut Rng) -> Vec<Vec<f64>> {
143    let n = (k * samples_per_niche).max(k);
144    let samples: Vec<Vec<f64>> = (0..n)
145        .map(|_| (0..dim).map(|_| rng.uniform01()).collect())
146        .collect();
147
148    // k-means++ init.
149    let mut centers: Vec<Vec<f64>> = Vec::with_capacity(k);
150    centers.push(samples[rng.int_below(n as i64) as usize].clone());
151    let mut d2: Vec<f64> = samples.iter().map(|s| dist2(s, &centers[0])).collect();
152    while centers.len() < k {
153        let sum: f64 = d2.iter().sum();
154        let mut target = rng.uniform01() * sum;
155        let mut idx = 0;
156        for (i, &d) in d2.iter().enumerate() {
157            target -= d;
158            if target <= 0.0 {
159                idx = i;
160                break;
161            }
162        }
163        centers.push(samples[idx].clone());
164        let c = centers.last().unwrap();
165        d2.par_iter_mut().zip(&samples).for_each(|(distance, s)| {
166            let candidate = dist2(s, c);
167            if candidate < *distance {
168                *distance = candidate;
169            }
170        });
171    }
172
173    // A few Lloyd iterations. Assignment is the dominant O(samples * k)
174    // kernel, so use thread-local accumulators and merge them in parallel.
175    for _ in 0..10 {
176        let (sums, counts) = samples
177            .par_iter()
178            .fold(
179                || (vec![0.0; k * dim], vec![0usize; k]),
180                |(mut sums, mut counts), s| {
181                    let mut best = 0;
182                    let mut best_distance = f64::MAX;
183                    for (index, center) in centers.iter().enumerate() {
184                        let distance = dist2(s, center);
185                        if distance < best_distance {
186                            best_distance = distance;
187                            best = index;
188                        }
189                    }
190                    counts[best] += 1;
191                    for j in 0..dim {
192                        sums[best * dim + j] += s[j];
193                    }
194                    (sums, counts)
195                },
196            )
197            .reduce(
198                || (vec![0.0; k * dim], vec![0usize; k]),
199                |(mut left_sums, mut left_counts), (right_sums, right_counts)| {
200                    for index in 0..k {
201                        left_counts[index] += right_counts[index];
202                    }
203                    for index in 0..k * dim {
204                        left_sums[index] += right_sums[index];
205                    }
206                    (left_sums, left_counts)
207                },
208            );
209        let mut max_shift = 0.0_f64;
210        for ci in 0..k {
211            if counts[ci] > 0 {
212                let old = centers[ci].clone();
213                for j in 0..dim {
214                    centers[ci][j] = sums[ci * dim + j] / counts[ci] as f64;
215                }
216                max_shift = max_shift.max(dist2(&old, &centers[ci]));
217            }
218        }
219        if max_shift <= 1e-12 {
220            break;
221        }
222    }
223    centers
224}
225
226// ---------------------------------------------------------------------------
227// Archive
228// ---------------------------------------------------------------------------
229
230/// CVT quality-diversity archive: `capacity` niches, each holding the best
231/// solution found for it.
232pub struct Archive {
233    dim: usize,
234    qd_dim: usize,
235    capacity: usize,
236    desc_lb: Vec<f64>,
237    desc_scale: Vec<f64>,
238    centers: Vec<Vec<f64>>, // normalized [0,1]^qd_dim
239    grid_2d: bool,
240    xs: Vec<Vec<f64>>,
241    ds: Vec<Vec<f64>>,
242    ys: Vec<f64>,
243    counts: Vec<u64>,
244    occupied: usize,
245    si: Vec<usize>, // niche indices sorted ascending by fitness
246}
247
248impl Archive {
249    /// Construct a validated CVT archive.
250    ///
251    /// # Errors
252    ///
253    /// Returns an error if `dim` or `capacity` is zero, if the descriptor
254    /// bound slices are empty or of unequal length, or if any bound pair is
255    /// non-finite or does not satisfy `lower < upper`.
256    pub fn try_new(
257        dim: usize,
258        qd_lb: &[f64],
259        qd_ub: &[f64],
260        capacity: usize,
261        samples_per_niche: usize,
262        rng: &mut Rng,
263    ) -> Result<Self, &'static str> {
264        if dim == 0 {
265            return Err("archive decision dimension must be positive");
266        }
267        if qd_lb.is_empty() || qd_lb.len() != qd_ub.len() {
268            return Err("descriptor bounds must be non-empty and have equal lengths");
269        }
270        if qd_lb
271            .iter()
272            .zip(qd_ub)
273            .any(|(&lo, &hi)| !lo.is_finite() || !hi.is_finite() || lo >= hi)
274        {
275            return Err("descriptor bounds must be finite and satisfy lower < upper");
276        }
277        if capacity == 0 {
278            return Err("archive capacity must be positive");
279        }
280        Ok(Self::new_unchecked(
281            dim,
282            qd_lb,
283            qd_ub,
284            capacity,
285            samples_per_niche,
286            rng,
287        ))
288    }
289
290    /// Construct a CVT archive, panicking on invalid configuration. Prefer
291    /// [`Archive::try_new`] for user-supplied inputs.
292    ///
293    /// # Panics
294    ///
295    /// Panics on any configuration [`Archive::try_new`] rejects.
296    pub fn new(
297        dim: usize,
298        qd_lb: &[f64],
299        qd_ub: &[f64],
300        capacity: usize,
301        samples_per_niche: usize,
302        rng: &mut Rng,
303    ) -> Self {
304        Self::try_new(dim, qd_lb, qd_ub, capacity, samples_per_niche, rng)
305            .expect("invalid archive configuration")
306    }
307
308    fn new_unchecked(
309        dim: usize,
310        qd_lb: &[f64],
311        qd_ub: &[f64],
312        capacity: usize,
313        samples_per_niche: usize,
314        rng: &mut Rng,
315    ) -> Self {
316        let qd_dim = qd_lb.len();
317        let desc_lb = qd_lb.to_vec();
318        let desc_scale: Vec<f64> = qd_ub.iter().zip(qd_lb).map(|(u, l)| u - l).collect();
319        let grid_2d = samples_per_niche == 0 && qd_dim == 2;
320        let centers = if grid_2d {
321            grid_centers_2d(capacity)
322        } else {
323            cvt_centers(capacity, qd_dim, samples_per_niche.max(1), rng)
324        };
325        Archive {
326            dim,
327            qd_dim,
328            capacity,
329            desc_lb,
330            desc_scale,
331            centers,
332            grid_2d,
333            xs: vec![vec![0.0; dim]; capacity],
334            ds: vec![vec![0.0; qd_dim]; capacity],
335            ys: vec![f64::INFINITY; capacity],
336            counts: vec![0; capacity],
337            occupied: 0,
338            si: (0..capacity).collect(),
339        }
340    }
341
342    /// Number of decision variables stored for each elite.
343    pub fn dim(&self) -> usize {
344        self.dim
345    }
346    /// Number of behavior-descriptor dimensions.
347    pub fn qd_dim(&self) -> usize {
348        self.qd_dim
349    }
350    /// Total number of niches.
351    pub fn capacity(&self) -> usize {
352        self.capacity
353    }
354    /// Number of niches containing an evaluated elite.
355    pub fn occupied(&self) -> usize {
356        self.occupied
357    }
358
359    /// Seed all niche solutions with uniform random samples in `[lower, upper]`
360    /// (never evaluated — they serve as the initial SBX/Iso parent pool, as the
361    /// Python original documents).
362    ///
363    /// # Panics
364    ///
365    /// Panics if `lower.len()` or `upper.len()` differs from the archive's
366    /// decision dimension.
367    pub fn seed_uniform(&mut self, lower: &[f64], upper: &[f64], rng: &mut Rng) {
368        assert_eq!(lower.len(), self.dim, "lower bounds length must equal dim");
369        assert_eq!(upper.len(), self.dim, "upper bounds length must equal dim");
370        assert!(
371            lower
372                .iter()
373                .zip(upper)
374                .all(|(&lo, &hi)| lo.is_finite() && hi.is_finite() && lo < hi),
375            "decision bounds must be finite and satisfy lower < upper"
376        );
377        for x in self.xs.iter_mut() {
378            for i in 0..self.dim {
379                x[i] = lower[i] + (upper[i] - lower[i]) * rng.uniform01();
380            }
381        }
382    }
383
384    fn encode_d(&self, d: &[f64]) -> Vec<f64> {
385        (0..self.qd_dim)
386            .map(|i| (d[i] - self.desc_lb[i]) / self.desc_scale[i])
387            .collect()
388    }
389
390    /// Index of the niche whose center is nearest the (encoded) descriptor.
391    ///
392    /// # Panics
393    ///
394    /// Panics if `d.len()` differs from the archive's descriptor dimension.
395    pub fn index_of_niche(&self, d: &[f64]) -> usize {
396        assert_eq!(d.len(), self.qd_dim, "descriptor length must equal qd_dim");
397        let e = self.encode_d(d);
398        if self.grid_2d {
399            return grid_index_2d(self.capacity, &e);
400        }
401        let mut best = 0;
402        let mut bd = f64::MAX;
403        for (i, c) in self.centers.iter().enumerate() {
404            let dist = dist2(&e, c);
405            if dist < bd {
406                bd = dist;
407                best = i;
408            }
409        }
410        best
411    }
412
413    /// Add a solution to niche `i` if it improves it.
414    ///
415    /// # Panics
416    ///
417    /// Panics if `i` is not below the archive capacity, or if `d` or `x` do
418    /// not match the descriptor and decision dimensions.
419    pub fn set(&mut self, i: usize, y: f64, d: &[f64], x: &[f64]) {
420        assert!(i < self.capacity, "niche index out of bounds");
421        assert_eq!(d.len(), self.qd_dim, "descriptor length mismatch");
422        assert_eq!(x.len(), self.dim, "solution length mismatch");
423        self.counts[i] += 1;
424        if y.is_finite() && d.iter().all(|v| v.is_finite()) && y < self.ys[i] {
425            if self.ys[i].is_infinite() {
426                self.occupied += 1;
427            }
428            self.ys[i] = y;
429            self.xs[i].copy_from_slice(x);
430            self.ds[i].copy_from_slice(d);
431        }
432    }
433
434    /// Evaluate `xs`, add to the archive, and return `(improvements, real_ys)`
435    /// where `improvement = fitness - niche's previous fitness` (negative is an
436    /// improvement — the objective the Diversifier's optimizer minimizes).
437    ///
438    /// # Panics
439    ///
440    /// Panics if `fitness` returns a descriptor whose length differs from the
441    /// archive's descriptor dimension.
442    pub fn update(&mut self, xs: &[Vec<f64>], fitness: &mut dyn QdFitness) -> (Vec<f64>, Vec<f64>) {
443        let evaluations: Vec<(f64, Vec<f64>)> = xs.iter().map(|x| fitness.eval(x)).collect();
444        self.update_evaluated(xs, &evaluations)
445            .expect("serial QD evaluation preserves batch length")
446    }
447
448    /// Apply already evaluated `(fitness, descriptor)` values in input order.
449    /// Keeping this step separate lets callers parallelize expensive objective
450    /// functions without concurrently mutating the archive.
451    ///
452    /// # Errors
453    ///
454    /// Returns an error if `evaluated` does not have the same length as `xs`.
455    pub fn update_evaluated(
456        &mut self,
457        xs: &[Vec<f64>],
458        evaluations: &[(f64, Vec<f64>)],
459    ) -> Result<(Vec<f64>, Vec<f64>), &'static str> {
460        if xs.len() != evaluations.len() {
461            return Err("QD evaluation batch length must match candidate batch length");
462        }
463        let mut improvements = Vec::with_capacity(xs.len());
464        let mut real_ys = Vec::with_capacity(xs.len());
465        for (x, (y, desc)) in xs.iter().zip(evaluations) {
466            if x.len() != self.dim
467                || desc.len() != self.qd_dim
468                || !y.is_finite()
469                || desc.iter().any(|value| !value.is_finite())
470            {
471                improvements.push(f64::INFINITY);
472                real_ys.push(f64::INFINITY);
473                continue;
474            }
475            let niche = self.index_of_niche(desc);
476            let oldy = self.ys[niche];
477            let improvement = if oldy.is_infinite() { *y } else { *y - oldy };
478            self.set(niche, *y, desc, x);
479            improvements.push(improvement);
480            real_ys.push(*y);
481        }
482        Ok((improvements, real_ys))
483    }
484
485    /// Evaluate and apply a complete batch. Evaluation may be parallel inside
486    /// `fitness`; archive updates are deterministic and retain input order.
487    ///
488    /// # Errors
489    ///
490    /// Returns an error if `fitness` does not return exactly one
491    /// `(fitness, descriptor)` pair per candidate.
492    pub fn update_batch(
493        &mut self,
494        xs: &[Vec<f64>],
495        fitness: &mut dyn QdBatchFitness,
496    ) -> Result<(Vec<f64>, Vec<f64>), &'static str> {
497        let evaluations = fitness.eval_batch(xs);
498        self.update_evaluated(xs, &evaluations)
499    }
500
501    /// Re-sort niche indices ascending by fitness.
502    pub fn argsort(&mut self) {
503        let mut si: Vec<usize> = (0..self.capacity).collect();
504        si.sort_by(|&a, &b| self.ys[a].total_cmp(&self.ys[b]));
505        self.si = si;
506    }
507
508    /// Sample `chunk` solutions from the best `best_n` niches (by fitness).
509    pub fn random_xs(&self, best_n: usize, chunk: usize, rng: &mut Rng) -> Vec<Vec<f64>> {
510        let bn = best_n.max(1).min(self.capacity);
511        (0..chunk)
512            .map(|_| {
513                let sel = rng.int_below(bn as i64) as usize;
514                let niche = if bn < self.capacity {
515                    self.si[sel]
516                } else {
517                    sel
518                };
519                self.xs[niche].clone()
520            })
521            .collect()
522    }
523
524    /// A random solution from the best `best_n` niches (with fitness).
525    pub fn random_x_one(&self, best_n: usize, rng: &mut Rng) -> (Vec<f64>, f64) {
526        let bn = best_n.max(1).min(self.capacity);
527        let selected = rng.int_below(bn as i64) as usize;
528        let niche = if bn < self.capacity {
529            self.si[selected]
530        } else {
531            selected
532        };
533        (self.xs[niche].clone(), self.ys[niche])
534    }
535
536    /// Lowest finite quality currently stored, or infinity for an empty archive.
537    pub fn best_y(&self) -> f64 {
538        self.ys.iter().cloned().fold(f64::INFINITY, f64::min)
539    }
540
541    /// QD-score matching Python `Archive.get_qd_score`: for an all-positive
542    /// archive, sum reciprocal fitness; otherwise sum the negated negative
543    /// fitness values. Higher is better in both cases.
544    pub fn qd_score(&self) -> f64 {
545        let finite: Vec<f64> = self
546            .ys
547            .iter()
548            .copied()
549            .filter(|value| value.is_finite())
550            .collect();
551        if finite.is_empty() {
552            return 0.0;
553        }
554        if finite.iter().copied().fold(f64::INFINITY, f64::min) > 0.0 {
555            finite
556                .iter()
557                .filter(|&&value| value != 0.0)
558                .map(|&value| value.recip())
559                .sum()
560        } else {
561            finite
562                .iter()
563                .filter(|&&value| value < 0.0)
564                .map(|&value| -value)
565                .sum()
566        }
567    }
568
569    /// Per-niche quality values; empty niches contain infinity.
570    pub fn ys(&self) -> &[f64] {
571        &self.ys
572    }
573    /// Per-niche decision vectors.
574    pub fn xs(&self) -> &[Vec<f64>] {
575        &self.xs
576    }
577    /// Per-niche behavior descriptors.
578    pub fn descriptors(&self) -> &[Vec<f64>] {
579        &self.ds
580    }
581    /// Number of evaluated candidates mapped to each niche.
582    pub fn counts(&self) -> &[u64] {
583        &self.counts
584    }
585
586    /// Occupied `(x, y, descriptor)` triples.
587    pub fn occupied_data(&self) -> Vec<(Vec<f64>, f64, Vec<f64>)> {
588        (0..self.capacity)
589            .filter(|&i| self.ys[i].is_finite())
590            .map(|i| (self.xs[i].clone(), self.ys[i], self.ds[i].clone()))
591            .collect()
592    }
593}
594
595// ---------------------------------------------------------------------------
596// Emitters
597// ---------------------------------------------------------------------------
598
599/// SBX (simulated binary crossover) + polynomial mutation (the Python
600/// `variation_`), clamped to `[lower, upper]`.
601pub fn variation(
602    pop: &[Vec<f64>],
603    lower: &[f64],
604    upper: &[f64],
605    rng: &mut Rng,
606    dis_c: f64,
607    dis_m: f64,
608) -> Vec<Vec<f64>> {
609    let dis_c = dis_c * (0.5 + 0.5 * rng.uniform01());
610    let dis_m = dis_m * (0.5 + 0.5 * rng.uniform01());
611    let n = (pop.len() / 2) * 2;
612    let d = lower.len();
613    let half = n / 2;
614    let mut offspring = vec![vec![0.0; d]; n];
615    for p in 0..half {
616        let p1 = &pop[p];
617        let p2 = &pop[half + p];
618        for i in 0..d {
619            let mu = rng.uniform01();
620            let mut beta = if mu <= 0.5 {
621                (2.0 * mu).powf(1.0 / (dis_c + 1.0))
622            } else {
623                (2.0 * mu).powf(-1.0 / (dis_c + 1.0))
624            };
625            if rng.int_below(2) == 1 {
626                beta = -beta;
627            }
628            if rng.uniform01() < 0.5 {
629                beta = 1.0;
630            }
631            let mean = (p1[i] + p2[i]) * 0.5;
632            let diff = (p1[i] - p2[i]) * 0.5;
633            offspring[p][i] = mean + beta * diff;
634            offspring[half + p][i] = mean - beta * diff;
635        }
636    }
637    // polynomial mutation
638    let site_p = 1.0 / d as f64;
639    for op in offspring.iter_mut() {
640        for i in 0..d {
641            if rng.uniform01() < site_p {
642                let mu = rng.uniform01();
643                let span = upper[i] - lower[i];
644                if mu <= 0.5 {
645                    let norm = (op[i] - lower[i]) / span;
646                    op[i] += span
647                        * ((2.0 * mu + (1.0 - 2.0 * mu) * (1.0 - norm).abs().powf(dis_m + 1.0))
648                            .powf(1.0 / (dis_m + 1.0))
649                            - 1.0);
650                } else {
651                    let norm = (upper[i] - op[i]) / span;
652                    op[i] += span
653                        * (1.0
654                            - (2.0 * (1.0 - mu)
655                                + 2.0 * (mu - 0.5) * (1.0 - norm).abs().powf(dis_m + 1.0))
656                            .powf(1.0 / (dis_m + 1.0)));
657                }
658            }
659            op[i] = op[i].clamp(lower[i], upper[i]);
660        }
661    }
662    offspring
663}
664
665/// Iso+LineDD emitter (the Python `iso_dd_`): `x1 + N(0,iso) + N(0,line)*(x1-x2)`.
666pub fn iso_dd(
667    x1: &[Vec<f64>],
668    x2: &[Vec<f64>],
669    lower: &[f64],
670    upper: &[f64],
671    rng: &mut Rng,
672    iso_sigma: f64,
673    line_sigma: f64,
674) -> Vec<Vec<f64>> {
675    let d = lower.len();
676    x1.iter()
677        .zip(x2)
678        .map(|(a, b)| {
679            (0..d)
680                .map(|i| {
681                    let z = a[i]
682                        + rng.normreal(0.0, iso_sigma)
683                        + rng.normreal(0.0, line_sigma) * (a[i] - b[i]);
684                    z.clamp(lower[i], upper[i])
685                })
686                .collect()
687        })
688        .collect()
689}
690
691// ---------------------------------------------------------------------------
692// Drivers
693// ---------------------------------------------------------------------------
694
695/// MAP-Elites parameters.
696#[derive(Clone, Debug)]
697pub struct MapElitesParams {
698    /// Number of ordinary emitter generations.
699    pub generations: usize,
700    /// Candidates requested per ordinary generation.
701    pub chunk_size: usize,
702    /// Use simulated binary crossover; false selects Iso+LineDD.
703    pub use_sbx: bool,
704    /// Simulated-binary-crossover distribution index.
705    pub dis_c: f64,
706    /// Polynomial-mutation distribution index.
707    pub dis_m: f64,
708    /// Isotropic noise standard deviation for Iso+LineDD.
709    pub iso_sigma: f64,
710    /// Directional noise standard deviation for Iso+LineDD.
711    pub line_sigma: f64,
712    /// Additional CMA-ES emitter generations after ordinary generations.
713    pub cma_generations: usize,
714}
715
716impl Default for MapElitesParams {
717    fn default() -> Self {
718        Self {
719            generations: 100,
720            chunk_size: 20,
721            use_sbx: true,
722            dis_c: 20.0,
723            dis_m: 20.0,
724            iso_sigma: 0.02,
725            line_sigma: 0.2,
726            cma_generations: 0,
727        }
728    }
729}
730
731/// Run CVT-MAP-Elites into `archive` using the SBX / Iso+LineDD emitter, with
732/// optional CMA-ES emitter generations.
733///
734/// # Panics
735///
736/// Panics if the serial adapter around `fitness` returns a batch of the wrong
737/// length, which indicates a bug in this crate rather than in caller code.
738/// Use [`map_elites_batch`] to handle batch-length mismatches as an error.
739pub fn map_elites(
740    archive: &mut Archive,
741    fitness: &mut dyn QdFitness,
742    lower: &[f64],
743    upper: &[f64],
744    p: &MapElitesParams,
745    rng: &mut Rng,
746) {
747    let mut batch_fitness = SerialQdBatchFitness { fitness };
748    map_elites_batch(archive, &mut batch_fitness, lower, upper, p, rng)
749        .expect("serial QD evaluation preserves batch length");
750}
751
752/// Batch-evaluation variant of [`map_elites`]. Candidate generation and
753/// archive updates remain deterministic; `fitness` controls evaluation
754/// parallelism.
755///
756/// # Errors
757///
758/// Returns an error if `fitness` does not return one result per requested
759/// candidate.
760pub fn map_elites_batch(
761    archive: &mut Archive,
762    fitness: &mut dyn QdBatchFitness,
763    lower: &[f64],
764    upper: &[f64],
765    p: &MapElitesParams,
766    rng: &mut Rng,
767) -> Result<(), &'static str> {
768    map_elites_batch_with_progress(archive, fitness, lower, upper, p, rng, &mut |_, _| {})
769}
770
771/// Batch MAP-Elites with an ordered callback after every archive update.
772///
773/// The callback runs after the evaluated generation has been committed and
774/// sorted. It is intended for convergence logging and must not mutate the
775/// archive. The generation index is one-based and continues through optional
776/// CMA-emitter generations.
777///
778/// # Errors
779///
780/// Returns an error if `fitness` does not return one result per requested
781/// candidate. The archive keeps every generation committed before the
782/// failure.
783pub fn map_elites_batch_with_progress(
784    archive: &mut Archive,
785    fitness: &mut dyn QdBatchFitness,
786    lower: &[f64],
787    upper: &[f64],
788    p: &MapElitesParams,
789    rng: &mut Rng,
790    progress: &mut dyn FnMut(usize, &Archive),
791) -> Result<(), &'static str> {
792    let mut select_n = archive.capacity();
793    for generation in 0..p.generations {
794        let xs = if p.use_sbx {
795            let pop = archive.random_xs(select_n, p.chunk_size, rng);
796            variation(&pop, lower, upper, rng, p.dis_c, p.dis_m)
797        } else {
798            let x1 = archive.random_xs(select_n, p.chunk_size, rng);
799            let x2 = archive.random_xs(select_n, p.chunk_size, rng);
800            iso_dd(&x1, &x2, lower, upper, rng, p.iso_sigma, p.line_sigma)
801        };
802        archive.update_batch(&xs, fitness)?;
803        archive.argsort();
804        select_n = archive.occupied().max(1);
805        progress(generation + 1, archive);
806    }
807    for generation in 0..p.cma_generations {
808        cma_emitter_batch(archive, fitness, lower, upper, rng)?;
809        archive.argsort();
810        progress(p.generations + generation + 1, archive);
811    }
812    Ok(())
813}
814
815/// One CMA-ES emitter run: seed CMA-ES at a random good niche and drive it by
816/// per-niche improvement (the Python `optimize_cma_`).
817fn cma_emitter_batch(
818    archive: &mut Archive,
819    fitness: &mut dyn QdBatchFitness,
820    lower: &[f64],
821    upper: &[f64],
822    rng: &mut Rng,
823) -> Result<(), &'static str> {
824    let best_n = 100.min(archive.capacity());
825    let (x0, _) = archive.random_x_one(best_n, rng);
826    let sigma = {
827        let u = 0.03 + rng.uniform01() * 0.27;
828        u * u
829    };
830    let mut fit = Fitness::bounded(archive.dim(), 1, lower, upper);
831    fit.set_normalize(true);
832    let params = CmaesParams {
833        popsize: 31,
834        max_evaluations: 100_000,
835        seed: rng.int_below(i64::MAX) as u64,
836        ..Default::default()
837    };
838    let mut es = Cmaes::new(fit, &x0, &[sigma], &params);
839    let stall = 5;
840    let mut last_improve = 0i32;
841    let mut old_ys: Option<Vec<f64>> = None;
842    for iter in 0..100 {
843        let xs = es.ask();
844        let (improvement, _real) = archive.update_batch(&xs, fitness)?;
845        let mut sorted = improvement.clone();
846        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
847        if let Some(oy) = &old_ys
848            && sorted.iter().zip(oy).any(|(&a, &b)| a < b)
849        {
850            last_improve = iter;
851        }
852        if last_improve + stall < iter {
853            break;
854        }
855        if es.tell(&improvement) != 0 {
856            break;
857        }
858        old_ys = Some(sorted);
859    }
860    Ok(())
861}
862
863/// Diversifier parameters.
864#[derive(Clone, Debug)]
865pub struct DiversifierParams {
866    /// Maximum number of candidate evaluations.
867    pub max_evaluations: u64,
868    /// CMA-ES population size used by each emitter.
869    pub popsize: i32,
870    /// Stop an emitter after this many generations without improvement.
871    pub stall_criterion: i32,
872}
873
874impl Default for DiversifierParams {
875    fn default() -> Self {
876        Self {
877            max_evaluations: 100_000,
878            popsize: 31,
879            stall_criterion: 20,
880        }
881    }
882}
883
884/// The Diversifier meta-algorithm (CMA-ME-style): drive a CMA-ES ask/tell loop
885/// whose objective is per-niche improvement, filling the archive. Returns the
886/// best real solution found.
887///
888/// # Panics
889///
890/// Panics if the serial adapter around `fitness` returns a batch of the wrong
891/// length, which indicates a bug in this crate rather than in caller code.
892/// Use [`diversify_batch`] to handle batch-length mismatches as an error.
893pub fn diversify(
894    archive: &mut Archive,
895    fitness: &mut dyn QdFitness,
896    lower: &[f64],
897    upper: &[f64],
898    p: &DiversifierParams,
899    rng: &mut Rng,
900) -> (Vec<f64>, f64) {
901    let mut batch_fitness = SerialQdBatchFitness { fitness };
902    diversify_batch(archive, &mut batch_fitness, lower, upper, p, rng)
903        .expect("serial QD evaluation preserves batch length")
904}
905
906/// Batch-evaluation variant of [`diversify`]. CMA-ES asks and tells remain
907/// serial while each requested population can be evaluated concurrently.
908///
909/// # Errors
910///
911/// Returns an error if `fitness` does not return one result per requested
912/// candidate.
913pub fn diversify_batch(
914    archive: &mut Archive,
915    fitness: &mut dyn QdBatchFitness,
916    lower: &[f64],
917    upper: &[f64],
918    p: &DiversifierParams,
919    rng: &mut Rng,
920) -> Result<(Vec<f64>, f64), &'static str> {
921    let mut best_x = vec![0.0; archive.dim()];
922    let mut best_y = f64::INFINITY;
923    let mut evals: u64 = 0;
924    while evals < p.max_evaluations {
925        let (x0, _) = archive.random_x_one(archive.occupied().max(1), rng);
926        let sigma = {
927            let u = 0.03 + rng.uniform01() * 0.27;
928            u * u
929        };
930        let mut fit = Fitness::bounded(archive.dim(), 1, lower, upper);
931        fit.set_normalize(true);
932        let params = CmaesParams {
933            popsize: p.popsize,
934            max_evaluations: 100_000,
935            seed: rng.int_below(i64::MAX) as u64,
936            ..Default::default()
937        };
938        let mut es = Cmaes::new(fit, &x0, &[sigma], &params);
939        let max_iters = 50_000 / p.popsize.max(1) as usize;
940        let stall = p.stall_criterion;
941        let mut last_improve = 0i32;
942        let mut old_ys: Option<Vec<f64>> = None;
943        for iter in 0..max_iters as i32 {
944            let xs = es.ask();
945            let (improvement, real_ys) = archive.update_batch(&xs, fitness)?;
946            evals += xs.len() as u64;
947            // track best real solution
948            for (x, &ry) in xs.iter().zip(&real_ys) {
949                if ry < best_y {
950                    best_y = ry;
951                    best_x.copy_from_slice(x);
952                }
953            }
954            let mut sorted = improvement.clone();
955            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
956            if let Some(oy) = &old_ys
957                && sorted.iter().zip(oy).any(|(&a, &b)| a < b)
958            {
959                last_improve = iter;
960            }
961            if last_improve + stall < iter {
962                break;
963            }
964            if es.tell(&improvement) != 0 || evals >= p.max_evaluations {
965                break;
966            }
967            old_ys = Some(sorted);
968        }
969        archive.argsort();
970    }
971    Ok((best_x, best_y))
972}
973
974#[cfg(test)]
975mod tests {
976    use super::*;
977
978    // A QD problem: minimize sphere; behavior = first two coordinates.
979    fn qd(x: &[f64]) -> (f64, Vec<f64>) {
980        let f = x.iter().map(|v| v * v).sum();
981        (f, vec![x[0], x[1]])
982    }
983
984    #[test]
985    fn cvt_centers_are_in_unit_box() {
986        let mut rng = Rng::new(1);
987        let c = cvt_centers(20, 2, 10, &mut rng);
988        assert_eq!(c.len(), 20);
989        for center in &c {
990            for &v in center {
991                assert!((0.0..=1.0).contains(&v));
992            }
993        }
994    }
995
996    #[test]
997    fn grid_centers_are_fast_exact_and_cover_the_box() {
998        for capacity in [1, 10, 64, 1_000] {
999            let centers = grid_centers_2d(capacity);
1000            assert_eq!(centers.len(), capacity);
1001            assert!(
1002                centers
1003                    .iter()
1004                    .flatten()
1005                    .all(|value| (0.0..=1.0).contains(value))
1006            );
1007        }
1008        let mut rng = Rng::new(1);
1009        let archive = Archive::new(2, &[0.0, 0.0], &[1.0, 1.0], 100, 0, &mut rng);
1010        assert_eq!(archive.centers.len(), 100);
1011        assert_eq!(archive.index_of_niche(&[0.0, 0.0]), 0);
1012        assert_eq!(archive.index_of_niche(&[1.0, 1.0]), 99);
1013    }
1014
1015    #[test]
1016    fn map_elites_fills_niches() {
1017        let mut rng = Rng::new(2);
1018        let lower = vec![-2.0; 4];
1019        let upper = vec![2.0; 4];
1020        let mut archive = Archive::new(4, &[-2.0, -2.0], &[2.0, 2.0], 64, 10, &mut rng);
1021        // seed the archive with random samples so SBX has parents
1022        let seed_pop: Vec<Vec<f64>> = (0..64)
1023            .map(|_| (0..4).map(|_| -2.0 + 4.0 * rng.uniform01()).collect())
1024            .collect();
1025        archive.update(&seed_pop, &mut qd);
1026        archive.argsort();
1027        let params = MapElitesParams {
1028            generations: 200,
1029            chunk_size: 16,
1030            ..Default::default()
1031        };
1032        map_elites(&mut archive, &mut qd, &lower, &upper, &params, &mut rng);
1033        assert!(archive.occupied() > 20, "occupied={}", archive.occupied());
1034        assert!(archive.best_y() < 0.5, "best_y={}", archive.best_y());
1035    }
1036
1037    #[test]
1038    fn evaluated_batches_match_serial_updates_and_validate_lengths() {
1039        let mut rng_a = Rng::new(7);
1040        let mut rng_b = Rng::new(7);
1041        let mut serial = Archive::new(2, &[-2.0, -2.0], &[2.0, 2.0], 16, 0, &mut rng_a);
1042        let mut batch = Archive::new(2, &[-2.0, -2.0], &[2.0, 2.0], 16, 0, &mut rng_b);
1043        let xs = vec![vec![-1.0, 0.5], vec![0.25, -0.75], vec![1.0, 1.0]];
1044        let expected = serial.update(&xs, &mut qd);
1045        let evaluations: Vec<_> = xs.iter().map(|x| qd(x)).collect();
1046        let actual = batch.update_evaluated(&xs, &evaluations).unwrap();
1047        assert_eq!(actual, expected);
1048        assert_eq!(batch.ys(), serial.ys());
1049        assert_eq!(batch.descriptors(), serial.descriptors());
1050        assert!(batch.update_evaluated(&xs, &evaluations[..2]).is_err());
1051    }
1052
1053    #[test]
1054    fn batch_map_elites_preserves_serial_results() {
1055        let lower = vec![-2.0; 4];
1056        let upper = vec![2.0; 4];
1057        let mut rng_a = Rng::new(19);
1058        let mut rng_b = Rng::new(19);
1059        let mut serial = Archive::new(4, &[-2.0, -2.0], &[2.0, 2.0], 32, 0, &mut rng_a);
1060        let mut batch = Archive::new(4, &[-2.0, -2.0], &[2.0, 2.0], 32, 0, &mut rng_b);
1061        serial.seed_uniform(&lower, &upper, &mut rng_a);
1062        batch.seed_uniform(&lower, &upper, &mut rng_b);
1063        let initial = serial.xs().to_vec();
1064        let initial_evaluations: Vec<_> = initial.iter().map(|x| qd(x)).collect();
1065        serial.update(&initial, &mut qd);
1066        batch
1067            .update_evaluated(&initial, &initial_evaluations)
1068            .unwrap();
1069        serial.argsort();
1070        batch.argsort();
1071        let params = MapElitesParams {
1072            generations: 10,
1073            chunk_size: 8,
1074            ..Default::default()
1075        };
1076        map_elites(&mut serial, &mut qd, &lower, &upper, &params, &mut rng_a);
1077        let mut batch_qd = |xs: &[Vec<f64>]| xs.iter().map(|x| qd(x)).collect();
1078        map_elites_batch(
1079            &mut batch,
1080            &mut batch_qd,
1081            &lower,
1082            &upper,
1083            &params,
1084            &mut rng_b,
1085        )
1086        .unwrap();
1087        assert_eq!(batch.ys(), serial.ys());
1088        assert_eq!(batch.xs(), serial.xs());
1089        assert_eq!(batch.descriptors(), serial.descriptors());
1090    }
1091
1092    #[test]
1093    fn batch_progress_callback_is_ordered_and_observes_committed_updates() {
1094        let lower = vec![-2.0; 4];
1095        let upper = vec![2.0; 4];
1096        let mut rng = Rng::new(23);
1097        let mut archive = Archive::new(4, &[-2.0, -2.0], &[2.0, 2.0], 16, 0, &mut rng);
1098        archive.seed_uniform(&lower, &upper, &mut rng);
1099        let params = MapElitesParams {
1100            generations: 5,
1101            chunk_size: 8,
1102            ..Default::default()
1103        };
1104        let mut observed = Vec::new();
1105        let mut batch_qd = |xs: &[Vec<f64>]| xs.iter().map(|x| qd(x)).collect();
1106        map_elites_batch_with_progress(
1107            &mut archive,
1108            &mut batch_qd,
1109            &lower,
1110            &upper,
1111            &params,
1112            &mut rng,
1113            &mut |generation, committed| {
1114                observed.push((generation, committed.occupied(), committed.best_y()));
1115            },
1116        )
1117        .unwrap();
1118        assert_eq!(
1119            observed.iter().map(|sample| sample.0).collect::<Vec<_>>(),
1120            vec![1, 2, 3, 4, 5]
1121        );
1122        assert!(
1123            observed
1124                .iter()
1125                .all(|(_, occupied, best)| *occupied > 0 && best.is_finite())
1126        );
1127        assert!(observed.windows(2).all(|pair| pair[1].2 <= pair[0].2));
1128    }
1129
1130    #[test]
1131    fn diversify_improves_and_fills() {
1132        let mut rng = Rng::new(3);
1133        let lower = vec![-2.0; 4];
1134        let upper = vec![2.0; 4];
1135        let mut archive = Archive::new(4, &[-2.0, -2.0], &[2.0, 2.0], 64, 10, &mut rng);
1136        let seed_pop: Vec<Vec<f64>> = (0..64)
1137            .map(|_| (0..4).map(|_| -2.0 + 4.0 * rng.uniform01()).collect())
1138            .collect();
1139        archive.update(&seed_pop, &mut qd);
1140        let params = DiversifierParams {
1141            max_evaluations: 20_000,
1142            ..Default::default()
1143        };
1144        let (bx, by) = diversify(&mut archive, &mut qd, &lower, &upper, &params, &mut rng);
1145        assert_eq!(bx.len(), 4);
1146        assert!(by < 1e-3, "diversifier best_y={by}");
1147        assert!(archive.occupied() > 20, "occupied={}", archive.occupied());
1148    }
1149
1150    #[test]
1151    fn archive_validation_and_bad_evaluations() {
1152        let mut rng = Rng::new(4);
1153        assert!(Archive::try_new(0, &[0.0], &[1.0], 4, 2, &mut rng).is_err());
1154        assert!(Archive::try_new(2, &[0.0], &[0.0], 4, 2, &mut rng).is_err());
1155        assert!(Archive::try_new(2, &[0.0], &[1.0], 0, 2, &mut rng).is_err());
1156
1157        let mut archive = Archive::try_new(2, &[0.0], &[1.0], 4, 2, &mut rng).unwrap();
1158        let (improvements, values) =
1159            archive.update(&[vec![0.0, 0.0]], &mut |_: &[f64]| (f64::NAN, vec![0.5]));
1160        assert!(improvements[0].is_infinite());
1161        assert!(values[0].is_infinite());
1162        assert_eq!(archive.occupied(), 0);
1163    }
1164
1165    #[test]
1166    fn qd_score_matches_positive_and_negative_python_rules() {
1167        let mut rng = Rng::new(5);
1168        let mut positive = Archive::new(1, &[0.0], &[1.0], 3, 2, &mut rng);
1169        positive.set(0, 2.0, &[0.1], &[0.0]);
1170        positive.set(1, 4.0, &[0.5], &[0.0]);
1171        assert_eq!(positive.qd_score(), 0.75);
1172
1173        let mut mixed = Archive::new(1, &[0.0], &[1.0], 3, 2, &mut rng);
1174        mixed.set(0, -2.0, &[0.1], &[0.0]);
1175        mixed.set(1, 4.0, &[0.5], &[0.0]);
1176        assert_eq!(mixed.qd_score(), 2.0);
1177    }
1178
1179    #[test]
1180    fn random_x_one_uses_sorted_best_niches() {
1181        let mut rng = Rng::new(6);
1182        let mut archive = Archive::new(1, &[0.0], &[1.0], 4, 2, &mut rng);
1183        for (index, value) in [4.0, 1.0, 3.0, 2.0].into_iter().enumerate() {
1184            archive.set(index, value, &[index as f64 / 4.0], &[index as f64]);
1185        }
1186        archive.argsort();
1187        for _ in 0..10 {
1188            let (x, y) = archive.random_x_one(1, &mut rng);
1189            assert_eq!(x, vec![1.0]);
1190            assert_eq!(y, 1.0);
1191        }
1192    }
1193}