pub mod evaluation_log;
pub mod genetic;
pub mod genome;
pub mod hill_climb;
pub mod pse;
pub mod random;
#[cfg(test)]
mod tests;
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::str::FromStr;
use crate::explore::design::MAX_CONFIGS;
use crate::explore::factor::FactorSpec;
use crate::explore::search::evaluation_log::CandidateRecord;
use crate::explore::search::genetic::{GeneticAlgorithm, GeneticSettings};
use crate::explore::search::genome::{ConfigKey, Genome, SearchSpace};
use crate::explore::search::hill_climb::{HillClimb, HillClimbSettings};
use crate::explore::search::pse::{ArchiveEntry, PatternCell, PatternSpaceExploration, PatternSpaceSettings};
use crate::explore::search::random::RandomSearch;
use crate::explore::seed::search_seed;
pub trait Searcher {
fn ask(&mut self, max: usize) -> Vec<Candidate>;
fn tell(&mut self, evaluations: &[Evaluation]);
fn is_done(&self) -> bool;
fn report(&self) -> SearchReport;
fn best(&self) -> Option<RankingEntry> {
None
}
fn ranking_entry(&self, _candidate_id: u64) -> Option<RankingEntry> {
None
}
fn archive_entry(&self, _cell: PatternCell) -> Option<ArchiveEntry> {
None
}
fn filled_cells(&self) -> u64 {
0
}
fn generations(&self) -> &[GenerationSummary] {
&[]
}
fn pattern_settings(&self) -> Option<&PatternSpaceSettings> {
None
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Candidate {
pub id: u64,
pub genome: Genome,
pub replicate_offset: u64,
pub origin: CandidateOrigin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CandidateOrigin {
Random,
Restart,
Neighbor {
parent_id: u64,
},
Mutation {
parent_id: u64,
},
Crossover {
first_parent_id: u64,
second_parent_id: u64,
},
Reevaluation {
candidate_id: u64,
},
}
impl CandidateOrigin {
pub fn as_str(self) -> &'static str {
match self {
Self::Random => "random",
Self::Restart => "restart",
Self::Neighbor { .. } => "neighbor",
Self::Mutation { .. } => "mutation",
Self::Crossover { .. } => "crossover",
Self::Reevaluation { .. } => "reevaluation",
}
}
pub fn parent_ids(self) -> [Option<u64>; 2] {
match self {
Self::Random | Self::Restart | Self::Reevaluation { .. } => [None, None],
Self::Neighbor { parent_id } | Self::Mutation { parent_id } => [Some(parent_id), None],
Self::Crossover {
first_parent_id,
second_parent_id,
} => [Some(first_parent_id), Some(second_parent_id)],
}
}
pub fn reevaluated_id(self) -> Option<u64> {
match self {
Self::Reevaluation { candidate_id } => Some(candidate_id),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Evaluation {
pub candidate_id: u64,
pub outputs: Vec<Vec<Option<f64>>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Objective {
pub column: String,
pub goal: Goal,
pub aggregate: Aggregate,
}
impl Objective {
pub fn score(&self, evaluation: &Evaluation) -> f64 {
self.score_values(evaluation.outputs.iter().map(|row| row.first().copied().flatten()))
}
fn score_values(&self, values: impl Iterator<Item = Option<f64>>) -> f64 {
let worst = self.goal.worst();
let mut values: Vec<f64> = values
.map(|value| value.filter(|value| value.is_finite()).unwrap_or(worst))
.collect();
self.aggregate.combine(&mut values).unwrap_or(worst)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Goal {
#[default]
Minimize,
Maximize,
}
impl Goal {
pub fn as_str(self) -> &'static str {
match self {
Self::Minimize => "minimize",
Self::Maximize => "maximize",
}
}
pub fn worst(self) -> f64 {
match self {
Self::Minimize => f64::INFINITY,
Self::Maximize => f64::NEG_INFINITY,
}
}
pub fn is_better(self, value: f64, reference: f64) -> bool {
self.compare(value, reference) == Ordering::Less
}
pub fn compare(self, first: f64, second: f64) -> Ordering {
let ordering = first.partial_cmp(&second).unwrap_or(Ordering::Equal);
match self {
Self::Minimize => ordering,
Self::Maximize => ordering.reverse(),
}
}
}
impl FromStr for Goal {
type Err = SearchKeywordError;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
match raw {
"minimize" => Ok(Self::Minimize),
"maximize" => Ok(Self::Maximize),
_ => Err(SearchKeywordError {
raw: raw.to_owned(),
expected: &["minimize", "maximize"],
}),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Aggregate {
Mean,
#[default]
Median,
}
impl Aggregate {
pub fn as_str(self) -> &'static str {
match self {
Self::Mean => "mean",
Self::Median => "median",
}
}
pub fn combine(self, values: &mut [f64]) -> Option<f64> {
if values.is_empty() {
return None;
}
match self {
Self::Mean => Some(values.iter().sum::<f64>() / values.len() as f64),
Self::Median => {
values.sort_unstable_by(f64::total_cmp);
let middle = values.len() / 2;
if values.len() % 2 == 1 {
Some(values[middle])
} else {
Some(f64::midpoint(values[middle - 1], values[middle]))
}
}
}
}
}
impl FromStr for Aggregate {
type Err = SearchKeywordError;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
match raw {
"mean" => Ok(Self::Mean),
"median" => Ok(Self::Median),
_ => Err(SearchKeywordError {
raw: raw.to_owned(),
expected: &["mean", "median"],
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchKeywordError {
pub raw: String,
pub expected: &'static [&'static str],
}
impl fmt::Display for SearchKeywordError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"unknown value '{}', expected {}",
self.raw,
self.expected.join(" or ")
)
}
}
impl std::error::Error for SearchKeywordError {}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SearchReport {
pub ranking: Vec<RankingEntry>,
pub generations: Vec<GenerationSummary>,
pub archive: Vec<ArchiveEntry>,
}
impl SearchReport {
pub fn best(&self) -> Option<&RankingEntry> {
self.ranking.first()
}
pub fn ranking_entry(&self, candidate_id: u64) -> Option<&RankingEntry> {
self.ranking.iter().find(|entry| entry.candidate_id == candidate_id)
}
pub fn archive_entry(&self, cell: PatternCell) -> Option<&ArchiveEntry> {
self.archive
.binary_search_by(|entry| entry.cell.cmp(&cell))
.ok()
.map(|index| &self.archive[index])
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RankingEntry {
pub candidate_id: u64,
pub objective: f64,
pub replicate_count: u64,
pub failed_count: u64,
pub evaluations: u64,
pub first_batch: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct GenerationSummary {
pub generation: u64,
pub best: f64,
pub median: f64,
pub worst: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SearchSpec {
pub algorithm: SearchAlgorithm,
pub max_evaluations: u64,
pub batch_size: usize,
pub objective: Option<Objective>,
pub space: Vec<FactorSpec>,
}
impl SearchSpec {
pub fn check(&self) -> Result<(), SearchSpecError> {
if self.max_evaluations == 0 {
return Err(SearchSpecError::NoEvaluations);
}
if self.batch_size == 0 {
return Err(SearchSpecError::NoBatch);
}
if self.batch_size > MAX_CONFIGS {
return Err(SearchSpecError::BatchTooLarge {
batch_size: self.batch_size,
});
}
match (&self.algorithm, &self.objective) {
(SearchAlgorithm::PatternSpaceExploration(settings), None) => settings.check(),
(SearchAlgorithm::PatternSpaceExploration(_), Some(_)) => Err(SearchSpecError::UnusedObjective),
(algorithm, None) => Err(SearchSpecError::MissingObjective {
algorithm: algorithm.as_str(),
}),
(SearchAlgorithm::Random, Some(_)) => Ok(()),
(SearchAlgorithm::HillClimb(settings), Some(_)) => settings.check(),
(SearchAlgorithm::Genetic(settings), Some(_)) => settings.check(),
}
}
pub fn watched_columns(&self) -> Vec<&str> {
match &self.algorithm {
SearchAlgorithm::PatternSpaceExploration(settings) => {
vec![settings.x_axis.column.as_str(), settings.y_axis.column.as_str()]
}
SearchAlgorithm::Random | SearchAlgorithm::HillClimb(_) | SearchAlgorithm::Genetic(_) => self
.objective
.iter()
.map(|objective| objective.column.as_str())
.collect(),
}
}
pub fn searcher(&self, space: &SearchSpace, root: u64) -> Result<Box<dyn Searcher + Send>, SearchSpecError> {
self.check()?;
let space = space.clone();
let seed = search_seed(root);
let budget = self.max_evaluations;
let objective = || {
self.objective.clone().ok_or(SearchSpecError::MissingObjective {
algorithm: self.algorithm.as_str(),
})
};
Ok(match &self.algorithm {
SearchAlgorithm::Random => Box::new(RandomSearch::new(space, objective()?, budget, seed)),
SearchAlgorithm::HillClimb(settings) => {
Box::new(HillClimb::new(space, *settings, objective()?, budget, seed)?)
}
SearchAlgorithm::Genetic(settings) => {
Box::new(GeneticAlgorithm::new(space, *settings, objective()?, budget, seed)?)
}
SearchAlgorithm::PatternSpaceExploration(settings) => {
Box::new(PatternSpaceExploration::new(space, settings.clone(), budget, seed)?)
}
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum SearchAlgorithm {
Random,
HillClimb(HillClimbSettings),
Genetic(GeneticSettings),
PatternSpaceExploration(PatternSpaceSettings),
}
impl SearchAlgorithm {
pub fn as_str(&self) -> &'static str {
match self {
Self::Random => "random",
Self::HillClimb(_) => "hill_climb",
Self::Genetic(_) => "genetic",
Self::PatternSpaceExploration(_) => "pse",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SearchSpecError {
NoEvaluations,
NoBatch,
BatchTooLarge {
batch_size: usize,
},
MissingObjective {
algorithm: &'static str,
},
UnusedObjective,
Setting {
key: &'static str,
value: String,
expected: String,
},
}
impl fmt::Display for SearchSpecError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoEvaluations => write!(f, "max_evaluations must be at least 1"),
Self::NoBatch => write!(f, "batch_size must be at least 1"),
Self::BatchTooLarge { batch_size } => {
write!(f, "batch_size must be at most {MAX_CONFIGS}, got {batch_size}")
}
Self::MissingObjective { algorithm } => write!(f, "a {algorithm} search needs an objective"),
Self::UnusedObjective => write!(f, "a pse search takes x_axis and y_axis, and no objective"),
Self::Setting { key, value, expected } => write!(f, "{key} must be {expected}, got {value}"),
}
}
}
impl std::error::Error for SearchSpecError {}
fn check_setting(
holds: bool,
key: &'static str,
value: impl fmt::Display,
expected: impl Into<String>,
) -> Result<(), SearchSpecError> {
if holds {
Ok(())
} else {
Err(SearchSpecError::Setting {
key,
value: value.to_string(),
expected: expected.into(),
})
}
}
#[derive(Debug, Clone, PartialEq)]
struct Proposal {
genome: Genome,
replicate_offset: u64,
origin: CandidateOrigin,
}
impl Proposal {
fn first_evaluation(genome: Genome, origin: CandidateOrigin) -> Self {
Self {
genome,
replicate_offset: 0,
origin,
}
}
fn reevaluation(candidate_id: u64, record: &CandidateRecord) -> Self {
Self {
genome: record.genome().clone(),
replicate_offset: record.replicate_count(),
origin: CandidateOrigin::Reevaluation { candidate_id },
}
}
}
const MAX_CONFIG_DRAWS: usize = 16;
#[derive(Debug, Clone, PartialEq)]
enum ConfigDraw {
New(Genome, ConfigKey),
Known { candidate_id: u64 },
Proposed,
}
fn draw_config(
space: &SearchSpace,
config_candidates: &BTreeMap<ConfigKey, u64>,
proposed: &BTreeSet<ConfigKey>,
mut draw: impl FnMut() -> Genome,
) -> ConfigDraw {
let mut first = None;
for _ in 0..MAX_CONFIG_DRAWS {
let genome = draw();
let key = space.config_key(&genome);
let candidate_id = config_candidates.get(&key).copied();
if candidate_id.is_none() && !proposed.contains(&key) {
return ConfigDraw::New(genome, key);
}
first.get_or_insert(candidate_id);
}
match first.flatten() {
Some(candidate_id) => ConfigDraw::Known { candidate_id },
None => ConfigDraw::Proposed,
}
}
#[derive(Debug, Clone, PartialEq)]
struct CandidateTracker {
max_evaluations: u64,
issued: u64,
batches: u64,
pending: BTreeMap<u64, (Candidate, u64)>,
}
impl CandidateTracker {
fn new(max_evaluations: u64) -> Self {
Self {
max_evaluations,
issued: 0,
batches: 0,
pending: BTreeMap::new(),
}
}
fn capacity(&self, max: usize) -> usize {
let remaining = self.max_evaluations.saturating_sub(self.issued);
usize::try_from(remaining).map_or(max, |remaining| remaining.min(max))
}
fn issue(&mut self, proposals: Vec<Proposal>) -> Vec<Candidate> {
if proposals.is_empty() {
return Vec::new();
}
let batch = self.batches;
self.batches += 1;
proposals
.into_iter()
.map(|proposal| {
let candidate = Candidate {
id: self.issued,
genome: proposal.genome,
replicate_offset: proposal.replicate_offset,
origin: proposal.origin,
};
self.issued += 1;
self.pending.insert(candidate.id, (candidate.clone(), batch));
candidate
})
.collect()
}
fn settle(&mut self, candidate_id: u64) -> Option<(Candidate, u64)> {
self.pending.remove(&candidate_id)
}
fn is_waiting(&self) -> bool {
!self.pending.is_empty()
}
fn is_done(&self) -> bool {
self.issued >= self.max_evaluations && self.pending.is_empty()
}
}