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, VecDeque};
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    pub(crate) 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:
33    ///   engine_eval_id → (algorithm_candidate_id, Chromosome, sent_at).
34    ///
35    /// `algorithm_candidate_id` is the ID the *search algorithm*
36    /// originally minted in `request_evaluations` and the same ID it
37    /// expects to see back in `submit_evaluations`. Population-based
38    /// algorithms (MapElites, NoveltySearch) keep their own private
39    /// `in_flight` keyed by that ID — if we forwarded the engine's
40    /// `eval_id` instead, their lookup misses and the evaluation is
41    /// silently dropped (the grid / archive never gets updated).
42    pub in_flight: HashMap<u64, (u64, Chromosome, Instant)>,
43    /// Search statistics.
44    pub stats: SearchStats,
45    /// Target health monitor.
46    pub target_health: TargetHealthMonitor,
47    /// Optional path for automatic checkpointing.
48    pub checkpoint_path: Option<PathBuf>,
49    /// Total oracle requests issued.
50    pub request_count: usize,
51    /// Per-gene success tracking: `(gene_name, gene_value, successes, attempts)`.
52    pub gene_stats: Vec<(String, String, u32, u32)>,
53    /// Fitness history: average fitness per generation (sliding window).
54    pub fitness_history: VecDeque<f64>,
55    /// Number of consecutive generations with no improvement.
56    pub stagnation_counter: u32,
57    /// Saved bypass corpus.
58    pub corpus: BypassCorpus,
59    /// Evaluations this generation.
60    generation_evals: usize,
61    /// Next candidate ID.
62    next_id: u64,
63    /// Pending single candidate for legacy sequential API.
64    pending_single: Option<(usize, Chromosome)>,
65}
66
67impl Clone for EvolutionEngine {
68    fn clone(&self) -> Self {
69        // Algorithm state is duplicated via the trait's `clone_box`
70        // method, which all in-tree algorithms override with a direct
71        // `Box::new(self.clone())` — no serde_json round-trip.
72        // The previous checkpoint/restore path was 10-100× slower
73        // on populated MapElites grids and was the original "clone
74        // spike on the proxy hot path" blocker (see #113).
75        Self {
76            algorithm: self.algorithm.clone_box(),
77            gene_pool: self.gene_pool.clone(),
78            rng: self.rng.clone(),
79            // The LRU cache deliberately does not survive cloning —
80            // each cloned engine gets a fresh same-capacity cache.
81            // Sharing the cache across clones is what `SharedEngine`
82            // is for (Arc<RwLock<EvolutionEngine>>); deep-cloning the
83            // cache itself would just balloon allocation.
84            cache: LruCache::new(self.cache.cap()),
85            budget: self.budget,
86            // Mid-flight evaluations belong to the caller, not the
87            // clone — drop them.
88            in_flight: HashMap::new(),
89            stats: self.stats,
90            target_health: self.target_health.clone(),
91            checkpoint_path: self.checkpoint_path.clone(),
92            request_count: self.request_count,
93            gene_stats: self.gene_stats.clone(),
94            fitness_history: self.fitness_history.clone(),
95            stagnation_counter: self.stagnation_counter,
96            corpus: self.corpus.clone(),
97            generation_evals: self.generation_evals,
98            next_id: self.next_id,
99            pending_single: None,
100        }
101    }
102}
103
104/// Shared engine pointer — what the proxy and any future
105/// shared-state worker pool should hold.
106///
107/// Use this instead of `Clone` whenever multiple async tasks need
108/// access to the same engine's cache + corpus + gene_stats. Cloning
109/// the `Arc` is O(1); cloning the engine itself is O(grid + archive +
110/// gene_stats) and produces an *independent* engine with a fresh
111/// (empty) cache.
112///
113/// Locking discipline:
114/// - hot read paths (cache hits, diversity_score, best()) → `read()`
115/// - mutation paths (submit_evaluations, gene_stats updates,
116///   checkpoint persistence) → `write()`
117/// - never hold the write lock across an `await` that performs network
118///   I/O — drop it before the await, re-acquire after
119pub type SharedEngine = std::sync::Arc<tokio::sync::RwLock<EvolutionEngine>>;
120
121impl EvolutionEngine {
122    /// Move this engine behind the canonical [`SharedEngine`] pointer.
123    ///
124    /// Equivalent to `Arc::new(RwLock::new(self))` — exists so the
125    /// shared-access pattern is discoverable on the type itself
126    /// rather than buried in module-level docs.
127    #[must_use]
128    pub fn into_shared(self) -> SharedEngine {
129        std::sync::Arc::new(tokio::sync::RwLock::new(self))
130    }
131}
132
133impl EvolutionEngine {
134    /// Create a new engine with the given algorithm and population size.
135    #[must_use]
136    pub fn new(population_size: usize) -> Self {
137        Self::new_seeded(population_size, 0)
138    }
139
140    /// Create a new engine with a seeded RNG.
141    /// `population_size` is clamped to the inclusive range `[1, 10_000]`:
142    /// 0 would leave the selection helpers (tournament/roulette) with
143    /// nothing to index — a contract violation that used to panic.
144    /// 10_000 caps memory at construction so a misconfigured caller
145    /// can't OOM the process by passing `usize::MAX`.
146    #[must_use]
147    pub fn new_seeded(population_size: usize, seed: u64) -> Self {
148        let population_size = population_size.clamp(1, 10_000);
149        let gene_pool = GenePool::default_wafrift();
150        let mut rng = StdRng::seed_from_u64(seed);
151        let mut population: Vec<Chromosome> = (0..population_size)
152            .map(|_| random_chromosome(&gene_pool, &mut rng))
153            .collect();
154        if population_size > 0 {
155            population[0] = baseline_chromosome(&gene_pool);
156        }
157
158        let mut engine = Self::with_algorithm("hill_climbing", gene_pool, rng, Budget::default())
159            .expect("hill_climbing is built-in");
160        engine
161            .algorithm
162            .initialize(population, &engine.gene_pool, &mut engine.rng.clone());
163        // Re-initialize with the same RNG to avoid double-use
164        let mut population2: Vec<Chromosome> = (0..population_size)
165            .map(|_| random_chromosome(&engine.gene_pool, &mut engine.rng))
166            .collect();
167        if population_size > 0 {
168            population2[0] = baseline_chromosome(&engine.gene_pool);
169        }
170        engine
171            .algorithm
172            .initialize(population2, &engine.gene_pool, &mut engine.rng);
173        engine
174    }
175
176    /// Create an engine with a specific algorithm by name.
177    pub fn with_algorithm(
178        algorithm_name: &str,
179        gene_pool: GenePool,
180        rng: StdRng,
181        budget: Budget,
182    ) -> Result<Self, EvolutionError> {
183        let algorithm: Box<dyn SearchAlgorithm> = match algorithm_name {
184            "hill_climbing" => Box::new(crate::search::HillClimbing::new()),
185            "simulated_annealing" => Box::new(crate::search::SimulatedAnnealing::new()),
186            "tabu_search" => Box::new(crate::search::TabuSearch::new(20)),
187            "novelty_search" => Box::new(crate::search::NoveltySearch::new(15, 0.3)),
188            "map_elites" => Box::new(crate::search::MapElites::new()),
189            _ => {
190                return Err(EvolutionError::AlgorithmError(format!(
191                    "unknown algorithm: {algorithm_name}"
192                )));
193            }
194        };
195
196        Ok(Self {
197            algorithm,
198            gene_pool,
199            rng,
200            cache: LruCache::new(NonZeroUsize::new(10_000).expect("10_000 is non-zero")),
201            budget,
202            in_flight: HashMap::new(),
203            stats: SearchStats::new(),
204            target_health: TargetHealthMonitor::new(),
205            checkpoint_path: None,
206            request_count: 0,
207            gene_stats: Vec::new(),
208            fitness_history: VecDeque::new(),
209            stagnation_counter: 0,
210            corpus: BypassCorpus::new(),
211            generation_evals: 0,
212            next_id: 0,
213            pending_single: None,
214        })
215    }
216
217    fn cache_key(chromosome: &Chromosome) -> String {
218        let mut parts: Vec<_> = chromosome
219            .genes
220            .iter()
221            .map(|(n, v)| format!("{n}={v}"))
222            .collect();
223        parts.sort();
224        parts.join(";")
225    }
226
227    /// Read-only view of the engine's next eval-id counter.
228    /// Exposed so checkpoint round-trip tests can verify the counter
229    /// is preserved across save/load. The field itself stays private
230    /// so external callers can't desync it.
231    #[must_use]
232    pub fn next_id(&self) -> u64 {
233        self.next_id
234    }
235
236    fn next_eval_id(&mut self) -> u64 {
237        self.next_id += 1;
238        self.next_id
239    }
240
241    /// Get the next candidate to try (legacy sequential API).
242    ///
243    /// Returns a synthetic index and a reference to the stored candidate.
244    #[must_use]
245    pub fn next_candidate(&mut self) -> Option<(usize, &Chromosome)> {
246        if self.should_terminate() {
247            return None;
248        }
249        if self.pending_single.is_none() {
250            self.pending_single = self.batch_candidates(1).into_iter().next();
251        }
252        self.pending_single
253            .as_ref()
254            .map(|(idx, chrom)| (*idx, chrom))
255    }
256
257    /// Request a batch of up to `n` candidates for parallel evaluation.
258    ///
259    /// Checks cache, budget, and target health before returning candidates.
260    /// `n` is also clamped to the remaining `budget.max_requests` headroom
261    /// so a single batch call can never overshoot the hard request budget
262    /// (the underlying algorithm is free to request whatever it likes
263    /// internally; the engine bounds the request count it actually
264    /// surfaces).
265    pub fn batch_candidates(&mut self, n: usize) -> Vec<(usize, Chromosome)> {
266        if self.should_terminate() || n == 0 {
267            return Vec::new();
268        }
269        let remaining = self.budget.max_requests.saturating_sub(self.request_count);
270        if remaining == 0 {
271            return Vec::new();
272        }
273        let n = n.min(remaining);
274
275        let mut result = Vec::with_capacity(n);
276        let mut cached_results = Vec::new();
277        let requested = self.algorithm.request_evaluations(n, &mut self.rng);
278
279        for candidate in requested {
280            let key = Self::cache_key(&candidate.chromosome);
281            if let Some(verdict) = self.cache.get(&key).copied() {
282                cached_results.push((candidate.id, verdict));
283            } else {
284                let eval_id = self.next_eval_id();
285                // Pair the engine's eval_id (handed to the caller and
286                // used as the in_flight key) with the algorithm's
287                // own candidate.id (used to look up its private
288                // in_flight on submit). See the in_flight field doc.
289                self.in_flight.insert(
290                    eval_id,
291                    (candidate.id, candidate.chromosome.clone(), Instant::now()),
292                );
293                result.push((eval_id as usize, candidate.chromosome));
294            }
295        }
296
297        if !cached_results.is_empty() {
298            self.algorithm.submit_evaluations(cached_results);
299        }
300
301        self.request_count = self.request_count.saturating_add(result.len());
302        result
303    }
304
305    /// Submit a batch of evaluation results.
306    ///
307    /// # Errors
308    ///
309    /// Returns an error if an evaluation ID is not in the in-flight set.
310    pub fn submit_batch(
311        &mut self,
312        results: Vec<(usize, OracleVerdict)>,
313    ) -> Result<(), EvolutionError> {
314        let mut to_submit: Vec<(u64, OracleVerdict)> = Vec::with_capacity(results.len());
315        for (id_usize, verdict) in results {
316            let id = id_usize as u64;
317            let (algorithm_candidate_id, mut chromosome, _sent_at) = self
318                .in_flight
319                .remove(&id)
320                .ok_or(EvolutionError::InvalidChromosomeIndex(id_usize))?;
321
322            chromosome.record_verdict(&verdict);
323            let key = Self::cache_key(&chromosome);
324            self.cache.put(key, verdict);
325
326            update_gene_stats(&mut self.gene_stats, &chromosome.genes, verdict.passed);
327            let adjusted = evolutionary_fitness(&chromosome, &self.gene_stats);
328            chromosome.fitness = adjusted;
329
330            // Save high-fitness bypasses to corpus
331            let hash_str = format!("{:016x}", chromosome.hash());
332            if chromosome.fitness >= 0.85
333                && !self
334                    .corpus
335                    .entries
336                    .iter()
337                    .any(|e| e.payload_hash == hash_str)
338            {
339                self.corpus
340                    .add(BypassEntry::from_chromosome(&chromosome, None));
341            }
342
343            // Forward the *algorithm's* candidate ID, not the engine's
344            // eval_id — population-based algorithms key their own
345            // in_flight by it (see in_flight doc).
346            to_submit.push((algorithm_candidate_id, verdict));
347            self.generation_evals += 1;
348            self.stats.evaluations += 1;
349
350            if verdict.passed {
351                self.target_health.record_success();
352            } else if verdict.status_delta >= 500 {
353                self.target_health.record_error();
354            }
355        }
356
357        self.algorithm.submit_evaluations(to_submit);
358        Ok(())
359    }
360
361    /// Record legacy boolean feedback for a candidate.
362    pub fn record_feedback(
363        &mut self,
364        chromosome_index: usize,
365        passed: bool,
366    ) -> Result<(), EvolutionError> {
367        // Clear pending_single if it matches the index
368        if let Some((idx, _)) = self.pending_single
369            && idx == chromosome_index
370        {
371            self.pending_single = None;
372        }
373        self.record_verdict(chromosome_index, &OracleVerdict::from_bool(passed))
374    }
375
376    /// Record rich oracle verdict feedback.
377    pub fn record_verdict(
378        &mut self,
379        chromosome_index: usize,
380        verdict: &OracleVerdict,
381    ) -> Result<(), EvolutionError> {
382        self.submit_batch(vec![(chromosome_index, *verdict)])
383    }
384
385    /// Record target-error feedback.
386    pub fn record_target_error(&mut self, error: String) -> Result<(), EvolutionError> {
387        self.target_health.record_error();
388        if !self.target_health.is_healthy() {
389            return Err(EvolutionError::TargetHealthCritical(error));
390        }
391        Ok(())
392    }
393
394    /// Evolve the population to the next generation.
395    pub fn evolve(&mut self) {
396        if self.algorithm.best().is_none() {
397            return;
398        }
399
400        // Update fitness history with sliding window
401        if let Some(best) = self.algorithm.best() {
402            self.fitness_history.push_back(best.fitness);
403        }
404        if self.fitness_history.len() > 1000 {
405            self.fitness_history.pop_front();
406        }
407
408        // Detect stagnation
409        let window = 10_usize;
410        if self.fitness_history.len() >= window {
411            let skip = self.fitness_history.len().saturating_sub(window);
412            let recent: Vec<f64> = self.fitness_history.iter().skip(skip).copied().collect();
413            let improved = recent.windows(2).any(|w| w[1] > w[0] + 0.001);
414            if !improved {
415                self.stagnation_counter += 1;
416            } else {
417                self.stagnation_counter = 0;
418            }
419        }
420        // Mirror into stats so should_terminate() (which reads
421        // self.stats.stagnation_counter, not self.stagnation_counter)
422        // and the search algorithms' own should_terminate() impls see
423        // the same value. Without this sync the stagnation_limit
424        // budget would be silently ignored.
425        self.stats.stagnation_counter = self.stagnation_counter;
426
427        self.stats.generation += 1;
428        self.generation_evals = 0;
429
430        if let Some(ref path) = self.checkpoint_path
431            && let Err(e) = self.save_checkpoint(path)
432        {
433            tracing::warn!(error = %e, path = %path.display(), "checkpoint save failed");
434        }
435    }
436
437    /// Check if evolution should terminate.
438    #[must_use]
439    pub fn should_terminate(&self) -> bool {
440        if !self.target_health.is_healthy() {
441            return true;
442        }
443        self.algorithm.should_terminate(&self.stats, &self.budget)
444            || self.request_count >= self.budget.max_requests
445            || self.stats.stagnation_counter >= self.budget.stagnation_limit
446    }
447
448    /// Get the best-performing chromosome.
449    #[must_use]
450    pub fn best(&self) -> Option<&Chromosome> {
451        self.algorithm.best()
452    }
453
454    /// Save engine state to disk.
455    pub fn save_checkpoint(&self, path: &Path) -> Result<(), EvolutionError> {
456        let state = EngineState {
457            algorithm_name: self.algorithm.name().to_string(),
458            algorithm_state: self.algorithm.checkpoint()?,
459            gene_pool: self.gene_pool.clone(),
460            // The engine-level rng is not serializable; the algorithm
461            // captures its own rng state inside algorithm_state. Any
462            // engine-side draws after a restore will diverge from
463            // pre-crash, but the algorithm's exploration sequence is
464            // preserved.
465            rng_seed: 0,
466            budget: self.budget,
467            gene_stats: self.gene_stats.clone(),
468            fitness_history: self.fitness_history.clone(),
469            stagnation_counter: self.stagnation_counter,
470            request_count: self.request_count,
471            stats: self.stats,
472            schema_version: 2,
473            corpus: self.corpus.clone(),
474            next_id: self.next_id,
475            generation_evals: self.generation_evals,
476        };
477        save_checkpoint(path, &state)
478    }
479
480    /// Load engine state from disk.
481    pub fn load_checkpoint(&mut self, path: &Path) -> Result<(), EvolutionError> {
482        let mut state: EngineState = load_checkpoint(path)?;
483        state.stats.fixup_start_time();
484        self.algorithm.restore(&state.algorithm_state)?;
485        self.gene_pool = state.gene_pool;
486        self.budget = state.budget;
487        self.gene_stats = state.gene_stats;
488        self.fitness_history = state.fitness_history;
489        self.stagnation_counter = state.stagnation_counter;
490        self.request_count = state.request_count;
491        self.stats = state.stats;
492        // v2 fields — `#[serde(default)]` on EngineState means a v1
493        // checkpoint loads cleanly with empty corpus / next_id=0.
494        self.corpus = state.corpus;
495        self.next_id = state.next_id;
496        self.generation_evals = state.generation_evals;
497        Ok(())
498    }
499
500    /// Get per-gene success rates.
501    #[must_use]
502    pub fn gene_success_rates(&self) -> Vec<(&str, &str, f64)> {
503        crate::evolution::fitness::gene_success_rates(&self.gene_stats)
504    }
505
506    /// Get a human-readable summary.
507    #[must_use]
508    pub fn learned_summary(&self) -> String {
509        crate::evolution::fitness::learned_summary(
510            self.stats.generation,
511            self.algorithm.best(),
512            &self.gene_stats,
513            self.request_count,
514        )
515    }
516
517    /// Seed the underlying algorithm with an explicit population —
518    /// the public path callers use to warm-start search from a known
519    /// good corpus (or to inject a synthetic population from tests).
520    pub fn seed_population(&mut self, population: Vec<Chromosome>) {
521        let mut rng = self.rng.clone();
522        self.algorithm
523            .initialize(population, &self.gene_pool, &mut rng);
524    }
525
526    /// Snapshot the algorithm's live population (test/diagnostic
527    /// surface). Population-based algorithms return their full pool;
528    /// single-state algorithms return the singleton current/best.
529    #[must_use]
530    pub fn population_snapshot(&self) -> Vec<Chromosome> {
531        self.algorithm.population_snapshot()
532    }
533
534    /// Population diversity in `[0.0, 1.0]` — drives adaptive mutation
535    /// pressure (see `crossover::diversity::adaptive_mutation_rate`).
536    ///
537    /// Strategy:
538    /// 1. Snapshot the algorithm's live population and union it with
539    ///    the engine's `in_flight` candidates.
540    /// 2. If `len() >= 2`, return mean pairwise gene-mismatch ratio
541    ///    via `crossover::diversity::diversity_score`.
542    /// 3. Otherwise (single-state algorithm with nothing in-flight),
543    ///    fall back to gene-pool exploration entropy from
544    ///    [`Self::gene_stats_diversity`] — measures how broadly the
545    ///    engine has *explored* the gene space rather than how varied
546    ///    the *current* population is. With no exploration history
547    ///    either, return 1.0 (max-safe default — keeps mutation
548    ///    pressure conservative on a fresh engine).
549    #[must_use]
550    pub fn diversity_score(&self) -> f64 {
551        let mut population = self.algorithm.population_snapshot();
552        for (_, chromosome, _) in self.in_flight.values() {
553            population.push(chromosome.clone());
554        }
555        if population.len() >= 2 {
556            return crate::evolution::crossover::diversity::diversity_score(&population);
557        }
558        let gene_div = self.gene_stats_diversity();
559        if gene_div > 0.0 { gene_div } else { 1.0 }
560    }
561
562    /// Shannon-entropy style diversity over the engine's per-gene
563    /// exploration history.
564    ///
565    /// For each unique gene name in `gene_stats`, computes the
566    /// normalised entropy of its value distribution weighted by
567    /// `attempts`. The per-gene entropies are averaged. Range
568    /// `[0.0, 1.0]`: 0.0 means we tried only one value for every
569    /// gene (no exploration), 1.0 means a uniform distribution
570    /// across the maximum-cardinality gene's value space.
571    ///
572    /// Useful as a fallback signal when the active search algorithm
573    /// is single-state (e.g. simulated annealing) and the population
574    /// snapshot is too small to give meaningful pairwise distance.
575    #[must_use]
576    pub fn gene_stats_diversity(&self) -> f64 {
577        if self.gene_stats.is_empty() {
578            return 0.0;
579        }
580        // Bucket per-gene attempt counts.
581        let mut by_gene: HashMap<&str, Vec<u32>> = HashMap::new();
582        for (name, _value, _successes, attempts) in &self.gene_stats {
583            if *attempts == 0 {
584                continue;
585            }
586            by_gene.entry(name.as_str()).or_default().push(*attempts);
587        }
588        if by_gene.is_empty() {
589            return 0.0;
590        }
591        let mut entropy_sum = 0.0_f64;
592        let mut counted = 0_usize;
593        for attempts in by_gene.values() {
594            let total: u64 = attempts.iter().map(|a| u64::from(*a)).sum();
595            if total == 0 || attempts.len() < 2 {
596                // Single value tried — zero entropy contribution. Still
597                // counted so the per-gene mean isn't biased by skipping.
598                counted += 1;
599                continue;
600            }
601            #[allow(clippy::cast_precision_loss)]
602            let total_f = total as f64;
603            let mut h = 0.0_f64;
604            for a in attempts {
605                #[allow(clippy::cast_precision_loss)]
606                let p = f64::from(*a) / total_f;
607                if p > 0.0 {
608                    h -= p * p.log2();
609                }
610            }
611            // Normalise by max entropy log2(k) where k is the number of
612            // distinct values tried for this gene. Falls in `[0, 1]`.
613            #[allow(clippy::cast_precision_loss)]
614            let h_max = (attempts.len() as f64).log2();
615            let normalised = if h_max > 0.0 { h / h_max } else { 0.0 };
616            entropy_sum += normalised;
617            counted += 1;
618        }
619        if counted == 0 {
620            0.0
621        } else {
622            #[allow(clippy::cast_precision_loss)]
623            let avg = entropy_sum / counted as f64;
624            avg.clamp(0.0, 1.0)
625        }
626    }
627}
628
629/// Serializable engine state.
630///
631/// Schema version 2 (2026-05-10) adds `corpus`, `next_id`, and
632/// `generation_evals` so a restored engine doesn't lose all of its
633/// bypass discoveries and doesn't reset its eval-id counter (which
634/// would collide with any in-flight evaluation that survived the
635/// crash).
636///
637/// What is intentionally NOT serialized:
638///   - `in_flight`: by definition transient; any pending eval at
639///     checkpoint time is lost on crash, but the corpus capture
640///     above means the *useful* bypasses are preserved.
641///   - `cache`: LRU cache of payload→verdict; recomputable.
642///   - `target_health`: runtime stats; resets on resume.
643///   - `checkpoint_path`: re-injected by the caller after load.
644///   - `pending_single`: legacy sequential API state, transient.
645///   - RNG state: search algorithms each capture their own RNG
646///     state inside `algorithm_state`; the engine-level rng is
647///     used only for next_eval_id minting and gene-pool sampling
648///     when the algorithm doesn't override.
649#[derive(Debug, Clone, Serialize, Deserialize)]
650pub struct EngineState {
651    pub algorithm_name: String,
652    pub algorithm_state: Vec<u8>,
653    pub gene_pool: GenePool,
654    pub rng_seed: u64,
655    pub budget: Budget,
656    pub gene_stats: Vec<(String, String, u32, u32)>,
657    pub fitness_history: VecDeque<f64>,
658    pub stagnation_counter: u32,
659    pub request_count: usize,
660    pub stats: SearchStats,
661    pub schema_version: u32,
662    /// Saved bypass discoveries — added in schema_version 2.
663    /// Defaults to empty for v1 checkpoints loaded by a v2 engine.
664    #[serde(default)]
665    pub corpus: BypassCorpus,
666    /// Next eval_id to mint — added in schema_version 2 so a
667    /// restored engine doesn't recycle IDs that may collide with
668    /// any in-flight evaluation that survived the crash.
669    #[serde(default)]
670    pub next_id: u64,
671    /// Evaluations issued in the current generation — added in v2.
672    #[serde(default)]
673    pub generation_evals: usize,
674}
675
676use serde::{Deserialize, Serialize};