rlevo_evolution/algorithms/memetic.rs
1//! Memetic-algorithm strategy adapter.
2//!
3//! A *memetic algorithm* (MA) interleaves a population-level evolutionary
4//! [`Strategy`] with a per-individual [`LocalSearch`] that polishes promising
5//! genomes between an inner strategy's `ask` and `tell`. [`MemeticWrapper`]
6//! makes that interleaving a *zero-cost-to-adopt* upgrade: wrap any existing
7//! `Strategy<B, Genome = Tensor<B, 2>>` together with any
8//! [`LocalSearch<B>`](crate::local_search::LocalSearch) and the wrapper itself
9//! implements `Strategy<B>`, so it drops straight into
10//! [`EvolutionaryHarness`](crate::strategy::EvolutionaryHarness).
11//!
12//! # Lamarckian / Baldwinian / Partial refinement
13//!
14//! After the inner strategy proposes a population, the wrapper refines a subset
15//! of individuals (the [`CoveragePolicy`]) and then decides — per the
16//! [`WritebackPolicy`] — whether each refined genome is written *back* into the
17//! population handed to the inner `tell`:
18//!
19//! - **Lamarckian** — refined genome *and* refined fitness flow to `tell`. The
20//! inherited traits (the genome) change.
21//! - **Baldwinian** — the *original* genome flows to `tell` but carries the
22//! *refined* fitness. The phenotype's learned advantage shows up as fitness
23//! pressure without altering the inherited genome.
24//! - **`Partial(p)`** — each refined individual is written back Lamarckian-style
25//! with probability `p` (drawn per refined individual), Baldwinian otherwise.
26//!
27//! Regardless of policy, the refined fitness *always* replaces the original
28//! fitness for covered rows — Baldwinian differs only in that the genome is left
29//! untouched.
30//!
31//! # Intentional break of the `Strategy` purity convention
32//!
33//! [`Strategy`] documents itself as *pure*: `ask`/`tell` take `&self` and carry
34//! no interior mutability so many instances can run in parallel without locks.
35//! **This wrapper deliberately breaks that convention.** It holds a
36//! [`parking_lot::Mutex`] around its fitness function because local search needs
37//! `&mut F` (an [`FitnessFn`] is `&mut self`) while `tell` only has `&self`. The
38//! lock is wrapper-private and uncontended in the single-harness driving model
39//! (one `tell` in flight at a time), so it costs an uncontended lock/unlock per
40//! generation and never blocks. A reader writing code generic over
41//! `S: Strategy<B>` should be aware that `MemeticWrapper` is *not* a pure,
42//! lock-free strategy like the others in this crate.
43//!
44//! # RNG discipline
45//!
46//! All refinement randomness flows through [`seed_stream`]; the wrapper never
47//! touches the process-wide backend RNG. See the
48//! [`tell`](MemeticWrapper#impl-Strategy<B>-for-MemeticWrapper<B,+S,+L,+F>)
49//! flow for the two-stream scheme that makes `Partial(1.0)` bit-identical to
50//! `Lamarckian` and `Partial(0.0)` to `Baldwinian`.
51
52use std::fmt::Debug;
53use std::marker::PhantomData;
54
55use burn::tensor::{Tensor, TensorData, backend::Backend};
56use parking_lot::Mutex;
57use rand::{Rng, RngExt};
58
59use crate::fitness::{BatchFitnessFn, FitnessFn};
60use crate::local_search::LocalSearch;
61use crate::rng::{SeedPurpose, seed_stream};
62use crate::strategy::{Strategy, StrategyMetrics};
63use rlevo_core::config::{ConfigError, Validate};
64use rlevo_core::objective::ObjectiveSense;
65use rlevo_core::probability::Probability;
66
67/// Controls how a refined genome's gains are written back into the population.
68///
69/// See the [module docs](self) for the semantics of each policy. The default,
70/// [`Partial(0.5)`](WritebackPolicy::Partial), is a deliberate middle ground:
71/// half of refined individuals inherit their refined genome, the other half keep
72/// their original genome but pay the refined fitness — a blend that avoids both
73/// Lamarckian premature convergence and the slow Baldwin effect.
74#[derive(Clone, Copy, Debug, PartialEq)]
75pub enum WritebackPolicy {
76 /// Refined genome *and* refined fitness flow to the inner `tell`.
77 Lamarckian,
78 /// Original genome flows to the inner `tell`, carrying the refined fitness.
79 Baldwinian,
80 /// Per refined individual: write the refined genome back (Lamarckian) with
81 /// probability `p`, otherwise keep the original genome (Baldwinian). The
82 /// refined fitness is used either way.
83 ///
84 /// `p` is a [`Probability`], valid by construction (`[0, 1]`, NaN/Inf
85 /// rejected), so the writeback draw `rng < p` can never silently degenerate.
86 ///
87 /// Because `Partial` writeback draws from a dedicated mask RNG stream that
88 /// is independent of the refinement stream, `Partial(Probability::new(1.0))`
89 /// is **bit-identical** to [`Lamarckian`](WritebackPolicy::Lamarckian) and
90 /// `Partial(Probability::new(0.0))` is bit-identical to
91 /// [`Baldwinian`](WritebackPolicy::Baldwinian) on the same seed.
92 Partial(Probability),
93}
94
95impl Default for WritebackPolicy {
96 fn default() -> Self {
97 Self::Partial(Probability::new(0.5))
98 }
99}
100
101/// Determines which population members are refined each generation.
102///
103/// # Cost and tuning
104///
105/// Coverage is the dominant cost knob: each refined row spends up to
106/// `Params::max_iters` fitness evaluations, so [`Full`](Self::Full) costs
107/// `pop_size`× a [`TopK { k: 1 }`](Self::TopK) generation. When the budget that
108/// matters is
109/// *evaluations to reach a target* (not wall-clock or final-gen fitness), wide
110/// coverage with a heavy searcher can lose to bare evolution: it spends its
111/// eval budget polishing individuals that selection would have discarded
112/// anyway. **Tune against evals-to-target**, not against a fixed generation
113/// count — a fixed-gens comparison hides the refinement evals and flatters wide
114/// coverage. The default, [`TopK { k: 1 }`](Self::TopK), refines only the
115/// single best individual and is the cheapest sane starting point.
116///
117/// One caveat cuts the other way: on a *separable* landscape with basin-width
118/// search steps, axis-aligned hill climbing is nearly a direct solver, so wide
119/// coverage with an untuned high-`max_iters` searcher can dominate. That is a
120/// landscape artifact, not a config to copy — re-tune per problem.
121///
122/// The wrapper avoids the seeding-eval waste that an unaware caller would pay:
123/// it hands each searcher the fitness the harness already computed via
124/// [`LocalSearch::refine_with_known_fitness`], so a refined row spends its evals
125/// on probes rather than re-scoring its own input (ADR 0016 reversal criteria).
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127pub enum CoveragePolicy {
128 /// Refine every individual.
129 Full,
130 /// Refine only the `k` fittest (largest-fitness, canonical maximise)
131 /// individuals, ties broken by lower index. `k` is clamped to the
132 /// population size.
133 TopK {
134 /// Number of fittest individuals to refine.
135 k: usize,
136 },
137}
138
139impl Default for CoveragePolicy {
140 fn default() -> Self {
141 Self::TopK { k: 1 }
142 }
143}
144
145/// Static parameters for a [`MemeticWrapper`] run.
146///
147/// Composes the inner strategy's parameters with the local searcher's, plus the
148/// two memetic policies.
149#[derive(Clone, Debug)]
150pub struct MemeticParams<SP, LP> {
151 /// Parameters forwarded verbatim to the inner [`Strategy`].
152 pub inner: SP,
153 /// Parameters forwarded verbatim to the [`LocalSearch`] on every `refine`.
154 pub local: LP,
155 /// How refined gains are written back. See [`WritebackPolicy`].
156 pub writeback: WritebackPolicy,
157 /// Which individuals are refined. See [`CoveragePolicy`].
158 pub coverage: CoveragePolicy,
159}
160
161/// Validation delegates to the wrapped inner strategy's config — the memetic
162/// wrapper is the harness chokepoint for that inner config. Only `SP: Validate`
163/// is required (the local-searcher params `LP` carry only simple step-size
164/// knobs and are left unconstrained), so `MemeticParams` stays usable with any
165/// local searcher while still rejecting an invalid inner configuration.
166impl<SP: Validate, LP> Validate for MemeticParams<SP, LP> {
167 fn validate(&self) -> Result<(), ConfigError> {
168 self.inner.validate()
169 }
170}
171
172/// Generation-to-generation state for a [`MemeticWrapper`].
173///
174/// Wraps the inner strategy's state and carries the memetic generation counter
175/// (used to derive deterministic per-generation refinement seeds).
176///
177/// Fields are private for consistency with the rest of the crate's state
178/// types; the wrapped `inner` is an opaque `St` with no invariant this wrapper
179/// can check, so construction is the infallible [`new`](MemeticState::new)
180/// rather than a `try_new`.
181#[derive(Clone, Debug)]
182pub struct MemeticState<St> {
183 /// The wrapped inner [`Strategy`] state.
184 inner: St,
185 /// Number of completed `tell` calls — the generation index threaded into
186 /// [`seed_stream`] so each generation refines from an independent stream.
187 generation: u64,
188}
189
190impl<St> MemeticState<St> {
191 /// Wraps an inner strategy state with a memetic generation counter.
192 #[must_use]
193 pub fn new(inner: St, generation: u64) -> Self {
194 Self { inner, generation }
195 }
196
197 /// Borrows the wrapped inner strategy state.
198 #[must_use]
199 pub fn inner(&self) -> &St {
200 &self.inner
201 }
202
203 /// Mutably borrows the wrapped inner strategy state.
204 pub fn inner_mut(&mut self) -> &mut St {
205 &mut self.inner
206 }
207
208 /// Number of completed `tell` calls.
209 #[must_use]
210 pub fn generation(&self) -> u64 {
211 self.generation
212 }
213}
214
215/// Wraps an inner [`Strategy`] with per-individual [`LocalSearch`] refinement.
216///
217/// `MemeticWrapper` is itself a `Strategy<B, Genome = Tensor<B, 2>>`, so it
218/// composes with any real-valued strategy and drops into
219/// [`EvolutionaryHarness`](crate::strategy::EvolutionaryHarness) unchanged.
220///
221/// # The two fitness instances
222///
223/// The harness owns *its own* fitness instance (it calls `evaluate_batch` once
224/// per generation to score the asked population); this wrapper owns a *separate*
225/// instance behind a [`Mutex`], used only to score local-search probes.
226///
227/// **If `F` is stateful (counters, caches, RNG), the two instances must share
228/// that state via interior mutability (e.g. `Arc<AtomicUsize>`) — otherwise
229/// they silently diverge.** A naive `#[derive(Clone)]`-then-pass approach gives
230/// each instance an independent counter, and an evaluation-budget accounting
231/// across both will under-count. The headline Rastrigin benchmark shares a
232/// single `Arc<AtomicUsize>` eval counter across both instances for exactly this
233/// reason.
234///
235/// # Example
236///
237/// Wrap Differential Evolution with hill-climbing refinement and drive a couple
238/// of generations by hand:
239///
240/// ```
241/// use burn::backend::Flex;
242/// use burn::tensor::{Tensor, TensorData, backend::Backend};
243/// use rand::{rngs::StdRng, SeedableRng};
244/// use rlevo_evolution::Strategy;
245/// use rlevo_evolution::algorithms::de::{DeConfig, DifferentialEvolution};
246/// use rlevo_evolution::algorithms::memetic::{
247/// CoveragePolicy, MemeticParams, MemeticWrapper, WritebackPolicy,
248/// };
249/// use rlevo_evolution::fitness::BatchFitnessFn;
250/// use rlevo_evolution::local_search::{HillClimbing, HillClimbingParams};
251/// use rlevo_core::bounds::Bounds;
252///
253/// // Sphere objective: sum of squares per row (a cost → Minimize).
254/// use rlevo_core::objective::ObjectiveSense;
255/// struct Sphere;
256/// impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2>> for Sphere {
257/// fn evaluate_batch(
258/// &mut self,
259/// pop: &Tensor<B, 2>,
260/// device: &B::Device,
261/// ) -> Tensor<B, 1> {
262/// let squared = pop.clone() * pop.clone();
263/// squared.sum_dim(1).squeeze_dim::<1>(1)
264/// }
265/// fn sense(&self) -> ObjectiveSense { ObjectiveSense::Minimize }
266/// }
267///
268/// let device = Default::default();
269/// let bounds = Bounds::new(-5.12, 5.12);
270/// let strategy = MemeticWrapper::<Flex, _, _, _>::new(
271/// DifferentialEvolution::<Flex>::new(),
272/// HillClimbing,
273/// Sphere,
274/// );
275/// let params = MemeticParams {
276/// inner: DeConfig::default_for(16, 4),
277/// local: HillClimbingParams::default_for(bounds),
278/// writeback: WritebackPolicy::Lamarckian,
279/// coverage: CoveragePolicy::TopK { k: 2 },
280/// };
281///
282/// let mut rng = StdRng::seed_from_u64(0);
283/// let mut state = strategy.init(¶ms, &mut rng, &device);
284/// let mut scorer = Sphere;
285/// for _ in 0..3 {
286/// let (pop, asked) = strategy.ask(¶ms, &state, &mut rng, &device);
287/// // The harness would do this; here we score it ourselves.
288/// let fitness = scorer.evaluate_batch(&pop, &device);
289/// let (next, _metrics) = strategy.tell(¶ms, pop, fitness, asked, &mut rng);
290/// state = next;
291/// }
292/// assert!(strategy.best(&state).is_some());
293/// ```
294pub struct MemeticWrapper<B, S, L, F>
295where
296 B: Backend,
297 S: Strategy<B, Genome = Tensor<B, 2>>,
298 L: LocalSearch<B>,
299 F: BatchFitnessFn<B, Tensor<B, 2>>,
300{
301 inner: S,
302 local: L,
303 fitness: Mutex<F>,
304 _backend: PhantomData<fn() -> B>,
305}
306
307impl<B, S, L, F> MemeticWrapper<B, S, L, F>
308where
309 B: Backend,
310 S: Strategy<B, Genome = Tensor<B, 2>>,
311 L: LocalSearch<B>,
312 F: BatchFitnessFn<B, Tensor<B, 2>>,
313{
314 /// Builds a memetic wrapper from an inner strategy, a local searcher, and a
315 /// fitness function used **only** for local-search probes.
316 ///
317 /// The harness owns a separate fitness instance; **if `F` is stateful
318 /// (counters, caches, RNG), the two instances must share that state via
319 /// interior mutability (e.g. `Arc<AtomicUsize>`) — otherwise they silently
320 /// diverge.** See the [type-level docs](MemeticWrapper#the-two-fitness-instances).
321 pub fn new(inner: S, local: L, fitness: F) -> Self {
322 Self {
323 inner,
324 local,
325 fitness: Mutex::new(fitness),
326 _backend: PhantomData,
327 }
328 }
329}
330
331// `F` is not `Debug`; mirror the `EvolutionaryHarness` precedent and use
332// `finish_non_exhaustive()` so the `missing_debug_implementations` lint is
333// satisfied without bounding `F: Debug`.
334impl<B, S, L, F> Debug for MemeticWrapper<B, S, L, F>
335where
336 B: Backend,
337 S: Strategy<B, Genome = Tensor<B, 2>>,
338 L: LocalSearch<B>,
339 F: BatchFitnessFn<B, Tensor<B, 2>>,
340{
341 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342 // `S`/`L`/`F` are not bounded `Debug`; mirror `EvolutionaryHarness` and
343 // emit a non-exhaustive shell so the lint is satisfied without forcing
344 // those bounds onto the public type.
345 f.debug_struct("MemeticWrapper").finish_non_exhaustive()
346 }
347}
348
349/// Adapts a population-level [`BatchFitnessFn`] into a single-row
350/// [`FitnessFn`] for local search, in **canonical (maximise)** space.
351///
352/// Each [`evaluate_one`](FitnessFn::evaluate_one) builds a `[1, D]` tensor from
353/// the host-side row, calls `evaluate_batch`, pulls the single scalar back, and
354/// maps it into canonical space via the wrapped fn's
355/// [`sense`](BatchFitnessFn::sense). Local searchers maximise, and the seed
356/// fitness the memetic wrapper hands them (the harness-canonicalised value) is
357/// also canonical, so both ends agree. This is deliberately the slow path —
358/// local search re-uploads one row at a time — and is private plumbing
359/// internal to the wrapper.
360struct RowFitness<'a, B: Backend, F> {
361 inner: &'a mut F,
362 device: &'a B::Device,
363 sense: ObjectiveSense,
364}
365
366impl<B, F> FitnessFn<Vec<f32>> for RowFitness<'_, B, F>
367where
368 B: Backend,
369 F: BatchFitnessFn<B, Tensor<B, 2>>,
370{
371 fn evaluate_one(&mut self, member: &Vec<f32>) -> f32 {
372 let dim: usize = member.len();
373 let data: TensorData = TensorData::new(member.clone(), [1, dim]);
374 let row: Tensor<B, 2> = Tensor::<B, 2>::from_data(data, self.device);
375 let fitness: Tensor<B, 1> = self.inner.evaluate_batch(&row, self.device);
376 let values: Vec<f32> = fitness
377 .into_data()
378 .into_vec::<f32>()
379 .expect("fitness tensor must be readable as f32");
380 let natural = values.first().copied().unwrap_or(f32::NEG_INFINITY);
381 self.sense.to_canonical(natural)
382 }
383}
384
385impl<B, S, L, F> Strategy<B> for MemeticWrapper<B, S, L, F>
386where
387 B: Backend,
388 S: Strategy<B, Genome = Tensor<B, 2>>,
389 L: LocalSearch<B>,
390 F: BatchFitnessFn<B, Tensor<B, 2>>,
391{
392 type Params = MemeticParams<S::Params, L::Params>;
393 type State = MemeticState<S::State>;
394 type Genome = Tensor<B, 2>;
395
396 /// Delegates to the inner strategy's `init` and seeds the memetic
397 /// generation counter to zero.
398 fn init(
399 &self,
400 params: &Self::Params,
401 rng: &mut dyn Rng,
402 device: &<B as burn::tensor::backend::BackendTypes>::Device,
403 ) -> Self::State {
404 let inner: S::State = self.inner.init(¶ms.inner, rng, device);
405 MemeticState {
406 inner,
407 generation: 0,
408 }
409 }
410
411 /// Pure delegation to the inner strategy's `ask`. The generation counter is
412 /// unchanged here — it increments only in [`tell`](Self::tell).
413 fn ask(
414 &self,
415 params: &Self::Params,
416 state: &Self::State,
417 rng: &mut dyn Rng,
418 device: &<B as burn::tensor::backend::BackendTypes>::Device,
419 ) -> (Self::Genome, Self::State) {
420 let (population, inner): (Tensor<B, 2>, S::State) =
421 self.inner.ask(¶ms.inner, &state.inner, rng, device);
422 (
423 population,
424 MemeticState {
425 inner,
426 generation: state.generation,
427 },
428 )
429 }
430
431 /// Refines a covered subset of the population, writes back the refined gains
432 /// per the [`WritebackPolicy`], then delegates to the inner `tell`.
433 ///
434 /// # Flow
435 ///
436 /// 1. Host-pull the fitness vector and one flat read-only host copy of the
437 /// population; read `[pop_size, dim]` and the device.
438 /// 2. Compute coverage indices ([`Full`](CoveragePolicy::Full) = all;
439 /// [`TopK`](CoveragePolicy::TopK) = the `k` largest fitnesses, ties by
440 /// lower index), then process them in ascending index order so RNG
441 /// consumption is a pure function of the `(fitness, index)` ranking.
442 /// 3. Draw **exactly one** `rng.next_u64()` unconditionally (so the harness
443 /// RNG stream position is policy-invariant) and derive two independent
444 /// sub-streams: `ls_rng` for refinement
445 /// ([`SeedPurpose::LocalSearch`]) and `mask_rng` for the writeback
446 /// Bernoulli ([`SeedPurpose::Replacement`]). The split is load-bearing:
447 /// mask draws never perturb refinement draws, which makes `Partial(1.0)`
448 /// bit-identical to `Lamarckian` and `Partial(0.0)` to `Baldwinian`.
449 /// 4. Lock the fitness once, refine each covered row, always set
450 /// `refined_fit[i]` to the refined fitness, and decide writeback
451 /// (Lamarckian → always; Baldwinian → never; `Partial(p)` → one
452 /// `mask_rng` Bernoulli per refined index).
453 /// 5. Write back only Lamarckian rows via `slice_assign` onto the *original*
454 /// population tensor. When there are zero writeback rows, the exact tensor
455 /// returned by `ask` is handed to the inner `tell` — no host round-trip.
456 /// 6. Rebuild the fitness tensor and delegate to the inner `tell`, returning
457 /// its metrics verbatim alongside `generation + 1`.
458 ///
459 /// Refinement runs on **every** `tell`, including the first. For a wrapped
460 /// DE this means gen-0 refinement happens before DE's empty-fitness sentinel
461 /// stash; under Baldwinian writeback the inner population still carries the
462 /// *unrefined* genomes but the *refined* fitness, which raises DE's greedy
463 /// replacement bar — the intended Baldwin effect.
464 ///
465 /// Refined fitness is **never** clamped against the old fitness: the
466 /// [`LocalSearch`] contract already guarantees monotone non-worsening, and
467 /// clamping would manufacture a stale fitness on Lamarckian rows.
468 fn tell(
469 &self,
470 params: &Self::Params,
471 population: Self::Genome,
472 fitness: Tensor<B, 1>,
473 state: Self::State,
474 rng: &mut dyn Rng,
475 ) -> (Self::State, StrategyMetrics) {
476 let generation: u64 = state.generation;
477
478 // (1) Host-pull fitness and one flat read-only host copy of the population.
479 let mut refined_fit: Vec<f32> = fitness
480 .into_data()
481 .into_vec::<f32>()
482 .expect("fitness tensor must be readable as f32");
483 let dims: [usize; 2] = population.dims();
484 let pop_size: usize = dims[0];
485 let dim: usize = dims[1];
486 let device: B::Device = population.device();
487 let flat: Vec<f32> = population
488 .to_data()
489 .into_vec::<f32>()
490 .expect("population tensor must be readable as f32");
491
492 // (2) Coverage indices, processed in ascending index order.
493 let mut indices: Vec<usize> = coverage_indices(¶ms.coverage, &refined_fit, pop_size);
494 indices.sort_unstable();
495
496 // (3) Exactly one host RNG draw — unconditionally — so the harness
497 // stream position is policy- and coverage-invariant.
498 let base: u64 = rng.next_u64();
499 let mut ls_rng = seed_stream(base, generation, SeedPurpose::LocalSearch);
500 let mut mask_rng = seed_stream(base, generation, SeedPurpose::Replacement);
501
502 // `WritebackPolicy::Partial` now carries a `Probability` (valid by
503 // construction), so the old debug-only range assert is unnecessary —
504 // see ADR 0031.
505
506 // (4) Refine each covered row; collect Lamarckian writebacks.
507 let mut writeback_rows: Vec<(usize, Vec<f32>)> = Vec::with_capacity(indices.len());
508 {
509 let mut guard = self.fitness.lock();
510 // Read the sense before the mutable borrow for `inner`; local
511 // search runs in canonical space, so `RowFitness` canonicalises.
512 let sense = guard.sense();
513 let mut row_fitness: RowFitness<'_, B, F> = RowFitness {
514 inner: &mut *guard,
515 device: &device,
516 sense,
517 };
518 for &i in &indices {
519 let start: usize = i * dim;
520 let row: Vec<f32> = flat[start..start + dim].to_vec();
521 // The harness already scored this row this generation; hand that
522 // fitness to the searcher so it skips the seeding eval instead of
523 // re-scoring its own input. `refined_fit[i]` still holds the
524 // original harness value — it is overwritten only below, and each
525 // covered index `i` is distinct.
526 let known_fit: f32 = refined_fit[i];
527 let (refined, f_refined): (Vec<f32>, f32) = self.local.refine_with_known_fitness(
528 ¶ms.local,
529 row,
530 known_fit,
531 &mut row_fitness,
532 &mut ls_rng,
533 );
534 debug_assert_eq!(
535 refined.len(),
536 dim,
537 "local search must preserve genome length"
538 );
539 // Baldwinian keeps the original genome but pays refined fitness;
540 // Lamarckian writes the genome too. Either way the fitness is
541 // the refined value.
542 refined_fit[i] = f_refined;
543 let writeback: bool = match params.writeback {
544 WritebackPolicy::Lamarckian => true,
545 WritebackPolicy::Baldwinian => false,
546 // One Bernoulli draw per refined index, from the dedicated
547 // mask stream, so Lamarckian/Baldwinian runs share an
548 // identical `ls_rng` schedule (they draw nothing here).
549 WritebackPolicy::Partial(p) => mask_rng.random::<f32>() < p.get(),
550 };
551 if writeback {
552 writeback_rows.push((i, refined));
553 }
554 }
555 } // guard dropped before delegating to the inner tell.
556
557 // (5) Writeback. Start from the ORIGINAL population tensor (moved,
558 // untouched). With zero writeback rows the inner `tell` receives the
559 // exact tensor `ask` returned — no host round-trip, no rebuild.
560 //
561 // `writeback_rows` is strictly ascending (indices sorted in step 2), so
562 // we coalesce maximal runs of consecutive indices into ONE `[run_len,
563 // dim]` upload + ONE `slice_assign` each. Under `CoveragePolicy::Full`
564 // this collapses to a single upload + slice_assign rather than
565 // `pop_size` of each. The uploaded bytes are identical to a per-row
566 // loop, so the module's bit-identity tests still hold.
567 let mut new_pop: Tensor<B, 2> = population;
568 let mut run_start: Option<usize> = None;
569 let mut run_len: usize = 0;
570 let mut run_buf: Vec<f32> = Vec::new();
571 for (i, row) in writeback_rows {
572 match run_start {
573 // Contiguous with the open run: append and grow it.
574 Some(s) if i == s + run_len => {
575 run_buf.extend(row);
576 run_len += 1;
577 }
578 // Gap: flush the open run, then open a fresh one at `i`.
579 Some(s) => {
580 let flushed: Tensor<B, 2> = Tensor::<B, 2>::from_data(
581 TensorData::new(core::mem::take(&mut run_buf), [run_len, dim]),
582 &device,
583 );
584 new_pop = new_pop.slice_assign([s..s + run_len, 0..dim], flushed);
585 run_start = Some(i);
586 run_len = 1;
587 run_buf = row;
588 }
589 // First writeback row: open the initial run.
590 None => {
591 run_start = Some(i);
592 run_len = 1;
593 run_buf = row;
594 }
595 }
596 }
597 // Flush the trailing run (empty only when there were no writebacks).
598 if let (Some(s), false) = (run_start, run_buf.is_empty()) {
599 let flushed: Tensor<B, 2> =
600 Tensor::<B, 2>::from_data(TensorData::new(run_buf, [run_len, dim]), &device);
601 new_pop = new_pop.slice_assign([s..s + run_len, 0..dim], flushed);
602 }
603
604 // (6) Rebuild fitness and delegate.
605 let new_fit: Tensor<B, 1> =
606 Tensor::<B, 1>::from_data(TensorData::new(refined_fit, [pop_size]), &device);
607 let (inner, metrics): (S::State, StrategyMetrics) =
608 self.inner
609 .tell(¶ms.inner, new_pop, new_fit, state.inner, rng);
610 (
611 MemeticState {
612 inner,
613 generation: generation + 1,
614 },
615 metrics,
616 )
617 }
618
619 /// Delegates to the inner strategy's `best`.
620 fn best(&self, state: &Self::State) -> Option<(Self::Genome, f32)> {
621 self.inner.best(&state.inner)
622 }
623}
624
625/// Computes the refinement coverage indices for a generation (unsorted).
626///
627/// `Full` yields `0..pop_size`; `TopK { k }` yields the indices of the `k`
628/// largest fitness values (canonical maximise: higher is fitter), ties broken
629/// by lower index (stable sort over `(fitness, index)`), with `k` clamped to
630/// `pop_size`. The caller is responsible for sorting the result into ascending
631/// index order.
632fn coverage_indices(policy: &CoveragePolicy, fitness: &[f32], pop_size: usize) -> Vec<usize> {
633 match *policy {
634 CoveragePolicy::Full => (0..pop_size).collect(),
635 CoveragePolicy::TopK { k } => {
636 let k: usize = k.min(pop_size);
637 let mut ranked: Vec<usize> = (0..pop_size).collect();
638 // Sanitize NaN → −inf (worst) so a NaN-fitness member can never be
639 // covered as a top-k member. Stable sort by (fitness desc, index):
640 // `sort_by` is stable so equal fitnesses keep ascending-index order,
641 // making ties break by lower index.
642 let sane: Vec<f32> = fitness
643 .iter()
644 .map(|&f| crate::fitness::sanitize_fitness(f))
645 .collect();
646 ranked.sort_by(|&a, &b| sane[b].total_cmp(&sane[a]));
647 ranked.truncate(k);
648 ranked
649 }
650 }
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656 use crate::algorithms::de::{DeConfig, DifferentialEvolution};
657 use crate::algorithms::ga::{GaConfig, GeneticAlgorithm};
658 use crate::local_search::{
659 HillClimbing, HillClimbingParams, SimulatedAnnealing, SimulatedAnnealingParams,
660 };
661 use crate::strategy::EvolutionaryHarness;
662 use burn::backend::Flex;
663 use burn::tensor::backend::BackendTypes;
664 use rand::SeedableRng;
665 use rand::rngs::StdRng;
666 use rlevo_core::bounds::Bounds;
667
668 type TestBackend = Flex;
669
670 #[test]
671 fn memetic_state_new_round_trips() {
672 let mut state = MemeticState::new(7_u32, 3);
673 assert_eq!(*state.inner(), 7);
674 assert_eq!(state.generation(), 3);
675 *state.inner_mut() = 11;
676 assert_eq!(*state.inner(), 11);
677 }
678
679 const BOUNDS: Bounds = Bounds::new(-5.12, 5.12);
680
681 // ---------------------------------------------------------------------
682 // Probes.
683 // ---------------------------------------------------------------------
684
685 /// A strategy probe (mirrors the `strategy.rs` Constant-probe pattern):
686 /// `ask` returns a fixed population built from `state`; `tell` records the
687 /// exact population tensor + fitness it received so a test can assert on
688 /// them. No real evolutionary dynamics.
689 #[derive(Debug, Clone, Copy)]
690 struct RecordingStrategy;
691
692 #[derive(Debug, Clone)]
693 struct RecParams {
694 /// The fixed population every `ask` returns (row-major, `[pop, dim]`).
695 rows: Vec<f32>,
696 pop: usize,
697 dim: usize,
698 }
699
700 #[derive(Debug, Clone)]
701 struct RecState {
702 /// Population tensor handed to the most recent `tell`, as flat host f32.
703 received_pop: Option<Vec<f32>>,
704 /// Fitness handed to the most recent `tell`, as host f32.
705 received_fit: Option<Vec<f32>>,
706 best: f32,
707 generation: usize,
708 }
709
710 impl Strategy<TestBackend> for RecordingStrategy {
711 type Params = RecParams;
712 type State = RecState;
713 type Genome = Tensor<TestBackend, 2>;
714
715 fn init(
716 &self,
717 _params: &RecParams,
718 _rng: &mut dyn Rng,
719 _device: &<TestBackend as BackendTypes>::Device,
720 ) -> RecState {
721 RecState {
722 received_pop: None,
723 received_fit: None,
724 best: f32::NEG_INFINITY,
725 generation: 0,
726 }
727 }
728
729 fn ask(
730 &self,
731 params: &RecParams,
732 state: &RecState,
733 _rng: &mut dyn Rng,
734 device: &<TestBackend as BackendTypes>::Device,
735 ) -> (Tensor<TestBackend, 2>, RecState) {
736 let data = TensorData::new(params.rows.clone(), [params.pop, params.dim]);
737 let pop = Tensor::<TestBackend, 2>::from_data(data, device);
738 (pop, state.clone())
739 }
740
741 fn tell(
742 &self,
743 _params: &RecParams,
744 population: Tensor<TestBackend, 2>,
745 fitness: Tensor<TestBackend, 1>,
746 mut state: RecState,
747 _rng: &mut dyn Rng,
748 ) -> (RecState, StrategyMetrics) {
749 let pop_host = population
750 .into_data()
751 .into_vec::<f32>()
752 .expect("population host-read of a tensor this test just built");
753 let fit_host = fitness
754 .into_data()
755 .into_vec::<f32>()
756 .expect("fitness host-read of a tensor this test just built");
757 state.received_pop = Some(pop_host);
758 state.received_fit = Some(fit_host.clone());
759 state.generation += 1;
760 let metrics =
761 StrategyMetrics::from_host_fitness(state.generation, &fit_host, state.best);
762 state.best = metrics.best_fitness_ever();
763 (state, metrics)
764 }
765
766 fn best(&self, _state: &RecState) -> Option<(Tensor<TestBackend, 2>, f32)> {
767 None
768 }
769 }
770
771 /// Negated-sphere fitness (a maximise objective with optimum 0 at the
772 /// origin) counting the total number of evaluated ROWS. Each
773 /// `RowFitness::evaluate_one` is one `[1, D]` batch, so refinement evals are
774 /// counted too.
775 #[derive(Debug, Default)]
776 struct CountingBatchFitness {
777 rows: usize,
778 }
779
780 impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2>> for CountingBatchFitness {
781 fn evaluate_batch(
782 &mut self,
783 population: &Tensor<B, 2>,
784 device: &<B as BackendTypes>::Device,
785 ) -> Tensor<B, 1> {
786 let dims = population.dims();
787 self.rows += dims[0];
788 let flat = population
789 .clone()
790 .into_data()
791 .into_vec::<f32>()
792 .expect("population host-read of a tensor this test just built");
793 let (pop, dim) = (dims[0], dims[1]);
794 let mut out = Vec::with_capacity(pop);
795 for r in 0..pop {
796 let start = r * dim;
797 let f: f32 = -flat[start..start + dim].iter().map(|v| v * v).sum::<f32>();
798 out.push(f);
799 }
800 Tensor::<B, 1>::from_data(TensorData::new(out, [pop]), device)
801 }
802
803 fn sense(&self) -> ObjectiveSense {
804 ObjectiveSense::Maximize
805 }
806 }
807
808 /// Negated sphere on a flat host genome — the canonical fitness of a row,
809 /// for re-deriving expected values. Higher (closer to 0) is better.
810 fn neg_sphere(row: &[f32]) -> f32 {
811 -row.iter().map(|v| v * v).sum::<f32>()
812 }
813
814 fn rec_params(rows: Vec<f32>, pop: usize, dim: usize) -> RecParams {
815 RecParams { rows, pop, dim }
816 }
817
818 /// A deterministic, spread-out population whose fitnesses are all distinct.
819 fn fixed_population(pop: usize, dim: usize) -> Vec<f32> {
820 let mut rows = Vec::with_capacity(pop * dim);
821 for r in 0..pop {
822 for c in 0..dim {
823 #[allow(clippy::cast_precision_loss)]
824 let v = 0.5 + (r as f32) * 0.37 + (c as f32) * 0.11;
825 rows.push(v);
826 }
827 }
828 rows
829 }
830
831 // ---------------------------------------------------------------------
832 // 0. Documented defaults (falsifiable equality checks).
833 // ---------------------------------------------------------------------
834
835 #[test]
836 fn writeback_policy_default_is_partial_half() {
837 assert_eq!(
838 WritebackPolicy::default(),
839 WritebackPolicy::Partial(Probability::new(0.5))
840 );
841 }
842
843 #[test]
844 fn coverage_policy_default_is_top_k_one() {
845 assert_eq!(CoveragePolicy::default(), CoveragePolicy::TopK { k: 1 });
846 }
847
848 #[test]
849 fn coverage_indices_never_covers_nan_fitness() {
850 // NaN sanitises to −inf (worst), so a NaN-fitness member must never be
851 // selected as a top-k covered member ahead of a finite one.
852 let fitness = [3.0f32, f32::NAN, 5.0, 1.0];
853 let top3 = coverage_indices(&CoveragePolicy::TopK { k: 3 }, &fitness, 4);
854 // Best-first among finite fitnesses: 5.0 (idx 2), 3.0 (idx 0), 1.0 (idx 3);
855 // the NaN member (idx 1) is excluded.
856 assert_eq!(top3, vec![2, 0, 3]);
857 assert!(!top3.contains(&1));
858 // Covering all four ranks the NaN member last.
859 let all = coverage_indices(&CoveragePolicy::TopK { k: 4 }, &fitness, 4);
860 assert_eq!(all, vec![2, 0, 3, 1]);
861 }
862
863 #[test]
864 fn coverage_indices_topk_zero_is_empty() {
865 // `TopK { k: 0 }` refines nobody — a degenerate but valid no-op policy.
866 let cover = coverage_indices(&CoveragePolicy::TopK { k: 0 }, &[3.0, 1.0, 2.0], 3);
867 assert!(cover.is_empty(), "TopK{{0}} must cover no rows");
868 }
869
870 #[test]
871 fn coverage_indices_empty_population_is_empty() {
872 // Empty population: every policy yields an empty coverage set, so the
873 // refinement loop is a no-op (no rows to refine, no writeback).
874 assert!(coverage_indices(&CoveragePolicy::Full, &[], 0).is_empty());
875 assert!(coverage_indices(&CoveragePolicy::TopK { k: 3 }, &[], 0).is_empty());
876 }
877
878 #[test]
879 fn coverage_indices_all_equal_breaks_ties_by_lowest_index() {
880 // All-equal fitness: the stable (fitness desc, index asc) sort must
881 // select the lowest indices, so `TopK { k: 2 }` is exactly [0, 1].
882 let fitness = [5.0f32; 4];
883 let top2 = coverage_indices(&CoveragePolicy::TopK { k: 2 }, &fitness, 4);
884 assert_eq!(top2, vec![0, 1], "ties must break toward the lowest index");
885 }
886
887 // ---------------------------------------------------------------------
888 // Minimize-sense refinement path.
889 // ---------------------------------------------------------------------
890
891 /// A `Minimize` sphere: `evaluate_batch` returns the raw sum-of-squares
892 /// *cost* (higher is worse) and declares [`ObjectiveSense::Minimize`]. This
893 /// exercises the wrapper's canonicalisation seam ([`RowFitness`] flips the
894 /// natural cost into canonical maximise space), which every other fixture in
895 /// this module leaves untested because they are all `Maximize`.
896 #[derive(Debug, Default)]
897 struct MinSphereBatch;
898 impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2>> for MinSphereBatch {
899 fn evaluate_batch(
900 &mut self,
901 population: &Tensor<B, 2>,
902 device: &<B as BackendTypes>::Device,
903 ) -> Tensor<B, 1> {
904 let dims = population.dims();
905 let flat = population
906 .clone()
907 .into_data()
908 .into_vec::<f32>()
909 .expect("population host-read of a tensor this test just built");
910 let (pop, dim) = (dims[0], dims[1]);
911 let mut out: Vec<f32> = Vec::with_capacity(pop);
912 for r in 0..pop {
913 let start = r * dim;
914 out.push(flat[start..start + dim].iter().map(|v| v * v).sum::<f32>());
915 }
916 Tensor::<B, 1>::from_data(TensorData::new(out, [pop]), device)
917 }
918
919 fn sense(&self) -> ObjectiveSense {
920 ObjectiveSense::Minimize
921 }
922 }
923
924 /// Raw sphere cost of a host row (the `Minimize` natural objective).
925 fn sphere_cost(row: &[f32]) -> f32 {
926 row.iter().map(|v| v * v).sum::<f32>()
927 }
928
929 /// The Minimize path must *lower* cost. Under `Full`/`Lamarckian` coverage
930 /// the local searcher runs in canonical maximise space, so for every covered
931 /// row the refined natural cost must not increase, the canonical fitness
932 /// handed to the inner `tell` must not decrease, and that fitness must equal
933 /// `−cost` of the written-back row. At least one row must strictly improve.
934 #[test]
935 #[allow(clippy::float_cmp)]
936 fn minimize_sense_refinement_reduces_cost() {
937 let device = <TestBackend as BackendTypes>::Device::default();
938 let (pop, dim) = (5usize, 3usize);
939 let rows = fixed_population(pop, dim);
940
941 let strategy = MemeticWrapper::<TestBackend, _, _, _>::new(
942 RecordingStrategy,
943 HillClimbing,
944 MinSphereBatch,
945 );
946 let params = MemeticParams {
947 inner: rec_params(rows, pop, dim),
948 local: HillClimbingParams::default_for(BOUNDS),
949 writeback: WritebackPolicy::Lamarckian,
950 coverage: CoveragePolicy::Full,
951 };
952
953 let mut rng = StdRng::seed_from_u64(9);
954 let state = strategy.init(¶ms, &mut rng, &device);
955 let (ask_pop, asked) = strategy.ask(¶ms, &state, &mut rng, &device);
956 let ask_bytes = ask_pop
957 .clone()
958 .into_data()
959 .into_vec::<f32>()
960 .expect("population host-read of a tensor this test just built");
961
962 // Seed fitness in canonical (maximise) space: for a Minimize objective
963 // the harness hands the strategy `−cost`, so mirror that here.
964 let canonical: Vec<f32> = (0..pop)
965 .map(|i| {
966 let s = i * dim;
967 -sphere_cost(&ask_bytes[s..s + dim])
968 })
969 .collect();
970 let fit =
971 Tensor::<TestBackend, 1>::from_data(TensorData::new(canonical.clone(), [pop]), &device);
972
973 let (next, _m) = strategy.tell(¶ms, ask_pop, fit, asked, &mut rng);
974 let recv_pop = next.inner.received_pop.clone().unwrap();
975 let recv_fit = next.inner.received_fit.clone().unwrap();
976
977 let mut any_improved = false;
978 // Indexing several parallel host buffers by row; an iterator over one of
979 // them would not read more clearly than the explicit row index.
980 #[allow(clippy::needless_range_loop)]
981 for i in 0..pop {
982 let s = i * dim;
983 let recv_row = &recv_pop[s..s + dim];
984 let ask_row = &ask_bytes[s..s + dim];
985 let recv_cost = sphere_cost(recv_row);
986 let ask_cost = sphere_cost(ask_row);
987 // Refinement never worsens the natural cost (minimise).
988 assert!(
989 recv_cost <= ask_cost + 1e-6,
990 "row {i}: refined cost {recv_cost} must not exceed original {ask_cost}"
991 );
992 // Canonical fitness handed to `tell` never decreases.
993 assert!(
994 recv_fit[i] >= canonical[i] - 1e-6,
995 "row {i}: canonical fitness must not drop"
996 );
997 // And it equals `−cost` of the written-back row.
998 approx::assert_relative_eq!(recv_fit[i], -recv_cost, epsilon = 1e-5);
999 if recv_cost < ask_cost - 1e-6 {
1000 any_improved = true;
1001 }
1002 }
1003 assert!(
1004 any_improved,
1005 "the Minimize path must strictly reduce cost on at least one row"
1006 );
1007 }
1008
1009 // ---------------------------------------------------------------------
1010 // 1. Baldwinian bit-identity.
1011 // ---------------------------------------------------------------------
1012
1013 #[test]
1014 #[allow(clippy::float_cmp)]
1015 fn baldwinian_population_bit_identical_to_ask() {
1016 let device = <TestBackend as BackendTypes>::Device::default();
1017 let (pop, dim) = (5usize, 3usize);
1018 let rows = fixed_population(pop, dim);
1019
1020 let strategy = MemeticWrapper::<TestBackend, _, _, _>::new(
1021 RecordingStrategy,
1022 HillClimbing,
1023 CountingBatchFitness::default(),
1024 );
1025 let params = MemeticParams {
1026 inner: rec_params(rows.clone(), pop, dim),
1027 local: HillClimbingParams::default_for(BOUNDS),
1028 writeback: WritebackPolicy::Baldwinian,
1029 coverage: CoveragePolicy::TopK { k: 2 },
1030 };
1031
1032 let mut rng = StdRng::seed_from_u64(7);
1033 let state = strategy.init(¶ms, &mut rng, &device);
1034 let (ask_pop, asked) = strategy.ask(¶ms, &state, &mut rng, &device);
1035 let ask_bytes = ask_pop
1036 .clone()
1037 .into_data()
1038 .into_vec::<f32>()
1039 .expect("population host-read of a tensor this test just built");
1040 // Original fitness for the asked population.
1041 let mut orig_fit = CountingBatchFitness::default();
1042 let orig = <CountingBatchFitness as BatchFitnessFn<TestBackend, _>>::evaluate_batch(
1043 &mut orig_fit,
1044 &ask_pop,
1045 &device,
1046 )
1047 .into_data()
1048 .into_vec::<f32>()
1049 .expect("fitness host-read of a tensor this test just built");
1050 let fit =
1051 Tensor::<TestBackend, 1>::from_data(TensorData::new(orig.clone(), [pop]), &device);
1052
1053 let (next, _m) = strategy.tell(¶ms, ask_pop, fit, asked, &mut rng);
1054
1055 // Population handed to RecordingStrategy::tell is byte-identical to ask.
1056 let recv_pop = next.inner.received_pop.clone().unwrap();
1057 assert_eq!(recv_pop, ask_bytes, "Baldwinian must not alter the genome");
1058
1059 // Covered rows (TopK{2} = the two fittest, highest-canonical rows) have
1060 // refined fitness >= original (canonical maximise); all others
1061 // unchanged. Covered = indices 0,1 here (canonical −sphere fitness
1062 // decreases with row index for this population, so the lowest indices
1063 // are the fittest).
1064 let recv_fit = next.inner.received_fit.clone().unwrap();
1065 for i in 0..pop {
1066 if i < 2 {
1067 assert!(
1068 recv_fit[i] >= orig[i],
1069 "covered row {i}: refined {} must be >= original {}",
1070 recv_fit[i],
1071 orig[i]
1072 );
1073 // The refined fitness cannot exceed the global maximum of the
1074 // negated-sphere objective (0 at the origin).
1075 assert!(recv_fit[i] <= 1e-6);
1076 } else {
1077 assert_eq!(recv_fit[i], orig[i], "uncovered row {i} must be unchanged");
1078 }
1079 }
1080 }
1081
1082 // ---------------------------------------------------------------------
1083 // 2. Lamarckian row equality.
1084 // ---------------------------------------------------------------------
1085
1086 #[test]
1087 #[allow(clippy::float_cmp)]
1088 fn lamarckian_covered_rows_change_uncovered_identical() {
1089 let device = <TestBackend as BackendTypes>::Device::default();
1090 let (pop, dim) = (5usize, 3usize);
1091 let rows = fixed_population(pop, dim);
1092
1093 let strategy = MemeticWrapper::<TestBackend, _, _, _>::new(
1094 RecordingStrategy,
1095 HillClimbing,
1096 CountingBatchFitness::default(),
1097 );
1098 let params = MemeticParams {
1099 inner: rec_params(rows.clone(), pop, dim),
1100 local: HillClimbingParams::default_for(BOUNDS),
1101 writeback: WritebackPolicy::Lamarckian,
1102 coverage: CoveragePolicy::TopK { k: 2 },
1103 };
1104
1105 let mut rng = StdRng::seed_from_u64(11);
1106 let state = strategy.init(¶ms, &mut rng, &device);
1107 let (ask_pop, asked) = strategy.ask(¶ms, &state, &mut rng, &device);
1108 let ask_bytes = ask_pop
1109 .clone()
1110 .into_data()
1111 .into_vec::<f32>()
1112 .expect("population host-read of a tensor this test just built");
1113 let mut fitfn = CountingBatchFitness::default();
1114 let orig = <CountingBatchFitness as BatchFitnessFn<TestBackend, _>>::evaluate_batch(
1115 &mut fitfn, &ask_pop, &device,
1116 )
1117 .into_data()
1118 .into_vec::<f32>()
1119 .expect("fitness host-read of a tensor this test just built");
1120 let fit = Tensor::<TestBackend, 1>::from_data(TensorData::new(orig, [pop]), &device);
1121
1122 let (next, _m) = strategy.tell(¶ms, ask_pop, fit, asked, &mut rng);
1123 let recv_pop = next.inner.received_pop.clone().unwrap();
1124 let recv_fit = next.inner.received_fit.clone().unwrap();
1125
1126 // Indexing several parallel host buffers by row; an iterator over one of
1127 // them would not read more clearly than the explicit row index.
1128 #[allow(clippy::needless_range_loop)]
1129 for i in 0..pop {
1130 let start = i * dim;
1131 let recv_row = &recv_pop[start..start + dim];
1132 let ask_row = &ask_bytes[start..start + dim];
1133 if i < 2 {
1134 // Covered rows changed (HillClimbing improves the negated
1135 // sphere from a non-optimal start).
1136 assert_ne!(recv_row, ask_row, "covered row {i} should have changed");
1137 // received fitness[i] equals a fresh canonical eval of received
1138 // row i (the negated sphere).
1139 approx::assert_relative_eq!(recv_fit[i], neg_sphere(recv_row), epsilon = 1e-5);
1140 } else {
1141 assert_eq!(recv_row, ask_row, "uncovered row {i} must be bit-identical");
1142 }
1143 }
1144 }
1145
1146 /// Non-contiguous Lamarckian writeback: the covered set has gaps, forcing
1147 /// the step-5 run coalescer down its flush-on-gap path (multiple runs)
1148 /// rather than the single-run `Full` fast path. Covered rows must change,
1149 /// the gap rows must stay byte-identical to `ask`, and every covered
1150 /// fitness must equal a fresh canonical eval of the written-back row.
1151 #[test]
1152 #[allow(clippy::float_cmp)]
1153 fn lamarckian_noncontiguous_covered_rows_coalesce_correctly() {
1154 let device = <TestBackend as BackendTypes>::Device::default();
1155 let (pop, dim) = (5usize, 3usize);
1156 // Rows 0, 2, 4 sit near the origin (high canonical negated-sphere
1157 // fitness); rows 1, 3 are far out. `TopK{3}` therefore selects the
1158 // non-contiguous set {0, 2, 4}, which sorts to a gapped index list.
1159 let rows: Vec<f32> = vec![
1160 0.3, 0.3, 0.3, // row 0 — fit
1161 3.0, 3.0, 3.0, // row 1 — unfit
1162 0.4, 0.4, 0.4, // row 2 — fit
1163 3.5, 3.5, 3.5, // row 3 — unfit
1164 0.5, 0.5, 0.5, // row 4 — fit
1165 ];
1166
1167 let strategy = MemeticWrapper::<TestBackend, _, _, _>::new(
1168 RecordingStrategy,
1169 HillClimbing,
1170 CountingBatchFitness::default(),
1171 );
1172 let params = MemeticParams {
1173 inner: rec_params(rows.clone(), pop, dim),
1174 local: HillClimbingParams::default_for(BOUNDS),
1175 writeback: WritebackPolicy::Lamarckian,
1176 coverage: CoveragePolicy::TopK { k: 3 },
1177 };
1178
1179 let mut rng = StdRng::seed_from_u64(13);
1180 let state = strategy.init(¶ms, &mut rng, &device);
1181 let (ask_pop, asked) = strategy.ask(¶ms, &state, &mut rng, &device);
1182 let ask_bytes = ask_pop
1183 .clone()
1184 .into_data()
1185 .into_vec::<f32>()
1186 .expect("population host-read of a tensor this test just built");
1187 let mut fitfn = CountingBatchFitness::default();
1188 let orig = <CountingBatchFitness as BatchFitnessFn<TestBackend, _>>::evaluate_batch(
1189 &mut fitfn, &ask_pop, &device,
1190 )
1191 .into_data()
1192 .into_vec::<f32>()
1193 .expect("fitness host-read of a tensor this test just built");
1194 let fit = Tensor::<TestBackend, 1>::from_data(TensorData::new(orig, [pop]), &device);
1195
1196 let (next, _m) = strategy.tell(¶ms, ask_pop, fit, asked, &mut rng);
1197 let recv_pop = next.inner.received_pop.clone().unwrap();
1198 let recv_fit = next.inner.received_fit.clone().unwrap();
1199
1200 // Rows 0, 2, 4 are the covered (non-contiguous) set; 1, 3 are the gaps.
1201 let covered = [true, false, true, false, true];
1202 // Indexing several parallel host buffers by row; an iterator over one of
1203 // them would not read more clearly than the explicit row index.
1204 #[allow(clippy::needless_range_loop)]
1205 for i in 0..pop {
1206 let start = i * dim;
1207 let recv_row = &recv_pop[start..start + dim];
1208 let ask_row = &ask_bytes[start..start + dim];
1209 if covered[i] {
1210 // Covered rows changed (HillClimbing improves the negated
1211 // sphere from a non-optimal start).
1212 assert_ne!(recv_row, ask_row, "covered row {i} should have changed");
1213 // received fitness[i] equals a fresh canonical eval of received
1214 // row i (the negated sphere) — the coalesced upload preserved
1215 // the exact refined bytes.
1216 approx::assert_relative_eq!(recv_fit[i], neg_sphere(recv_row), epsilon = 1e-5);
1217 } else {
1218 // Gap rows must be bit-identical across the coalesced runs.
1219 assert_eq!(recv_row, ask_row, "gap row {i} must be bit-identical");
1220 }
1221 }
1222 }
1223
1224 /// Multi-row-run Lamarckian writeback: the covered set is shaped as a run
1225 /// of length 2, a gap, a run of length 3, a gap, then a singleton
1226 /// ({0,1,3,4,5,7} on pop 8). This is the one arrangement that drives the
1227 /// step-5 coalescer through the `extend` arm (growing `run_len` past 1)
1228 /// and THEN the gap-flush arm with a multi-row `run_buf` — the interaction
1229 /// the singleton-run test cannot reach. Covered rows must change, gap rows
1230 /// stay byte-identical to `ask`, and each covered fitness must equal a
1231 /// fresh canonical eval of the coalesced-back row.
1232 #[test]
1233 #[allow(clippy::float_cmp)]
1234 fn lamarckian_multirow_runs_coalesce_correctly() {
1235 let device = <TestBackend as BackendTypes>::Device::default();
1236 let (pop, dim) = (8usize, 3usize);
1237 // Rows 0,1,3,4,5,7 sit near the origin (high canonical negated-sphere
1238 // fitness); the gap rows 2, 6 are far out. `TopK{6}` therefore selects
1239 // the covered set {0,1,3,4,5,7}, whose runs are lengths 2, 3, 1.
1240 let near: [f32; 3] = [0.3, 0.3, 0.3];
1241 let far: [f32; 3] = [4.0, 4.0, 4.0];
1242 let gaps = [2usize, 6usize];
1243 let mut rows: Vec<f32> = Vec::with_capacity(pop * dim);
1244 for r in 0..pop {
1245 if gaps.contains(&r) {
1246 rows.extend_from_slice(&far);
1247 } else {
1248 rows.extend_from_slice(&near);
1249 }
1250 }
1251
1252 let strategy = MemeticWrapper::<TestBackend, _, _, _>::new(
1253 RecordingStrategy,
1254 HillClimbing,
1255 CountingBatchFitness::default(),
1256 );
1257 let params = MemeticParams {
1258 inner: rec_params(rows.clone(), pop, dim),
1259 local: HillClimbingParams::default_for(BOUNDS),
1260 writeback: WritebackPolicy::Lamarckian,
1261 coverage: CoveragePolicy::TopK { k: 6 },
1262 };
1263
1264 let mut rng = StdRng::seed_from_u64(17);
1265 let state = strategy.init(¶ms, &mut rng, &device);
1266 let (ask_pop, asked) = strategy.ask(¶ms, &state, &mut rng, &device);
1267 let ask_bytes = ask_pop
1268 .clone()
1269 .into_data()
1270 .into_vec::<f32>()
1271 .expect("population host-read of a tensor this test just built");
1272 let mut fitfn = CountingBatchFitness::default();
1273 let orig = <CountingBatchFitness as BatchFitnessFn<TestBackend, _>>::evaluate_batch(
1274 &mut fitfn, &ask_pop, &device,
1275 )
1276 .into_data()
1277 .into_vec::<f32>()
1278 .expect("fitness host-read of a tensor this test just built");
1279 let fit = Tensor::<TestBackend, 1>::from_data(TensorData::new(orig, [pop]), &device);
1280
1281 let (next, _m) = strategy.tell(¶ms, ask_pop, fit, asked, &mut rng);
1282 let recv_pop = next.inner.received_pop.clone().unwrap();
1283 let recv_fit = next.inner.received_fit.clone().unwrap();
1284
1285 // Runs of length 2, 3, 1 with gaps at 2 and 6.
1286 let covered = [true, true, false, true, true, true, false, true];
1287 // Indexing several parallel host buffers by row; an iterator over one of
1288 // them would not read more clearly than the explicit row index.
1289 #[allow(clippy::needless_range_loop)]
1290 for i in 0..pop {
1291 let start = i * dim;
1292 let recv_row = &recv_pop[start..start + dim];
1293 let ask_row = &ask_bytes[start..start + dim];
1294 if covered[i] {
1295 // Covered rows changed (HillClimbing improves the negated
1296 // sphere from a non-optimal start).
1297 assert_ne!(recv_row, ask_row, "covered row {i} should have changed");
1298 // received fitness[i] equals a fresh canonical eval of received
1299 // row i (the negated sphere) — the multi-row coalesced upload
1300 // preserved the exact refined bytes at each in-run offset.
1301 approx::assert_relative_eq!(recv_fit[i], neg_sphere(recv_row), epsilon = 1e-5);
1302 } else {
1303 // Gap rows must be bit-identical, unshifted by the coalescing.
1304 assert_eq!(recv_row, ask_row, "gap row {i} must be bit-identical");
1305 }
1306 }
1307 }
1308
1309 // ---------------------------------------------------------------------
1310 // 3. Partial boundaries (stochastic searcher pins the two-stream split).
1311 // ---------------------------------------------------------------------
1312
1313 /// Drives `gens` wrapper tell cycles and returns the full trajectory of
1314 /// (received population, received fitness) the `RecordingStrategy` saw.
1315 fn sa_trajectory(
1316 writeback: WritebackPolicy,
1317 seed: u64,
1318 gens: usize,
1319 ) -> Vec<(Vec<f32>, Vec<f32>)> {
1320 let device = <TestBackend as BackendTypes>::Device::default();
1321 let (pop, dim) = (4usize, 3usize);
1322 let rows = fixed_population(pop, dim);
1323 let strategy = MemeticWrapper::<TestBackend, _, _, _>::new(
1324 RecordingStrategy,
1325 SimulatedAnnealing,
1326 CountingBatchFitness::default(),
1327 );
1328 let params = MemeticParams {
1329 inner: rec_params(rows, pop, dim),
1330 local: SimulatedAnnealingParams::default_for(BOUNDS),
1331 writeback,
1332 coverage: CoveragePolicy::Full,
1333 };
1334 let mut rng = StdRng::seed_from_u64(seed);
1335 let mut state = strategy.init(¶ms, &mut rng, &device);
1336 let mut trajectory = Vec::with_capacity(gens);
1337 for _ in 0..gens {
1338 let (ask_pop, asked) = strategy.ask(¶ms, &state, &mut rng, &device);
1339 let mut fitfn = CountingBatchFitness::default();
1340 let orig = <CountingBatchFitness as BatchFitnessFn<TestBackend, _>>::evaluate_batch(
1341 &mut fitfn, &ask_pop, &device,
1342 );
1343 let (next, _m) = strategy.tell(¶ms, ask_pop, orig, asked, &mut rng);
1344 trajectory.push((
1345 next.inner.received_pop.clone().unwrap(),
1346 next.inner.received_fit.clone().unwrap(),
1347 ));
1348 state = next;
1349 }
1350 trajectory
1351 }
1352
1353 #[test]
1354 fn partial_one_equals_lamarckian_partial_zero_equals_baldwinian() {
1355 let lam = sa_trajectory(WritebackPolicy::Lamarckian, 33, 3);
1356 let p1 = sa_trajectory(WritebackPolicy::Partial(Probability::new(1.0)), 33, 3);
1357 assert_eq!(lam, p1, "Partial(1.0) must be bit-identical to Lamarckian");
1358
1359 let bald = sa_trajectory(WritebackPolicy::Baldwinian, 33, 3);
1360 let p0 = sa_trajectory(WritebackPolicy::Partial(Probability::new(0.0)), 33, 3);
1361 assert_eq!(bald, p0, "Partial(0.0) must be bit-identical to Baldwinian");
1362 }
1363
1364 // ---------------------------------------------------------------------
1365 // 4. Partial mask seeded-replay.
1366 // ---------------------------------------------------------------------
1367
1368 #[test]
1369 fn partial_is_seed_reproducible_and_seed_sensitive() {
1370 let a = sa_trajectory(WritebackPolicy::Partial(Probability::new(0.5)), 55, 3);
1371 let b = sa_trajectory(WritebackPolicy::Partial(Probability::new(0.5)), 55, 3);
1372 assert_eq!(a, b, "same seed must replay identically");
1373
1374 // A different seed (almost surely) yields a different writeback pattern.
1375 let c = sa_trajectory(WritebackPolicy::Partial(Probability::new(0.5)), 56, 3);
1376 assert_ne!(a, c, "different seed should diverge");
1377 }
1378
1379 // ---------------------------------------------------------------------
1380 // 5. TopK count.
1381 // ---------------------------------------------------------------------
1382
1383 #[test]
1384 #[allow(clippy::float_cmp)]
1385 fn topk_refines_exactly_k_rows_and_k_ge_pop_equals_full() {
1386 let device = <TestBackend as BackendTypes>::Device::default();
1387 let (pop, dim) = (6usize, 2usize);
1388 let rows = fixed_population(pop, dim);
1389
1390 let run = |coverage: CoveragePolicy| -> Vec<bool> {
1391 let strategy = MemeticWrapper::<TestBackend, _, _, _>::new(
1392 RecordingStrategy,
1393 HillClimbing,
1394 CountingBatchFitness::default(),
1395 );
1396 let params = MemeticParams {
1397 inner: rec_params(rows.clone(), pop, dim),
1398 local: HillClimbingParams::default_for(BOUNDS),
1399 writeback: WritebackPolicy::Lamarckian,
1400 coverage,
1401 };
1402 let mut rng = StdRng::seed_from_u64(3);
1403 let state = strategy.init(¶ms, &mut rng, &device);
1404 let (ask_pop, asked) = strategy.ask(¶ms, &state, &mut rng, &device);
1405 let ask_bytes = ask_pop
1406 .clone()
1407 .into_data()
1408 .into_vec::<f32>()
1409 .expect("population host-read of a tensor this test just built");
1410 let mut fitfn = CountingBatchFitness::default();
1411 let orig = <CountingBatchFitness as BatchFitnessFn<TestBackend, _>>::evaluate_batch(
1412 &mut fitfn, &ask_pop, &device,
1413 );
1414 let (next, _m) = strategy.tell(¶ms, ask_pop, orig, asked, &mut rng);
1415 let recv = next.inner.received_pop.clone().unwrap();
1416 // A row "changed" iff its bytes differ from ask.
1417 (0..pop)
1418 .map(|i| {
1419 let s = i * dim;
1420 recv[s..s + dim] != ask_bytes[s..s + dim]
1421 })
1422 .collect()
1423 };
1424
1425 let changed_k3 = run(CoveragePolicy::TopK { k: 3 });
1426 assert_eq!(
1427 changed_k3.iter().filter(|&&c| c).count(),
1428 3,
1429 "TopK{{3}} must refine exactly 3 rows"
1430 );
1431
1432 let changed_full = run(CoveragePolicy::Full);
1433 let changed_big_k = run(CoveragePolicy::TopK { k: pop + 4 });
1434 assert_eq!(
1435 changed_full, changed_big_k,
1436 "TopK{{k>=pop}} must equal Full"
1437 );
1438 }
1439
1440 // ---------------------------------------------------------------------
1441 // 6. DE round-trip.
1442 // ---------------------------------------------------------------------
1443
1444 /// Inline negated-sphere `BatchFitnessFn` (maximise, optimum 0 at origin)
1445 /// evaluated on-device.
1446 #[derive(Debug, Default)]
1447 struct SphereBatch;
1448 impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2>> for SphereBatch {
1449 fn evaluate_batch(
1450 &mut self,
1451 population: &Tensor<B, 2>,
1452 device: &<B as BackendTypes>::Device,
1453 ) -> Tensor<B, 1> {
1454 let dims = population.dims();
1455 let flat = population
1456 .clone()
1457 .into_data()
1458 .into_vec::<f32>()
1459 .expect("population host-read of a tensor this test just built");
1460 let (pop, dim) = (dims[0], dims[1]);
1461 let mut out: Vec<f32> = Vec::with_capacity(pop);
1462 for r in 0..pop {
1463 let start = r * dim;
1464 out.push(-flat[start..start + dim].iter().map(|v| v * v).sum::<f32>());
1465 }
1466 Tensor::<B, 1>::from_data(TensorData::new(out, [pop]), device)
1467 }
1468
1469 fn sense(&self) -> ObjectiveSense {
1470 ObjectiveSense::Maximize
1471 }
1472 }
1473
1474 #[test]
1475 fn de_roundtrip_improves_over_generations() {
1476 let device = <TestBackend as BackendTypes>::Device::default();
1477 let dim = 4usize;
1478 let strategy = MemeticWrapper::<TestBackend, _, _, _>::new(
1479 DifferentialEvolution::<TestBackend>::new(),
1480 HillClimbing,
1481 SphereBatch,
1482 );
1483 let params = MemeticParams {
1484 inner: DeConfig::default_for(20, dim),
1485 local: HillClimbingParams::default_for(BOUNDS),
1486 writeback: WritebackPolicy::Lamarckian,
1487 coverage: CoveragePolicy::TopK { k: 3 },
1488 };
1489 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
1490 strategy,
1491 params,
1492 SphereBatch,
1493 17,
1494 device,
1495 20,
1496 )
1497 .expect("valid params");
1498 harness.reset();
1499 let _ = harness.step(());
1500 let first: f32 = harness.latest_metrics().unwrap().best_fitness_ever();
1501 loop {
1502 if harness.step(()).done {
1503 break;
1504 }
1505 }
1506 let last: f32 = harness.latest_metrics().unwrap().best_fitness_ever();
1507 assert!(last.is_finite(), "best must stay finite");
1508 // Maximise objective: best_fitness_ever climbs toward the optimum 0.
1509 assert!(
1510 last >= first,
1511 "best_fitness_ever must improve: {last} >= {first}"
1512 );
1513 }
1514
1515 // ---------------------------------------------------------------------
1516 // 7. GA round-trip smoke.
1517 // ---------------------------------------------------------------------
1518
1519 #[test]
1520 fn ga_roundtrip_smoke() {
1521 let device = <TestBackend as BackendTypes>::Device::default();
1522 let dim = 4usize;
1523 let strategy = MemeticWrapper::<TestBackend, _, _, _>::new(
1524 GeneticAlgorithm::<TestBackend>::new(),
1525 HillClimbing,
1526 SphereBatch,
1527 );
1528 let params = MemeticParams {
1529 inner: GaConfig::default_for(16, dim),
1530 local: HillClimbingParams::default_for(BOUNDS),
1531 writeback: WritebackPolicy::default(),
1532 coverage: CoveragePolicy::default(),
1533 };
1534 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
1535 strategy,
1536 params,
1537 SphereBatch,
1538 5,
1539 device,
1540 5,
1541 )
1542 .expect("valid params");
1543 harness.reset();
1544 for _ in 0..5 {
1545 let _ = harness.step(());
1546 }
1547 assert!(
1548 harness
1549 .latest_metrics()
1550 .unwrap()
1551 .best_fitness_ever()
1552 .is_finite()
1553 );
1554 }
1555
1556 // ---------------------------------------------------------------------
1557 // 8. One-draw invariance: the harness RNG advances identically regardless
1558 // of policy/coverage.
1559 // ---------------------------------------------------------------------
1560
1561 #[test]
1562 fn one_draw_invariant_across_policies() {
1563 let device = <TestBackend as BackendTypes>::Device::default();
1564 let (pop, dim) = (5usize, 3usize);
1565 let rows = fixed_population(pop, dim);
1566
1567 // For RecordingStrategy, `tell` never draws from the rng, so the only
1568 // wrapper-side consumption is the single `next_u64()`. After one tell
1569 // the rng's next value must be equal across every policy/coverage.
1570 let next_after = |writeback: WritebackPolicy, coverage: CoveragePolicy| -> u64 {
1571 let strategy = MemeticWrapper::<TestBackend, _, _, _>::new(
1572 RecordingStrategy,
1573 HillClimbing,
1574 CountingBatchFitness::default(),
1575 );
1576 let params = MemeticParams {
1577 inner: rec_params(rows.clone(), pop, dim),
1578 local: HillClimbingParams::default_for(BOUNDS),
1579 writeback,
1580 coverage,
1581 };
1582 let mut rng = StdRng::seed_from_u64(101);
1583 let state = strategy.init(¶ms, &mut rng, &device);
1584 let (ask_pop, asked) = strategy.ask(¶ms, &state, &mut rng, &device);
1585 let mut fitfn = CountingBatchFitness::default();
1586 let orig = <CountingBatchFitness as BatchFitnessFn<TestBackend, _>>::evaluate_batch(
1587 &mut fitfn, &ask_pop, &device,
1588 );
1589 let (_next, _m) = strategy.tell(¶ms, ask_pop, orig, asked, &mut rng);
1590 rng.next_u64()
1591 };
1592
1593 let baseline = next_after(WritebackPolicy::Lamarckian, CoveragePolicy::TopK { k: 1 });
1594 assert_eq!(
1595 baseline,
1596 next_after(WritebackPolicy::Baldwinian, CoveragePolicy::TopK { k: 1 }),
1597 );
1598 assert_eq!(
1599 baseline,
1600 next_after(
1601 WritebackPolicy::Partial(Probability::new(0.5)),
1602 CoveragePolicy::Full
1603 ),
1604 );
1605 assert_eq!(
1606 baseline,
1607 next_after(WritebackPolicy::Lamarckian, CoveragePolicy::Full),
1608 );
1609 }
1610}