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};