1use crate::error::{OptimError, Result};
74use scirs2_core::random::{Random, Rng};
75use serde::{Deserialize, Serialize};
76use std::collections::HashMap;
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
87pub enum SpeechLayerType {
88 Conv1D {
90 channels: u32,
92 kernel: u32,
94 },
95 MelFilterBank {
97 n_mels: u32,
99 },
100 BiLSTM {
102 hidden: u32,
104 },
105 LSTM {
107 hidden: u32,
109 },
110 Attention {
112 heads: u32,
114 dim: u32,
116 },
117 LinearProjection {
119 out: u32,
121 },
122 LayerNorm,
124 Dropout {
126 p_milli: u32,
128 },
129 CTCDecoder,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135pub enum LayerPositionConstraint {
136 StartOnly,
139 EndOnly,
142 Body,
144 Any,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct SpeechSearchSpace {
156 pub allowed_layers: Vec<SpeechLayerType>,
158 pub min_depth: usize,
160 pub max_depth: usize,
162 pub sample_rate_hz: u32,
164 pub frame_stride_ms: f64,
166 pub max_latency_ms: f64,
168 pub max_memory_mb: f64,
170}
171
172impl Default for SpeechSearchSpace {
173 fn default() -> Self {
174 let mut allowed_layers: Vec<SpeechLayerType> = Vec::new();
179
180 for &n_mels in &[64u32, 80, 128] {
183 allowed_layers.push(SpeechLayerType::MelFilterBank { n_mels });
184 }
185
186 for &channels in &[32u32, 64, 128, 256] {
188 for &kernel in &[3u32, 5, 7, 9] {
189 allowed_layers.push(SpeechLayerType::Conv1D { channels, kernel });
190 }
191 }
192
193 for &hidden in &[128u32, 256, 384, 512] {
195 allowed_layers.push(SpeechLayerType::LSTM { hidden });
196 allowed_layers.push(SpeechLayerType::BiLSTM { hidden });
197 }
198
199 allowed_layers.push(SpeechLayerType::Attention { heads: 4, dim: 64 });
203
204 allowed_layers.push(SpeechLayerType::LayerNorm);
206 for &p_milli in &[50u32, 100, 150] {
207 allowed_layers.push(SpeechLayerType::Dropout { p_milli });
208 }
209
210 for &out in &[128u32, 256, 512, 1024] {
212 allowed_layers.push(SpeechLayerType::LinearProjection { out });
213 }
214
215 allowed_layers.push(SpeechLayerType::CTCDecoder);
217
218 Self {
219 allowed_layers,
220 min_depth: 4,
221 max_depth: 12,
222 sample_rate_hz: 16_000,
223 frame_stride_ms: 10.0,
224 max_latency_ms: 500.0,
225 max_memory_mb: 200.0,
226 }
227 }
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct SpeechModelConfig {
239 pub layers: Vec<SpeechLayerType>,
241 pub parameters: HashMap<String, f64>,
243 pub model_id: String,
245}
246
247#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
257pub struct SpeechModelEvaluation {
258 pub word_error_rate: f64,
260 pub latency_ms: f64,
262 pub memory_mb: f64,
264 pub param_count: u64,
266}
267
268#[derive(Debug, Clone)]
276pub struct SpeechNasEngine {
277 search_space: SpeechSearchSpace,
278 evaluated: Vec<(SpeechModelConfig, SpeechModelEvaluation)>,
279 best_model: Option<SpeechModelConfig>,
280 best_score: f64,
281 next_id: usize,
282}
283
284pub fn layer_position_constraint(layer: SpeechLayerType) -> LayerPositionConstraint {
290 match layer {
291 SpeechLayerType::MelFilterBank { .. } => LayerPositionConstraint::StartOnly,
292 SpeechLayerType::CTCDecoder => LayerPositionConstraint::EndOnly,
293 _ => LayerPositionConstraint::Any,
294 }
295}
296
297const PREFER_START_PROB: f64 = 0.8;
300
301const PREFER_END_PROB: f64 = 0.8;
304
305const SUBSTITUTE_PROB: f64 = 0.3;
308
309impl SpeechNasEngine {
310 pub fn new(search_space: SpeechSearchSpace) -> Self {
315 Self {
316 search_space,
317 evaluated: Vec::new(),
318 best_model: None,
319 best_score: f64::INFINITY,
320 next_id: 0,
321 }
322 }
323
324 pub fn with_default_search_space() -> Self {
326 Self::new(SpeechSearchSpace::default())
327 }
328
329 pub fn search_space(&self) -> &SpeechSearchSpace {
331 &self.search_space
332 }
333
334 pub fn evaluated(&self) -> &[(SpeechModelConfig, SpeechModelEvaluation)] {
336 &self.evaluated
337 }
338
339 pub fn best(&self) -> Option<&SpeechModelConfig> {
341 self.best_model.as_ref()
342 }
343
344 pub fn best_evaluation(&self) -> Option<SpeechModelEvaluation> {
346 let best_id = self.best_model.as_ref()?.model_id.clone();
347 self.evaluated
348 .iter()
349 .find(|(m, _)| m.model_id == best_id)
350 .map(|(_, e)| *e)
351 }
352
353 pub fn reset(&mut self) {
358 self.evaluated.clear();
359 self.best_model = None;
360 self.best_score = f64::INFINITY;
361 self.next_id = 0;
362 }
363
364 fn allocate_model_id(&mut self) -> String {
370 let id = format!("speech_model_{}", self.next_id);
371 self.next_id += 1;
372 id
373 }
374
375 fn ensure_search_space_valid(&self) -> Result<()> {
377 if self.search_space.allowed_layers.is_empty() {
378 return Err(OptimError::SearchSpaceError(
379 "allowed_layers is empty".to_string(),
380 ));
381 }
382 if self.search_space.min_depth == 0 {
383 return Err(OptimError::SearchSpaceError(
384 "min_depth must be at least 1".to_string(),
385 ));
386 }
387 if self.search_space.max_depth < self.search_space.min_depth {
388 return Err(OptimError::SearchSpaceError(format!(
389 "max_depth ({}) must be >= min_depth ({})",
390 self.search_space.max_depth, self.search_space.min_depth
391 )));
392 }
393 let has_body = self.search_space.allowed_layers.iter().any(|l| {
396 matches!(
397 layer_position_constraint(*l),
398 LayerPositionConstraint::Any | LayerPositionConstraint::Body
399 )
400 });
401 if !has_body && self.search_space.max_depth >= 3 {
402 return Err(OptimError::SearchSpaceError(
403 "search space has no Body/Any layer for middle positions".to_string(),
404 ));
405 }
406 Ok(())
407 }
408
409 fn pick_position_layer<R: Rng>(
415 &self,
416 rng: &mut Random<R>,
417 strict: LayerPositionConstraint,
418 prefer_strict_prob: f64,
419 ) -> Result<SpeechLayerType> {
420 let strict_layers: Vec<SpeechLayerType> = self
421 .search_space
422 .allowed_layers
423 .iter()
424 .copied()
425 .filter(|l| layer_position_constraint(*l) == strict)
426 .collect();
427 let any_layers: Vec<SpeechLayerType> = self
428 .search_space
429 .allowed_layers
430 .iter()
431 .copied()
432 .filter(|l| layer_position_constraint(*l) == LayerPositionConstraint::Any)
433 .collect();
434
435 let use_strict = if strict_layers.is_empty() {
436 false
437 } else if any_layers.is_empty() {
438 true
439 } else {
440 rng.gen_range(0.0_f64..1.0) < prefer_strict_prob
441 };
442
443 let pool = if use_strict {
444 &strict_layers
445 } else {
446 &any_layers
447 };
448 if pool.is_empty() {
449 return Err(OptimError::SearchSpaceError(format!(
450 "no candidate layer available for constraint {:?}",
451 strict
452 )));
453 }
454 let idx: usize = rng.gen_range(0..pool.len());
455 Ok(pool[idx])
456 }
457
458 fn pick_body_layer<R: Rng>(&self, rng: &mut Random<R>) -> Result<SpeechLayerType> {
460 let body_layers: Vec<SpeechLayerType> = self
461 .search_space
462 .allowed_layers
463 .iter()
464 .copied()
465 .filter(|l| {
466 matches!(
467 layer_position_constraint(*l),
468 LayerPositionConstraint::Any | LayerPositionConstraint::Body
469 )
470 })
471 .collect();
472 if body_layers.is_empty() {
473 return Err(OptimError::SearchSpaceError(
474 "no body-eligible layers in search space".to_string(),
475 ));
476 }
477 let idx: usize = rng.gen_range(0..body_layers.len());
478 Ok(body_layers[idx])
479 }
480
481 pub fn propose<R: Rng>(&mut self, rng: &mut Random<R>) -> Result<SpeechModelConfig> {
493 self.ensure_search_space_valid()?;
494
495 let depth: usize =
498 rng.gen_range(self.search_space.min_depth..(self.search_space.max_depth + 1));
499
500 let mut layers: Vec<SpeechLayerType> = Vec::with_capacity(depth);
501
502 if depth == 1 {
503 let layer = self
506 .pick_position_layer(rng, LayerPositionConstraint::EndOnly, PREFER_END_PROB)
507 .or_else(|_| {
508 self.pick_position_layer(rng, LayerPositionConstraint::StartOnly, 1.0)
509 })?;
510 layers.push(layer);
511 } else {
512 let first = self.pick_position_layer(
514 rng,
515 LayerPositionConstraint::StartOnly,
516 PREFER_START_PROB,
517 )?;
518 layers.push(first);
519
520 for _ in 1..(depth - 1) {
522 layers.push(self.pick_body_layer(rng)?);
523 }
524
525 let last =
527 self.pick_position_layer(rng, LayerPositionConstraint::EndOnly, PREFER_END_PROB)?;
528 layers.push(last);
529 }
530
531 let model_id = self.allocate_model_id();
532 Ok(SpeechModelConfig {
533 layers,
534 parameters: HashMap::new(),
535 model_id,
536 })
537 }
538
539 pub fn mutate<R: Rng>(
551 &self,
552 model: &SpeechModelConfig,
553 rng: &mut Random<R>,
554 ) -> Result<SpeechModelConfig> {
555 self.ensure_search_space_valid()?;
556
557 if model.layers.is_empty() {
558 return Err(OptimError::ArchitectureError(
559 "cannot mutate an empty model".to_string(),
560 ));
561 }
562
563 let mut new_layers = model.layers.clone();
564 let n = new_layers.len();
565
566 let do_substitute = rng.gen_range(0.0_f64..1.0) < SUBSTITUTE_PROB;
568
569 if do_substitute && n >= 3 {
570 let pos: usize = rng.gen_range(1..(n - 1));
572 let original = new_layers[pos];
573
574 let mut replacement = self.pick_body_layer(rng)?;
576 for _ in 0..8 {
577 if replacement != original {
578 break;
579 }
580 replacement = self.pick_body_layer(rng)?;
581 }
582 new_layers[pos] = replacement;
583 } else {
584 let target_pos: usize = if n >= 3 {
591 rng.gen_range(1..(n - 1))
592 } else {
593 rng.gen_range(0..n)
594 };
595 new_layers[target_pos] = self.perturb_layer(new_layers[target_pos], rng);
596 }
597
598 Ok(SpeechModelConfig {
599 layers: new_layers,
600 parameters: model.parameters.clone(),
601 model_id: format!("{}_mut", model.model_id),
602 })
603 }
604
605 fn perturb_layer<R: Rng>(
612 &self,
613 layer: SpeechLayerType,
614 rng: &mut Random<R>,
615 ) -> SpeechLayerType {
616 match layer {
617 SpeechLayerType::Conv1D { channels, kernel } => {
618 let scale = if rng.gen_range(0.0_f64..1.0) < 0.5 {
619 0.75
620 } else {
621 1.25
622 };
623 let new_channels = ((channels as f64) * scale).round().max(1.0) as u32;
624 SpeechLayerType::Conv1D {
625 channels: new_channels,
626 kernel,
627 }
628 }
629 SpeechLayerType::MelFilterBank { n_mels } => {
630 let delta: i32 = match rng.gen_range(0_u32..3) {
632 0 => -16,
633 1 => 0,
634 _ => 16,
635 };
636 let new_n = ((n_mels as i64) + delta as i64).max(8) as u32;
637 SpeechLayerType::MelFilterBank { n_mels: new_n }
638 }
639 SpeechLayerType::BiLSTM { hidden } => {
640 let scale = if rng.gen_range(0.0_f64..1.0) < 0.5 {
641 0.75
642 } else {
643 1.25
644 };
645 let new_hidden = ((hidden as f64) * scale).round().max(8.0) as u32;
646 SpeechLayerType::BiLSTM { hidden: new_hidden }
647 }
648 SpeechLayerType::LSTM { hidden } => {
649 let scale = if rng.gen_range(0.0_f64..1.0) < 0.5 {
650 0.75
651 } else {
652 1.25
653 };
654 let new_hidden = ((hidden as f64) * scale).round().max(8.0) as u32;
655 SpeechLayerType::LSTM { hidden: new_hidden }
656 }
657 SpeechLayerType::Attention { heads, dim } => {
658 if rng.gen_range(0.0_f64..1.0) < 0.5 {
660 let new_heads = if rng.gen_range(0.0_f64..1.0) < 0.5 && heads > 1 {
661 heads - 1
662 } else {
663 heads + 1
664 };
665 SpeechLayerType::Attention {
666 heads: new_heads,
667 dim,
668 }
669 } else {
670 let scale = if rng.gen_range(0.0_f64..1.0) < 0.5 {
671 0.75
672 } else {
673 1.25
674 };
675 let new_dim = ((dim as f64) * scale).round().max(8.0) as u32;
676 SpeechLayerType::Attention {
677 heads,
678 dim: new_dim,
679 }
680 }
681 }
682 SpeechLayerType::LinearProjection { out } => {
683 let scale = if rng.gen_range(0.0_f64..1.0) < 0.5 {
684 0.75
685 } else {
686 1.25
687 };
688 let new_out = ((out as f64) * scale).round().max(8.0) as u32;
689 SpeechLayerType::LinearProjection { out: new_out }
690 }
691 SpeechLayerType::LayerNorm => SpeechLayerType::LayerNorm,
692 SpeechLayerType::Dropout { p_milli } => {
693 let delta: i32 = if rng.gen_range(0.0_f64..1.0) < 0.5 {
696 -50
697 } else {
698 50
699 };
700 let new_p = ((p_milli as i64) + delta as i64).clamp(0, 900) as u32;
701 SpeechLayerType::Dropout { p_milli: new_p }
702 }
703 SpeechLayerType::CTCDecoder => SpeechLayerType::CTCDecoder,
704 }
705 }
706
707 pub fn crossover<R: Rng>(
723 &self,
724 a: &SpeechModelConfig,
725 b: &SpeechModelConfig,
726 rng: &mut Random<R>,
727 ) -> Result<SpeechModelConfig> {
728 self.ensure_search_space_valid()?;
729
730 if a.layers.is_empty() || b.layers.is_empty() {
731 return Err(OptimError::ArchitectureError(
732 "cannot cross over empty parents".to_string(),
733 ));
734 }
735 let min_len = a.layers.len().min(b.layers.len());
736 if min_len < 2 {
737 let split = if a.layers.len() >= 2 {
740 1
741 } else {
742 a.layers.len()
743 };
744 let mut layers: Vec<SpeechLayerType> = a.layers[..split].to_vec();
745 layers.extend(b.layers[split.min(b.layers.len())..].iter().copied());
746 return self.finish_crossover(a, b, layers);
747 }
748
749 let split: usize = rng.gen_range(1..min_len);
750 let mut layers: Vec<SpeechLayerType> = a.layers[..split].to_vec();
751 layers.extend(b.layers[split..].iter().copied());
752 self.finish_crossover(a, b, layers)
753 }
754
755 fn finish_crossover(
757 &self,
758 a: &SpeechModelConfig,
759 b: &SpeechModelConfig,
760 mut layers: Vec<SpeechLayerType>,
761 ) -> Result<SpeechModelConfig> {
762 if !matches!(layers.first(), Some(SpeechLayerType::MelFilterBank { .. })) {
764 let mel = a
769 .layers
770 .iter()
771 .copied()
772 .chain(b.layers.iter().copied())
773 .find(|l| matches!(l, SpeechLayerType::MelFilterBank { .. }))
774 .or_else(|| {
775 self.search_space
776 .allowed_layers
777 .iter()
778 .copied()
779 .find(|l| matches!(l, SpeechLayerType::MelFilterBank { .. }))
780 });
781 if let Some(mel) = mel {
782 layers.insert(0, mel);
783 } else {
784 return Err(OptimError::SearchSpaceError(
785 "no MelFilterBank available to repair crossover offspring".to_string(),
786 ));
787 }
788 }
789
790 if !matches!(layers.last(), Some(SpeechLayerType::CTCDecoder)) {
792 layers.push(SpeechLayerType::CTCDecoder);
794 }
795
796 if layers.len() > self.search_space.max_depth {
800 let mut trimmed = Vec::with_capacity(self.search_space.max_depth);
801 trimmed.push(layers[0]);
802 let body_target = self.search_space.max_depth.saturating_sub(2);
804 for i in 1..(1 + body_target) {
805 if i < layers.len() - 1 {
806 trimmed.push(layers[i]);
807 }
808 }
809 trimmed.push(layers[layers.len() - 1]);
810 layers = trimmed;
811 }
812
813 let mut parameters = a.parameters.clone();
816 for (k, v) in &b.parameters {
817 parameters.insert(k.clone(), *v);
818 }
819
820 Ok(SpeechModelConfig {
821 layers,
822 parameters,
823 model_id: format!("{}_x_{}", a.model_id, b.model_id),
824 })
825 }
826
827 pub fn record_evaluation(
838 &mut self,
839 model: SpeechModelConfig,
840 eval: SpeechModelEvaluation,
841 ) -> Result<()> {
842 if !eval.word_error_rate.is_finite() {
843 return Err(OptimError::InvalidParameter(format!(
844 "word_error_rate must be finite, got {}",
845 eval.word_error_rate
846 )));
847 }
848 if !(0.0..=1.0).contains(&eval.word_error_rate) {
849 return Err(OptimError::InvalidParameter(format!(
850 "word_error_rate must lie in [0, 1], got {}",
851 eval.word_error_rate
852 )));
853 }
854 if !eval.latency_ms.is_finite() || eval.latency_ms < 0.0 {
855 return Err(OptimError::InvalidParameter(format!(
856 "latency_ms must be non-negative finite, got {}",
857 eval.latency_ms
858 )));
859 }
860 if !eval.memory_mb.is_finite() || eval.memory_mb < 0.0 {
861 return Err(OptimError::InvalidParameter(format!(
862 "memory_mb must be non-negative finite, got {}",
863 eval.memory_mb
864 )));
865 }
866
867 if eval.word_error_rate < self.best_score {
868 self.best_score = eval.word_error_rate;
869 self.best_model = Some(model.clone());
870 }
871 self.evaluated.push((model, eval));
872 Ok(())
873 }
874
875 pub fn pareto_front(&self) -> Vec<&SpeechModelConfig> {
882 let mut front: Vec<&SpeechModelConfig> = Vec::new();
883 for (i, (model, eval)) in self.evaluated.iter().enumerate() {
884 let mut dominated = false;
885 for (j, (_, other)) in self.evaluated.iter().enumerate() {
886 if i == j {
887 continue;
888 }
889 if Self::dominates(other, eval) {
890 dominated = true;
891 break;
892 }
893 }
894 if !dominated {
895 front.push(model);
896 }
897 }
898 front
899 }
900
901 fn dominates(a: &SpeechModelEvaluation, b: &SpeechModelEvaluation) -> bool {
904 let no_worse = a.word_error_rate <= b.word_error_rate
905 && a.latency_ms <= b.latency_ms
906 && a.memory_mb <= b.memory_mb;
907 let strictly_better = a.word_error_rate < b.word_error_rate
908 || a.latency_ms < b.latency_ms
909 || a.memory_mb < b.memory_mb;
910 no_worse && strictly_better
911 }
912}
913
914#[cfg(test)]
919mod tests {
920 use super::*;
921
922 fn make_mel_default() -> SpeechLayerType {
923 SpeechLayerType::MelFilterBank { n_mels: 80 }
924 }
925
926 fn make_ctc() -> SpeechLayerType {
927 SpeechLayerType::CTCDecoder
928 }
929
930 fn make_simple_model(id: &str) -> SpeechModelConfig {
931 SpeechModelConfig {
932 layers: vec![
933 make_mel_default(),
934 SpeechLayerType::Conv1D {
935 channels: 64,
936 kernel: 3,
937 },
938 SpeechLayerType::BiLSTM { hidden: 256 },
939 SpeechLayerType::LinearProjection { out: 512 },
940 make_ctc(),
941 ],
942 parameters: HashMap::new(),
943 model_id: id.to_string(),
944 }
945 }
946
947 #[test]
948 fn test_default_search_space_has_speech_layers() {
949 let space = SpeechSearchSpace::default();
950 let has_mel = space
951 .allowed_layers
952 .iter()
953 .any(|l| matches!(l, SpeechLayerType::MelFilterBank { .. }));
954 let has_bilstm = space
955 .allowed_layers
956 .iter()
957 .any(|l| matches!(l, SpeechLayerType::BiLSTM { .. }));
958 let has_ctc = space
959 .allowed_layers
960 .iter()
961 .any(|l| matches!(l, SpeechLayerType::CTCDecoder));
962 let has_conv = space
963 .allowed_layers
964 .iter()
965 .any(|l| matches!(l, SpeechLayerType::Conv1D { .. }));
966 let has_attention = space
967 .allowed_layers
968 .iter()
969 .any(|l| matches!(l, SpeechLayerType::Attention { .. }));
970 assert!(has_mel, "MelFilterBank must be in default search space");
971 assert!(has_bilstm, "BiLSTM must be in default search space");
972 assert!(has_ctc, "CTCDecoder must be in default search space");
973 assert!(has_conv, "Conv1D must be in default search space");
974 assert!(has_attention, "Attention must be in default search space");
975 assert_eq!(space.min_depth, 4);
976 assert_eq!(space.max_depth, 12);
977 assert_eq!(space.sample_rate_hz, 16_000);
978 }
979
980 #[test]
981 fn test_propose_returns_valid_architecture() {
982 let mut engine = SpeechNasEngine::with_default_search_space();
983 let mut rng = Random::seed(1);
984 let model = engine.propose(&mut rng).expect("propose");
985 assert!(model.layers.len() >= engine.search_space.min_depth);
986 assert!(model.layers.len() <= engine.search_space.max_depth);
987 assert!(!model.model_id.is_empty());
988 assert!(model.parameters.is_empty());
989 }
990
991 #[test]
992 fn test_propose_respects_depth_bounds() {
993 let mut engine = SpeechNasEngine::with_default_search_space();
994 let mut rng = Random::seed(2);
995 let min_d = engine.search_space.min_depth;
996 let max_d = engine.search_space.max_depth;
997 for _ in 0..50 {
998 let model = engine.propose(&mut rng).expect("propose");
999 assert!(
1000 model.layers.len() >= min_d,
1001 "depth {} below min {}",
1002 model.layers.len(),
1003 min_d
1004 );
1005 assert!(
1006 model.layers.len() <= max_d,
1007 "depth {} above max {}",
1008 model.layers.len(),
1009 max_d
1010 );
1011 }
1012 }
1013
1014 #[test]
1015 fn test_propose_mel_filterbank_at_position_zero() {
1016 let mut engine = SpeechNasEngine::with_default_search_space();
1017 let mut rng = Random::seed(3);
1018 let trials = 100;
1019 let mut mel_first = 0;
1020 for _ in 0..trials {
1021 let model = engine.propose(&mut rng).expect("propose");
1022 if matches!(
1023 model.layers.first(),
1024 Some(SpeechLayerType::MelFilterBank { .. })
1025 ) {
1026 mel_first += 1;
1027 }
1028 }
1029 let ratio = mel_first as f64 / trials as f64;
1030 assert!(
1031 ratio > 0.5,
1032 "MelFilterBank at position 0 ratio {:.3} should exceed 0.5",
1033 ratio
1034 );
1035 }
1036
1037 #[test]
1038 fn test_propose_ctc_decoder_at_last_position() {
1039 let mut engine = SpeechNasEngine::with_default_search_space();
1040 let mut rng = Random::seed(4);
1041 let trials = 100;
1042 let mut ctc_last = 0;
1043 for _ in 0..trials {
1044 let model = engine.propose(&mut rng).expect("propose");
1045 if matches!(model.layers.last(), Some(SpeechLayerType::CTCDecoder)) {
1046 ctc_last += 1;
1047 }
1048 }
1049 let ratio = ctc_last as f64 / trials as f64;
1050 assert!(
1051 ratio > 0.5,
1052 "CTCDecoder at last position ratio {:.3} should exceed 0.5",
1053 ratio
1054 );
1055 }
1056
1057 #[test]
1058 fn test_mutate_changes_model_within_search_space() {
1059 let engine = SpeechNasEngine::with_default_search_space();
1060 let mut rng = Random::seed(5);
1061 let model = make_simple_model("seed");
1062 let mut saw_change = false;
1065 for _ in 0..30 {
1066 let mutated = engine.mutate(&model, &mut rng).expect("mutate");
1067 assert_eq!(mutated.layers.len(), model.layers.len());
1069 assert_ne!(mutated.model_id, model.model_id);
1071 if mutated.layers != model.layers {
1072 saw_change = true;
1073 }
1074 }
1075 assert!(
1076 saw_change,
1077 "mutate produced no observable change in 30 tries"
1078 );
1079 }
1080
1081 #[test]
1082 fn test_mutate_preserves_constraints() {
1083 let engine = SpeechNasEngine::with_default_search_space();
1084 let mut rng = Random::seed(6);
1085 let model = make_simple_model("seed");
1086 for _ in 0..50 {
1087 let mutated = engine.mutate(&model, &mut rng).expect("mutate");
1088 assert!(matches!(
1089 mutated.layers.first(),
1090 Some(SpeechLayerType::MelFilterBank { .. })
1091 ));
1092 assert!(matches!(
1093 mutated.layers.last(),
1094 Some(SpeechLayerType::CTCDecoder)
1095 ));
1096 }
1097 }
1098
1099 #[test]
1100 fn test_crossover_produces_valid_offspring() {
1101 let engine = SpeechNasEngine::with_default_search_space();
1102 let mut rng = Random::seed(7);
1103 let mut a = make_simple_model("a");
1104 a.layers = vec![
1105 SpeechLayerType::MelFilterBank { n_mels: 80 },
1106 SpeechLayerType::Conv1D {
1107 channels: 64,
1108 kernel: 3,
1109 },
1110 SpeechLayerType::BiLSTM { hidden: 256 },
1111 SpeechLayerType::LinearProjection { out: 512 },
1112 SpeechLayerType::CTCDecoder,
1113 ];
1114 let mut b = make_simple_model("b");
1115 b.layers = vec![
1116 SpeechLayerType::MelFilterBank { n_mels: 128 },
1117 SpeechLayerType::Attention { heads: 4, dim: 64 },
1118 SpeechLayerType::LayerNorm,
1119 SpeechLayerType::Dropout { p_milli: 100 },
1120 SpeechLayerType::CTCDecoder,
1121 ];
1122 let child = engine.crossover(&a, &b, &mut rng).expect("crossover");
1123 assert!(matches!(
1124 child.layers.first(),
1125 Some(SpeechLayerType::MelFilterBank { .. })
1126 ));
1127 assert!(matches!(
1128 child.layers.last(),
1129 Some(SpeechLayerType::CTCDecoder)
1130 ));
1131 assert!(child.layers.len() >= 2);
1132 assert!(child.model_id.contains("_x_"));
1133 }
1134
1135 #[test]
1136 fn test_crossover_seed_reproducibility() {
1137 let engine = SpeechNasEngine::with_default_search_space();
1138 let a = make_simple_model("a");
1139 let mut b = make_simple_model("b");
1140 b.layers[2] = SpeechLayerType::LSTM { hidden: 384 };
1141 b.layers[3] = SpeechLayerType::LayerNorm;
1142
1143 let mut rng1 = Random::seed(42);
1144 let mut rng2 = Random::seed(42);
1145 let c1 = engine.crossover(&a, &b, &mut rng1).expect("c1");
1146 let c2 = engine.crossover(&a, &b, &mut rng2).expect("c2");
1147 assert_eq!(c1.layers, c2.layers);
1148 assert_eq!(c1.model_id, c2.model_id);
1149 }
1150
1151 #[test]
1152 fn test_record_evaluation_updates_best() {
1153 let mut engine = SpeechNasEngine::with_default_search_space();
1154 let m1 = make_simple_model("m1");
1155 let m2 = make_simple_model("m2");
1156
1157 engine
1158 .record_evaluation(
1159 m1.clone(),
1160 SpeechModelEvaluation {
1161 word_error_rate: 0.2,
1162 latency_ms: 90.0,
1163 memory_mb: 30.0,
1164 param_count: 1_000_000,
1165 },
1166 )
1167 .expect("record m1");
1168 assert_eq!(
1169 engine.best().map(|m| m.model_id.clone()).expect("best m1"),
1170 "m1".to_string()
1171 );
1172
1173 engine
1174 .record_evaluation(
1175 m2.clone(),
1176 SpeechModelEvaluation {
1177 word_error_rate: 0.1,
1178 latency_ms: 120.0,
1179 memory_mb: 70.0,
1180 param_count: 4_000_000,
1181 },
1182 )
1183 .expect("record m2");
1184 assert_eq!(
1185 engine.best().map(|m| m.model_id.clone()).expect("best m2"),
1186 "m2".to_string()
1187 );
1188 let best_eval = engine.best_evaluation().expect("best eval");
1189 assert!((best_eval.word_error_rate - 0.1).abs() < 1e-12);
1190 }
1191
1192 #[test]
1193 fn test_record_evaluation_validates_wer_range() {
1194 let mut engine = SpeechNasEngine::with_default_search_space();
1195 let m = make_simple_model("bad");
1196 let neg = engine.record_evaluation(
1197 m.clone(),
1198 SpeechModelEvaluation {
1199 word_error_rate: -0.01,
1200 latency_ms: 50.0,
1201 memory_mb: 10.0,
1202 param_count: 100,
1203 },
1204 );
1205 assert!(matches!(neg, Err(OptimError::InvalidParameter(_))));
1206
1207 let too_big = engine.record_evaluation(
1208 m.clone(),
1209 SpeechModelEvaluation {
1210 word_error_rate: 1.5,
1211 latency_ms: 50.0,
1212 memory_mb: 10.0,
1213 param_count: 100,
1214 },
1215 );
1216 assert!(matches!(too_big, Err(OptimError::InvalidParameter(_))));
1217
1218 let bad_latency = engine.record_evaluation(
1219 m.clone(),
1220 SpeechModelEvaluation {
1221 word_error_rate: 0.2,
1222 latency_ms: -1.0,
1223 memory_mb: 10.0,
1224 param_count: 100,
1225 },
1226 );
1227 assert!(matches!(bad_latency, Err(OptimError::InvalidParameter(_))));
1228
1229 let bad_mem = engine.record_evaluation(
1230 m,
1231 SpeechModelEvaluation {
1232 word_error_rate: 0.2,
1233 latency_ms: 1.0,
1234 memory_mb: -10.0,
1235 param_count: 100,
1236 },
1237 );
1238 assert!(matches!(bad_mem, Err(OptimError::InvalidParameter(_))));
1239 }
1240
1241 #[test]
1242 fn test_pareto_front_no_dominated_solutions() {
1243 let mut engine = SpeechNasEngine::with_default_search_space();
1244 let a = make_simple_model("a");
1249 let mut b = make_simple_model("b");
1250 b.model_id = "b".to_string();
1251 let mut c = make_simple_model("c");
1252 c.model_id = "c".to_string();
1253
1254 engine
1255 .record_evaluation(
1256 a,
1257 SpeechModelEvaluation {
1258 word_error_rate: 0.15,
1259 latency_ms: 100.0,
1260 memory_mb: 60.0,
1261 param_count: 1_000_000,
1262 },
1263 )
1264 .expect("record a");
1265 engine
1266 .record_evaluation(
1267 b,
1268 SpeechModelEvaluation {
1269 word_error_rate: 0.20,
1270 latency_ms: 50.0,
1271 memory_mb: 30.0,
1272 param_count: 500_000,
1273 },
1274 )
1275 .expect("record b");
1276 engine
1277 .record_evaluation(
1278 c,
1279 SpeechModelEvaluation {
1280 word_error_rate: 0.25,
1281 latency_ms: 150.0,
1282 memory_mb: 80.0,
1283 param_count: 1_500_000,
1284 },
1285 )
1286 .expect("record c");
1287
1288 let front = engine.pareto_front();
1289 assert_eq!(front.len(), 2, "expected exactly 2 non-dominated models");
1290 let ids: Vec<String> = front.iter().map(|m| m.model_id.clone()).collect();
1291 assert!(ids.contains(&"a".to_string()));
1292 assert!(ids.contains(&"b".to_string()));
1293 assert!(!ids.contains(&"c".to_string()));
1294 }
1295
1296 #[test]
1297 fn test_pareto_front_empty_initially() {
1298 let engine = SpeechNasEngine::with_default_search_space();
1299 let front = engine.pareto_front();
1300 assert!(front.is_empty());
1301 assert!(engine.best().is_none());
1302 assert!(engine.best_evaluation().is_none());
1303 }
1304
1305 #[test]
1306 fn test_speech_model_serde_roundtrip() {
1307 let model = make_simple_model("rt");
1308 let json = serde_json::to_string(&model).expect("serialise");
1309 let back: SpeechModelConfig = serde_json::from_str(&json).expect("deserialise");
1310 assert_eq!(model.layers, back.layers);
1311 assert_eq!(model.model_id, back.model_id);
1312
1313 let eval = SpeechModelEvaluation {
1314 word_error_rate: 0.123,
1315 latency_ms: 45.6,
1316 memory_mb: 78.9,
1317 param_count: 7_777_777,
1318 };
1319 let json_eval = serde_json::to_string(&eval).expect("serialise eval");
1320 let back_eval: SpeechModelEvaluation =
1321 serde_json::from_str(&json_eval).expect("deserialise eval");
1322 assert!((back_eval.word_error_rate - eval.word_error_rate).abs() < 1e-12);
1323 assert_eq!(back_eval.param_count, eval.param_count);
1324
1325 let space = SpeechSearchSpace::default();
1326 let json_space = serde_json::to_string(&space).expect("serialise space");
1327 let back_space: SpeechSearchSpace =
1328 serde_json::from_str(&json_space).expect("deserialise space");
1329 assert_eq!(back_space.allowed_layers.len(), space.allowed_layers.len());
1330 assert_eq!(back_space.min_depth, space.min_depth);
1331 assert_eq!(back_space.max_depth, space.max_depth);
1332 }
1333
1334 #[test]
1335 fn test_layer_position_constraint_correct() {
1336 assert_eq!(
1337 layer_position_constraint(SpeechLayerType::MelFilterBank { n_mels: 80 }),
1338 LayerPositionConstraint::StartOnly
1339 );
1340 assert_eq!(
1341 layer_position_constraint(SpeechLayerType::CTCDecoder),
1342 LayerPositionConstraint::EndOnly
1343 );
1344 assert_eq!(
1345 layer_position_constraint(SpeechLayerType::Conv1D {
1346 channels: 32,
1347 kernel: 3
1348 }),
1349 LayerPositionConstraint::Any
1350 );
1351 assert_eq!(
1352 layer_position_constraint(SpeechLayerType::BiLSTM { hidden: 128 }),
1353 LayerPositionConstraint::Any
1354 );
1355 assert_eq!(
1356 layer_position_constraint(SpeechLayerType::LSTM { hidden: 128 }),
1357 LayerPositionConstraint::Any
1358 );
1359 assert_eq!(
1360 layer_position_constraint(SpeechLayerType::Attention { heads: 4, dim: 64 }),
1361 LayerPositionConstraint::Any
1362 );
1363 assert_eq!(
1364 layer_position_constraint(SpeechLayerType::LinearProjection { out: 256 }),
1365 LayerPositionConstraint::Any
1366 );
1367 assert_eq!(
1368 layer_position_constraint(SpeechLayerType::LayerNorm),
1369 LayerPositionConstraint::Any
1370 );
1371 assert_eq!(
1372 layer_position_constraint(SpeechLayerType::Dropout { p_milli: 100 }),
1373 LayerPositionConstraint::Any
1374 );
1375 }
1376
1377 #[test]
1378 fn test_reset_clears_state() {
1379 let mut engine = SpeechNasEngine::with_default_search_space();
1380 let mut rng = Random::seed(11);
1381 let model = engine.propose(&mut rng).expect("propose");
1382 engine
1383 .record_evaluation(
1384 model,
1385 SpeechModelEvaluation {
1386 word_error_rate: 0.2,
1387 latency_ms: 50.0,
1388 memory_mb: 25.0,
1389 param_count: 1000,
1390 },
1391 )
1392 .expect("record");
1393 assert!(engine.best().is_some());
1394 assert!(!engine.evaluated().is_empty());
1395
1396 engine.reset();
1397 assert!(engine.best().is_none());
1398 assert!(engine.evaluated().is_empty());
1399 assert!(engine.best_evaluation().is_none());
1400 let mut rng2 = Random::seed(12);
1402 let m2 = engine.propose(&mut rng2).expect("propose after reset");
1403 assert_eq!(m2.model_id, "speech_model_0");
1404 }
1405
1406 #[test]
1407 fn test_search_space_accessor_returns_current_space() {
1408 let engine = SpeechNasEngine::with_default_search_space();
1409 let space = engine.search_space();
1410 assert_eq!(space.min_depth, 4);
1411 assert_eq!(space.max_depth, 12);
1412 assert!((space.frame_stride_ms - 10.0).abs() < 1e-12);
1413 }
1414
1415 #[test]
1416 fn test_proposed_model_ids_are_unique_and_sequential() {
1417 let mut engine = SpeechNasEngine::with_default_search_space();
1418 let mut rng = Random::seed(13);
1419 let m0 = engine.propose(&mut rng).expect("p0");
1420 let m1 = engine.propose(&mut rng).expect("p1");
1421 let m2 = engine.propose(&mut rng).expect("p2");
1422 assert_eq!(m0.model_id, "speech_model_0");
1423 assert_eq!(m1.model_id, "speech_model_1");
1424 assert_eq!(m2.model_id, "speech_model_2");
1425 }
1426
1427 #[test]
1428 fn test_pareto_front_all_non_dominated() {
1429 let mut engine = SpeechNasEngine::with_default_search_space();
1430 let mut a = make_simple_model("a");
1432 a.model_id = "a".to_string();
1433 let mut b = make_simple_model("b");
1434 b.model_id = "b".to_string();
1435 let mut c = make_simple_model("c");
1436 c.model_id = "c".to_string();
1437 engine
1438 .record_evaluation(
1439 a,
1440 SpeechModelEvaluation {
1441 word_error_rate: 0.10,
1442 latency_ms: 200.0,
1443 memory_mb: 80.0,
1444 param_count: 1,
1445 },
1446 )
1447 .expect("rec a");
1448 engine
1449 .record_evaluation(
1450 b,
1451 SpeechModelEvaluation {
1452 word_error_rate: 0.30,
1453 latency_ms: 40.0,
1454 memory_mb: 70.0,
1455 param_count: 2,
1456 },
1457 )
1458 .expect("rec b");
1459 engine
1460 .record_evaluation(
1461 c,
1462 SpeechModelEvaluation {
1463 word_error_rate: 0.20,
1464 latency_ms: 100.0,
1465 memory_mb: 20.0,
1466 param_count: 3,
1467 },
1468 )
1469 .expect("rec c");
1470 let front = engine.pareto_front();
1471 assert_eq!(front.len(), 3);
1472 }
1473
1474 #[test]
1475 fn test_invalid_search_space_rejected() {
1476 let mut space = SpeechSearchSpace::default();
1477 space.allowed_layers.clear();
1478 let mut engine = SpeechNasEngine::new(space);
1479 let mut rng = Random::seed(99);
1480 let result = engine.propose(&mut rng);
1481 assert!(matches!(result, Err(OptimError::SearchSpaceError(_))));
1482 }
1483
1484 #[test]
1485 fn test_record_evaluation_rejects_nan_wer() {
1486 let mut engine = SpeechNasEngine::with_default_search_space();
1487 let m = make_simple_model("nan");
1488 let result = engine.record_evaluation(
1489 m,
1490 SpeechModelEvaluation {
1491 word_error_rate: f64::NAN,
1492 latency_ms: 10.0,
1493 memory_mb: 10.0,
1494 param_count: 1,
1495 },
1496 );
1497 assert!(matches!(result, Err(OptimError::InvalidParameter(_))));
1498 }
1499
1500 #[test]
1501 fn test_speech_layer_type_hash_eq() {
1502 use std::collections::HashSet;
1504 let mut set: HashSet<SpeechLayerType> = HashSet::new();
1505 set.insert(SpeechLayerType::Conv1D {
1506 channels: 64,
1507 kernel: 3,
1508 });
1509 set.insert(SpeechLayerType::Conv1D {
1510 channels: 64,
1511 kernel: 3,
1512 });
1513 set.insert(SpeechLayerType::Conv1D {
1514 channels: 64,
1515 kernel: 5,
1516 });
1517 assert_eq!(set.len(), 2);
1518 }
1519}