Skip to main content

henad_core/explore/search/
pse.rs

1//! Pattern Space Exploration (PSE), a search for candidates whose outputs land where no candidate has landed yet.
2//!
3//! A grid of cells spans two output columns. The archive keeps each filled cell with its exemplar, the first
4//! candidate to land in it, and counts every candidate that did. After its initial samples, drawn at random, the
5//! search draws two filled cells, takes the one with fewer hits, and mutates its exemplar.
6//!
7//! The initial samples fill their own batches, and breeding starts once every initial sample is told. Without initial
8//! samples, the first candidate is drawn at random, alone in its batch. A later batch requested while the archive is
9//! still empty is drawn at random in full.
10//!
11//! An axis given no bounds has an automatic range. The search holds every evaluation until the initial samples are
12//! all told, takes the range from their outputs, and then places the held evaluations in candidate order.
13
14use std::collections::BTreeMap;
15
16use crate::explore::design_rng::DesignRng;
17use crate::explore::search::genome::{Genome, SearchSpace};
18use crate::explore::search::{
19    Aggregate, Candidate, CandidateOrigin, CandidateTracker, Evaluation, Proposal, SearchReport, SearchSpecError,
20    Searcher, check_setting,
21};
22
23/// Fraction of an automatic range's span added below its smallest output and above its largest.
24pub const AUTOMATIC_RANGE_MARGIN: f64 = 0.05;
25
26/// One axis of the grid, over one output column.
27#[derive(Debug, Clone, PartialEq)]
28pub struct PatternAxis {
29    /// Output column, a reducer column such as `Infected:max`.
30    pub column: String,
31    /// Lower bound, or `None` for an automatic range.
32    pub min: Option<f64>,
33    /// Upper bound, or `None` for an automatic range.
34    pub max: Option<f64>,
35    /// Number of cells from the lower bound to the upper bound, all of one width.
36    pub cells: u32,
37}
38
39impl PatternAxis {
40    /// Returns an axis over `column` from `min` to `max`, cut into `cells` cells.
41    pub fn bounded(column: impl Into<String>, min: f64, max: f64, cells: u32) -> Self {
42        Self {
43            column: column.into(),
44            min: Some(min),
45            max: Some(max),
46            cells,
47        }
48    }
49
50    /// Returns an axis over `column` cut into `cells` cells, whose range is taken from the initial samples.
51    pub fn automatic(column: impl Into<String>, cells: u32) -> Self {
52        Self {
53            column: column.into(),
54            min: None,
55            max: None,
56            cells,
57        }
58    }
59
60    /// Returns the lower and upper bounds, or `None` unless both bounds are given.
61    pub fn range(&self) -> Option<(f64, f64)> {
62        self.min.zip(self.max)
63    }
64
65    /// Returns whether the range is left to the initial samples, with neither bound given.
66    pub fn is_automatic(&self) -> bool {
67        self.min.is_none() && self.max.is_none()
68    }
69
70    /// Returns the index of the cell `value` lands in and whether `value` lay outside the axis, or `None` for an
71    /// axis without both bounds.
72    ///
73    /// A value below the lower bound or above the upper bound lands in the edge cell nearer to it, and `NaN` in the
74    /// first cell. Each such value lies outside the axis.
75    pub fn cell_index(&self, value: f64) -> Option<(u32, bool)> {
76        let (min, max) = self.range()?;
77        if value.is_nan() || value < min {
78            return Some((0, true));
79        }
80        if value > max {
81            return Some((self.cells - 1, true));
82        }
83        let fraction = (value - min) / (max - min);
84        Some((((fraction * f64::from(self.cells)) as u32).min(self.cells - 1), false))
85    }
86
87    /// Returns the lower and upper bounds of cell `index`, or `None` for an axis without both bounds.
88    pub fn cell_bounds(&self, index: u32) -> Option<(f64, f64)> {
89        let (min, max) = self.range()?;
90        let width = (max - min) / f64::from(self.cells);
91        let upper = if index + 1 >= self.cells {
92            max
93        } else {
94            min + f64::from(index + 1) * width
95        };
96        Some((min + f64::from(index) * width, upper))
97    }
98
99    /// Checks the bounds and the cells of the axis, identifying each by its key in `keys`.
100    ///
101    /// An axis needs both bounds or no bounds.
102    fn check(&self, keys: [&'static str; 3]) -> Result<(), SearchSpecError> {
103        let [min_key, max_key, cells_key] = keys;
104        let lone_bound = |missing_key: &'static str, given_key: &str| SearchSpecError::Setting {
105            key: missing_key,
106            value: "none".to_owned(),
107            expected: format!("given with {given_key}, or both left out for an automatic range"),
108        };
109        match (self.min, self.max) {
110            (None, None) => {}
111            (Some(min), Some(max)) => {
112                check_setting(min.is_finite(), min_key, min, "a finite number")?;
113                check_setting(
114                    max.is_finite() && max > min,
115                    max_key,
116                    max,
117                    format!("a finite number greater than {min}"),
118                )?;
119            }
120            (Some(_), None) => return Err(lone_bound(max_key, min_key)),
121            (None, Some(_)) => return Err(lone_bound(min_key, max_key)),
122        }
123        check_setting(self.cells >= 1, cells_key, self.cells, "at least 1")
124    }
125}
126
127/// Returns the range of an automatic axis, computed from `values`, the outputs of the initial samples on that axis.
128///
129/// The range runs from the smallest finite value to the largest, widened at each end by [`AUTOMATIC_RANGE_MARGIN`]
130/// of its span. A single value `v` is widened to a span of `max(1, |v|)` around it, and no finite value yields the
131/// range 0 to 1.
132pub fn automatic_range(values: impl IntoIterator<Item = f64>) -> (f64, f64) {
133    let extremes =
134        values
135            .into_iter()
136            .filter(|value| value.is_finite())
137            .fold(None, |extremes: Option<(f64, f64)>, value| match extremes {
138                None => Some((value, value)),
139                Some((low, high)) => Some((low.min(value), high.max(value))),
140            });
141    let Some((low, high)) = extremes else {
142        return (0.0, 1.0);
143    };
144    if high <= low {
145        // A span of 1 alone would put a large value's neighbours outside the axis.
146        let half = 0.5 * low.abs().max(1.0);
147        return ((low - half).max(f64::MIN), (low + half).min(f64::MAX));
148    }
149    let margin = (high - low) * AUTOMATIC_RANGE_MARGIN;
150    let widened = (low - margin, high + margin);
151    if widened.0.is_finite() && widened.1.is_finite() {
152        widened
153    } else {
154        (low, high)
155    }
156}
157
158/// Settings of a Pattern Space Exploration.
159#[derive(Debug, Clone, PartialEq)]
160pub struct PatternSpaceSettings {
161    /// Axis over the first watched column.
162    pub x_axis: PatternAxis,
163    /// Axis over the second watched column.
164    pub y_axis: PatternAxis,
165    /// Number of candidates drawn at random before any candidate is bred from the archive.
166    pub initial_samples: u64,
167    /// Largest step of a mutated gene, as a fraction of its range.
168    pub mutation_scale: f64,
169    /// Rule that folds the replicates of a candidate into one value per axis.
170    pub aggregate: Aggregate,
171}
172
173impl PatternSpaceSettings {
174    /// Returns settings over `x_axis` and `y_axis`, with 64 initial samples, a mutation scale of 0.1 and the median.
175    pub fn new(x_axis: PatternAxis, y_axis: PatternAxis) -> Self {
176        Self {
177            x_axis,
178            y_axis,
179            initial_samples: 64,
180            mutation_scale: 0.1,
181            aggregate: Aggregate::Median,
182        }
183    }
184
185    /// Checks both axes, the initial samples and the mutation scale.
186    ///
187    /// # Errors
188    ///
189    /// Returns [`SearchSpecError::Setting`] for an axis with no cells, one bound without the other, a bound that is
190    /// not finite or a maximum not above the minimum, no initial samples with an automatic range, or a mutation
191    /// scale that is not a positive number.
192    pub fn check(&self) -> Result<(), SearchSpecError> {
193        self.x_axis
194            .check(["pse.x_axis.min", "pse.x_axis.max", "pse.x_axis.cells"])?;
195        self.y_axis
196            .check(["pse.y_axis.min", "pse.y_axis.max", "pse.y_axis.cells"])?;
197        check_setting(
198            self.initial_samples >= 1 || self.has_ranges(),
199            "pse.initial_samples",
200            self.initial_samples,
201            "at least 1 for an automatic range",
202        )?;
203        check_setting(
204            self.mutation_scale.is_finite() && self.mutation_scale > 0.0,
205            "pse.mutation_scale",
206            self.mutation_scale,
207            "a positive number",
208        )
209    }
210
211    /// Returns whether both axes have both bounds.
212    pub fn has_ranges(&self) -> bool {
213        self.x_axis.range().is_some() && self.y_axis.range().is_some()
214    }
215
216    /// Returns the number of initial samples an automatic range is taken from, under a budget of `max_evaluations`.
217    pub fn range_sample_count(&self, max_evaluations: u64) -> u64 {
218        self.initial_samples.min(max_evaluations)
219    }
220
221    /// Returns the number of batches that an exploration of `max_evaluations` evaluations runs, each of at most
222    /// `batch_size` candidates.
223    ///
224    /// The initial samples fill their own batches, and so does the first candidate when there are no initial samples.
225    pub fn batch_count(&self, max_evaluations: u64, batch_size: usize) -> u64 {
226        let batch = (batch_size as u64).max(1);
227        let random_samples = self.initial_samples.max(1).min(max_evaluations);
228        random_samples.div_ceil(batch) + (max_evaluations - random_samples).div_ceil(batch)
229    }
230
231    /// Returns the settings with each automatic axis given the range that [`automatic_range`] computes from `outputs`.
232    ///
233    /// `outputs` holds the x and y output of each initial sample that has both outputs. An axis with bounds keeps them.
234    pub fn with_automatic_ranges(&self, outputs: &[(f64, f64)]) -> Self {
235        let mut settings = self.clone();
236        let axes = [
237            (
238                &mut settings.x_axis,
239                outputs.iter().map(|&(x, _)| x).collect::<Vec<f64>>(),
240            ),
241            (&mut settings.y_axis, outputs.iter().map(|&(_, y)| y).collect()),
242        ];
243        for (axis, values) in axes {
244            if axis.is_automatic() {
245                let (min, max) = automatic_range(values);
246                axis.min = Some(min);
247                axis.max = Some(max);
248            }
249        }
250        settings
251    }
252
253    /// Returns the outputs of `evaluation` on the x and y axes, or `None` when an axis has no finite value.
254    ///
255    /// Each axis folds the finite values of its column with [`Self::aggregate`], leaving failed replicates out.
256    pub fn outputs(&self, evaluation: &Evaluation) -> Option<(f64, f64)> {
257        let axis_value = |column: usize| {
258            let mut values: Vec<f64> = evaluation
259                .outputs
260                .iter()
261                .filter_map(|row| row.get(column).copied().flatten())
262                .filter(|value| value.is_finite())
263                .collect();
264            self.aggregate.combine(&mut values)
265        };
266        Some((axis_value(0)?, axis_value(1)?))
267    }
268
269    /// Returns the placement of outputs `x` and `y`, with no cell while an axis has no range.
270    pub fn locate(&self, x: f64, y: f64) -> PatternPlacement {
271        let cell = self.x_axis.cell_index(x).zip(self.y_axis.cell_index(y));
272        PatternPlacement {
273            x,
274            y,
275            cell: cell.map(|((x_index, _), (y_index, _))| PatternCell { x_index, y_index }),
276            outside: cell.is_some_and(|((_, x_outside), (_, y_outside))| x_outside || y_outside),
277        }
278    }
279
280    /// Returns the placement of the replicates of `evaluation`, or `None` when an axis has no finite value.
281    ///
282    /// The outputs are those [`Self::outputs`] returns, and the placement has no cell while an axis has no range.
283    pub fn place(&self, evaluation: &Evaluation) -> Option<PatternPlacement> {
284        let (x, y) = self.outputs(evaluation)?;
285        Some(self.locate(x, y))
286    }
287}
288
289/// Cell of the grid, by its index along each axis.
290///
291/// Cells are ordered by `x_index`, then by `y_index`.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
293pub struct PatternCell {
294    /// Index of the cell along the x axis.
295    pub x_index: u32,
296    /// Index of the cell along the y axis.
297    pub y_index: u32,
298}
299
300/// Outputs of an evaluation, and the cell they land in.
301#[derive(Debug, Clone, Copy, PartialEq)]
302pub struct PatternPlacement {
303    /// Output on the x axis.
304    pub x: f64,
305    /// Output on the y axis.
306    pub y: f64,
307    /// Cell the outputs land in, `None` while an automatic range waits for the initial samples.
308    pub cell: Option<PatternCell>,
309    /// Whether an output lay outside its axis and was moved into the edge cell.
310    pub outside: bool,
311}
312
313/// A filled cell of the archive.
314#[derive(Debug, Clone, PartialEq)]
315pub struct ArchiveEntry {
316    /// Cell the entry fills.
317    pub cell: PatternCell,
318    /// Number of candidates that landed in the cell.
319    pub hits: u64,
320    /// Id of the exemplar, the first candidate to land in the cell.
321    pub candidate_id: u64,
322    /// Output of the exemplar on the x axis.
323    pub x: f64,
324    /// Output of the exemplar on the y axis.
325    pub y: f64,
326}
327
328/// Record of a filled cell, its entry with the exemplar's genome.
329#[derive(Debug, Clone, PartialEq)]
330struct ArchiveRecord {
331    entry: ArchiveEntry,
332    genome: Genome,
333}
334
335/// An evaluation told before its automatic range was taken, with its outputs.
336#[derive(Debug, Clone, PartialEq)]
337struct HeldEvaluation {
338    candidate: Candidate,
339    x: f64,
340    y: f64,
341}
342
343/// A Pattern Space Exploration.
344#[derive(Debug, Clone)]
345pub struct PatternSpaceExploration {
346    space: SearchSpace,
347    /// Settings as given, and once the initial samples are told, with each automatic range taken.
348    settings: PatternSpaceSettings,
349    rng: DesignRng,
350    tracker: CandidateTracker,
351    archive: BTreeMap<PatternCell, ArchiveRecord>,
352    /// Number of initial samples within the budget, the candidates an automatic range is taken from.
353    range_samples: u64,
354    /// Number of initial samples told so far.
355    told_samples: u64,
356    /// Evaluations with outputs on both axes told while an automatic range waits, in candidate order.
357    held: Vec<HeldEvaluation>,
358}
359
360impl PatternSpaceExploration {
361    /// Returns an exploration of `max_evaluations` evaluations over `space`, drawing from `seed`.
362    ///
363    /// # Errors
364    ///
365    /// Returns [`SearchSpecError`] when [`PatternSpaceSettings::check`] rejects `settings`.
366    pub fn new(
367        space: SearchSpace,
368        settings: PatternSpaceSettings,
369        max_evaluations: u64,
370        seed: u64,
371    ) -> Result<Self, SearchSpecError> {
372        settings.check()?;
373        Ok(Self {
374            space,
375            range_samples: settings.range_sample_count(max_evaluations),
376            settings,
377            rng: DesignRng::new(seed),
378            tracker: CandidateTracker::new(max_evaluations),
379            archive: BTreeMap::new(),
380            told_samples: 0,
381            held: Vec::new(),
382        })
383    }
384
385    /// Files a candidate with outputs `x` and `y` in the cell they land in, as its exemplar when the cell is empty.
386    fn file(&mut self, candidate: Candidate, x: f64, y: f64) {
387        let Some(cell) = self.settings.locate(x, y).cell else {
388            return;
389        };
390        self.archive
391            .entry(cell)
392            .or_insert_with(|| ArchiveRecord {
393                entry: ArchiveEntry {
394                    cell,
395                    hits: 0,
396                    candidate_id: candidate.id,
397                    x,
398                    y,
399                },
400                genome: candidate.genome,
401            })
402            .entry
403            .hits += 1;
404    }
405}
406
407/// Draws two filled cells uniformly from `records`, and returns a child of the exemplar of the cell with fewer hits.
408fn offspring(space: &SearchSpace, rng: &mut DesignRng, mutation_scale: f64, records: &[&ArchiveRecord]) -> Proposal {
409    let first = records[rng.index(records.len() as u64) as usize];
410    let second = records[rng.index(records.len() as u64) as usize];
411    let parent = if second.entry.hits < first.entry.hits {
412        second
413    } else {
414        first
415    };
416    Proposal::first_evaluation(
417        space.mutate(&parent.genome, rng, 1.0, mutation_scale),
418        CandidateOrigin::Mutation {
419            parent_id: parent.entry.candidate_id,
420        },
421    )
422}
423
424impl Searcher for PatternSpaceExploration {
425    fn ask(&mut self, max: usize) -> Vec<Candidate> {
426        let mut count = self.tracker.capacity(max) as u64;
427        let random_samples = self.range_samples.max(1);
428        let sampling = self.tracker.issued < random_samples;
429        if sampling {
430            count = count.min(random_samples - self.tracker.issued);
431        }
432        let records: Vec<&ArchiveRecord> = self.archive.values().collect();
433        let proposals = (0..count)
434            .map(|_| {
435                if sampling || records.is_empty() {
436                    Proposal::first_evaluation(self.space.random_genome(&mut self.rng), CandidateOrigin::Random)
437                } else {
438                    offspring(&self.space, &mut self.rng, self.settings.mutation_scale, &records)
439                }
440            })
441            .collect();
442        self.tracker.issue(proposals)
443    }
444
445    fn tell(&mut self, evaluations: &[Evaluation]) {
446        let mut sorted: Vec<&Evaluation> = evaluations.iter().collect();
447        sorted.sort_by_key(|evaluation| evaluation.candidate_id);
448        let waiting = !self.settings.has_ranges();
449        for evaluation in sorted {
450            let Some((candidate, _)) = self.tracker.settle(evaluation.candidate_id) else {
451                continue;
452            };
453            if candidate.id < self.range_samples {
454                self.told_samples += 1;
455            }
456            let Some((x, y)) = self.settings.outputs(evaluation) else {
457                continue;
458            };
459            if waiting {
460                self.held.push(HeldEvaluation { candidate, x, y });
461            } else {
462                self.file(candidate, x, y);
463            }
464        }
465        if waiting && self.told_samples >= self.range_samples {
466            let samples: Vec<(f64, f64)> = self
467                .held
468                .iter()
469                .filter(|held| held.candidate.id < self.range_samples)
470                .map(|held| (held.x, held.y))
471                .collect();
472            self.settings = self.settings.with_automatic_ranges(&samples);
473            for held in std::mem::take(&mut self.held) {
474                self.file(held.candidate, held.x, held.y);
475            }
476        }
477    }
478
479    fn is_done(&self) -> bool {
480        self.tracker.is_done()
481    }
482
483    fn report(&self) -> SearchReport {
484        SearchReport {
485            archive: self.archive.values().map(|record| record.entry.clone()).collect(),
486            ..SearchReport::default()
487        }
488    }
489
490    fn archive_entry(&self, cell: PatternCell) -> Option<ArchiveEntry> {
491        self.archive.get(&cell).map(|record| record.entry.clone())
492    }
493
494    fn filled_cells(&self) -> u64 {
495        self.archive.len() as u64
496    }
497
498    fn pattern_settings(&self) -> Option<&PatternSpaceSettings> {
499        self.settings.has_ranges().then_some(&self.settings)
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::{
506        AUTOMATIC_RANGE_MARGIN, PatternAxis, PatternCell, PatternSpaceExploration, PatternSpaceSettings,
507        automatic_range,
508    };
509    use crate::explore::fingerprint::fnv1a64;
510    use crate::explore::search::tests::support::{drive, drive_batches, noise, unit_space};
511    use crate::explore::search::{Candidate, CandidateOrigin, Evaluation, SearchSpecError, Searcher as _};
512
513    fn axis(column: &str, cells: u32) -> PatternAxis {
514        PatternAxis::bounded(column, 0.0, 100.0, cells)
515    }
516
517    /// Returns outputs that crowd toward 0 on both axes, as `100 * gene^6`.
518    fn skewed(candidate: &Candidate, _: u64) -> Vec<Option<f64>> {
519        candidate
520            .genome
521            .genes()
522            .iter()
523            .map(|&gene| {
524                let cube = gene * gene * gene;
525                Some(100.0 * cube * cube)
526            })
527            .collect()
528    }
529
530    /// Returns noisy outputs, with the y output of replicate 1 missing for every seventh candidate.
531    fn noisy(candidate: &Candidate, replicate: u64) -> Vec<Option<f64>> {
532        let genes = candidate.genome.genes();
533        let cube = genes[0] * genes[0] * genes[0];
534        vec![
535            Some(100.0 * cube * cube + noise(candidate.id, replicate)),
536            (replicate != 1 || !candidate.id.is_multiple_of(7))
537                .then(|| 80.0 * genes[1] + 30.0 * noise(candidate.id, replicate)),
538        ]
539    }
540
541    /// Returns the outputs of [`noisy`], with no y output at all for every ninth candidate.
542    fn gappy(candidate: &Candidate, replicate: u64) -> Vec<Option<f64>> {
543        let mut row = noisy(candidate, replicate);
544        if candidate.id % 9 == 4 {
545            row[1] = None;
546        }
547        row
548    }
549
550    /// Returns a hash of every candidate that `search` returns, over batches of 16 with 3 replicates of [`noisy`],
551    /// and of its archive at the end.
552    fn trajectory_hash(search: &mut PatternSpaceExploration) -> u64 {
553        let asked = drive(search, 16, 3, noisy);
554        let mut bytes = Vec::new();
555        for candidate in &asked {
556            bytes.extend(candidate.id.to_le_bytes());
557            for gene in candidate.genome.genes() {
558                bytes.extend(gene.to_bits().to_le_bytes());
559            }
560            bytes.extend(candidate.origin.parent_ids()[0].unwrap_or(u64::MAX).to_le_bytes());
561        }
562        for entry in search.report().archive {
563            bytes.extend(entry.cell.x_index.to_le_bytes());
564            bytes.extend(entry.cell.y_index.to_le_bytes());
565            bytes.extend(entry.hits.to_le_bytes());
566            bytes.extend(entry.candidate_id.to_le_bytes());
567            bytes.extend(entry.x.to_bits().to_le_bytes());
568            bytes.extend(entry.y.to_bits().to_le_bytes());
569        }
570        fnv1a64(&bytes)
571    }
572
573    fn filled_cells(initial_samples: u64, seed: u64) -> usize {
574        let settings = PatternSpaceSettings {
575            initial_samples,
576            ..PatternSpaceSettings::new(axis("Infected:max", 20), axis("Infected:argmax", 20))
577        };
578        let mut search = PatternSpaceExploration::new(unit_space(2), settings, 400, seed).expect("valid settings");
579        drive(&mut search, 16, 1, skewed);
580        search.report().archive.len()
581    }
582
583    #[test]
584    fn pse_fills_more_cells_than_random_search() {
585        for seed in 1..=3 {
586            // With every sample random, the exploration is a random search of the same budget.
587            let random = filled_cells(400, seed);
588            let explored = filled_cells(48, seed);
589            assert!(
590                explored > random,
591                "seed {seed}: {explored} cells explored, {random} at random"
592            );
593        }
594    }
595
596    #[test]
597    fn explicit_bounds_keep_their_trajectory() {
598        // Hashes of the trajectories of an exploration with both bounds set.
599        for (seed, expected) in [(3, 0x2b4e_69fa_34f1_1ba3_u64), (11, 0xccf5_0d16_6148_66ed)] {
600            let settings = PatternSpaceSettings {
601                initial_samples: 40,
602                ..PatternSpaceSettings::new(
603                    PatternAxis::bounded("Infected:max", 0.0, 90.0, 20),
604                    PatternAxis::bounded("Infected:argmax", 0.0, 70.0, 12),
605                )
606            };
607            let mut search = PatternSpaceExploration::new(unit_space(2), settings.clone(), 300, seed).expect("valid");
608            assert_eq!(trajectory_hash(&mut search), expected, "seed {seed}");
609            assert_eq!(search.pattern_settings(), Some(&settings), "bounds given are kept");
610        }
611    }
612
613    /// Returns the size of each batch in `batches`.
614    fn sizes(batches: &[Vec<Candidate>]) -> Vec<usize> {
615        batches.iter().map(Vec::len).collect()
616    }
617
618    fn is_mutation(candidate: &Candidate) -> bool {
619        matches!(candidate.origin, CandidateOrigin::Mutation { .. })
620    }
621
622    #[test]
623    fn a_budget_within_one_batch_breeds_after_the_initial_samples() {
624        for (initial_samples, max_evaluations, expected) in [(1, 2, [1, 1]), (3, 8, [3, 5])] {
625            let settings = PatternSpaceSettings {
626                initial_samples,
627                ..PatternSpaceSettings::new(axis("Infected:max", 10), axis("Infected:argmax", 10))
628            };
629            let mut search =
630                PatternSpaceExploration::new(unit_space(2), settings, max_evaluations, 5).expect("valid settings");
631            let batches = drive_batches(&mut search, 16, 1, skewed);
632            assert_eq!(sizes(&batches), expected, "the initial samples alone, then the rest");
633            assert!(
634                batches[0]
635                    .iter()
636                    .all(|candidate| candidate.origin == CandidateOrigin::Random)
637            );
638            assert!(
639                batches[1].iter().all(is_mutation),
640                "every candidate after the initial samples comes from the archive"
641            );
642        }
643    }
644
645    #[test]
646    fn one_automatic_axis_takes_its_range_and_the_other_keeps_its_bounds() {
647        let settings = PatternSpaceSettings {
648            initial_samples: 10,
649            ..PatternSpaceSettings::new(
650                PatternAxis::automatic("Infected:max", 8),
651                PatternAxis::bounded("Infected:argmax", 0.0, 50.0, 5),
652            )
653        };
654        let mut search = PatternSpaceExploration::new(unit_space(2), settings.clone(), 60, 3).expect("valid settings");
655        let batches = drive_batches(&mut search, 4, 1, skewed);
656        assert_eq!(
657            sizes(&batches)[..4],
658            [4, 4, 2, 4],
659            "the third batch ends at the last initial sample"
660        );
661        let asked = batches.concat();
662        for candidate in &asked {
663            assert_eq!(
664                candidate.origin == CandidateOrigin::Random,
665                candidate.id < 10,
666                "candidate {}",
667                candidate.id
668            );
669        }
670        let resolved = search.pattern_settings().expect("every initial sample told");
671        let x_values = asked[..10]
672            .iter()
673            .map(|candidate| skewed(candidate, 0)[0].expect("a finite output"));
674        assert_eq!(resolved.x_axis.range(), Some(automatic_range(x_values)));
675        assert_eq!(resolved.y_axis, settings.y_axis, "the bounded axis keeps its bounds");
676        assert!(search.filled_cells() > 1, "{} cells", search.filled_cells());
677    }
678
679    #[test]
680    fn without_initial_samples_the_first_candidate_fills_a_batch_alone() {
681        let settings = PatternSpaceSettings {
682            initial_samples: 0,
683            ..PatternSpaceSettings::new(axis("Infected:max", 10), axis("Infected:argmax", 10))
684        };
685        let mut search = PatternSpaceExploration::new(unit_space(2), settings.clone(), 20, 7)
686            .expect("bounded axes need no initial sample");
687        let batches = drive_batches(&mut search, 8, 1, skewed);
688        assert_eq!(sizes(&batches), [1, 8, 8, 3]);
689        assert_eq!(batches[0][0].origin, CandidateOrigin::Random);
690        assert!(
691            batches[1..].iter().flatten().all(is_mutation),
692            "the first candidate fills the archive"
693        );
694
695        // With no output on the y axis, no candidate lands in a cell.
696        let mut search = PatternSpaceExploration::new(unit_space(2), settings, 13, 7).expect("valid settings");
697        let missing = |_: &Candidate, _| vec![Some(1.0), None];
698        let batches = drive_batches(&mut search, 8, 1, missing);
699        assert_eq!(sizes(&batches), [1, 8, 4], "an empty archive asks for whole batches");
700        assert!(
701            batches
702                .iter()
703                .flatten()
704                .all(|candidate| candidate.origin == CandidateOrigin::Random)
705        );
706    }
707
708    #[test]
709    fn the_batch_count_matches_the_batches_asked_for() {
710        let missing = |_: &Candidate, _| vec![None, Some(1.0)];
711        let bounded = |initial_samples| PatternSpaceSettings {
712            initial_samples,
713            ..PatternSpaceSettings::new(axis("Infected:max", 10), axis("Infected:argmax", 10))
714        };
715        let automatic = |initial_samples| PatternSpaceSettings {
716            initial_samples,
717            ..automatic_settings()
718        };
719        for (settings, max_evaluations, batch_size, landing) in [
720            (bounded(0), 20, 8, true),
721            (bounded(0), 1, 8, true),
722            (bounded(3), 8, 16, true),
723            (bounded(10), 30, 6, true),
724            (bounded(12), 30, 6, true),
725            (bounded(50), 30, 6, true),
726            (bounded(10), 30, 6, false),
727            (automatic(10), 30, 4, true),
728            (automatic(10), 30, 4, false),
729            (automatic(1), 5, 1, true),
730        ] {
731            let mut search = PatternSpaceExploration::new(unit_space(2), settings.clone(), max_evaluations, 9)
732                .expect("valid settings");
733            let batches = if landing {
734                drive_batches(&mut search, batch_size, 1, skewed)
735            } else {
736                drive_batches(&mut search, batch_size, 1, missing)
737            };
738            assert_eq!(
739                settings.batch_count(max_evaluations, batch_size),
740                batches.len() as u64,
741                "{} initial samples, {max_evaluations} evaluations in batches of {batch_size}, sizes {:?}",
742                settings.initial_samples,
743                sizes(&batches)
744            );
745        }
746        assert_eq!(bounded(4).batch_count(0, 8), 0);
747    }
748
749    /// Returns the settings of an exploration with automatic ranges on both axes and 40 initial samples.
750    fn automatic_settings() -> PatternSpaceSettings {
751        PatternSpaceSettings {
752            initial_samples: 40,
753            ..PatternSpaceSettings::new(
754                PatternAxis::automatic("Infected:max", 20),
755                PatternAxis::automatic("Infected:argmax", 12),
756            )
757        }
758    }
759
760    #[test]
761    fn an_automatic_range_spans_the_initial_samples_with_a_margin() {
762        let settings = automatic_settings();
763        let mut search = PatternSpaceExploration::new(unit_space(2), settings.clone(), 300, 3).expect("valid");
764        let mut asked = Vec::new();
765        while asked.len() < 32 {
766            let batch = search.ask(16);
767            let evaluations: Vec<Evaluation> = batch
768                .iter()
769                .map(|candidate| Evaluation {
770                    candidate_id: candidate.id,
771                    outputs: (0..3).map(|replicate| gappy(candidate, replicate)).collect(),
772                })
773                .collect();
774            search.tell(&evaluations);
775            asked.extend(batch);
776        }
777        assert_eq!(search.pattern_settings(), None, "32 of 40 initial samples told");
778        assert_eq!(search.filled_cells(), 0, "the archive waits for the range");
779
780        let batch = search.ask(16);
781        assert_eq!(batch.len(), 8, "the third batch holds the last 8 initial samples alone");
782        let evaluations: Vec<Evaluation> = batch
783            .iter()
784            .map(|candidate| Evaluation {
785                candidate_id: candidate.id,
786                outputs: (0..3).map(|replicate| gappy(candidate, replicate)).collect(),
787            })
788            .collect();
789        search.tell(&evaluations);
790        asked.extend(batch);
791        let resolved = search.pattern_settings().expect("every initial sample told").clone();
792
793        let outputs: Vec<(f64, f64)> = asked[..40]
794            .iter()
795            .filter_map(|candidate| {
796                settings.outputs(&Evaluation {
797                    candidate_id: candidate.id,
798                    outputs: (0..3).map(|replicate| gappy(candidate, replicate)).collect(),
799                })
800            })
801            .collect();
802        assert!(outputs.len() < 40, "a candidate with no y output is left out");
803        for (axis, values) in [
804            (&resolved.x_axis, outputs.iter().map(|&(x, _)| x).collect::<Vec<f64>>()),
805            (&resolved.y_axis, outputs.iter().map(|&(_, y)| y).collect()),
806        ] {
807            let low = values.iter().copied().fold(f64::INFINITY, f64::min);
808            let high = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
809            let margin = (high - low) * AUTOMATIC_RANGE_MARGIN;
810            assert_eq!(axis.range(), Some((low - margin, high + margin)), "{}", axis.column);
811        }
812        assert_eq!(
813            (resolved.x_axis.cells, resolved.y_axis.cells, resolved.initial_samples),
814            (20, 12, 40)
815        );
816
817        // Every candidate told so far is placed with the range, the exemplar of a cell being the first to land in it.
818        let report = search.report();
819        let mut hits = 0;
820        for (position, candidate) in asked.iter().enumerate() {
821            let evaluation = Evaluation {
822                candidate_id: candidate.id,
823                outputs: (0..3).map(|replicate| gappy(candidate, replicate)).collect(),
824            };
825            let Some(cell) = resolved.place(&evaluation).and_then(|placement| placement.cell) else {
826                continue;
827            };
828            hits += 1;
829            let entry = report.archive_entry(cell).expect("the cell is filled");
830            let first = asked[..position].iter().all(|earlier| {
831                resolved
832                    .place(&Evaluation {
833                        candidate_id: earlier.id,
834                        outputs: (0..3).map(|replicate| gappy(earlier, replicate)).collect(),
835                    })
836                    .and_then(|placement| placement.cell)
837                    != Some(cell)
838            });
839            assert_eq!(entry.candidate_id == candidate.id, first, "candidate {}", candidate.id);
840        }
841        assert_eq!(report.archive.iter().map(|entry| entry.hits).sum::<u64>(), hits);
842    }
843
844    #[test]
845    fn an_automatic_range_is_reproducible_from_the_seed() {
846        let run = |seed| {
847            let mut search =
848                PatternSpaceExploration::new(unit_space(2), automatic_settings(), 300, seed).expect("valid settings");
849            let hash = trajectory_hash(&mut search);
850            (hash, search.pattern_settings().cloned())
851        };
852        let (hash, settings) = run(3);
853        assert_eq!(
854            run(3),
855            (hash, settings.clone()),
856            "one seed, one trajectory and one range"
857        );
858        let (other_hash, other_settings) = run(11);
859        assert_ne!(hash, other_hash);
860        assert_ne!(settings, other_settings, "another seed draws other initial samples");
861        assert!(settings.is_some_and(|settings| settings.has_ranges()));
862    }
863
864    #[test]
865    fn an_automatic_range_widens_a_single_value_and_falls_back_without_one() {
866        assert_eq!(automatic_range([0.25, f64::NAN, 0.25]), (-0.25, 0.75));
867        assert_eq!(automatic_range([-4.0]), (-6.0, -2.0), "a span as wide as the value");
868        assert_eq!(
869            automatic_range([f64::MAX]),
870            (f64::MAX / 2.0, f64::MAX),
871            "a finite range"
872        );
873        assert_eq!(automatic_range([f64::INFINITY]), (0.0, 1.0));
874        assert_eq!(automatic_range([]), (0.0, 1.0));
875        assert_eq!(automatic_range([10.0, 30.0, 20.0]), (9.0, 31.0));
876    }
877
878    #[test]
879    fn an_axis_takes_both_bounds_or_neither() {
880        let check = |x_axis: PatternAxis, initial_samples| {
881            PatternSpaceSettings {
882                initial_samples,
883                ..PatternSpaceSettings::new(x_axis, PatternAxis::automatic("Infected:argmax", 4))
884            }
885            .check()
886        };
887        assert_eq!(check(PatternAxis::automatic("Infected:max", 4), 8), Ok(()));
888        assert_eq!(check(axis("Infected:max", 4), 8), Ok(()));
889        let lone_min = PatternAxis {
890            max: None,
891            ..axis("Infected:max", 4)
892        };
893        assert!(matches!(
894            check(lone_min, 8),
895            Err(SearchSpecError::Setting {
896                key: "pse.x_axis.max",
897                ..
898            })
899        ));
900        assert!(
901            matches!(
902                check(PatternAxis::automatic("Infected:max", 4), 0),
903                Err(SearchSpecError::Setting {
904                    key: "pse.initial_samples",
905                    ..
906                })
907            ),
908            "an automatic range needs an initial sample"
909        );
910    }
911
912    #[test]
913    fn pse_archive_iterates_in_cell_order() {
914        let settings = PatternSpaceSettings {
915            initial_samples: 20,
916            ..PatternSpaceSettings::new(axis("Infected:max", 8), axis("Infected:argmax", 8))
917        };
918        let mut search = PatternSpaceExploration::new(unit_space(2), settings, 200, 9).expect("valid settings");
919        let asked = drive(&mut search, 10, 2, skewed);
920        let report = search.report();
921        assert!(report.archive.len() > 1, "{} cells", report.archive.len());
922        let cells: Vec<PatternCell> = report.archive.iter().map(|entry| entry.cell).collect();
923        let mut sorted = cells.clone();
924        sorted.sort();
925        assert_eq!(cells, sorted);
926        assert_eq!(report.archive.iter().map(|entry| entry.hits).sum::<u64>(), 200);
927        for entry in &report.archive {
928            assert_eq!(report.archive_entry(entry.cell), Some(entry));
929            let exemplar = &asked[entry.candidate_id as usize];
930            let placed = search
931                .settings
932                .place(&Evaluation {
933                    candidate_id: exemplar.id,
934                    outputs: vec![skewed(exemplar, 0)],
935                })
936                .expect("finite outputs");
937            assert_eq!(placed.cell, Some(entry.cell), "the exemplar lands in its own cell");
938            let earlier = asked[..entry.candidate_id as usize].iter().any(|candidate| {
939                search
940                    .settings
941                    .place(&Evaluation {
942                        candidate_id: candidate.id,
943                        outputs: vec![skewed(candidate, 0)],
944                    })
945                    .is_some_and(|placement| placement.cell == Some(entry.cell))
946            });
947            assert!(!earlier, "no earlier candidate landed in cell {:?}", entry.cell);
948        }
949    }
950
951    #[test]
952    fn a_value_outside_an_axis_lands_in_the_edge_cell() {
953        let axis = axis("Infected:max", 4);
954        assert_eq!(axis.cell_index(-3.0), Some((0, true)));
955        assert_eq!(axis.cell_index(0.0), Some((0, false)));
956        assert_eq!(axis.cell_index(24.9), Some((0, false)));
957        assert_eq!(axis.cell_index(25.0), Some((1, false)));
958        assert_eq!(axis.cell_index(100.0), Some((3, false)));
959        assert_eq!(axis.cell_index(250.0), Some((3, true)));
960        assert_eq!(axis.cell_index(f64::NAN), Some((0, true)), "NaN lies outside the axis");
961        assert_eq!(axis.cell_index(f64::NEG_INFINITY), Some((0, true)));
962        assert_eq!(axis.cell_index(f64::INFINITY), Some((3, true)));
963        assert_eq!(axis.cell_bounds(1), Some((25.0, 50.0)));
964        assert_eq!(axis.cell_bounds(3), Some((75.0, 100.0)));
965        let automatic = PatternAxis::automatic("Infected:max", 4);
966        assert_eq!((automatic.cell_index(3.0), automatic.cell_bounds(0)), (None, None));
967    }
968
969    #[test]
970    fn a_replicate_without_a_finite_value_is_left_out_of_its_axis() {
971        let settings = PatternSpaceSettings::new(axis("Infected:max", 10), axis("Infected:argmax", 10));
972        let evaluation = |outputs| Evaluation {
973            candidate_id: 0,
974            outputs,
975        };
976        let placed = settings
977            .place(&evaluation(vec![
978                vec![Some(15.0), None],
979                vec![None, Some(95.0)],
980                vec![Some(f64::NAN), Some(96.0)],
981            ]))
982            .expect("each axis has a finite value");
983        assert_eq!((placed.x, placed.y), (15.0, 95.5));
984        assert_eq!(placed.cell, Some(PatternCell { x_index: 1, y_index: 9 }));
985        assert!(settings.place(&evaluation(vec![vec![None, Some(1.0)]])).is_none());
986    }
987}