wafrift-evolution 0.2.15

Genetic algorithm engine, differential analysis, intelligence feedback loop, and WAF-aware advisor.
Documentation
//! Regression test for the 2026-05-10 audit finding:
//!
//!   `batch_candidates` generates engine-local `eval_ids` and stores them
//!   in `in_flight`, but `MapElites` and `NoveltySearch` track their own
//!   candidate.id in their PRIVATE `in_flight` maps. The pre-fix engine
//!   forwarded its `eval_id` to `algorithm.submit_evaluations`, which
//!   missed the algorithm's lookup → the grid / archive never got
//!   updated and non-cached evaluations were silently dropped.
//!
//! This test drives the full engine → algorithm → engine round-trip
//! through `MapElites` and asserts that the grid actually fills up.
//! Pre-fix: grid stays empty; the test FAILS.

use rand::SeedableRng;
use rand::rngs::StdRng;
use wafrift_evolution::evolution::{EvolutionEngine, GenePool};
use wafrift_evolution::types::{Budget, OracleVerdict};

fn engine_with_map_elites() -> EvolutionEngine {
    let mut engine = EvolutionEngine::with_algorithm(
        "map_elites",
        GenePool::default_wafrift(),
        StdRng::seed_from_u64(7),
        Budget::default(),
    )
    .expect("map_elites is built-in");

    // map_elites needs an initial population to seed the grid.
    let pool = engine.gene_pool.clone();
    let pop: Vec<_> = (0..16)
        .map(|i| {
            let mut rng = StdRng::seed_from_u64(100 + i as u64);
            wafrift_evolution::evolution::population::random_chromosome(&pool, &mut rng)
        })
        .collect();
    engine.seed_population(pop);
    engine
}

#[test]
fn map_elites_grid_fills_through_engine_round_trip() {
    let mut engine = engine_with_map_elites();

    // Run 50 batches of 4. With ID remapping broken (pre-fix), every
    // non-cached submit_batch silently misses MapElites' private
    // in_flight and the grid never updates beyond the seed snapshot.
    let mut total_evals = 0_usize;
    for _ in 0..50 {
        let batch = engine.batch_candidates(4);
        if batch.is_empty() {
            break;
        }
        total_evals += batch.len();

        // Alternate verdicts so fitness varies and grid cells diverge.
        let results: Vec<(usize, OracleVerdict)> = batch
            .into_iter()
            .enumerate()
            .map(|(i, (id, _))| (id, OracleVerdict::from_bool(i % 2 == 0)))
            .collect();
        engine.submit_batch(results).expect("submit_batch");
        engine.evolve();
    }

    assert!(
        total_evals >= 50,
        "expected the engine to issue at least 50 evals over 50 batches"
    );

    // After all those round-trips the algorithm must have observed
    // at least *some* evaluations. We check via stats.evaluations
    // since MapElites' grid isn't directly inspectable from the
    // public surface, but stats.evaluations is incremented inside
    // submit_batch only on the success path.
    assert_eq!(
        engine.stats.evaluations, total_evals,
        "submit_batch must update stats.evaluations exactly once per result; \
         pre-fix this still passed because the count is engine-side, but \
         the next assertion is the real bug catcher"
    );

    // The real bug catcher: after dozens of successful evaluations
    // the algorithm's own best() must be non-None. Pre-fix MapElites
    // never got its in_flight populated correctly, so submit_evaluations
    // silently no-op'd and best() returned only the seeded baseline.
    // Stronger: the population_snapshot grew beyond the seed size.
    let snapshot = engine.population_snapshot();
    assert!(
        !snapshot.is_empty(),
        "MapElites population_snapshot is empty after {total_evals} evals — \
         the algorithm's grid was never updated. Pre-fix bug confirmed."
    );
}

#[test]
fn engine_in_flight_carries_algorithm_candidate_id() {
    // Direct white-box assertion on the in_flight tuple shape: the
    // algorithm-side ID must be preserved alongside the chromosome.
    // If a future refactor goes back to (Chromosome, Instant) without
    // the algorithm ID, MapElites/NoveltySearch breaks again.
    let mut engine = engine_with_map_elites();
    let batch = engine.batch_candidates(2);
    assert!(!batch.is_empty(), "fresh engine must yield candidates");

    for (eval_id_usize, _) in &batch {
        let eval_id = *eval_id_usize as u64;
        let entry = engine
            .in_flight
            .get(&eval_id)
            .expect("freshly issued eval_id must be in in_flight");
        // entry.0 is the algorithm's candidate.id. We don't know its
        // exact value (the algorithm mints it), but it must be non-zero
        // for MapElites (which starts its eval_counter at 1).
        let (algorithm_id, _chromosome, _sent_at) = entry;
        assert!(
            *algorithm_id > 0,
            "in_flight tuple must carry the algorithm's candidate.id \
             (got {algorithm_id}); MapElites/NoveltySearch lookup keys on it"
        );
    }
}