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    /// Drop entries from the in-flight map that have been outstanding
306    /// longer than `max_age`. The proxy / scan loop should call this
307    /// periodically (or when a Worker pool is reaped) so a dropped
308    /// evaluation doesn't permanently consume a budget slot.
309    ///
310    /// Audit (2026-05-10): pre-fix in_flight grew without any TTL —
311    /// every dropped eval permanently consumed a `max_requests` slot,
312    /// so a long scan with even moderate eval-loss would terminate
313    /// prematurely with budget exhausted while the in-flight map
314    /// silently accumulated. Returns the number of pruned entries.
315    pub fn prune_stale_in_flight(&mut self, max_age: std::time::Duration) -> usize {
316        let now = Instant::now();
317        let before = self.in_flight.len();
318        self.in_flight
319            .retain(|_, (_, _, sent_at)| now.duration_since(*sent_at) <= max_age);
320        let pruned = before - self.in_flight.len();
321        // Repay the budget for stale entries: they never returned a
322        // verdict so they shouldn't count against max_requests.
323        self.request_count = self.request_count.saturating_sub(pruned);
324        pruned
325    }
326
327    /// Submit a batch of evaluation results.
328    ///
329    /// # Errors
330    ///
331    /// Returns an error if an evaluation ID is not in the in-flight set.
332    pub fn submit_batch(
333        &mut self,
334        results: Vec<(usize, OracleVerdict)>,
335    ) -> Result<(), EvolutionError> {
336        let mut to_submit: Vec<(u64, OracleVerdict)> = Vec::with_capacity(results.len());
337        for (id_usize, verdict) in results {
338            let id = id_usize as u64;
339            let (algorithm_candidate_id, mut chromosome, _sent_at) = self
340                .in_flight
341                .remove(&id)
342                .ok_or(EvolutionError::InvalidChromosomeIndex(id_usize))?;
343
344            chromosome.record_verdict(&verdict);
345            let key = Self::cache_key(&chromosome);
346            self.cache.put(key, verdict);
347
348            update_gene_stats(&mut self.gene_stats, &chromosome.genes, verdict.passed);
349            let adjusted = evolutionary_fitness(&chromosome, &self.gene_stats);
350            chromosome.fitness = adjusted;
351
352            // Save high-fitness bypasses to corpus
353            let hash_str = format!("{:016x}", chromosome.hash());
354            if chromosome.fitness >= 0.85
355                && !self
356                    .corpus
357                    .entries
358                    .iter()
359                    .any(|e| e.payload_hash == hash_str)
360            {
361                self.corpus
362                    .add(BypassEntry::from_chromosome(&chromosome, None));
363            }
364
365            // Forward the *algorithm's* candidate ID, not the engine's
366            // eval_id — population-based algorithms key their own
367            // in_flight by it (see in_flight doc).
368            to_submit.push((algorithm_candidate_id, verdict));
369            self.generation_evals += 1;
370            self.stats.evaluations += 1;
371
372            if verdict.passed {
373                self.target_health.record_success();
374            } else if verdict.status_delta >= 500 {
375                self.target_health.record_error();
376            }
377        }
378
379        self.algorithm.submit_evaluations(to_submit);
380        Ok(())
381    }
382
383    /// Record legacy boolean feedback for a candidate.
384    pub fn record_feedback(
385        &mut self,
386        chromosome_index: usize,
387        passed: bool,
388    ) -> Result<(), EvolutionError> {
389        // Clear pending_single if it matches the index
390        if let Some((idx, _)) = self.pending_single
391            && idx == chromosome_index
392        {
393            self.pending_single = None;
394        }
395        self.record_verdict(chromosome_index, &OracleVerdict::from_bool(passed))
396    }
397
398    /// Record rich oracle verdict feedback.
399    pub fn record_verdict(
400        &mut self,
401        chromosome_index: usize,
402        verdict: &OracleVerdict,
403    ) -> Result<(), EvolutionError> {
404        self.submit_batch(vec![(chromosome_index, *verdict)])
405    }
406
407    /// Record target-error feedback.
408    pub fn record_target_error(&mut self, error: String) -> Result<(), EvolutionError> {
409        self.target_health.record_error();
410        if !self.target_health.is_healthy() {
411            return Err(EvolutionError::TargetHealthCritical(error));
412        }
413        Ok(())
414    }
415
416    /// Evolve the population to the next generation.
417    pub fn evolve(&mut self) {
418        if self.algorithm.best().is_none() {
419            return;
420        }
421
422        // Update fitness history with sliding window
423        if let Some(best) = self.algorithm.best() {
424            self.fitness_history.push_back(best.fitness);
425        }
426        if self.fitness_history.len() > 1000 {
427            self.fitness_history.pop_front();
428        }
429
430        // Detect stagnation
431        let window = 10_usize;
432        if self.fitness_history.len() >= window {
433            let skip = self.fitness_history.len().saturating_sub(window);
434            let recent: Vec<f64> = self.fitness_history.iter().skip(skip).copied().collect();
435            let improved = recent.windows(2).any(|w| w[1] > w[0] + 0.001);
436            if !improved {
437                self.stagnation_counter += 1;
438            } else {
439                self.stagnation_counter = 0;
440            }
441        }
442        // Mirror into stats so should_terminate() (which reads
443        // self.stats.stagnation_counter, not self.stagnation_counter)
444        // and the search algorithms' own should_terminate() impls see
445        // the same value. Without this sync the stagnation_limit
446        // budget would be silently ignored.
447        self.stats.stagnation_counter = self.stagnation_counter;
448
449        self.stats.generation += 1;
450        self.generation_evals = 0;
451
452        if let Some(ref path) = self.checkpoint_path
453            && let Err(e) = self.save_checkpoint(path)
454        {
455            tracing::warn!(error = %e, path = %path.display(), "checkpoint save failed");
456        }
457    }
458
459    /// Check if evolution should terminate.
460    #[must_use]
461    pub fn should_terminate(&self) -> bool {
462        if !self.target_health.is_healthy() {
463            return true;
464        }
465        self.algorithm.should_terminate(&self.stats, &self.budget)
466            || self.request_count >= self.budget.max_requests
467            || self.stats.stagnation_counter >= self.budget.stagnation_limit
468    }
469
470    /// Get the best-performing chromosome.
471    #[must_use]
472    pub fn best(&self) -> Option<&Chromosome> {
473        self.algorithm.best()
474    }
475
476    /// Save engine state to disk.
477    pub fn save_checkpoint(&self, path: &Path) -> Result<(), EvolutionError> {
478        let state = EngineState {
479            algorithm_name: self.algorithm.name().to_string(),
480            algorithm_state: self.algorithm.checkpoint()?,
481            gene_pool: self.gene_pool.clone(),
482            // The engine-level rng is not serializable; the algorithm
483            // captures its own rng state inside algorithm_state. Any
484            // engine-side draws after a restore will diverge from
485            // pre-crash, but the algorithm's exploration sequence is
486            // preserved.
487            rng_seed: 0,
488            budget: self.budget,
489            gene_stats: self.gene_stats.clone(),
490            fitness_history: self.fitness_history.clone(),
491            stagnation_counter: self.stagnation_counter,
492            request_count: self.request_count,
493            stats: self.stats,
494            schema_version: 2,
495            corpus: self.corpus.clone(),
496            next_id: self.next_id,
497            generation_evals: self.generation_evals,
498        };
499        save_checkpoint(path, &state)
500    }
501
502    /// Load engine state from disk.
503    pub fn load_checkpoint(&mut self, path: &Path) -> Result<(), EvolutionError> {
504        let mut state: EngineState = load_checkpoint(path)?;
505        state.stats.fixup_start_time();
506        self.algorithm.restore(&state.algorithm_state)?;
507        self.gene_pool = state.gene_pool;
508        self.budget = state.budget;
509        self.gene_stats = state.gene_stats;
510        self.fitness_history = state.fitness_history;
511        self.stagnation_counter = state.stagnation_counter;
512        self.request_count = state.request_count;
513        self.stats = state.stats;
514        // v2 fields — `#[serde(default)]` on EngineState means a v1
515        // checkpoint loads cleanly with empty corpus / next_id=0.
516        self.corpus = state.corpus;
517        self.next_id = state.next_id;
518        self.generation_evals = state.generation_evals;
519        Ok(())
520    }
521
522    /// Get per-gene success rates.
523    #[must_use]
524    pub fn gene_success_rates(&self) -> Vec<(&str, &str, f64)> {
525        crate::evolution::fitness::gene_success_rates(&self.gene_stats)
526    }
527
528    /// Get a human-readable summary.
529    #[must_use]
530    pub fn learned_summary(&self) -> String {
531        crate::evolution::fitness::learned_summary(
532            self.stats.generation,
533            self.algorithm.best(),
534            &self.gene_stats,
535            self.request_count,
536        )
537    }
538
539    /// Seed the underlying algorithm with an explicit population —
540    /// the public path callers use to warm-start search from a known
541    /// good corpus (or to inject a synthetic population from tests).
542    pub fn seed_population(&mut self, population: Vec<Chromosome>) {
543        let mut rng = self.rng.clone();
544        self.algorithm
545            .initialize(population, &self.gene_pool, &mut rng);
546    }
547
548    /// Snapshot the algorithm's live population (test/diagnostic
549    /// surface). Population-based algorithms return their full pool;
550    /// single-state algorithms return the singleton current/best.
551    #[must_use]
552    pub fn population_snapshot(&self) -> Vec<Chromosome> {
553        self.algorithm.population_snapshot()
554    }
555
556    /// Population diversity in `[0.0, 1.0]` — drives adaptive mutation
557    /// pressure (see `crossover::diversity::adaptive_mutation_rate`).
558    ///
559    /// Strategy:
560    /// 1. Snapshot the algorithm's live population and union it with
561    ///    the engine's `in_flight` candidates.
562    /// 2. If `len() >= 2`, return mean pairwise gene-mismatch ratio
563    ///    via `crossover::diversity::diversity_score`.
564    /// 3. Otherwise (single-state algorithm with nothing in-flight),
565    ///    fall back to gene-pool exploration entropy from
566    ///    [`Self::gene_stats_diversity`] — measures how broadly the
567    ///    engine has *explored* the gene space rather than how varied
568    ///    the *current* population is. With no exploration history
569    ///    either, return 1.0 (max-safe default — keeps mutation
570    ///    pressure conservative on a fresh engine).
571    #[must_use]
572    pub fn diversity_score(&self) -> f64 {
573        let mut population = self.algorithm.population_snapshot();
574        for (_, chromosome, _) in self.in_flight.values() {
575            population.push(chromosome.clone());
576        }
577        if population.len() >= 2 {
578            return crate::evolution::crossover::diversity::diversity_score(&population);
579        }
580        let gene_div = self.gene_stats_diversity();
581        if gene_div > 0.0 { gene_div } else { 1.0 }
582    }
583
584    /// Shannon-entropy style diversity over the engine's per-gene
585    /// exploration history.
586    ///
587    /// For each unique gene name in `gene_stats`, computes the
588    /// normalised entropy of its value distribution weighted by
589    /// `attempts`. The per-gene entropies are averaged. Range
590    /// `[0.0, 1.0]`: 0.0 means we tried only one value for every
591    /// gene (no exploration), 1.0 means a uniform distribution
592    /// across the maximum-cardinality gene's value space.
593    ///
594    /// Useful as a fallback signal when the active search algorithm
595    /// is single-state (e.g. simulated annealing) and the population
596    /// snapshot is too small to give meaningful pairwise distance.
597    #[must_use]
598    pub fn gene_stats_diversity(&self) -> f64 {
599        if self.gene_stats.is_empty() {
600            return 0.0;
601        }
602        // Bucket per-gene attempt counts.
603        let mut by_gene: HashMap<&str, Vec<u32>> = HashMap::new();
604        for (name, _value, _successes, attempts) in &self.gene_stats {
605            if *attempts == 0 {
606                continue;
607            }
608            by_gene.entry(name.as_str()).or_default().push(*attempts);
609        }
610        if by_gene.is_empty() {
611            return 0.0;
612        }
613        let mut entropy_sum = 0.0_f64;
614        let mut counted = 0_usize;
615        for attempts in by_gene.values() {
616            let total: u64 = attempts.iter().map(|a| u64::from(*a)).sum();
617            if total == 0 || attempts.len() < 2 {
618                // Single value tried — zero entropy contribution. Still
619                // counted so the per-gene mean isn't biased by skipping.
620                counted += 1;
621                continue;
622            }
623            #[allow(clippy::cast_precision_loss)]
624            let total_f = total as f64;
625            let mut h = 0.0_f64;
626            for a in attempts {
627                #[allow(clippy::cast_precision_loss)]
628                let p = f64::from(*a) / total_f;
629                if p > 0.0 {
630                    h -= p * p.log2();
631                }
632            }
633            // Normalise by max entropy log2(k) where k is the number of
634            // distinct values tried for this gene. Falls in `[0, 1]`.
635            #[allow(clippy::cast_precision_loss)]
636            let h_max = (attempts.len() as f64).log2();
637            let normalised = if h_max > 0.0 { h / h_max } else { 0.0 };
638            entropy_sum += normalised;
639            counted += 1;
640        }
641        if counted == 0 {
642            0.0
643        } else {
644            #[allow(clippy::cast_precision_loss)]
645            let avg = entropy_sum / counted as f64;
646            avg.clamp(0.0, 1.0)
647        }
648    }
649}
650
651/// Serializable engine state.
652///
653/// Schema version 2 (2026-05-10) adds `corpus`, `next_id`, and
654/// `generation_evals` so a restored engine doesn't lose all of its
655/// bypass discoveries and doesn't reset its eval-id counter (which
656/// would collide with any in-flight evaluation that survived the
657/// crash).
658///
659/// What is intentionally NOT serialized:
660///   - `in_flight`: by definition transient; any pending eval at
661///     checkpoint time is lost on crash, but the corpus capture
662///     above means the *useful* bypasses are preserved.
663///   - `cache`: LRU cache of payload→verdict; recomputable.
664///   - `target_health`: runtime stats; resets on resume.
665///   - `checkpoint_path`: re-injected by the caller after load.
666///   - `pending_single`: legacy sequential API state, transient.
667///   - RNG state: search algorithms each capture their own RNG
668///     state inside `algorithm_state`; the engine-level rng is
669///     used only for next_eval_id minting and gene-pool sampling
670///     when the algorithm doesn't override.
671#[derive(Debug, Clone, Serialize, Deserialize)]
672pub struct EngineState {
673    pub algorithm_name: String,
674    pub algorithm_state: Vec<u8>,
675    pub gene_pool: GenePool,
676    pub rng_seed: u64,
677    pub budget: Budget,
678    pub gene_stats: Vec<(String, String, u32, u32)>,
679    pub fitness_history: VecDeque<f64>,
680    pub stagnation_counter: u32,
681    pub request_count: usize,
682    pub stats: SearchStats,
683    pub schema_version: u32,
684    /// Saved bypass discoveries — added in schema_version 2.
685    /// Defaults to empty for v1 checkpoints loaded by a v2 engine.
686    #[serde(default)]
687    pub corpus: BypassCorpus,
688    /// Next eval_id to mint — added in schema_version 2 so a
689    /// restored engine doesn't recycle IDs that may collide with
690    /// any in-flight evaluation that survived the crash.
691    #[serde(default)]
692    pub next_id: u64,
693    /// Evaluations issued in the current generation — added in v2.
694    #[serde(default)]
695    pub generation_evals: usize,
696}
697
698use serde::{Deserialize, Serialize};