Skip to main content

wafrift_evolution/evolution/
engine.rs

1use crate::evolution::fitness::{evolutionary_fitness, update_gene_stats};
2use crate::evolution::{
3    Chromosome, GenePool,
4    population::{baseline_chromosome, random_chromosome},
5};
6use crate::lineage::{BypassCorpus, BypassEntry};
7use crate::search::SearchAlgorithm;
8use crate::types::{
9    Budget, EvolutionError, OracleVerdict, SearchStats, TargetHealthMonitor, load_checkpoint,
10    save_checkpoint,
11};
12use lru::LruCache;
13use rand::{SeedableRng, rngs::StdRng};
14use std::collections::HashMap;
15use std::num::NonZeroUsize;
16use std::path::{Path, PathBuf};
17use std::time::Instant;
18
19/// The evolutionary engine that maintains a population and evolves it.
20#[derive(Debug)]
21pub struct EvolutionEngine {
22    /// Search algorithm implementation.
23    algorithm: Box<dyn SearchAlgorithm>,
24    /// Gene pool for creating/mutating chromosomes.
25    pub gene_pool: GenePool,
26    /// Seeded random number generator.
27    pub rng: StdRng,
28    /// Payload→verdict LRU cache.
29    pub cache: LruCache<String, OracleVerdict>,
30    /// Hard budget limits.
31    pub budget: Budget,
32    /// Candidates currently being evaluated: ID → (Chromosome, sent_at).
33    pub in_flight: HashMap<u64, (Chromosome, Instant)>,
34    /// Search statistics.
35    pub stats: SearchStats,
36    /// Target health monitor.
37    pub target_health: TargetHealthMonitor,
38    /// Optional path for automatic checkpointing.
39    pub checkpoint_path: Option<PathBuf>,
40    /// Total oracle requests issued.
41    pub request_count: usize,
42    /// Per-gene success tracking: `(gene_name, gene_value, successes, attempts)`.
43    pub gene_stats: Vec<(String, String, u32, u32)>,
44    /// Fitness history: average fitness per generation (sliding window).
45    pub fitness_history: Vec<f64>,
46    /// Number of consecutive generations with no improvement.
47    pub stagnation_counter: u32,
48    /// Saved bypass corpus.
49    pub corpus: BypassCorpus,
50    /// Evaluations this generation.
51    generation_evals: usize,
52    /// Next candidate ID.
53    next_id: u64,
54    /// Pending single candidate for legacy sequential API.
55    pending_single: Option<(usize, Chromosome)>,
56}
57
58impl Clone for EvolutionEngine {
59    fn clone(&self) -> Self {
60        // Clone via checkpoint/restore. Both calls panic-on-error
61        // because they only fail when invariants are corrupt — the
62        // earlier silent-fallback path produced a clone with the
63        // wrong algorithm + default state, masking the bug. Loud
64        // panic > silent state corruption.
65        let alg_bytes = self
66            .algorithm
67            .checkpoint()
68            .expect("algorithm checkpoint must succeed for a live engine");
69        let mut restored = Self::with_algorithm(
70            self.algorithm.name(),
71            self.gene_pool.clone(),
72            self.rng.clone(),
73            self.budget,
74        )
75        .expect("re-constructing the same registered algorithm must succeed");
76        restored
77            .algorithm
78            .restore(&alg_bytes)
79            .expect("restoring fresh checkpoint into matching algorithm must succeed");
80        restored.cache = LruCache::new(self.cache.cap());
81        restored.gene_stats = self.gene_stats.clone();
82        restored.fitness_history = self.fitness_history.clone();
83        restored.stagnation_counter = self.stagnation_counter;
84        restored.corpus = self.corpus.clone();
85        restored.request_count = self.request_count;
86        restored.stats = self.stats;
87        restored.next_id = self.next_id;
88        restored.pending_single = None;
89        restored
90    }
91}
92
93impl EvolutionEngine {
94    /// Create a new engine with the given algorithm and population size.
95    #[must_use]
96    pub fn new(population_size: usize) -> Self {
97        Self::new_seeded(population_size, 0)
98    }
99
100    /// Create a new engine with a seeded RNG.
101    /// `population_size` is clamped to the inclusive range `[1, 10_000]`:
102    /// 0 would leave the selection helpers (tournament/roulette) with
103    /// nothing to index — a contract violation that used to panic.
104    /// 10_000 caps memory at construction so a misconfigured caller
105    /// can't OOM the process by passing `usize::MAX`.
106    #[must_use]
107    pub fn new_seeded(population_size: usize, seed: u64) -> Self {
108        let population_size = population_size.clamp(1, 10_000);
109        let gene_pool = GenePool::default_wafrift();
110        let mut rng = StdRng::seed_from_u64(seed);
111        let mut population: Vec<Chromosome> = (0..population_size)
112            .map(|_| random_chromosome(&gene_pool, &mut rng))
113            .collect();
114        if population_size > 0 {
115            population[0] = baseline_chromosome(&gene_pool);
116        }
117
118        let mut engine = Self::with_algorithm("hill_climbing", gene_pool, rng, Budget::default())
119            .expect("hill_climbing is built-in");
120        engine
121            .algorithm
122            .initialize(population, &engine.gene_pool, &mut engine.rng.clone());
123        // Re-initialize with the same RNG to avoid double-use
124        let mut population2: Vec<Chromosome> = (0..population_size)
125            .map(|_| random_chromosome(&engine.gene_pool, &mut engine.rng))
126            .collect();
127        if population_size > 0 {
128            population2[0] = baseline_chromosome(&engine.gene_pool);
129        }
130        engine
131            .algorithm
132            .initialize(population2, &engine.gene_pool, &mut engine.rng);
133        engine
134    }
135
136    /// Create an engine with a specific algorithm by name.
137    pub fn with_algorithm(
138        algorithm_name: &str,
139        gene_pool: GenePool,
140        rng: StdRng,
141        budget: Budget,
142    ) -> Result<Self, EvolutionError> {
143        let algorithm: Box<dyn SearchAlgorithm> = match algorithm_name {
144            "hill_climbing" => Box::new(crate::search::HillClimbing::new()),
145            "simulated_annealing" => Box::new(crate::search::SimulatedAnnealing::new()),
146            "tabu_search" => Box::new(crate::search::TabuSearch::new(20)),
147            "novelty_search" => Box::new(crate::search::NoveltySearch::new(15, 0.3)),
148            "map_elites" => Box::new(crate::search::MapElites::new()),
149            _ => {
150                return Err(EvolutionError::AlgorithmError(format!(
151                    "unknown algorithm: {algorithm_name}"
152                )));
153            }
154        };
155
156        Ok(Self {
157            algorithm,
158            gene_pool,
159            rng,
160            cache: LruCache::new(NonZeroUsize::new(10_000).unwrap()),
161            budget,
162            in_flight: HashMap::new(),
163            stats: SearchStats::new(),
164            target_health: TargetHealthMonitor::new(),
165            checkpoint_path: None,
166            request_count: 0,
167            gene_stats: Vec::new(),
168            fitness_history: Vec::new(),
169            stagnation_counter: 0,
170            corpus: BypassCorpus::new(),
171            generation_evals: 0,
172            next_id: 0,
173            pending_single: None,
174        })
175    }
176
177    fn cache_key(chromosome: &Chromosome) -> String {
178        let mut parts: Vec<_> = chromosome
179            .genes
180            .iter()
181            .map(|(n, v)| format!("{n}={v}"))
182            .collect();
183        parts.sort();
184        parts.join(";")
185    }
186
187    fn next_eval_id(&mut self) -> u64 {
188        self.next_id += 1;
189        self.next_id
190    }
191
192    /// Get the next candidate to try (legacy sequential API).
193    ///
194    /// Returns a synthetic index and a reference to the stored candidate.
195    #[must_use]
196    pub fn next_candidate(&mut self) -> Option<(usize, &Chromosome)> {
197        if self.should_terminate() {
198            return None;
199        }
200        if self.pending_single.is_none() {
201            let batch = self.batch_candidates(1);
202            if batch.is_empty() {
203                return None;
204            }
205            self.pending_single = Some(batch.into_iter().next().unwrap());
206        }
207        self.pending_single
208            .as_ref()
209            .map(|(idx, chrom)| (*idx, chrom))
210    }
211
212    /// Request a batch of up to `n` candidates for parallel evaluation.
213    ///
214    /// Checks cache, budget, and target health before returning candidates.
215    /// `n` is also clamped to the remaining `budget.max_requests` headroom
216    /// so a single batch call can never overshoot the hard request budget
217    /// (the underlying algorithm is free to request whatever it likes
218    /// internally; the engine bounds the request count it actually
219    /// surfaces).
220    pub fn batch_candidates(&mut self, n: usize) -> Vec<(usize, Chromosome)> {
221        if self.should_terminate() || n == 0 {
222            return Vec::new();
223        }
224        let remaining = self
225            .budget
226            .max_requests
227            .saturating_sub(self.request_count);
228        if remaining == 0 {
229            return Vec::new();
230        }
231        let n = n.min(remaining);
232
233        let mut result = Vec::with_capacity(n);
234        let mut cached_results = Vec::new();
235        let requested = self.algorithm.request_evaluations(n, &mut self.rng);
236
237        for candidate in requested {
238            let key = Self::cache_key(&candidate.chromosome);
239            if let Some(verdict) = self.cache.get(&key).copied() {
240                cached_results.push((candidate.id, verdict));
241            } else {
242                let eval_id = self.next_eval_id();
243                self.in_flight
244                    .insert(eval_id, (candidate.chromosome.clone(), Instant::now()));
245                result.push((eval_id as usize, candidate.chromosome));
246            }
247        }
248
249        if !cached_results.is_empty() {
250            self.algorithm.submit_evaluations(cached_results);
251        }
252
253        self.request_count += result.len();
254        result
255    }
256
257    /// Submit a batch of evaluation results.
258    ///
259    /// # Errors
260    ///
261    /// Returns an error if an evaluation ID is not in the in-flight set.
262    pub fn submit_batch(
263        &mut self,
264        results: Vec<(usize, OracleVerdict)>,
265    ) -> Result<(), EvolutionError> {
266        let mut to_submit: Vec<(u64, OracleVerdict)> = Vec::with_capacity(results.len());
267        for (id_usize, verdict) in results {
268            let id = id_usize as u64;
269            let (mut chromosome, _sent_at) = self
270                .in_flight
271                .remove(&id)
272                .ok_or(EvolutionError::InvalidChromosomeIndex(id_usize))?;
273
274            chromosome.record_verdict(&verdict);
275            let key = Self::cache_key(&chromosome);
276            self.cache.put(key, verdict);
277
278            update_gene_stats(&mut self.gene_stats, &chromosome.genes, verdict.passed);
279            let adjusted = evolutionary_fitness(&chromosome, &self.gene_stats);
280            chromosome.fitness = adjusted;
281
282            // Save high-fitness bypasses to corpus
283            if chromosome.fitness >= 0.85
284                && !self
285                    .corpus
286                    .entries
287                    .iter()
288                    .any(|e| e.payload_hash == format!("{:016x}", chromosome.hash()))
289            {
290                self.corpus
291                    .add(BypassEntry::from_chromosome(&chromosome, None));
292            }
293
294            to_submit.push((id, verdict));
295            self.generation_evals += 1;
296            self.stats.evaluations += 1;
297
298            if verdict.passed {
299                self.target_health.record_success();
300            } else if verdict.status_delta >= 500 {
301                self.target_health.record_error();
302            }
303        }
304
305        self.algorithm.submit_evaluations(to_submit);
306        Ok(())
307    }
308
309    /// Record legacy boolean feedback for a candidate.
310    pub fn record_feedback(
311        &mut self,
312        chromosome_index: usize,
313        passed: bool,
314    ) -> Result<(), EvolutionError> {
315        // Clear pending_single if it matches the index
316        if let Some((idx, _)) = self.pending_single
317            && idx == chromosome_index
318        {
319            self.pending_single = None;
320        }
321        self.record_verdict(chromosome_index, &OracleVerdict::from_bool(passed))
322    }
323
324    /// Record rich oracle verdict feedback.
325    pub fn record_verdict(
326        &mut self,
327        chromosome_index: usize,
328        verdict: &OracleVerdict,
329    ) -> Result<(), EvolutionError> {
330        self.submit_batch(vec![(chromosome_index, *verdict)])
331    }
332
333    /// Record target-error feedback.
334    pub fn record_target_error(&mut self, error: String) -> Result<(), EvolutionError> {
335        self.target_health.record_error();
336        if !self.target_health.is_healthy() {
337            return Err(EvolutionError::TargetHealthCritical(error));
338        }
339        Ok(())
340    }
341
342    /// Evolve the population to the next generation.
343    pub fn evolve(&mut self) {
344        if self.algorithm.best().is_none() {
345            return;
346        }
347
348        // Update fitness history with sliding window
349        if let Some(best) = self.algorithm.best() {
350            self.fitness_history.push(best.fitness);
351        }
352        if self.fitness_history.len() > 1000 {
353            self.fitness_history.remove(0);
354        }
355
356        // Detect stagnation
357        let window = 10_usize;
358        if self.fitness_history.len() >= window {
359            let recent = &self.fitness_history[self.fitness_history.len() - window..];
360            let improved = recent.windows(2).any(|w| w[1] > w[0] + 0.001);
361            if !improved {
362                self.stagnation_counter += 1;
363            } else {
364                self.stagnation_counter = 0;
365            }
366        }
367
368        self.stats.generation += 1;
369        self.generation_evals = 0;
370
371        if let Some(ref path) = self.checkpoint_path {
372            let _ = self.save_checkpoint(path);
373        }
374    }
375
376    /// Check if evolution should terminate.
377    #[must_use]
378    pub fn should_terminate(&self) -> bool {
379        if !self.target_health.is_healthy() {
380            return true;
381        }
382        self.algorithm.should_terminate(&self.stats, &self.budget)
383            || self.request_count >= self.budget.max_requests
384            || self.stats.stagnation_counter >= self.budget.stagnation_limit
385    }
386
387    /// Get the best-performing chromosome.
388    #[must_use]
389    pub fn best(&self) -> Option<&Chromosome> {
390        self.algorithm.best()
391    }
392
393    /// Save engine state to disk.
394    pub fn save_checkpoint(&self, path: &Path) -> Result<(), EvolutionError> {
395        let state = EngineState {
396            algorithm_name: self.algorithm.name().to_string(),
397            algorithm_state: self.algorithm.checkpoint()?,
398            gene_pool: self.gene_pool.clone(),
399            rng_seed: 0, // We can't easily extract seed; we rely on algorithm state
400            budget: self.budget,
401            gene_stats: self.gene_stats.clone(),
402            fitness_history: self.fitness_history.clone(),
403            stagnation_counter: self.stagnation_counter,
404            request_count: self.request_count,
405            stats: self.stats,
406            schema_version: 1,
407        };
408        save_checkpoint(path, &state)
409    }
410
411    /// Load engine state from disk.
412    pub fn load_checkpoint(&mut self, path: &Path) -> Result<(), EvolutionError> {
413        let mut state: EngineState = load_checkpoint(path)?;
414        state.stats.fixup_start_time();
415        self.algorithm.restore(&state.algorithm_state)?;
416        self.gene_pool = state.gene_pool;
417        self.budget = state.budget;
418        self.gene_stats = state.gene_stats;
419        self.fitness_history = state.fitness_history;
420        self.stagnation_counter = state.stagnation_counter;
421        self.request_count = state.request_count;
422        self.stats = state.stats;
423        Ok(())
424    }
425
426    /// Get per-gene success rates.
427    #[must_use]
428    pub fn gene_success_rates(&self) -> Vec<(&str, &str, f64)> {
429        crate::evolution::fitness::gene_success_rates(&self.gene_stats)
430    }
431
432    /// Get a human-readable summary.
433    #[must_use]
434    pub fn learned_summary(&self) -> String {
435        crate::evolution::fitness::learned_summary(
436            self.stats.generation,
437            self.algorithm.best(),
438            &self.gene_stats,
439            self.request_count,
440        )
441    }
442
443    /// Compute diversity score using algorithm population if available.
444    #[must_use]
445    pub fn diversity_score(&self) -> f64 {
446        // Fallback: use gene stats diversity heuristic
447        0.5
448    }
449}
450
451/// Serializable engine state.
452#[derive(Debug, Clone, Serialize, Deserialize)]
453pub struct EngineState {
454    pub algorithm_name: String,
455    pub algorithm_state: Vec<u8>,
456    pub gene_pool: GenePool,
457    pub rng_seed: u64,
458    pub budget: Budget,
459    pub gene_stats: Vec<(String, String, u32, u32)>,
460    pub fitness_history: Vec<f64>,
461    pub stagnation_counter: u32,
462    pub request_count: usize,
463    pub stats: SearchStats,
464    pub schema_version: u32,
465}
466
467use serde::{Deserialize, Serialize};