1use 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
23pub const AUTOMATIC_RANGE_MARGIN: f64 = 0.05;
25
26#[derive(Debug, Clone, PartialEq)]
28pub struct PatternAxis {
29 pub column: String,
31 pub min: Option<f64>,
33 pub max: Option<f64>,
35 pub cells: u32,
37}
38
39impl PatternAxis {
40 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 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 pub fn range(&self) -> Option<(f64, f64)> {
62 self.min.zip(self.max)
63 }
64
65 pub fn is_automatic(&self) -> bool {
67 self.min.is_none() && self.max.is_none()
68 }
69
70 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 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 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
127pub 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 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#[derive(Debug, Clone, PartialEq)]
160pub struct PatternSpaceSettings {
161 pub x_axis: PatternAxis,
163 pub y_axis: PatternAxis,
165 pub initial_samples: u64,
167 pub mutation_scale: f64,
169 pub aggregate: Aggregate,
171}
172
173impl PatternSpaceSettings {
174 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 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 pub fn has_ranges(&self) -> bool {
213 self.x_axis.range().is_some() && self.y_axis.range().is_some()
214 }
215
216 pub fn range_sample_count(&self, max_evaluations: u64) -> u64 {
218 self.initial_samples.min(max_evaluations)
219 }
220
221 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
293pub struct PatternCell {
294 pub x_index: u32,
296 pub y_index: u32,
298}
299
300#[derive(Debug, Clone, Copy, PartialEq)]
302pub struct PatternPlacement {
303 pub x: f64,
305 pub y: f64,
307 pub cell: Option<PatternCell>,
309 pub outside: bool,
311}
312
313#[derive(Debug, Clone, PartialEq)]
315pub struct ArchiveEntry {
316 pub cell: PatternCell,
318 pub hits: u64,
320 pub candidate_id: u64,
322 pub x: f64,
324 pub y: f64,
326}
327
328#[derive(Debug, Clone, PartialEq)]
330struct ArchiveRecord {
331 entry: ArchiveEntry,
332 genome: Genome,
333}
334
335#[derive(Debug, Clone, PartialEq)]
337struct HeldEvaluation {
338 candidate: Candidate,
339 x: f64,
340 y: f64,
341}
342
343#[derive(Debug, Clone)]
345pub struct PatternSpaceExploration {
346 space: SearchSpace,
347 settings: PatternSpaceSettings,
349 rng: DesignRng,
350 tracker: CandidateTracker,
351 archive: BTreeMap<PatternCell, ArchiveRecord>,
352 range_samples: u64,
354 told_samples: u64,
356 held: Vec<HeldEvaluation>,
358}
359
360impl PatternSpaceExploration {
361 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 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
407fn 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 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 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 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 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 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 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 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 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 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 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}