use crate::pipeline::types::{
BreedName, BreedNodeConfig, BreedPipeline, PipelineBoundedStatus, PipelineSearchConfig,
PipelineSearchResult,
};
pub trait FitnessEvaluator: Send + Sync {
fn evaluate(&self, breeds: &[BreedName]) -> f64;
}
pub struct Prng {
state: u64,
}
impl std::fmt::Debug for Prng {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Prng").finish()
}
}
impl Prng {
pub fn new(seed: u64) -> Self {
let mixed = seed ^ 0xcafef00d_deadbeef;
Self {
state: if mixed == 0 {
0x9e3779b97f4a7c15
} else {
mixed
},
}
}
pub fn next_u64(&mut self) -> u64 {
self.state ^= self.state << 13;
self.state ^= self.state >> 7;
self.state ^= self.state << 17;
self.state
}
pub fn next_f64(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
pub fn next_usize(&mut self, n: usize) -> usize {
(self.next_u64() % n as u64) as usize
}
}
pub struct PipelineSearch<'a> {
pub config: PipelineSearchConfig,
pub breed_pool: &'a [&'a str],
pub evaluator: Box<dyn FitnessEvaluator>,
pub rng: Prng,
pub tournament_size: usize,
pub crossover_rate: f64,
}
impl<'a> std::fmt::Debug for PipelineSearch<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PipelineSearch")
.field("config", &self.config)
.field("tournament_size", &self.tournament_size)
.field("crossover_rate", &self.crossover_rate)
.finish()
}
}
impl<'a> PipelineSearch<'a> {
pub fn new(
config: PipelineSearchConfig,
breed_pool: &'a [&'a str],
evaluator: Box<dyn FitnessEvaluator>,
seed: u64,
) -> Self {
Self {
config,
breed_pool,
evaluator,
rng: Prng::new(seed),
tournament_size: 3,
crossover_rate: 0.7,
}
}
fn random_pipeline(&mut self, len: usize) -> BreedPipeline {
let nodes: Vec<BreedNodeConfig> = (0..len)
.map(|_| {
let idx = self.rng.next_usize(self.breed_pool.len());
BreedNodeConfig {
breed: self.breed_pool[idx].to_string(),
params: serde_json::Value::Null,
}
})
.collect();
BreedPipeline {
id: format!("pipe-{:016x}", self.rng.next_u64()),
nodes,
}
}
fn init_population(&mut self) -> Vec<(BreedPipeline, f64)> {
let range = self.config.max_pipeline_length - self.config.min_pipeline_length + 1;
(0..self.config.population_size)
.map(|_| {
let len = self.rng.next_usize(range) + self.config.min_pipeline_length;
let pipe = self.random_pipeline(len);
(pipe, 0.0)
})
.collect()
}
fn evaluate_all(&self, population: &mut [(BreedPipeline, f64)]) -> usize {
let mut count = 0;
for (pipe, fitness) in population.iter_mut() {
let breeds: Vec<BreedName> = pipe.nodes.iter().map(|n| n.breed.clone()).collect();
*fitness = self.evaluator.evaluate(&breeds);
count += 1;
}
count
}
fn tournament_select(&mut self, population: &[(BreedPipeline, f64)]) -> usize {
let k = self.tournament_size.min(population.len());
let mut best = self.rng.next_usize(population.len());
for _ in 1..k {
let idx = self.rng.next_usize(population.len());
if population[idx].1 > population[best].1 {
best = idx;
}
}
best
}
fn crossover(&mut self, a: &BreedPipeline, b: &BreedPipeline) -> BreedPipeline {
if self.rng.next_f64() > self.crossover_rate || a.nodes.is_empty() || b.nodes.is_empty() {
return a.clone();
}
let split_a = self.rng.next_usize(a.nodes.len());
let split_b = self.rng.next_usize(b.nodes.len());
let mut nodes: Vec<BreedNodeConfig> = a.nodes[..split_a].to_vec();
nodes.extend_from_slice(&b.nodes[split_b..]);
nodes.truncate(self.config.max_pipeline_length);
if nodes.len() < self.config.min_pipeline_length {
let idx = self.rng.next_usize(self.breed_pool.len());
nodes.push(BreedNodeConfig {
breed: self.breed_pool[idx].to_string(),
params: serde_json::Value::Null,
});
}
BreedPipeline {
id: format!("pipe-xo-{:016x}", self.rng.next_u64()),
nodes,
}
}
fn mutate(&mut self, pipeline: &mut BreedPipeline) {
if pipeline.nodes.is_empty() || self.rng.next_f64() > self.config.mutation_rate {
return;
}
let node_idx = self.rng.next_usize(pipeline.nodes.len());
let new_idx = self.rng.next_usize(self.breed_pool.len());
pipeline.nodes[node_idx].breed = self.breed_pool[new_idx].to_string();
pipeline.id = format!("pipe-mut-{:08x}", self.rng.next_u64() as u32);
}
pub fn run(&mut self) -> PipelineSearchResult {
if self.breed_pool.is_empty() {
return PipelineSearchResult {
status: PipelineBoundedStatus::Refused,
best_pipeline: None,
best_fitness: 0.0,
generations_run: 0,
evaluations: 0,
summary: "breed pool empty; search refused".to_string(),
};
}
let mut population = self.init_population();
let mut total_evals = self.evaluate_all(&mut population);
population.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let mut best_fitness = population.first().map_or(0.0, |(_, f)| *f);
let mut best_pipeline = population.first().map(|(p, _)| p.clone());
let mut gens_run = 0;
for _ in 0..self.config.generations {
gens_run += 1;
if best_fitness >= self.config.admission_threshold {
break;
}
let mut next_gen: Vec<(BreedPipeline, f64)> =
Vec::with_capacity(self.config.population_size);
for (elite, fit) in population.iter().take(2) {
next_gen.push((elite.clone(), *fit));
}
while next_gen.len() < self.config.population_size {
let a_idx = self.tournament_select(&population);
let b_idx = self.tournament_select(&population);
let mut child = self.crossover(&population[a_idx].0, &population[b_idx].0);
self.mutate(&mut child);
next_gen.push((child, 0.0));
}
let mut new_evals = 0;
for (pipe, fitness) in next_gen.iter_mut() {
if *fitness == 0.0 {
let breeds: Vec<BreedName> =
pipe.nodes.iter().map(|n| n.breed.clone()).collect();
*fitness = self.evaluator.evaluate(&breeds);
new_evals += 1;
}
}
total_evals += new_evals;
next_gen.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
if let Some((candidate, f)) = next_gen.first() {
if *f > best_fitness {
best_fitness = *f;
best_pipeline = Some(candidate.clone());
}
}
population = next_gen;
}
let status = if best_fitness >= self.config.admission_threshold {
PipelineBoundedStatus::Admitted
} else {
PipelineBoundedStatus::Partial
};
PipelineSearchResult {
status,
best_pipeline,
best_fitness,
generations_run: gens_run,
evaluations: total_evals,
summary: format!(
"best_fitness={:.3} gens={} evals={}",
best_fitness, gens_run, total_evals
),
}
}
}
#[derive(Debug)]
pub struct DiversityFitnessEvaluator;
impl FitnessEvaluator for DiversityFitnessEvaluator {
fn evaluate(&self, breeds: &[BreedName]) -> f64 {
if breeds.is_empty() {
return 0.0;
}
let unique: std::collections::HashSet<&BreedName> = breeds.iter().collect();
let diversity = unique.len() as f64 / breeds.len() as f64;
let length_score = match breeds.len() {
2..=4 => 1.0,
1 => 0.5,
n if n > 4 => 4.0 / n as f64,
_ => 0.0,
};
(diversity * 0.7 + length_score * 0.3).min(1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline::types::PipelineSearchConfig;
const TEST_BREEDS: &[&str] = &[
"cbr",
"asp",
"bayesian_network",
"ltl_monitor",
"frame",
"production_rules",
];
#[test]
fn search_produces_nonempty_result() {
let mut search = PipelineSearch::new(
PipelineSearchConfig {
population_size: 5,
generations: 3,
..Default::default()
},
TEST_BREEDS,
Box::new(DiversityFitnessEvaluator),
42,
);
let result = search.run();
assert!(
result.best_pipeline.is_some(),
"search must produce a best pipeline"
);
assert!(
result.evaluations > 0,
"must have evaluated at least one pipeline"
);
assert!(
result.generations_run > 0,
"must have run at least one generation"
);
}
#[test]
fn search_early_stops_on_admission() {
let mut search = PipelineSearch::new(
PipelineSearchConfig {
population_size: 10,
generations: 100,
admission_threshold: 0.5,
..Default::default()
},
TEST_BREEDS,
Box::new(DiversityFitnessEvaluator),
99,
);
let result = search.run();
assert!(result.best_pipeline.is_some());
assert!(
result.generations_run < 100,
"expected early stop, but ran {} generations",
result.generations_run
);
}
#[test]
fn mutation_resets_pipeline_id() {
let mut search = PipelineSearch::new(
PipelineSearchConfig::default(),
TEST_BREEDS,
Box::new(DiversityFitnessEvaluator),
0,
);
let mut pipe = search.random_pipeline(2);
search.config.mutation_rate = 1.0;
search.mutate(&mut pipe);
assert!(pipe.id.starts_with("pipe-mut-"));
}
#[test]
fn prng_is_deterministic() {
let mut r1 = Prng::new(42);
let mut r2 = Prng::new(42);
for _ in 0..20 {
assert_eq!(r1.next_u64(), r2.next_u64());
}
}
#[test]
fn prng_seed_colliding_with_mix_constant_does_not_freeze() {
let mut r = Prng::new(0xcafef00d_deadbeef);
let first = r.next_u64();
assert_ne!(first, 0, "stream must not be frozen at zero");
let mut distinct = std::collections::HashSet::new();
for _ in 0..16 {
distinct.insert(r.next_u64());
}
assert!(distinct.len() > 1, "stream must vary, not repeat one value");
}
#[test]
fn empty_breed_pool_refused() {
let mut search = PipelineSearch::new(
PipelineSearchConfig::default(),
&[],
Box::new(DiversityFitnessEvaluator),
0,
);
let result = search.run();
assert_eq!(result.status, PipelineBoundedStatus::Refused);
assert!(result.best_pipeline.is_none());
}
#[test]
fn full_result_including_id_is_reproducible() {
let run = || {
let mut s = PipelineSearch::new(
PipelineSearchConfig {
population_size: 8,
generations: 5,
..Default::default()
},
TEST_BREEDS,
Box::new(DiversityFitnessEvaluator),
7,
);
serde_json::to_string(&s.run()).unwrap()
};
assert_eq!(
run(),
run(),
"fixed-seed search must reproduce its full result, id included"
);
}
}