1use 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#[derive(Debug)]
78pub struct NasGenome<B: Backend> {
79 arch_ids: Vec<usize>,
82 weights: Tensor<B, 2>,
85}
86
87impl<B: Backend> NasGenome<B> {
88 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 #[must_use]
109 pub fn arch_ids(&self) -> &[usize] {
110 &self.arch_ids
111 }
112
113 #[must_use]
115 pub fn weights(&self) -> &Tensor<B, 2> {
116 &self.weights
117 }
118}
119
120pub 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 #[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 #[must_use]
167 pub fn num_params(&self) -> usize {
168 self.num_params
169 }
170
171 #[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#[derive(Debug, Clone)]
194pub struct NasParams {
195 pop_size: usize,
197 num_variants: usize,
199 per_variant_params: Vec<usize>,
202 max_param_count: usize,
204 arch_mutation_rate: f32,
207 weight_mutation_std: f32,
209 weight_init_std: f32,
211 tournament_size: usize,
213 elite_count: usize,
216}
217
218impl NasParams {
219 #[must_use]
221 pub fn pop_size(&self) -> usize {
222 self.pop_size
223 }
224
225 #[must_use]
227 pub fn num_variants(&self) -> usize {
228 self.num_variants
229 }
230
231 #[must_use]
233 pub fn per_variant_params(&self) -> &[usize] {
234 &self.per_variant_params
235 }
236
237 #[must_use]
239 pub fn max_param_count(&self) -> usize {
240 self.max_param_count
241 }
242
243 #[must_use]
246 pub fn arch_mutation_rate(&self) -> f32 {
247 self.arch_mutation_rate
248 }
249
250 #[must_use]
252 pub fn weight_mutation_std(&self) -> f32 {
253 self.weight_mutation_std
254 }
255
256 #[must_use]
258 pub fn weight_init_std(&self) -> f32 {
259 self.weight_init_std
260 }
261
262 #[must_use]
264 pub fn tournament_size(&self) -> usize {
265 self.tournament_size
266 }
267
268 #[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#[derive(Debug)]
306pub struct NasState<B: Backend> {
307 population: NasGenome<B>,
308 fitness: Vec<f32>,
310 best_arch_id: Option<usize>,
311 best_weights: Option<Tensor<B, 1>>,
313 best_fitness: f32,
314 generation: usize,
315}
316
317impl<B: Backend> NasState<B> {
318 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 #[must_use]
336 pub fn generation(&self) -> usize {
337 self.generation
338 }
339
340 #[must_use]
347 pub fn population(&self) -> &NasGenome<B> {
348 &self.population
349 }
350
351 #[must_use]
354 pub fn best_fitness(&self) -> f32 {
355 self.best_fitness
356 }
357}
358
359#[derive(Debug, Clone, Copy)]
361pub struct NasBuilderConfig {
362 pub pop_size: usize,
364 pub arch_mutation_rate: f32,
366 pub weight_mutation_std: f32,
368 pub weight_init_std: f32,
370 pub tournament_size: usize,
372 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#[derive(Debug, Default)]
430pub struct ArchNasBuilder<B: Backend> {
431 evaluators: Vec<VariantEvaluator<B>>,
432}
433
434impl<B: Backend> ArchNasBuilder<B> {
435 #[must_use]
437 pub fn new() -> Self {
438 Self {
439 evaluators: Vec::new(),
440 }
441 }
442
443 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 #[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#[derive(Debug)]
502pub struct ArchNasFitnessFn<B: Backend> {
503 evaluators: Vec<VariantEvaluator<B>>,
504}
505
506impl<B: Backend> ArchNasFitnessFn<B> {
507 #[must_use]
509 pub fn num_variants(&self) -> usize {
510 self.evaluators.len()
511 }
512
513 #[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#[derive(Debug, Clone, Copy, Default)]
560pub struct ArchNasStrategy<B: Backend> {
561 _backend: PhantomData<fn() -> B>,
562}
563
564impl<B: Backend> ArchNasStrategy<B> {
565 #[must_use]
567 pub fn new() -> Self {
568 Self {
569 _backend: PhantomData,
570 }
571 }
572
573 #[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 #[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 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 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 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 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 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 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 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 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 #[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 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 #[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
835fn 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 #[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 #[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 #[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 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 .add_variant(ShallowMlp::<TestBackend>::new(4, device), zero_shallow)
981 .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 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 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 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(¶ms, &mut rng, &device);
1062
1063 assert!(state.population.arch_ids.contains(&0));
1065 assert!(state.population.arch_ids.contains(&1));
1066
1067 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 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(¶ms, &mut rng, &device);
1129
1130 let (genome, next) = strat.ask(¶ms, &state, &mut rng, &device);
1132 let fit = fitness.evaluate(&genome, &device);
1133 state = strat.tell(¶ms, genome, fit, next, &mut rng);
1134 let gen0_best = strat.best(&state).map(|(_, _, f)| f).unwrap();
1135
1136 for _ in 0..6 {
1138 let (genome, next) = strat.ask(¶ms, &state, &mut rng, &device);
1139 let fit = fitness.evaluate(&genome, &device);
1140 state = strat.tell(¶ms, 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 #[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(¶ms, &mut rng, &device);
1170
1171 let (genome, next) = strat.ask(¶ms, &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(¶ms, genome, fitness, next, &mut rng);
1180
1181 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 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}