genetic_algorithms 3.0.0

Library for solving genetic algorithm problems
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
// Wave 0 tests (Plan 01): SurrogateModel trait-level invariants and pure-math helpers.
// Wave 1 tests (Plan 02): Build-time validation and engine-runtime tests.
//
// Plan 01 tests:
//   SC-1a: predict() is callable on a user-defined surrogate implementation
//   SC-1d: prescreening floor formula (max(1, floor(n * f))) is correct
//   SC-1g: NaN predictions are treated as worst score (NEG_INFINITY substitution)
//   SC-2c: GenerationStats.true_fitness_calls deserialises as None from old JSON
//
// Plan 02 tests (Task 1 — build-time validation):
//   SC-1e: invalid_fraction_zero_rejected
//   SC-1f: invalid_fraction_over_one_rejected
//   boundary_fraction_one_accepted
//
// Plan 02 tests (Task 3 — engine-runtime):
//   SC-1b: ga_with_surrogate_runs
//   SC-1c: prescreening_fraction_reduces_evaluations
//   SC-2a: true_fitness_calls_populated_in_stats
//   SC-2b: true_fitness_calls_none_without_surrogate
//   SC-3:  surrogate_with_batch_evaluator_composes
// Zero ignore attributes in this file.

use genetic_algorithms::traits::{ChromosomeT, GeneT};
use genetic_algorithms::SurrogateModel;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

// ─── Plan 02 Task 1 helpers ───────────────────────────────────────────────────

use genetic_algorithms::chromosomes::Range as RangeChromosome;
use genetic_algorithms::configuration::ProblemSolving;
use genetic_algorithms::ga::Ga;
use genetic_algorithms::genotypes::Range as RangeGenotype;
use genetic_algorithms::initializers::range_random_initialization;
use genetic_algorithms::operations::{Crossover, GaussianParams, Mutation, Selection, Survivor};
use genetic_algorithms::traits::{
    ConfigurationT, CrossoverConfig, MutationConfig, SelectionConfig, StoppingConfig,
};
use genetic_algorithms::ChromosomeLength;

/// Trivial surrogate that always returns 0.0 — used for build-time validation tests.
struct ZeroSurrogate;

impl SurrogateModel<RangeChromosome<f64>> for ZeroSurrogate {
    fn predict(&self, _chromosome: &RangeChromosome<f64>) -> f64 {
        0.0
    }
}

// ─── Shared stub types ────────────────────────────────────────────────────────

/// Minimal gene — only GeneT required.
#[derive(Debug, Clone, Default)]
struct StubGene {
    id: i32,
}

impl GeneT for StubGene {
    fn id(&self) -> i32 {
        self.id
    }
    fn set_id(&mut self, id: i32) -> &mut Self {
        self.id = id;
        self
    }
}

/// Minimal chromosome — carries a single fitness value.
#[derive(Debug, Clone, Default)]
struct StubChromosome {
    fitness: f64,
    age: usize,
}

impl ChromosomeT for StubChromosome {
    type Gene = StubGene;

    fn fitness(&self) -> f64 {
        self.fitness
    }

    fn set_fitness(&mut self, fitness: f64) -> &mut Self {
        self.fitness = fitness;
        self
    }

    fn calculate_fitness(&mut self) {
        // no-op for stubs
    }

    fn age(&self) -> usize {
        self.age
    }

    fn set_age(&mut self, age: usize) -> &mut Self {
        self.age = age;
        self
    }
}

// ─── SC-1a: predict() is callable ────────────────────────────────────────────

/// Surrogate that counts the number of predict() invocations via an AtomicUsize.
struct CountingSurrogate {
    calls: Arc<AtomicUsize>,
}

impl SurrogateModel<StubChromosome> for CountingSurrogate {
    fn predict(&self, _chromosome: &StubChromosome) -> f64 {
        self.calls.fetch_add(1, Ordering::SeqCst);
        1.0
    }
}

/// SC-1a: A minimal SurrogateModel impl on a stub chromosome is callable.
/// Counts predict invocations via AtomicUsize and asserts the count matches.
#[test]
fn test_predict_called() {
    let calls = Arc::new(AtomicUsize::new(0));
    let surrogate = CountingSurrogate {
        calls: Arc::clone(&calls),
    };

    // Invoke predict 5 times on stub chromosomes.
    for _ in 0..5 {
        let c = StubChromosome::default();
        surrogate.predict(&c);
    }

    assert_eq!(
        calls.load(Ordering::SeqCst),
        5,
        "predict must be called exactly 5 times"
    );

    // Also verify the trait is storable in Arc<dyn SurrogateModel<_> + Send + Sync>
    // (compile-time proof that the Send + Sync bounds are satisfied).
    let _shared: Arc<dyn SurrogateModel<StubChromosome> + Send + Sync> =
        Arc::new(CountingSurrogate {
            calls: Arc::new(AtomicUsize::new(0)),
        });
}

// ─── SC-1d: prescreening floor formula ───────────────────────────────────────

/// Inline helper: the same formula Plan 02 will use in ga.rs.
/// Returns the number of offspring to retain after prescreening.
fn floor_keep(n: usize, f: f64) -> usize {
    ((n as f64 * f).floor() as usize).max(1)
}

/// SC-1d: The prescreening floor formula returns correct results for boundary inputs.
/// Pure-math test — no engine required.
#[test]
fn test_prescreening_floor() {
    // Near-zero fraction: must retain at least 1
    assert_eq!(
        floor_keep(10, 0.0001),
        1,
        "floor(10 * 0.0001) == 0, max(0,1) == 1"
    );

    // 50% fraction: retain exactly 5
    assert_eq!(floor_keep(10, 0.5), 5, "floor(10 * 0.5) == 5");

    // 100% fraction: retain all 10
    assert_eq!(floor_keep(10, 1.0), 10, "floor(10 * 1.0) == 10");

    // Exactly 0.0 fraction: must retain at least 1
    assert_eq!(
        floor_keep(10, 0.0),
        1,
        "floor(10 * 0.0) == 0, max(0,1) == 1"
    );

    // Larger population: n=100, f=0.3 → 30
    assert_eq!(floor_keep(100, 0.3), 30, "floor(100 * 0.3) == 30");

    // Single offspring: must always retain 1
    assert_eq!(floor_keep(1, 0.1), 1, "max(floor(0.1), 1) == 1");
}

// ─── SC-1g: NaN predictions treated as worst ─────────────────────────────────

/// Surrogate that returns NaN for the chromosome at the target index,
/// and a finite score otherwise.
struct NanSurrogate {
    nan_index: usize,
    call_count: AtomicUsize,
}

impl SurrogateModel<StubChromosome> for NanSurrogate {
    fn predict(&self, _chromosome: &StubChromosome) -> f64 {
        let idx = self.call_count.fetch_add(1, Ordering::SeqCst);
        if idx == self.nan_index {
            f64::NAN
        } else {
            idx as f64
        }
    }
}

/// SC-1g: NaN predicted scores are substituted with NEG_INFINITY so they sort last.
/// Reproduces the documented substitution inline (pure data manipulation; no engine).
#[test]
fn test_nan_prediction_treated_as_worst() {
    // Build a small offspring vector (5 chromosomes).
    let offspring: Vec<StubChromosome> = (0..5).map(|_| StubChromosome::default()).collect();

    let surrogate = NanSurrogate {
        nan_index: 2, // chromosome at index 2 gets NaN
        call_count: AtomicUsize::new(0),
    };

    // Score each offspring; reproduce the NaN → NEG_INFINITY substitution
    // exactly as Plan 02 will do it in ga.rs.
    let mut scored: Vec<(usize, f64)> = offspring
        .iter()
        .enumerate()
        .map(|(i, c)| {
            let raw = surrogate.predict(c);
            let score = if raw.is_nan() { f64::NEG_INFINITY } else { raw };
            (i, score)
        })
        .collect();

    // Sort descending by score (best-first, worst-last).
    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));

    // The NaN entry (original index 2) must be last after substitution.
    let last_original_index = scored.last().unwrap().0;
    assert_eq!(
        last_original_index, 2,
        "NaN entry (original index 2) must sort last (treated as NEG_INFINITY)"
    );

    // All other entries must have finite scores.
    for (orig_idx, score) in &scored[..scored.len() - 1] {
        assert!(
            score.is_finite(),
            "entry {orig_idx} should have a finite score, got {score}"
        );
    }

    // Last entry must be NEG_INFINITY (the substituted NaN).
    assert_eq!(
        scored.last().unwrap().1,
        f64::NEG_INFINITY,
        "substituted NaN must be NEG_INFINITY"
    );
}

// ─── SC-2c: serde default for true_fitness_calls ─────────────────────────────

/// SC-2c: Deserialising a GenerationStats JSON payload that lacks the
/// `true_fitness_calls` field yields None (serde(default) backward-compat).
///
/// Gated behind #[cfg(feature = "serde")] — only runs with `--features serde`.
#[cfg(feature = "serde")]
#[test]
fn stats_serde_default() {
    use genetic_algorithms::stats::GenerationStats;

    // Minimal GenerationStats JSON without the new field.
    // Matches the shape of existing checkpoints created before Phase 62.
    let json = r#"{
        "generation": 0,
        "best_fitness": 1.0,
        "worst_fitness": 3.0,
        "avg_fitness": 2.0,
        "fitness_std_dev": 0.816,
        "population_size": 3,
        "diversity": 0.816,
        "dynamic_mutation_probability": null,
        "avg_node_count": 0.0,
        "cache_hits": null,
        "cache_misses": null
    }"#;

    let parsed: GenerationStats = serde_json::from_str(json)
        .expect("GenerationStats must deserialise from JSON lacking true_fitness_calls");

    assert!(
        parsed.true_fitness_calls.is_none(),
        "true_fitness_calls must be None when absent from checkpoint JSON, got {:?}",
        parsed.true_fitness_calls
    );
}

// ─── Plan 02 Task 1: build-time validation tests ─────────────────────────────

/// Helper: alleles for a 2-gene RangeChromosome<f64> in [0.0, 1.0].
fn make_range_alleles() -> Vec<RangeGenotype<f64>> {
    vec![RangeGenotype::new(0, vec![(0.0_f64, 1.0_f64)], 0.0_f64)]
}

/// Helper: build a minimal Ga<RangeChromosome<f64>> with surrogate, fraction, and a
/// fitness function returning 1.0.
fn build_surrogate_ga(
    model: Arc<dyn SurrogateModel<RangeChromosome<f64>> + Send + Sync>,
    fraction: f64,
) -> Result<Ga<RangeChromosome<f64>>, genetic_algorithms::error::GaError> {
    let alleles = make_range_alleles();
    let alleles_clone = alleles.clone();
    Ga::new()
        .with_surrogate(model, fraction)
        .with_population_size(10)
        .with_chromosome_length(ChromosomeLength::Fixed(2))
        .with_alleles(alleles)
        .with_initialization_fn(move |n, _| range_random_initialization(n, Some(&alleles_clone)))
        .with_fitness_fn(|_dna: &[RangeGenotype<f64>]| 1.0)
        .with_selection_method(Selection::Tournament)
        .with_crossover_method(Crossover::SinglePoint)
        .with_mutation_method(Mutation::Gaussian(GaussianParams { sigma: None }))
        .with_survivor_method(Survivor::Fitness)
        .with_max_generations(1)
        .with_problem_solving(ProblemSolving::Maximization)
        .build()
}

/// SC-1e: .with_surrogate(model, 0.0) followed by .build() returns GaError::ConfigurationError
/// whose message contains "prescreening_fraction".
#[test]
fn invalid_fraction_zero_rejected() {
    use genetic_algorithms::error::GaError;
    let model =
        Arc::new(ZeroSurrogate) as Arc<dyn SurrogateModel<RangeChromosome<f64>> + Send + Sync>;
    let result = build_surrogate_ga(model, 0.0);
    assert!(result.is_err(), "build() must fail for fraction=0.0");
    match result.err().unwrap() {
        GaError::ConfigurationError(msg) => {
            assert!(
                msg.contains("prescreening_fraction"),
                "error message must contain 'prescreening_fraction', got: {}",
                msg
            );
        }
        e => panic!("Expected ConfigurationError, got: {:?}", e),
    }
}

/// SC-1f: .with_surrogate(model, 1.5) followed by .build() returns GaError::ConfigurationError.
#[test]
fn invalid_fraction_over_one_rejected() {
    use genetic_algorithms::error::GaError;
    let model =
        Arc::new(ZeroSurrogate) as Arc<dyn SurrogateModel<RangeChromosome<f64>> + Send + Sync>;
    let result = build_surrogate_ga(model, 1.5);
    assert!(result.is_err(), "build() must fail for fraction=1.5");
    match result.err().unwrap() {
        GaError::ConfigurationError(_) => {}
        e => panic!("Expected ConfigurationError, got: {:?}", e),
    }
}

/// boundary_fraction_one_accepted: .with_surrogate(model, 1.0) followed by .build() succeeds
/// (upper boundary is inclusive per D-03).
#[test]
fn boundary_fraction_one_accepted() {
    let model =
        Arc::new(ZeroSurrogate) as Arc<dyn SurrogateModel<RangeChromosome<f64>> + Send + Sync>;
    let result = build_surrogate_ga(model, 1.0);
    assert!(
        result.is_ok(),
        "build() must succeed for fraction=1.0, got: {:?}",
        result.err()
    );
}

// ─── Plan 02 Task 3: engine-runtime tests ────────────────────────────────────

use genetic_algorithms::fitness::BatchFitnessEvaluator;
use std::sync::atomic::AtomicU64;

/// Surrogate that returns the chromosome fitness directly (used for ordering).
struct IdentitySurrogate;

impl SurrogateModel<RangeChromosome<f64>> for IdentitySurrogate {
    fn predict(&self, chromosome: &RangeChromosome<f64>) -> f64 {
        chromosome.fitness()
    }
}

/// Batch evaluator that records the maximum slice length seen across all calls.
struct MaxSliceLengthEvaluator {
    max_len: Arc<AtomicU64>,
}

impl BatchFitnessEvaluator<RangeChromosome<f64>> for MaxSliceLengthEvaluator {
    fn evaluate_batch(&self, chromosomes: &[RangeChromosome<f64>]) -> Vec<f64> {
        let len = chromosomes.len() as u64;
        self.max_len.fetch_max(len, Ordering::SeqCst);
        vec![1.0; chromosomes.len()]
    }
}

/// Helper: build a fully-configured Ga<RangeChromosome<f64>> with surrogate for runtime tests.
fn build_runtime_ga(
    model: Arc<dyn SurrogateModel<RangeChromosome<f64>> + Send + Sync>,
    fraction: f64,
    generations: usize,
) -> Ga<RangeChromosome<f64>> {
    let alleles = make_range_alleles();
    let alleles_clone = alleles.clone();
    Ga::new()
        .with_surrogate(model, fraction)
        .with_population_size(20)
        .with_chromosome_length(ChromosomeLength::Fixed(2))
        .with_alleles(alleles)
        .with_initialization_fn(move |n, _| range_random_initialization(n, Some(&alleles_clone)))
        .with_fitness_fn(|_dna: &[RangeGenotype<f64>]| 1.0)
        .with_selection_method(Selection::Tournament)
        .with_crossover_method(Crossover::SinglePoint)
        .with_mutation_method(Mutation::Gaussian(GaussianParams { sigma: None }))
        .with_survivor_method(Survivor::Fitness)
        .with_max_generations(generations)
        .with_problem_solving(ProblemSolving::Maximization)
        .build()
        .expect("build_runtime_ga must succeed")
}

/// SC-1b: A Ga configured with .with_surrogate(model, 0.5) and a fitness fn
/// completes a multi-generation run without panic.
#[test]
fn ga_with_surrogate_runs() {
    let model =
        Arc::new(IdentitySurrogate) as Arc<dyn SurrogateModel<RangeChromosome<f64>> + Send + Sync>;
    let mut ga = build_runtime_ga(model, 0.5, 5);
    let result = ga.run();
    assert!(
        result.is_ok(),
        "ga.run() must succeed with surrogate: {:?}",
        result.err()
    );
}

/// SC-1c: With fraction=0.5, the post-prescreening true_fitness_calls is
/// Some(max(1, offspring_count / 2)) for at least one generation in a 5-gen run.
#[test]
fn prescreening_fraction_reduces_evaluations() {
    let model =
        Arc::new(IdentitySurrogate) as Arc<dyn SurrogateModel<RangeChromosome<f64>> + Send + Sync>;
    let mut ga = build_runtime_ga(model, 0.5, 5);
    ga.run().expect("run must succeed");
    let stats = ga.stats();
    assert!(!stats.is_empty(), "stats must be non-empty after run");
    // At least one generation must show prescreening reduced evaluations.
    let found = stats.iter().any(|s| {
        if let Some(calls) = s.true_fitness_calls {
            // After prescreening at 0.5, calls <= floor(offspring_count * 0.5) capped at max(1, ...)
            // We can't know exact offspring_count, but calls must be < population_size for reduction.
            // For population_size=20 and fraction=0.5, expected calls ≈ max(1, floor(20*0.5)) = 10
            calls <= 20 // trivially true, but confirms Some(n) is populated
        } else {
            false
        }
    });
    assert!(
        found,
        "at least one generation must have true_fitness_calls = Some(n)"
    );

    // Additionally, verify the prescreening formula was applied: calls should be ≤ half the
    // offspring count. Check that at least one generation has calls ≤ 10 (half of pop=20).
    let reduced = stats
        .iter()
        .any(|s| s.true_fitness_calls.is_some_and(|c| c <= 10));
    assert!(
        reduced,
        "at least one generation should have true_fitness_calls <= 10 with fraction=0.5 and pop=20; got: {:?}",
        stats.iter().map(|s| s.true_fitness_calls).collect::<Vec<_>>()
    );
}

/// SC-2a: With a surrogate configured, every emitted GenerationStats has
/// true_fitness_calls.is_some().
#[test]
fn true_fitness_calls_populated_in_stats() {
    let model =
        Arc::new(IdentitySurrogate) as Arc<dyn SurrogateModel<RangeChromosome<f64>> + Send + Sync>;
    let mut ga = build_runtime_ga(model, 0.5, 5);
    ga.run().expect("run must succeed");
    for (i, stat) in ga.stats().iter().enumerate() {
        assert!(
            stat.true_fitness_calls.is_some(),
            "generation {i}: true_fitness_calls must be Some when surrogate is configured"
        );
    }
}

/// SC-2b: With no surrogate, every emitted GenerationStats has
/// true_fitness_calls.is_none().
#[test]
fn true_fitness_calls_none_without_surrogate() {
    let alleles = make_range_alleles();
    let alleles_clone = alleles.clone();
    let mut ga: Ga<RangeChromosome<f64>> = Ga::new()
        .with_population_size(20)
        .with_chromosome_length(ChromosomeLength::Fixed(2))
        .with_alleles(alleles)
        .with_initialization_fn(move |n, _| range_random_initialization(n, Some(&alleles_clone)))
        .with_fitness_fn(|_dna: &[RangeGenotype<f64>]| 1.0)
        .with_selection_method(Selection::Tournament)
        .with_crossover_method(Crossover::SinglePoint)
        .with_mutation_method(Mutation::Gaussian(GaussianParams { sigma: None }))
        .with_survivor_method(Survivor::Fitness)
        .with_max_generations(5)
        .with_problem_solving(ProblemSolving::Maximization)
        .build()
        .expect("build must succeed");

    ga.run().expect("run must succeed");
    for (i, stat) in ga.stats().iter().enumerate() {
        assert!(
            stat.true_fitness_calls.is_none(),
            "generation {i}: true_fitness_calls must be None when no surrogate is configured"
        );
    }
}

/// SC-3: A Ga configured with both .with_surrogate(model, 0.5) and
/// .with_batch_evaluator(eval) runs to completion and the batch evaluator receives
/// at most max(1, floor(offspring_count * 0.5)) chromosomes per generation.
#[test]
fn surrogate_with_batch_evaluator_composes() {
    let max_len = Arc::new(AtomicU64::new(0));
    let evaluator = Arc::new(MaxSliceLengthEvaluator {
        max_len: Arc::clone(&max_len),
    });
    let model =
        Arc::new(IdentitySurrogate) as Arc<dyn SurrogateModel<RangeChromosome<f64>> + Send + Sync>;
    let alleles = make_range_alleles();
    let alleles_clone = alleles.clone();
    // population_size=20, fraction=0.5 → batch evaluator should see ≤ max(1, 10) per generation.
    let mut ga: Ga<RangeChromosome<f64>> = Ga::new()
        .with_surrogate(model, 0.5)
        .with_batch_evaluator(evaluator)
        .with_population_size(20)
        .with_chromosome_length(ChromosomeLength::Fixed(2))
        .with_alleles(alleles)
        .with_initialization_fn(move |n, _| range_random_initialization(n, Some(&alleles_clone)))
        .with_selection_method(Selection::Tournament)
        .with_crossover_method(Crossover::SinglePoint)
        .with_mutation_method(Mutation::Gaussian(GaussianParams { sigma: None }))
        .with_survivor_method(Survivor::Fitness)
        .with_max_generations(5)
        .with_problem_solving(ProblemSolving::Maximization)
        .build()
        .expect("build must succeed");

    ga.run().expect("run must succeed");

    // The batch evaluator should have been called, and the max slice it received should be
    // no larger than half the expected offspring count (≤ floor(offspring_count * 0.5)).
    // For pop=20, typical offspring_count ≈ 20; half ≈ 10.
    let max_received = max_len.load(Ordering::SeqCst);
    assert!(
        max_received > 0,
        "batch evaluator must have been called at least once"
    );
    // The initial population evaluation may be a larger batch (full pop), but subsequent
    // offspring batches should be ≤ 10 (half of pop=20). We check that the max observed is
    // ≤ population_size (20) to confirm surrogate didn't let the full offspring slice through.
    // A tighter bound: with fraction=0.5 the surrogate should cut offspring to ≤ half,
    // so no offspring batch should exceed 10. The initial evaluation may be exactly 20.
    // We verify at the stat level instead: every generation with a surrogate has
    // true_fitness_calls ≤ half the expected offspring (≤ 10).
    for (i, stat) in ga.stats().iter().enumerate() {
        assert!(
            stat.true_fitness_calls.is_some(),
            "generation {i}: true_fitness_calls must be Some when surrogate is configured"
        );
        let calls = stat.true_fitness_calls.unwrap();
        assert!(
            calls <= 10,
            "generation {i}: true_fitness_calls ({calls}) must be ≤ 10 (half of pop=20 with fraction=0.5)"
        );
    }
}