Skip to main content

rlevo_evolution/algorithms/neuroevolution/
arch_nas.rs

1//! Bounded architecture NAS — evolving *which* fixed-topology Burn `Module`
2//! variant wins a task, alongside that variant's weights.
3//!
4//! Where [`WeightOnly`](super::weight_only::WeightOnly) evolves the weights of
5//! **one** declared topology, this module adds the architecture axis without the
6//! full topology-evolution machinery of NEAT: the user declares a small, fixed
7//! menu of concrete `Module` variants, and each population member carries a
8//! categorical **architecture id** plus a per-variant **weight vector**.
9//!
10//! # Why a custom harness, not [`Strategy`](crate::strategy::Strategy)
11//!
12//! [`Strategy<B>`](crate::strategy::Strategy)'s `Genome = Tensor<B, 2>` is a
13//! homogeneous float contract. An architecture selector is a *categorical
14//! integer*; encoding it as a float is fragile under mutation, and a parallel
15//! `Int` tensor breaks the `Strategy` signature. So [`ArchNasStrategy`] is a
16//! **custom harness** with its own [`NasGenome`] (a `Vec<usize>` of arch ids
17//! beside a zero-padded `Tensor<B, 2>` of weights) and inherent
18//! `init`/`ask`/`tell`/`best` methods mirroring the `Strategy` shape — it does
19//! **not** implement `Strategy<B>`.
20//!
21//! # Architecture dispatch — closure-erased registry
22//!
23//! Because [`ParamReshaper`] carries an
24//! associated `type Module`, a `dyn ParamReshaper` is not object-safe and a
25//! `Vec` of heterogeneous-variant reshapers is impossible. Instead each variant
26//! is type-erased into a [`VariantEvaluator`]: a boxed closure that owns a
27//! concrete [`ModuleReshaper<B, Vi>`](crate::param_reshaper::ModuleReshaper),
28//! slices the active prefix of a (padded) flat weight row, unflattens it into
29//! the concrete `Vi`, and scores it. Dispatch is by `arch_id` index. The
30//! concrete variant types never appear in [`ArchNasStrategy`] or
31//! [`ArchNasFitnessFn`] generics — both are generic only over `B`.
32//!
33//! # The alignment invariant
34//!
35//! The single load-bearing invariant is that `arch_id` indexes the **same**
36//! variant in the strategy (which uses per-variant parameter counts to pad and
37//! re-initialize weights) and in the fitness adapter (which holds the scoring
38//! closures). [`ArchNasBuilder`] makes this structural: it is the *single*
39//! registration point, and [`ArchNasBuilder::build`] emits both [`NasParams`]
40//! and [`ArchNasFitnessFn`] from one ordered list, so the indices cannot drift.
41//!
42//! # Gradient isolation and host RNG
43//!
44//! Every type here is generic over `B: Backend`, never `AutodiffBackend` —
45//! gradient isolation at the type level. All sampling
46//! (architecture ids, selection, crossover, weight perturbation) goes through
47//! [`seed_stream`] host-side `StdRng` substreams; the
48//! process-wide backend RNG is never touched (see [`crate::rng`]).
49
50use std::marker::PhantomData;
51
52use burn::module::Module;
53use burn::tensor::{Tensor, TensorData, backend::Backend};
54use rand::{Rng, RngExt};
55use rand_distr::{Distribution as _, Normal};
56
57use rlevo_core::config::{self, ConfigError, ConstraintKind, Validate};
58
59use crate::ops::selection::{argmax_host, tournament_indices_host, truncation_indices_host};
60use crate::param_reshaper::{ModuleReshaper, ParamReshaper};
61use crate::rng::{SeedPurpose, seed_stream};
62
63/// One population's worth of architecture-NAS genomes.
64///
65/// `arch_ids[i]` is the architecture index of individual `i` (in
66/// `0..num_variants`); row `i` of `weights` holds that individual's flat
67/// weight vector, padded with trailing zeros out to `max_param_count`.
68///
69/// # Not `Clone`
70///
71/// `NasGenome` deliberately does **not** derive [`Clone`]: Burn tensors alias
72/// their underlying storage, so a derived `Clone` would silently share buffers
73/// rather than copy them. [`ArchNasStrategy::ask`] constructs a *fresh*
74/// `NasGenome` with newly allocated tensors (built host-side via
75/// [`Tensor::from_data`]) instead of cloning the struct — consistent with
76/// [`WeightOnly`](super::weight_only::WeightOnly) and the phase-1 strategies.
77#[derive(Debug)]
78pub struct NasGenome<B: Backend> {
79    /// Architecture index of each individual; length `pop_size`, each value in
80    /// `0..num_variants`.
81    arch_ids: Vec<usize>,
82    /// Per-individual flat weights, shape `[pop_size, max_param_count]`. Columns
83    /// beyond a variant's own parameter count are zero-padded.
84    weights: Tensor<B, 2>,
85}
86
87impl<B: Backend> NasGenome<B> {
88    /// Assembles a population genome, checking one architecture id per row.
89    ///
90    /// # Errors
91    ///
92    /// Returns a [`ConfigError`] if `weights` has zero rows or if `arch_ids`
93    /// does not have exactly one entry per weight row (`pop_size`).
94    pub fn try_new(arch_ids: Vec<usize>, weights: Tensor<B, 2>) -> Result<Self, ConfigError> {
95        let pop = weights.dims()[0];
96        config::nonzero("NasGenome", "pop_size", pop)?;
97        if arch_ids.len() != pop {
98            return Err(ConfigError {
99                config: "NasGenome",
100                field: "arch_ids",
101                kind: ConstraintKind::Custom("must have one architecture id per weight row"),
102            });
103        }
104        Ok(Self { arch_ids, weights })
105    }
106
107    /// Architecture index of each individual, length `pop_size`.
108    #[must_use]
109    pub fn arch_ids(&self) -> &[usize] {
110        &self.arch_ids
111    }
112
113    /// Per-individual flat weights, shape `[pop_size, max_param_count]`.
114    #[must_use]
115    pub fn weights(&self) -> &Tensor<B, 2> {
116        &self.weights
117    }
118}
119
120/// A type-erased per-variant evaluator.
121///
122/// Built from a concrete `Module` variant `M` and a scorer `Fn(&M) -> f32`, it
123/// captures a [`ModuleReshaper<B, M>`](crate::param_reshaper::ModuleReshaper)
124/// in a boxed closure and exposes only `B`-generic operations. The concrete
125/// `M` is invisible to callers.
126pub struct VariantEvaluator<B: Backend> {
127    num_params: usize,
128    score_fn: Box<dyn Fn(Tensor<B, 1>) -> f32 + Send + Sync>,
129}
130
131impl<B: Backend> std::fmt::Debug for VariantEvaluator<B> {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        f.debug_struct("VariantEvaluator")
134            .field("num_params", &self.num_params)
135            .finish_non_exhaustive()
136    }
137}
138
139impl<B: Backend> VariantEvaluator<B> {
140    /// Build an evaluator for one architecture variant.
141    ///
142    /// `template` is an instance of the concrete `Module` variant (its weights
143    /// are irrelevant — only its shape is used, to build the reshaper).
144    /// `scorer` maps an unflattened module to a fitness value in the canonical
145    /// **maximise** convention (higher is better).
146    #[must_use]
147    pub fn new<M, F>(template: M, scorer: F) -> Self
148    where
149        M: Module<B> + Sync + 'static,
150        F: Fn(&M) -> f32 + Send + Sync + 'static,
151    {
152        let reshaper = ModuleReshaper::new(template);
153        let num_params = reshaper.num_params();
154        let score_fn = Box::new(move |active: Tensor<B, 1>| {
155            let module = reshaper.unflatten(active);
156            scorer(&module)
157        });
158        Self {
159            num_params,
160            score_fn,
161        }
162    }
163
164    /// Number of flat float parameters this variant occupies (the active
165    /// prefix width of a padded weight row).
166    #[must_use]
167    pub fn num_params(&self) -> usize {
168        self.num_params
169    }
170
171    /// Score a single (padded) weight row.
172    ///
173    /// The leading `num_params` columns are sliced off, unflattened into the
174    /// concrete module, and scored; trailing padding is ignored. Returns a
175    /// fitness value in the canonical maximise convention.
176    ///
177    /// # Panics
178    ///
179    /// Panics if `padded_row` has fewer than `num_params` elements.
180    #[must_use]
181    pub fn score(&self, padded_row: Tensor<B, 1>) -> f32 {
182        #[allow(clippy::single_range_in_vec_init)]
183        let active = padded_row.slice([0..self.num_params]);
184        (self.score_fn)(active)
185    }
186}
187
188/// Static configuration for an [`ArchNasStrategy`] run. No `B` generic.
189///
190/// Produced by [`ArchNasBuilder::build`] so `per_variant_params` and
191/// `max_param_count` are derived from the registered variants and cannot drift
192/// from the fitness adapter's registry.
193#[derive(Debug, Clone)]
194pub struct NasParams {
195    /// Number of individuals per generation.
196    pop_size: usize,
197    /// Number of architecture variants (`arch_id` ranges over `0..num_variants`).
198    num_variants: usize,
199    /// Flat parameter count of each variant; length `num_variants`, indexed by
200    /// `arch_id`.
201    per_variant_params: Vec<usize>,
202    /// Width of the weight tensor — `per_variant_params.iter().max()`.
203    max_param_count: usize,
204    /// Per-individual probability of reassigning the architecture id each
205    /// generation (a changed id re-initializes that child's active weights).
206    arch_mutation_rate: f32,
207    /// Standard deviation of the isotropic Gaussian weight perturbation.
208    weight_mutation_std: f32,
209    /// Standard deviation used to initialize (and re-initialize) weights.
210    weight_init_std: f32,
211    /// Tournament size for parent selection (clamped to `>= 1`).
212    tournament_size: usize,
213    /// Number of best individuals carried over unmutated each generation
214    /// (clamped to `<= pop_size`).
215    elite_count: usize,
216}
217
218impl NasParams {
219    /// Number of individuals per generation.
220    #[must_use]
221    pub fn pop_size(&self) -> usize {
222        self.pop_size
223    }
224
225    /// Number of architecture variants (`arch_id` ranges over `0..num_variants`).
226    #[must_use]
227    pub fn num_variants(&self) -> usize {
228        self.num_variants
229    }
230
231    /// Flat parameter count of each variant, indexed by `arch_id`.
232    #[must_use]
233    pub fn per_variant_params(&self) -> &[usize] {
234        &self.per_variant_params
235    }
236
237    /// Width of the weight tensor (`per_variant_params.iter().max()`).
238    #[must_use]
239    pub fn max_param_count(&self) -> usize {
240        self.max_param_count
241    }
242
243    /// Per-individual probability of reassigning the architecture id each
244    /// generation.
245    #[must_use]
246    pub fn arch_mutation_rate(&self) -> f32 {
247        self.arch_mutation_rate
248    }
249
250    /// Standard deviation of the isotropic Gaussian weight perturbation.
251    #[must_use]
252    pub fn weight_mutation_std(&self) -> f32 {
253        self.weight_mutation_std
254    }
255
256    /// Standard deviation used to initialize (and re-initialize) weights.
257    #[must_use]
258    pub fn weight_init_std(&self) -> f32 {
259        self.weight_init_std
260    }
261
262    /// Tournament size for parent selection.
263    #[must_use]
264    pub fn tournament_size(&self) -> usize {
265        self.tournament_size
266    }
267
268    /// Number of best individuals carried over unmutated each generation.
269    #[must_use]
270    pub fn elite_count(&self) -> usize {
271        self.elite_count
272    }
273}
274
275impl Validate for NasParams {
276    fn validate(&self) -> Result<(), ConfigError> {
277        const C: &str = "NasParams";
278        config::at_least(C, "pop_size", self.pop_size, 1)?;
279        config::at_least(C, "num_variants", self.num_variants, 1)?;
280        if self.per_variant_params.len() != self.num_variants {
281            return Err(ConfigError {
282                config: C,
283                field: "per_variant_params",
284                kind: ConstraintKind::Custom("per_variant_params length must equal num_variants"),
285            });
286        }
287        config::at_least(C, "tournament_size", self.tournament_size, 1)?;
288        if self.elite_count > self.pop_size {
289            return Err(ConfigError {
290                config: C,
291                field: "elite_count",
292                kind: ConstraintKind::Custom("elite_count must not exceed pop_size"),
293            });
294        }
295        Ok(())
296    }
297}
298
299/// Evolving state for [`ArchNasStrategy`]: resident population plus best-ever
300/// tracking.
301///
302/// Carries no PRNG state (the harness owns all stochasticity). Not `Clone`
303/// (it holds a [`NasGenome`], which is intentionally not `Clone`); the strategy
304/// threads state by explicit reconstruction.
305#[derive(Debug)]
306pub struct NasState<B: Backend> {
307    population: NasGenome<B>,
308    /// Resident fitness; empty until the first [`tell`](ArchNasStrategy::tell).
309    fitness: Vec<f32>,
310    best_arch_id: Option<usize>,
311    /// Best-ever padded weight row, length `max_param_count`.
312    best_weights: Option<Tensor<B, 1>>,
313    best_fitness: f32,
314    generation: usize,
315}
316
317impl<B: Backend> NasState<B> {
318    /// Rebuild this state with freshly cloned tensors so it can be handed from
319    /// `ask` to `tell` without sharing the resident buffers.
320    fn carry_forward(&self) -> Self {
321        Self {
322            population: NasGenome {
323                arch_ids: self.population.arch_ids.clone(),
324                weights: self.population.weights.clone(),
325            },
326            fitness: self.fitness.clone(),
327            best_arch_id: self.best_arch_id,
328            best_weights: self.best_weights.clone(),
329            best_fitness: self.best_fitness,
330            generation: self.generation,
331        }
332    }
333
334    /// Completed-generation counter (number of `tell` calls).
335    #[must_use]
336    pub fn generation(&self) -> usize {
337        self.generation
338    }
339
340    /// Borrow the current resident population.
341    ///
342    /// After [`init`](ArchNasStrategy::init) this is the seed population; after
343    /// each [`tell`](ArchNasStrategy::tell) it is the most recently evaluated
344    /// population. Exposed so callers can inspect architecture coverage and the
345    /// padded weight tensor.
346    #[must_use]
347    pub fn population(&self) -> &NasGenome<B> {
348        &self.population
349    }
350
351    /// Best-ever fitness seen so far (canonical maximise), or `−∞` before the
352    /// first [`tell`](ArchNasStrategy::tell).
353    #[must_use]
354    pub fn best_fitness(&self) -> f32 {
355        self.best_fitness
356    }
357}
358
359/// Knobs [`ArchNasBuilder::build`] cannot derive from the registered variants.
360#[derive(Debug, Clone, Copy)]
361pub struct NasBuilderConfig {
362    /// Number of individuals per generation.
363    pub pop_size: usize,
364    /// Per-individual architecture-mutation probability.
365    pub arch_mutation_rate: f32,
366    /// Gaussian weight-perturbation standard deviation.
367    pub weight_mutation_std: f32,
368    /// Gaussian weight-initialization standard deviation.
369    pub weight_init_std: f32,
370    /// Tournament size for parent selection.
371    pub tournament_size: usize,
372    /// Number of elites preserved unmutated each generation.
373    pub elite_count: usize,
374}
375
376impl Validate for NasBuilderConfig {
377    fn validate(&self) -> Result<(), ConfigError> {
378        const C: &str = "NasBuilderConfig";
379        config::at_least(C, "pop_size", self.pop_size, 1)?;
380        config::in_range(
381            C,
382            "arch_mutation_rate",
383            0.0,
384            1.0,
385            f64::from(self.arch_mutation_rate),
386        )?;
387        config::in_range(
388            C,
389            "weight_mutation_std",
390            0.0,
391            f64::INFINITY,
392            f64::from(self.weight_mutation_std),
393        )?;
394        config::in_range(
395            C,
396            "weight_init_std",
397            0.0,
398            f64::INFINITY,
399            f64::from(self.weight_init_std),
400        )?;
401        config::at_least(C, "tournament_size", self.tournament_size, 1)?;
402        if self.elite_count > self.pop_size {
403            return Err(ConfigError {
404                config: C,
405                field: "elite_count",
406                kind: ConstraintKind::Custom("elite_count must not exceed pop_size"),
407            });
408        }
409        Ok(())
410    }
411}
412
413/// Builder that registers architecture variants in order and emits a matched
414/// `(NasParams, ArchNasFitnessFn)` pair.
415///
416/// `add_variant` is the single registration point, so `arch_id == registration
417/// index` for both the strategy and the fitness adapter — the alignment
418/// invariant is enforced structurally, not by convention.
419///
420/// # Example
421///
422/// ```ignore
423/// let mut builder = ArchNasBuilder::<B>::new();
424/// builder
425///     .add_variant(ShallowMlp::new(&device), shallow_scorer)
426///     .add_variant(DeepMlp::new(&device), deep_scorer);
427/// let (params, fitness) = builder.build(NasBuilderConfig { /* … */ });
428/// ```
429#[derive(Debug, Default)]
430pub struct ArchNasBuilder<B: Backend> {
431    evaluators: Vec<VariantEvaluator<B>>,
432}
433
434impl<B: Backend> ArchNasBuilder<B> {
435    /// Create an empty builder.
436    #[must_use]
437    pub fn new() -> Self {
438        Self {
439            evaluators: Vec::new(),
440        }
441    }
442
443    /// Register one architecture variant. The registration order is the
444    /// `arch_id` assigned to this variant.
445    pub fn add_variant<M, F>(&mut self, template: M, scorer: F) -> &mut Self
446    where
447        M: Module<B> + Sync + 'static,
448        F: Fn(&M) -> f32 + Send + Sync + 'static,
449    {
450        self.evaluators
451            .push(VariantEvaluator::new(template, scorer));
452        self
453    }
454
455    /// Consume the builder, producing a matched configuration and fitness
456    /// adapter from the same ordered variant list.
457    ///
458    /// # Panics
459    ///
460    /// Panics if no variants were registered.
461    #[must_use]
462    pub fn build(self, cfg: NasBuilderConfig) -> (NasParams, ArchNasFitnessFn<B>) {
463        assert!(
464            !self.evaluators.is_empty(),
465            "ArchNasBuilder requires at least one registered variant"
466        );
467        let per_variant_params: Vec<usize> = self
468            .evaluators
469            .iter()
470            .map(VariantEvaluator::num_params)
471            .collect();
472        let max_param_count = per_variant_params
473            .iter()
474            .copied()
475            .max()
476            .expect("non-empty variants");
477        let params = NasParams {
478            pop_size: cfg.pop_size,
479            num_variants: self.evaluators.len(),
480            per_variant_params,
481            max_param_count,
482            arch_mutation_rate: cfg.arch_mutation_rate,
483            weight_mutation_std: cfg.weight_mutation_std,
484            weight_init_std: cfg.weight_init_std,
485            tournament_size: cfg.tournament_size,
486            elite_count: cfg.elite_count,
487        };
488        let fitness = ArchNasFitnessFn {
489            evaluators: self.evaluators,
490        };
491        (params, fitness)
492    }
493}
494
495/// Arch-dispatched, loop-over-N fitness adapter; owns the type-erased registry.
496///
497/// `evaluate` walks the population row by row, dispatches each to its variant's
498/// evaluator by `arch_id` index, and assembles a `[pop_size]` fitness tensor.
499/// Burn 0.21 has no batched/`vmap` forward, so evaluation is loop-over-N; a
500/// batched arch-dispatched path is a future addition.
501#[derive(Debug)]
502pub struct ArchNasFitnessFn<B: Backend> {
503    evaluators: Vec<VariantEvaluator<B>>,
504}
505
506impl<B: Backend> ArchNasFitnessFn<B> {
507    /// Number of registered architecture variants.
508    #[must_use]
509    pub fn num_variants(&self) -> usize {
510        self.evaluators.len()
511    }
512
513    /// Evaluate a population, returning `[pop_size]` fitness (canonical maximise).
514    ///
515    /// Row `i` is dispatched to `evaluators[genome.arch_ids[i]]`.
516    ///
517    /// # Panics
518    ///
519    /// Panics if any `arch_id` is out of range for the registry, or if the
520    /// weight tensor's row count disagrees with `arch_ids.len()`.
521    #[must_use]
522    pub fn evaluate(&self, genome: &NasGenome<B>, device: &B::Device) -> Tensor<B, 1> {
523        let [pop_size, max_param_count] = genome.weights.dims();
524        assert_eq!(
525            pop_size,
526            genome.arch_ids.len(),
527            "weights row count must equal arch_ids length"
528        );
529        let mut fitness: Vec<f32> = Vec::with_capacity(pop_size);
530        for (i, &arch_id) in genome.arch_ids.iter().enumerate() {
531            #[allow(clippy::single_range_in_vec_init)]
532            let row: Tensor<B, 1> = genome
533                .weights
534                .clone()
535                .slice([i..i + 1])
536                .reshape([max_param_count]);
537            fitness.push(self.evaluators[arch_id].score(row));
538        }
539        Tensor::<B, 1>::from_data(TensorData::new(fitness, [pop_size]), device)
540    }
541}
542
543/// Custom NAS harness. Does **not** implement
544/// [`Strategy`](crate::strategy::Strategy) — see the module docs.
545///
546/// The strategy is stateless; all evolving state lives in [`NasState`]. Drive
547/// it with the same ask/tell loop as a [`Strategy`](crate::strategy::Strategy):
548///
549/// ```ignore
550/// let strat = ArchNasStrategy::<B>::new();
551/// let mut state = strat.init(&params, &mut rng, &device);
552/// for _ in 0..generations {
553///     let (genome, next) = strat.ask(&params, &state, &mut rng, &device);
554///     let fitness = fitness_fn.evaluate(&genome, &device);
555///     state = strat.tell(&params, genome, fitness, next, &mut rng);
556/// }
557/// let (arch_id, weights, fitness) = strat.best(&state).unwrap();
558/// ```
559#[derive(Debug, Clone, Copy, Default)]
560pub struct ArchNasStrategy<B: Backend> {
561    _backend: PhantomData<fn() -> B>,
562}
563
564impl<B: Backend> ArchNasStrategy<B> {
565    /// Build a new (stateless) NAS strategy.
566    #[must_use]
567    pub fn new() -> Self {
568        Self {
569            _backend: PhantomData,
570        }
571    }
572
573    /// Build the initial state: uniform random architecture ids and
574    /// Gaussian-initialized active weights (padding zeroed).
575    ///
576    /// `fitness` is left empty and `best_fitness` is `−∞`; the first
577    /// [`tell`](Self::tell) populates both.
578    ///
579    /// # Panics
580    ///
581    /// Panics if `weight_init_std` is non-finite (`+∞` or `NaN`).
582    #[must_use]
583    pub fn init(
584        &self,
585        params: &NasParams,
586        rng: &mut dyn Rng,
587        device: &<B as burn::tensor::backend::BackendTypes>::Device,
588    ) -> NasState<B> {
589        debug_assert!(
590            params.validate().is_ok(),
591            "invalid NasParams reached init: {params:?}"
592        );
593        let pop = params.pop_size;
594        let max = params.max_param_count;
595
596        let mut arch_rng = seed_stream(rng.next_u64(), 0, SeedPurpose::Representative);
597        let arch_ids: Vec<usize> = (0..pop)
598            .map(|_| arch_rng.random_range(0..params.num_variants))
599            .collect();
600
601        let mut weight_rng = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
602        let normal = Normal::new(0.0f32, params.weight_init_std).unwrap_or_else(|err| {
603            panic!(
604                "weight_init_std must be finite, got {}: {err}",
605                params.weight_init_std
606            )
607        });
608        let mut data = vec![0.0f32; pop * max];
609        for (i, &arch) in arch_ids.iter().enumerate() {
610            let n = params.per_variant_params[arch];
611            for slot in &mut data[i * max..i * max + n] {
612                *slot = normal.sample(&mut weight_rng);
613            }
614        }
615        let weights = Tensor::<B, 2>::from_data(TensorData::new(data, [pop, max]), device);
616
617        NasState {
618            population: NasGenome { arch_ids, weights },
619            fitness: Vec::new(),
620            best_arch_id: None,
621            best_weights: None,
622            best_fitness: f32::NEG_INFINITY,
623            generation: 0,
624        }
625    }
626
627    /// Propose the next population.
628    ///
629    /// Before the first [`tell`](Self::tell) (resident fitness still empty) the
630    /// unchanged resident population is returned for evaluation. Afterwards the
631    /// method produces offspring: `elite_count` best residents carried over
632    /// unmutated, then tournament selection, same-architecture blend crossover
633    /// (single-parent copy when parents' architectures differ), and either a
634    /// per-child architecture mutation (re-initializing the child's active
635    /// weights) at `arch_mutation_rate` or an isotropic Gaussian weight
636    /// perturbation. Five independent host substreams are derived from `rng`:
637    /// selection, crossover, mutation, architecture (re)assignment, and weight
638    /// re-initialization.
639    ///
640    /// The returned [`NasGenome`] is freshly allocated (no struct-level clone).
641    ///
642    /// # Panics
643    ///
644    /// Panics if `weight_init_std` or `weight_mutation_std` is non-finite
645    /// (`+∞` or `NaN`), or if the resident weight tensor cannot be read back to
646    /// the host as `f32`.
647    #[must_use]
648    pub fn ask(
649        &self,
650        params: &NasParams,
651        state: &NasState<B>,
652        rng: &mut dyn Rng,
653        device: &<B as burn::tensor::backend::BackendTypes>::Device,
654    ) -> (NasGenome<B>, NasState<B>) {
655        let pop = params.pop_size;
656        let max = params.max_param_count;
657
658        // First call: harness has not evaluated the seed population yet.
659        if state.fitness.is_empty() {
660            let genome = NasGenome {
661                arch_ids: state.population.arch_ids.clone(),
662                weights: state.population.weights.clone(),
663            };
664            return (genome, state.carry_forward());
665        }
666
667        let gen_idx = state.generation as u64;
668        let mut sel_rng = seed_stream(rng.next_u64(), gen_idx, SeedPurpose::Selection);
669        let mut xover_rng = seed_stream(rng.next_u64(), gen_idx, SeedPurpose::Crossover);
670        let mut mut_rng = seed_stream(rng.next_u64(), gen_idx, SeedPurpose::Mutation);
671        let mut arch_rng = seed_stream(rng.next_u64(), gen_idx, SeedPurpose::Representative);
672        let mut winit_rng = seed_stream(rng.next_u64(), gen_idx, SeedPurpose::Other);
673
674        let perturb = Normal::new(0.0f32, params.weight_mutation_std).unwrap_or_else(|err| {
675            panic!(
676                "weight_mutation_std must be finite, got {}: {err}",
677                params.weight_mutation_std
678            )
679        });
680        let reinit = Normal::new(0.0f32, params.weight_init_std).unwrap_or_else(|err| {
681            panic!(
682                "weight_init_std must be finite, got {}: {err}",
683                params.weight_init_std
684            )
685        });
686
687        let resident: Vec<f32> = state
688            .population
689            .weights
690            .clone()
691            .into_data()
692            .into_vec::<f32>()
693            .expect("weights tensor must be readable as f32");
694        let resident_arch = &state.population.arch_ids;
695
696        // Elitism: indices sorted by descending (better, canonical maximise)
697        // fitness — highest first; NaN sanitised to −inf so it never ranks
698        // as best.
699        let order: Vec<usize> = truncation_indices_host(&state.fitness, pop)
700            .into_iter()
701            .map(|i| usize::try_from(i).expect("winner index is non-negative"))
702            .collect();
703        let elite_count = params.elite_count.min(pop);
704        let tournament_size = params.tournament_size.max(1);
705
706        let mut child_arch: Vec<usize> = Vec::with_capacity(pop);
707        let mut child: Vec<f32> = vec![0.0f32; pop * max];
708
709        // Carry elites over unmutated.
710        for (ci, &ei) in order[..elite_count].iter().enumerate() {
711            child[ci * max..ci * max + max].copy_from_slice(&resident[ei * max..ei * max + max]);
712            child_arch.push(resident_arch[ei]);
713        }
714
715        // Fill the rest with offspring. All parent tournaments draw from
716        // the dedicated `sel_rng` stream, so batching the draws up front
717        // preserves the exact per-child draw order.
718        let parents = tournament_indices_host(
719            &state.fitness,
720            tournament_size,
721            2 * (pop - elite_count),
722            &mut sel_rng,
723        );
724        for ci in elite_count..pop {
725            let pair = 2 * (ci - elite_count);
726            let pa = usize::try_from(parents[pair]).expect("winner index is non-negative");
727            let pb = usize::try_from(parents[pair + 1]).expect("winner index is non-negative");
728            let arch = resident_arch[pa];
729            let n = params.per_variant_params[arch];
730            let base = ci * max;
731
732            if resident_arch[pa] == resident_arch[pb] {
733                // Same-architecture blend crossover on the active region.
734                for j in 0..n {
735                    let alpha: f32 = xover_rng.random::<f32>();
736                    child[base + j] =
737                        alpha * resident[pa * max + j] + (1.0 - alpha) * resident[pb * max + j];
738                }
739            } else {
740                // Architecture mismatch: single-parent copy (mutation-only).
741                child[base..base + n].copy_from_slice(&resident[pa * max..pa * max + n]);
742            }
743
744            if arch_rng.random::<f32>() < params.arch_mutation_rate {
745                // Architecture mutation: switch variant, re-initialize weights.
746                let new_arch = arch_rng.random_range(0..params.num_variants);
747                let nn = params.per_variant_params[new_arch];
748                child[base..base + max].fill(0.0);
749                for slot in &mut child[base..base + nn] {
750                    *slot = reinit.sample(&mut winit_rng);
751                }
752                child_arch.push(new_arch);
753            } else {
754                // Isotropic Gaussian weight perturbation on the active region.
755                for slot in &mut child[base..base + n] {
756                    *slot += perturb.sample(&mut mut_rng);
757                }
758                child_arch.push(arch);
759            }
760        }
761
762        let weights = Tensor::<B, 2>::from_data(TensorData::new(child, [pop, max]), device);
763        let genome = NasGenome {
764            arch_ids: child_arch,
765            weights,
766        };
767        (genome, state.carry_forward())
768    }
769
770    /// Consume a population's fitness and produce the next state.
771    ///
772    /// Records the told population as the new residents, updates the best-ever
773    /// `(arch_id, weights, fitness)` triple, and increments the generation.
774    /// `fitness` follows the canonical maximise convention (higher is better).
775    ///
776    /// # Fitness hygiene
777    ///
778    /// This is the `ArchNasStrategy` **driver chokepoint** (ADR 0034): the
779    /// strategy is its own driver and does **not** run through
780    /// [`EvolutionaryHarness`](crate::strategy::EvolutionaryHarness), so there is
781    /// no harness above it. `tell` applies
782    /// `sanitize_fitness` to the host fitness
783    /// vector (`NaN → −∞`, `+∞ → f32::MAX`, `−∞` and finite pass through)
784    /// **before** `update_best` and before it is stored as `state.fitness`, so
785    /// a non-finite fitness can never become an immortal champion nor win the
786    /// next [`ask`](Self::ask)'s elite carry or tournament.
787    ///
788    /// # Panics
789    ///
790    /// Panics if the `fitness` tensor cannot be read back to the host as `f32`.
791    #[must_use]
792    pub fn tell(
793        &self,
794        params: &NasParams,
795        population: NasGenome<B>,
796        fitness: Tensor<B, 1>,
797        mut state: NasState<B>,
798        _rng: &mut dyn Rng,
799    ) -> NasState<B> {
800        let raw = fitness
801            .into_data()
802            .into_vec::<f32>()
803            .expect("fitness tensor must be readable as f32");
804        // Driver chokepoint (ADR 0034): sanitize before update_best/store so a
805        // NaN/±∞ can neither become the champion nor pollute the next ask.
806        let fitness_host: Vec<f32> = raw
807            .into_iter()
808            .map(crate::fitness::sanitize_fitness)
809            .collect();
810        update_best(
811            &mut state,
812            &population,
813            &fitness_host,
814            params.max_param_count,
815        );
816        state.population = population;
817        state.fitness = fitness_host;
818        state.generation += 1;
819        state
820    }
821
822    /// Best-ever individual: `(arch_id, padded weights, fitness)`.
823    ///
824    /// The weight tensor has length `max_param_count` (trailing padding
825    /// retained). Returns `None` before the first [`tell`](Self::tell).
826    #[must_use]
827    pub fn best(&self, state: &NasState<B>) -> Option<(usize, Tensor<B, 1>, f32)> {
828        match (state.best_arch_id, state.best_weights.as_ref()) {
829            (Some(arch_id), Some(weights)) => Some((arch_id, weights.clone(), state.best_fitness)),
830            _ => None,
831        }
832    }
833}
834
835/// Update best-ever tracking from a freshly-evaluated population.
836///
837/// Both the argmax ([`argmax_host`], seeded at `f32::NEG_INFINITY`) and the
838/// running champion (`state.best_fitness`, initialised to `f32::NEG_INFINITY`
839/// in [`init`](ArchNasStrategy::init)) start from the maximise-space worst
840/// sentinel, so a leading broken member cannot suppress a later valid best.
841/// `fitness` is expected pre-sanitized by [`tell`](ArchNasStrategy::tell).
842fn update_best<B: Backend>(
843    state: &mut NasState<B>,
844    pop: &NasGenome<B>,
845    fitness: &[f32],
846    max_param_count: usize,
847) {
848    if fitness.is_empty() {
849        return;
850    }
851    let best_idx = argmax_host(fitness);
852    let best_f = fitness[best_idx];
853    if best_f > state.best_fitness {
854        #[allow(clippy::single_range_in_vec_init)]
855        let row: Tensor<B, 1> = pop
856            .weights
857            .clone()
858            .slice([best_idx..best_idx + 1])
859            .reshape([max_param_count]);
860        state.best_weights = Some(row);
861        state.best_arch_id = Some(pop.arch_ids[best_idx]);
862        state.best_fitness = best_f;
863    }
864}
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869    use burn::backend::Flex;
870    use burn::nn::{Linear, LinearConfig};
871    use rand::SeedableRng;
872    use rand::rngs::StdRng;
873
874    type TestBackend = Flex;
875
876    #[test]
877    fn nas_genome_try_new_checks_one_arch_id_per_row() {
878        let device = Default::default();
879        let weights = Tensor::<TestBackend, 2>::zeros([3, 4], &device);
880        assert!(NasGenome::try_new(vec![0, 1, 2], weights).is_ok());
881        let weights = Tensor::<TestBackend, 2>::zeros([3, 4], &device);
882        assert!(NasGenome::try_new(vec![0, 1], weights).is_err());
883        let empty = Tensor::<TestBackend, 2>::zeros([0, 4], &device);
884        assert!(NasGenome::try_new(vec![], empty).is_err());
885    }
886
887    fn valid_builder_config() -> NasBuilderConfig {
888        NasBuilderConfig {
889            pop_size: 16,
890            arch_mutation_rate: 0.1,
891            weight_mutation_std: 0.1,
892            weight_init_std: 0.5,
893            tournament_size: 2,
894            elite_count: 1,
895        }
896    }
897
898    #[test]
899    fn builder_config_validates() {
900        assert!(valid_builder_config().validate().is_ok());
901    }
902
903    #[test]
904    fn builder_config_rejects_elite_above_pop() {
905        let mut cfg = valid_builder_config();
906        cfg.elite_count = 32;
907        assert_eq!(cfg.validate().unwrap_err().field, "elite_count");
908    }
909
910    #[test]
911    fn nas_params_validate_and_reject() {
912        let good = NasParams {
913            pop_size: 16,
914            num_variants: 2,
915            per_variant_params: vec![10, 20],
916            max_param_count: 20,
917            arch_mutation_rate: 0.1,
918            weight_mutation_std: 0.1,
919            weight_init_std: 0.5,
920            tournament_size: 2,
921            elite_count: 1,
922        };
923        assert!(good.validate().is_ok());
924        let mut bad = good.clone();
925        bad.per_variant_params = vec![10];
926        assert_eq!(bad.validate().unwrap_err().field, "per_variant_params");
927    }
928
929    /// One-hidden-layer MLP variant: `2 -> H -> 1`.
930    #[derive(Module, Debug)]
931    struct ShallowMlp<B: Backend> {
932        l1: Linear<B>,
933        l2: Linear<B>,
934    }
935
936    impl<B: Backend> ShallowMlp<B> {
937        fn new(hidden: usize, device: &B::Device) -> Self {
938            Self {
939                l1: LinearConfig::new(2, hidden).init(device),
940                l2: LinearConfig::new(hidden, 1).init(device),
941            }
942        }
943    }
944
945    /// Two-hidden-layer MLP variant: `2 -> H -> H/2 -> 1`.
946    #[derive(Module, Debug)]
947    struct DeepMlp<B: Backend> {
948        l1: Linear<B>,
949        l2: Linear<B>,
950        l3: Linear<B>,
951    }
952
953    impl<B: Backend> DeepMlp<B> {
954        fn new(device: &B::Device) -> Self {
955            Self {
956                l1: LinearConfig::new(2, 8).init(device),
957                l2: LinearConfig::new(8, 4).init(device),
958                l3: LinearConfig::new(4, 1).init(device),
959            }
960        }
961    }
962
963    // `device` is borrowed to mirror the production `&B::Device` convention;
964    // the Flex test device is zero-sized, hence the targeted allow.
965    #[allow(clippy::trivially_copy_pass_by_ref)]
966    fn two_variant_builder(
967        device: &<TestBackend as burn::tensor::backend::BackendTypes>::Device,
968    ) -> ArchNasBuilder<TestBackend> {
969        // Two trivial scorers (constant 0.0) — these tests exercise only the
970        // builder/registry plumbing, not fitness dynamics.
971        fn zero_shallow(_m: &ShallowMlp<TestBackend>) -> f32 {
972            0.0
973        }
974        fn zero_deep(_m: &DeepMlp<TestBackend>) -> f32 {
975            0.0
976        }
977        let mut builder = ArchNasBuilder::<TestBackend>::new();
978        builder
979            // arch 0: shallow (2*4 + 4 + 4*1 + 1 = 17 params)
980            .add_variant(ShallowMlp::<TestBackend>::new(4, device), zero_shallow)
981            // arch 1: deep (2*8 + 8 + 8*4 + 4 + 4*1 + 1 = 65 params)
982            .add_variant(DeepMlp::<TestBackend>::new(device), zero_deep);
983        builder
984    }
985
986    #[test]
987    fn builder_aligns_arch_id_with_param_counts() {
988        let device = Default::default();
989        let (params, fitness) = two_variant_builder(&device).build(NasBuilderConfig {
990            pop_size: 8,
991            arch_mutation_rate: 0.1,
992            weight_mutation_std: 0.1,
993            weight_init_std: 0.5,
994            tournament_size: 2,
995            elite_count: 1,
996        });
997        assert_eq!(params.num_variants, 2);
998        assert_eq!(params.per_variant_params, vec![17, 65]);
999        assert_eq!(params.max_param_count, 65);
1000        assert_eq!(fitness.num_variants(), 2);
1001    }
1002
1003    #[test]
1004    fn variant_evaluator_dispatches_and_slices_active_prefix() {
1005        let device = Default::default();
1006        // Scorer reports the number of params it sees, proving the active
1007        // prefix (not the padded width) reaches the concrete module.
1008        let mut builder = ArchNasBuilder::<TestBackend>::new();
1009        builder.add_variant(
1010            ShallowMlp::<TestBackend>::new(4, &device),
1011            |m: &ShallowMlp<TestBackend>| {
1012                #[allow(clippy::cast_precision_loss)]
1013                let n = ModuleReshaper::new(ShallowMlp::<TestBackend>::new(4, &Default::default()))
1014                    .num_params() as f32;
1015                // forward to make the module non-dead; result unused beyond shape.
1016                let _ = &m.l1;
1017                n
1018            },
1019        );
1020        let (params, fitness) = builder.build(NasBuilderConfig {
1021            pop_size: 1,
1022            arch_mutation_rate: 0.0,
1023            weight_mutation_std: 0.0,
1024            weight_init_std: 0.1,
1025            tournament_size: 1,
1026            elite_count: 0,
1027        });
1028        // Build a one-row genome with arch 0 and 17 active params, padded to 17.
1029        let weights = Tensor::<TestBackend, 2>::from_data(
1030            TensorData::new(
1031                vec![0.0f32; params.max_param_count],
1032                [1, params.max_param_count],
1033            ),
1034            &device,
1035        );
1036        let genome = NasGenome {
1037            arch_ids: vec![0],
1038            weights,
1039        };
1040        let fit = fitness
1041            .evaluate(&genome, &device)
1042            .into_data()
1043            .into_vec::<f32>()
1044            .expect("fitness host-read of a tensor this test just built");
1045        approx::assert_relative_eq!(fit[0], 17.0, epsilon = 1e-6);
1046    }
1047
1048    #[test]
1049    fn init_populates_all_variants_and_zero_pads() {
1050        let device = Default::default();
1051        let (params, _fitness) = two_variant_builder(&device).build(NasBuilderConfig {
1052            pop_size: 60,
1053            arch_mutation_rate: 0.1,
1054            weight_mutation_std: 0.1,
1055            weight_init_std: 0.5,
1056            tournament_size: 2,
1057            elite_count: 1,
1058        });
1059        let strat = ArchNasStrategy::<TestBackend>::new();
1060        let mut rng = StdRng::seed_from_u64(7);
1061        let state = strat.init(&params, &mut rng, &device);
1062
1063        // Both architectures should be represented with pop 60.
1064        assert!(state.population.arch_ids.contains(&0));
1065        assert!(state.population.arch_ids.contains(&1));
1066
1067        // A shallow (arch 0) row must have zeros beyond its 17 active params.
1068        let rows = state
1069            .population
1070            .weights
1071            .clone()
1072            .into_data()
1073            .into_vec::<f32>()
1074            .expect("population host-read of a tensor this test just built");
1075        let max = params.max_param_count;
1076        let shallow_row = state
1077            .population
1078            .arch_ids
1079            .iter()
1080            .position(|&a| a == 0)
1081            .unwrap();
1082        for &v in &rows[shallow_row * max + 17..shallow_row * max + max] {
1083            approx::assert_relative_eq!(v, 0.0, epsilon = 1e-6);
1084        }
1085    }
1086
1087    #[test]
1088    fn ask_tell_runs_without_panic_and_tracks_best() {
1089        let device = Default::default();
1090        // Scorer: sum of squared active weights — a canonical (maximise)
1091        // fitness the strategy drives upward; the test only pins best-ever
1092        // monotonicity, not optimisation quality.
1093        let mut builder = ArchNasBuilder::<TestBackend>::new();
1094        let sq = |m: &ShallowMlp<TestBackend>| {
1095            let r = ModuleReshaper::new(ShallowMlp::<TestBackend>::new(4, &Default::default()));
1096            let flat = r.flatten(m, &Default::default());
1097            flat.clone()
1098                .mul(flat)
1099                .sum()
1100                .into_data()
1101                .into_vec::<f32>()
1102                .expect("output host-read of a tensor this test just built")[0]
1103        };
1104        let sq_deep = |m: &DeepMlp<TestBackend>| {
1105            let r = ModuleReshaper::new(DeepMlp::<TestBackend>::new(&Default::default()));
1106            let flat = r.flatten(m, &Default::default());
1107            flat.clone()
1108                .mul(flat)
1109                .sum()
1110                .into_data()
1111                .into_vec::<f32>()
1112                .expect("output host-read of a tensor this test just built")[0]
1113        };
1114        builder
1115            .add_variant(ShallowMlp::<TestBackend>::new(4, &device), sq)
1116            .add_variant(DeepMlp::<TestBackend>::new(&device), sq_deep);
1117        let (params, fitness) = builder.build(NasBuilderConfig {
1118            pop_size: 30,
1119            arch_mutation_rate: 0.1,
1120            weight_mutation_std: 0.05,
1121            weight_init_std: 0.5,
1122            tournament_size: 3,
1123            elite_count: 2,
1124        });
1125
1126        let strat = ArchNasStrategy::<TestBackend>::new();
1127        let mut rng = StdRng::seed_from_u64(123);
1128        let mut state = strat.init(&params, &mut rng, &device);
1129
1130        // Generation 0.
1131        let (genome, next) = strat.ask(&params, &state, &mut rng, &device);
1132        let fit = fitness.evaluate(&genome, &device);
1133        state = strat.tell(&params, genome, fit, next, &mut rng);
1134        let gen0_best = strat.best(&state).map(|(_, _, f)| f).unwrap();
1135
1136        // Generations 1..6.
1137        for _ in 0..6 {
1138            let (genome, next) = strat.ask(&params, &state, &mut rng, &device);
1139            let fit = fitness.evaluate(&genome, &device);
1140            state = strat.tell(&params, genome, fit, next, &mut rng);
1141        }
1142        let final_best = strat.best(&state).map(|(_, _, f)| f).unwrap();
1143
1144        assert!(
1145            final_best >= gen0_best,
1146            "best-ever must be monotone (maximise): final {final_best} < gen0 {gen0_best}"
1147        );
1148        assert_eq!(state.generation(), 7);
1149    }
1150
1151    /// Regression (ADR 0034, issue #133): `tell` is the `ArchNasStrategy` chokepoint
1152    /// and must sanitize non-finite fitness before `update_best`/store. A `NaN`
1153    /// member must never become the champion nor survive into the next ask's
1154    /// selection; a `+∞` member must rank top but as a **finite** value
1155    /// (`f32::MAX`).
1156    #[test]
1157    fn tell_sanitizes_nan_and_inf_so_nan_never_champions() {
1158        let device = Default::default();
1159        let (params, _fitness) = two_variant_builder(&device).build(NasBuilderConfig {
1160            pop_size: 4,
1161            arch_mutation_rate: 0.0,
1162            weight_mutation_std: 0.0,
1163            weight_init_std: 0.5,
1164            tournament_size: 2,
1165            elite_count: 1,
1166        });
1167        let strat = ArchNasStrategy::<TestBackend>::new();
1168        let mut rng = StdRng::seed_from_u64(9);
1169        let state = strat.init(&params, &mut rng, &device);
1170
1171        // One ask, then hand-craft the fitness: NaN (must never champion), +∞
1172        // (ranks top but finite), two plain values. Row 1 (+∞) is the champion.
1173        let (genome, next) = strat.ask(&params, &state, &mut rng, &device);
1174        let champion_arch = genome.arch_ids[1];
1175        let fitness = Tensor::<TestBackend, 1>::from_data(
1176            TensorData::new(vec![f32::NAN, f32::INFINITY, 1.0_f32, 2.0], [4]),
1177            &device,
1178        );
1179        let state = strat.tell(&params, genome, fitness, next, &mut rng);
1180
1181        // Stored fitness is sanitized: no NaN; NaN → −∞, +∞ → f32::MAX.
1182        assert!(
1183            state.fitness.iter().all(|f| !f.is_nan()),
1184            "no stored fitness is NaN after the tell chokepoint"
1185        );
1186        assert!(
1187            state.fitness[0].is_infinite() && state.fitness[0].is_sign_negative(),
1188            "the NaN member is sanitized to the maximise-space worst sentinel (−∞)"
1189        );
1190        approx::assert_relative_eq!(state.fitness[1], f32::MAX);
1191
1192        // The champion is the sanitized +∞ = f32::MAX (finite), at row 1 — not
1193        // the NaN row.
1194        let (best_arch, _weights, best_f) = strat.best(&state).expect("best exists after tell");
1195        assert!(
1196            best_f.is_finite(),
1197            "champion fitness is finite (never NaN/±∞); got {best_f}"
1198        );
1199        approx::assert_relative_eq!(best_f, f32::MAX);
1200        assert_eq!(
1201            best_arch, champion_arch,
1202            "champion is the +∞ row, never the NaN row"
1203        );
1204    }
1205}