1pub mod evaluation_log;
10pub mod genetic;
11pub mod genome;
12pub mod hill_climb;
13pub mod pse;
14pub mod random;
15
16#[cfg(test)]
17mod tests;
18
19use std::cmp::Ordering;
20use std::collections::{BTreeMap, BTreeSet};
21use std::fmt;
22use std::str::FromStr;
23
24use crate::explore::design::MAX_CONFIGS;
25use crate::explore::factor::FactorSpec;
26use crate::explore::search::evaluation_log::CandidateRecord;
27use crate::explore::search::genetic::{GeneticAlgorithm, GeneticSettings};
28use crate::explore::search::genome::{ConfigKey, Genome, SearchSpace};
29use crate::explore::search::hill_climb::{HillClimb, HillClimbSettings};
30use crate::explore::search::pse::{ArchiveEntry, PatternCell, PatternSpaceExploration, PatternSpaceSettings};
31use crate::explore::search::random::RandomSearch;
32use crate::explore::seed::search_seed;
33
34pub trait Searcher {
39 fn ask(&mut self, max: usize) -> Vec<Candidate>;
44
45 fn tell(&mut self, evaluations: &[Evaluation]);
49
50 fn is_done(&self) -> bool;
52
53 fn report(&self) -> SearchReport;
58
59 fn best(&self) -> Option<RankingEntry> {
61 None
62 }
63
64 fn ranking_entry(&self, _candidate_id: u64) -> Option<RankingEntry> {
67 None
68 }
69
70 fn archive_entry(&self, _cell: PatternCell) -> Option<ArchiveEntry> {
73 None
74 }
75
76 fn filled_cells(&self) -> u64 {
78 0
79 }
80
81 fn generations(&self) -> &[GenerationSummary] {
83 &[]
84 }
85
86 fn pattern_settings(&self) -> Option<&PatternSpaceSettings> {
89 None
90 }
91}
92
93#[derive(Debug, Clone, PartialEq)]
95pub struct Candidate {
96 pub id: u64,
98 pub genome: Genome,
100 pub replicate_offset: u64,
104 pub origin: CandidateOrigin,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum CandidateOrigin {
111 Random,
113 Restart,
115 Neighbor {
117 parent_id: u64,
119 },
120 Mutation {
122 parent_id: u64,
124 },
125 Crossover {
127 first_parent_id: u64,
129 second_parent_id: u64,
131 },
132 Reevaluation {
134 candidate_id: u64,
136 },
137}
138
139impl CandidateOrigin {
140 pub fn as_str(self) -> &'static str {
142 match self {
143 Self::Random => "random",
144 Self::Restart => "restart",
145 Self::Neighbor { .. } => "neighbor",
146 Self::Mutation { .. } => "mutation",
147 Self::Crossover { .. } => "crossover",
148 Self::Reevaluation { .. } => "reevaluation",
149 }
150 }
151
152 pub fn parent_ids(self) -> [Option<u64>; 2] {
156 match self {
157 Self::Random | Self::Restart | Self::Reevaluation { .. } => [None, None],
158 Self::Neighbor { parent_id } | Self::Mutation { parent_id } => [Some(parent_id), None],
159 Self::Crossover {
160 first_parent_id,
161 second_parent_id,
162 } => [Some(first_parent_id), Some(second_parent_id)],
163 }
164 }
165
166 pub fn reevaluated_id(self) -> Option<u64> {
168 match self {
169 Self::Reevaluation { candidate_id } => Some(candidate_id),
170 _ => None,
171 }
172 }
173}
174
175#[derive(Debug, Clone, PartialEq)]
177pub struct Evaluation {
178 pub candidate_id: u64,
180 pub outputs: Vec<Vec<Option<f64>>>,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct Objective {
190 pub column: String,
192 pub goal: Goal,
194 pub aggregate: Aggregate,
196}
197
198impl Objective {
199 pub fn score(&self, evaluation: &Evaluation) -> f64 {
203 self.score_values(evaluation.outputs.iter().map(|row| row.first().copied().flatten()))
204 }
205
206 fn score_values(&self, values: impl Iterator<Item = Option<f64>>) -> f64 {
208 let worst = self.goal.worst();
209 let mut values: Vec<f64> = values
210 .map(|value| value.filter(|value| value.is_finite()).unwrap_or(worst))
211 .collect();
212 self.aggregate.combine(&mut values).unwrap_or(worst)
213 }
214}
215
216#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
218pub enum Goal {
219 #[default]
221 Minimize,
222 Maximize,
224}
225
226impl Goal {
227 pub fn as_str(self) -> &'static str {
229 match self {
230 Self::Minimize => "minimize",
231 Self::Maximize => "maximize",
232 }
233 }
234
235 pub fn worst(self) -> f64 {
237 match self {
238 Self::Minimize => f64::INFINITY,
239 Self::Maximize => f64::NEG_INFINITY,
240 }
241 }
242
243 pub fn is_better(self, value: f64, reference: f64) -> bool {
245 self.compare(value, reference) == Ordering::Less
246 }
247
248 pub fn compare(self, first: f64, second: f64) -> Ordering {
252 let ordering = first.partial_cmp(&second).unwrap_or(Ordering::Equal);
253 match self {
254 Self::Minimize => ordering,
255 Self::Maximize => ordering.reverse(),
256 }
257 }
258}
259
260impl FromStr for Goal {
261 type Err = SearchKeywordError;
262
263 fn from_str(raw: &str) -> Result<Self, Self::Err> {
265 match raw {
266 "minimize" => Ok(Self::Minimize),
267 "maximize" => Ok(Self::Maximize),
268 _ => Err(SearchKeywordError {
269 raw: raw.to_owned(),
270 expected: &["minimize", "maximize"],
271 }),
272 }
273 }
274}
275
276#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
278pub enum Aggregate {
279 Mean,
281 #[default]
283 Median,
284}
285
286impl Aggregate {
287 pub fn as_str(self) -> &'static str {
289 match self {
290 Self::Mean => "mean",
291 Self::Median => "median",
292 }
293 }
294
295 pub fn combine(self, values: &mut [f64]) -> Option<f64> {
299 if values.is_empty() {
300 return None;
301 }
302 match self {
303 Self::Mean => Some(values.iter().sum::<f64>() / values.len() as f64),
304 Self::Median => {
305 values.sort_unstable_by(f64::total_cmp);
306 let middle = values.len() / 2;
307 if values.len() % 2 == 1 {
308 Some(values[middle])
309 } else {
310 Some(f64::midpoint(values[middle - 1], values[middle]))
311 }
312 }
313 }
314 }
315}
316
317impl FromStr for Aggregate {
318 type Err = SearchKeywordError;
319
320 fn from_str(raw: &str) -> Result<Self, Self::Err> {
322 match raw {
323 "mean" => Ok(Self::Mean),
324 "median" => Ok(Self::Median),
325 _ => Err(SearchKeywordError {
326 raw: raw.to_owned(),
327 expected: &["mean", "median"],
328 }),
329 }
330 }
331}
332
333#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct SearchKeywordError {
336 pub raw: String,
338 pub expected: &'static [&'static str],
340}
341
342impl fmt::Display for SearchKeywordError {
343 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344 write!(
345 f,
346 "unknown value '{}', expected {}",
347 self.raw,
348 self.expected.join(" or ")
349 )
350 }
351}
352
353impl std::error::Error for SearchKeywordError {}
354
355#[derive(Debug, Clone, Default, PartialEq)]
357pub struct SearchReport {
358 pub ranking: Vec<RankingEntry>,
362 pub generations: Vec<GenerationSummary>,
364 pub archive: Vec<ArchiveEntry>,
366}
367
368impl SearchReport {
369 pub fn best(&self) -> Option<&RankingEntry> {
371 self.ranking.first()
372 }
373
374 pub fn ranking_entry(&self, candidate_id: u64) -> Option<&RankingEntry> {
376 self.ranking.iter().find(|entry| entry.candidate_id == candidate_id)
377 }
378
379 pub fn archive_entry(&self, cell: PatternCell) -> Option<&ArchiveEntry> {
381 self.archive
382 .binary_search_by(|entry| entry.cell.cmp(&cell))
383 .ok()
384 .map(|index| &self.archive[index])
385 }
386}
387
388#[derive(Debug, Clone, PartialEq)]
390pub struct RankingEntry {
391 pub candidate_id: u64,
393 pub objective: f64,
395 pub replicate_count: u64,
397 pub failed_count: u64,
399 pub evaluations: u64,
401 pub first_batch: u64,
403}
404
405#[derive(Debug, Clone, PartialEq)]
407pub struct GenerationSummary {
408 pub generation: u64,
410 pub best: f64,
412 pub median: f64,
414 pub worst: f64,
416}
417
418#[derive(Debug, Clone, PartialEq)]
420pub struct SearchSpec {
421 pub algorithm: SearchAlgorithm,
423 pub max_evaluations: u64,
425 pub batch_size: usize,
427 pub objective: Option<Objective>,
429 pub space: Vec<FactorSpec>,
431}
432
433impl SearchSpec {
434 pub fn check(&self) -> Result<(), SearchSpecError> {
444 if self.max_evaluations == 0 {
445 return Err(SearchSpecError::NoEvaluations);
446 }
447 if self.batch_size == 0 {
448 return Err(SearchSpecError::NoBatch);
449 }
450 if self.batch_size > MAX_CONFIGS {
451 return Err(SearchSpecError::BatchTooLarge {
452 batch_size: self.batch_size,
453 });
454 }
455 match (&self.algorithm, &self.objective) {
456 (SearchAlgorithm::PatternSpaceExploration(settings), None) => settings.check(),
457 (SearchAlgorithm::PatternSpaceExploration(_), Some(_)) => Err(SearchSpecError::UnusedObjective),
458 (algorithm, None) => Err(SearchSpecError::MissingObjective {
459 algorithm: algorithm.as_str(),
460 }),
461 (SearchAlgorithm::Random, Some(_)) => Ok(()),
462 (SearchAlgorithm::HillClimb(settings), Some(_)) => settings.check(),
463 (SearchAlgorithm::Genetic(settings), Some(_)) => settings.check(),
464 }
465 }
466
467 pub fn watched_columns(&self) -> Vec<&str> {
470 match &self.algorithm {
471 SearchAlgorithm::PatternSpaceExploration(settings) => {
472 vec![settings.x_axis.column.as_str(), settings.y_axis.column.as_str()]
473 }
474 SearchAlgorithm::Random | SearchAlgorithm::HillClimb(_) | SearchAlgorithm::Genetic(_) => self
475 .objective
476 .iter()
477 .map(|objective| objective.column.as_str())
478 .collect(),
479 }
480 }
481
482 pub fn searcher(&self, space: &SearchSpace, root: u64) -> Result<Box<dyn Searcher + Send>, SearchSpecError> {
488 self.check()?;
489 let space = space.clone();
490 let seed = search_seed(root);
491 let budget = self.max_evaluations;
492 let objective = || {
493 self.objective.clone().ok_or(SearchSpecError::MissingObjective {
494 algorithm: self.algorithm.as_str(),
495 })
496 };
497 Ok(match &self.algorithm {
498 SearchAlgorithm::Random => Box::new(RandomSearch::new(space, objective()?, budget, seed)),
499 SearchAlgorithm::HillClimb(settings) => {
500 Box::new(HillClimb::new(space, *settings, objective()?, budget, seed)?)
501 }
502 SearchAlgorithm::Genetic(settings) => {
503 Box::new(GeneticAlgorithm::new(space, *settings, objective()?, budget, seed)?)
504 }
505 SearchAlgorithm::PatternSpaceExploration(settings) => {
506 Box::new(PatternSpaceExploration::new(space, settings.clone(), budget, seed)?)
507 }
508 })
509 }
510}
511
512#[derive(Debug, Clone, PartialEq)]
514pub enum SearchAlgorithm {
515 Random,
517 HillClimb(HillClimbSettings),
519 Genetic(GeneticSettings),
521 PatternSpaceExploration(PatternSpaceSettings),
523}
524
525impl SearchAlgorithm {
526 pub fn as_str(&self) -> &'static str {
528 match self {
529 Self::Random => "random",
530 Self::HillClimb(_) => "hill_climb",
531 Self::Genetic(_) => "genetic",
532 Self::PatternSpaceExploration(_) => "pse",
533 }
534 }
535}
536
537#[derive(Debug, Clone, PartialEq, Eq)]
539pub enum SearchSpecError {
540 NoEvaluations,
542 NoBatch,
544 BatchTooLarge {
546 batch_size: usize,
548 },
549 MissingObjective {
551 algorithm: &'static str,
553 },
554 UnusedObjective,
556 Setting {
558 key: &'static str,
560 value: String,
562 expected: String,
564 },
565}
566
567impl fmt::Display for SearchSpecError {
568 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
569 match self {
570 Self::NoEvaluations => write!(f, "max_evaluations must be at least 1"),
571 Self::NoBatch => write!(f, "batch_size must be at least 1"),
572 Self::BatchTooLarge { batch_size } => {
573 write!(f, "batch_size must be at most {MAX_CONFIGS}, got {batch_size}")
574 }
575 Self::MissingObjective { algorithm } => write!(f, "a {algorithm} search needs an objective"),
576 Self::UnusedObjective => write!(f, "a pse search takes x_axis and y_axis, and no objective"),
577 Self::Setting { key, value, expected } => write!(f, "{key} must be {expected}, got {value}"),
578 }
579 }
580}
581
582impl std::error::Error for SearchSpecError {}
583
584fn check_setting(
586 holds: bool,
587 key: &'static str,
588 value: impl fmt::Display,
589 expected: impl Into<String>,
590) -> Result<(), SearchSpecError> {
591 if holds {
592 Ok(())
593 } else {
594 Err(SearchSpecError::Setting {
595 key,
596 value: value.to_string(),
597 expected: expected.into(),
598 })
599 }
600}
601
602#[derive(Debug, Clone, PartialEq)]
604struct Proposal {
605 genome: Genome,
606 replicate_offset: u64,
607 origin: CandidateOrigin,
608}
609
610impl Proposal {
611 fn first_evaluation(genome: Genome, origin: CandidateOrigin) -> Self {
613 Self {
614 genome,
615 replicate_offset: 0,
616 origin,
617 }
618 }
619
620 fn reevaluation(candidate_id: u64, record: &CandidateRecord) -> Self {
622 Self {
623 genome: record.genome().clone(),
624 replicate_offset: record.replicate_count(),
625 origin: CandidateOrigin::Reevaluation { candidate_id },
626 }
627 }
628}
629
630const MAX_CONFIG_DRAWS: usize = 16;
632
633#[derive(Debug, Clone, PartialEq)]
635enum ConfigDraw {
636 New(Genome, ConfigKey),
638 Known { candidate_id: u64 },
640 Proposed,
642}
643
644fn draw_config(
650 space: &SearchSpace,
651 config_candidates: &BTreeMap<ConfigKey, u64>,
652 proposed: &BTreeSet<ConfigKey>,
653 mut draw: impl FnMut() -> Genome,
654) -> ConfigDraw {
655 let mut first = None;
656 for _ in 0..MAX_CONFIG_DRAWS {
657 let genome = draw();
658 let key = space.config_key(&genome);
659 let candidate_id = config_candidates.get(&key).copied();
660 if candidate_id.is_none() && !proposed.contains(&key) {
661 return ConfigDraw::New(genome, key);
662 }
663 first.get_or_insert(candidate_id);
664 }
665 match first.flatten() {
666 Some(candidate_id) => ConfigDraw::Known { candidate_id },
667 None => ConfigDraw::Proposed,
668 }
669}
670
671#[derive(Debug, Clone, PartialEq)]
673struct CandidateTracker {
674 max_evaluations: u64,
675 issued: u64,
677 batches: u64,
679 pending: BTreeMap<u64, (Candidate, u64)>,
681}
682
683impl CandidateTracker {
684 fn new(max_evaluations: u64) -> Self {
685 Self {
686 max_evaluations,
687 issued: 0,
688 batches: 0,
689 pending: BTreeMap::new(),
690 }
691 }
692
693 fn capacity(&self, max: usize) -> usize {
695 let remaining = self.max_evaluations.saturating_sub(self.issued);
696 usize::try_from(remaining).map_or(max, |remaining| remaining.min(max))
697 }
698
699 fn issue(&mut self, proposals: Vec<Proposal>) -> Vec<Candidate> {
701 if proposals.is_empty() {
702 return Vec::new();
703 }
704 let batch = self.batches;
705 self.batches += 1;
706 proposals
707 .into_iter()
708 .map(|proposal| {
709 let candidate = Candidate {
710 id: self.issued,
711 genome: proposal.genome,
712 replicate_offset: proposal.replicate_offset,
713 origin: proposal.origin,
714 };
715 self.issued += 1;
716 self.pending.insert(candidate.id, (candidate.clone(), batch));
717 candidate
718 })
719 .collect()
720 }
721
722 fn settle(&mut self, candidate_id: u64) -> Option<(Candidate, u64)> {
724 self.pending.remove(&candidate_id)
725 }
726
727 fn is_waiting(&self) -> bool {
728 !self.pending.is_empty()
729 }
730
731 fn is_done(&self) -> bool {
732 self.issued >= self.max_evaluations && self.pending.is_empty()
733 }
734}