Skip to main content

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(&params, &mut rng, &device);
284/// let mut scorer = Sphere;
285/// for _ in 0..3 {
286///     let (pop, asked) = strategy.ask(&params, &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(&params, 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(&params.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(&params.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(&params.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                    &params.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(&params.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(&params, &mut rng, &device);
955        let (ask_pop, asked) = strategy.ask(&params, &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(&params, 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(&params, &mut rng, &device);
1034        let (ask_pop, asked) = strategy.ask(&params, &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(&params, 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(&params, &mut rng, &device);
1107        let (ask_pop, asked) = strategy.ask(&params, &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(&params, 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(&params, &mut rng, &device);
1181        let (ask_pop, asked) = strategy.ask(&params, &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(&params, 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(&params, &mut rng, &device);
1266        let (ask_pop, asked) = strategy.ask(&params, &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(&params, 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(&params, &mut rng, &device);
1336        let mut trajectory = Vec::with_capacity(gens);
1337        for _ in 0..gens {
1338            let (ask_pop, asked) = strategy.ask(&params, &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(&params, 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(&params, &mut rng, &device);
1404            let (ask_pop, asked) = strategy.ask(&params, &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(&params, 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(&params, &mut rng, &device);
1584            let (ask_pop, asked) = strategy.ask(&params, &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(&params, 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}