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