Skip to main content

optirs_nas/
speech_nas.rs

1//! Speech recognition Neural Architecture Search.
2//!
3//! `speech_nas` provides a domain-specialised NAS engine for *automatic
4//! speech recognition* (ASR) architectures. It complements
5//! [`crate::domain_specific_nas`] — which uses generic optimizer
6//! components — by reasoning explicitly about the layer topology of a
7//! speech model: feature front-end (Mel filter bank), encoder body
8//! (recurrent / convolutional / attention stacks), and CTC decoder head.
9//!
10//! Unlike the generic NAS engine, the search space here is *positional*:
11//! certain layer types may only appear at specific positions. A
12//! [`SpeechLayerType::MelFilterBank`] must always be the very first layer
13//! (it converts a raw waveform into log-Mel features), and a
14//! [`SpeechLayerType::CTCDecoder`] must always be the last layer (it
15//! emits softmax distributions over the output vocabulary for
16//! connectionist temporal classification). The [`layer_position_constraint`]
17//! helper exposes that classification as an enum.
18//!
19//! # High-level workflow
20//!
21//! 1. Build a [`SpeechSearchSpace`] (or take the default) describing the
22//!    allowed layers, the min/max architecture depth, and hard constraints
23//!    on latency and memory. The default search space covers a typical
24//!    16 kHz / 10 ms stride ASR setup with a 200 MB / 500 ms budget.
25//! 2. Construct a [`SpeechNasEngine`] from the search space.
26//! 3. Repeatedly call [`SpeechNasEngine::propose`] to draw fresh models
27//!    from the search space, [`SpeechNasEngine::mutate`] to perturb a
28//!    known-good model, or [`SpeechNasEngine::crossover`] to recombine
29//!    two parents.
30//! 4. Evaluate each proposed [`SpeechModelConfig`] externally (training,
31//!    decoding, latency probe, …) and feed the result back via
32//!    [`SpeechNasEngine::record_evaluation`].
33//! 5. Inspect the [`SpeechNasEngine::best`] / [`SpeechNasEngine::pareto_front`]
34//!    outputs to extract the search frontier.
35//!
36//! All proposal / mutation / crossover operations honour the positional
37//! constraints by construction, and [`SpeechNasEngine::crossover`] performs
38//! a final *repair* pass so that even an unlucky split index produces a
39//! valid offspring.
40//!
41//! # Examples
42//!
43//! ```
44//! use optirs_nas::speech_nas::{SpeechModelEvaluation, SpeechNasEngine};
45//! use scirs2_core::random::Random;
46//!
47//! let mut engine = SpeechNasEngine::with_default_search_space();
48//! let mut rng = Random::seed(7);
49//! let model = engine.propose(&mut rng).expect("propose");
50//! engine
51//!     .record_evaluation(
52//!         model.clone(),
53//!         SpeechModelEvaluation {
54//!             word_error_rate: 0.12,
55//!             latency_ms: 80.0,
56//!             memory_mb: 35.0,
57//!             param_count: 8_000_000,
58//!         },
59//!     )
60//!     .expect("record");
61//! assert!(engine.best().is_some());
62//! ```
63//!
64//! # Pareto frontier
65//!
66//! Speech model selection is inherently multi-objective: a slightly
67//! higher word-error rate may be acceptable in exchange for a 4x smaller
68//! memory footprint or a 2x lower latency. [`SpeechNasEngine::pareto_front`]
69//! returns the set of *non-dominated* models — those for which no other
70//! evaluated model is simultaneously better-or-equal on word-error rate,
71//! latency, and memory while being strictly better on at least one axis.
72
73use crate::error::{OptimError, Result};
74use scirs2_core::random::{Random, Rng};
75use serde::{Deserialize, Serialize};
76use std::collections::HashMap;
77
78/// Layer types supported in speech recognition architectures.
79///
80/// Each variant carries the hyperparameters that influence its compute /
81/// memory footprint. Concrete fields use `u32` (rather than `usize`)
82/// because configurations are persisted to JSON and compared for
83/// equality across machines with different pointer widths. The
84/// [`SpeechLayerType::Dropout`] variant stores `p_milli = p * 1000` as
85/// an integer so that the enum can derive `Eq` / `Hash`.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
87pub enum SpeechLayerType {
88    /// 1D convolution layer applied along the time axis.
89    Conv1D {
90        /// Number of output channels.
91        channels: u32,
92        /// Convolution kernel size (in frames).
93        kernel: u32,
94    },
95    /// Mel-filter-bank front-end. Must appear at position 0.
96    MelFilterBank {
97        /// Number of Mel filters.
98        n_mels: u32,
99    },
100    /// Bidirectional LSTM. Higher accuracy but cannot stream.
101    BiLSTM {
102        /// Hidden state size.
103        hidden: u32,
104    },
105    /// Unidirectional LSTM. Cheaper and streaming-friendly.
106    LSTM {
107        /// Hidden state size.
108        hidden: u32,
109    },
110    /// Multi-head self attention layer.
111    Attention {
112        /// Number of attention heads.
113        heads: u32,
114        /// Per-head feature dimension.
115        dim: u32,
116    },
117    /// Dense linear projection.
118    LinearProjection {
119        /// Output feature dimension.
120        out: u32,
121    },
122    /// Layer normalisation.
123    LayerNorm,
124    /// Stochastic dropout.
125    Dropout {
126        /// Drop probability scaled by 1000 (so `p_milli = 100` means `p = 0.1`).
127        p_milli: u32,
128    },
129    /// CTC decoder head. Must appear at the last position.
130    CTCDecoder,
131}
132
133/// Position constraints for layer types within an ASR architecture.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135pub enum LayerPositionConstraint {
136    /// Layer must appear at the start of the model
137    /// (e.g. [`SpeechLayerType::MelFilterBank`]).
138    StartOnly,
139    /// Layer must appear at the end of the model
140    /// (e.g. [`SpeechLayerType::CTCDecoder`]).
141    EndOnly,
142    /// Layer can appear anywhere except start/end (reserved for future use).
143    Body,
144    /// Layer can appear anywhere in the model.
145    Any,
146}
147
148/// Search space describing which speech models can be built.
149///
150/// `allowed_layers` enumerates every concrete layer instantiation the
151/// search may draw from. Position constraints are *implicit* in the layer
152/// variant (see [`layer_position_constraint`]). The `min_depth` and
153/// `max_depth` bounds are inclusive.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct SpeechSearchSpace {
156    /// Concrete layer variants the search may sample from.
157    pub allowed_layers: Vec<SpeechLayerType>,
158    /// Minimum architecture depth (number of layers).
159    pub min_depth: usize,
160    /// Maximum architecture depth (number of layers).
161    pub max_depth: usize,
162    /// Target audio sample rate in Hertz.
163    pub sample_rate_hz: u32,
164    /// Frame stride for the Mel front-end in milliseconds.
165    pub frame_stride_ms: f64,
166    /// Hard upper bound on end-to-end inference latency.
167    pub max_latency_ms: f64,
168    /// Hard upper bound on resident model memory.
169    pub max_memory_mb: f64,
170}
171
172impl Default for SpeechSearchSpace {
173    fn default() -> Self {
174        // Build a rich default layer alphabet covering the four canonical
175        // speech building blocks (Mel front-end, convolution, recurrence,
176        // attention) at multiple widths, plus the regularisation /
177        // normalisation / decoder primitives.
178        let mut allowed_layers: Vec<SpeechLayerType> = Vec::new();
179
180        // Mel-filter-bank front-ends at the three sizes used by
181        // mainstream ASR recipes (Kaldi, ESPnet, Wav2Vec2).
182        for &n_mels in &[64u32, 80, 128] {
183            allowed_layers.push(SpeechLayerType::MelFilterBank { n_mels });
184        }
185
186        // Convolutional body cells at four widths x four kernel sizes.
187        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        // Recurrent cells (uni + bi) at four hidden sizes.
194        for &hidden in &[128u32, 256, 384, 512] {
195            allowed_layers.push(SpeechLayerType::LSTM { hidden });
196            allowed_layers.push(SpeechLayerType::BiLSTM { hidden });
197        }
198
199        // Multi-head attention. Fixed at four heads / dim 64 to keep the
200        // search space tractable; richer attention shapes are introduced
201        // through composition of multiple Attention layers.
202        allowed_layers.push(SpeechLayerType::Attention { heads: 4, dim: 64 });
203
204        // Normalisation and dropout.
205        allowed_layers.push(SpeechLayerType::LayerNorm);
206        for &p_milli in &[50u32, 100, 150] {
207            allowed_layers.push(SpeechLayerType::Dropout { p_milli });
208        }
209
210        // Linear projections at four output dims.
211        for &out in &[128u32, 256, 512, 1024] {
212            allowed_layers.push(SpeechLayerType::LinearProjection { out });
213        }
214
215        // CTC decoder head.
216        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/// A concrete speech model configuration produced by the NAS engine.
231///
232/// `layers` lists the architecture in execution order. `parameters` carries
233/// free-form scalar hyperparameters (e.g. `"learning_rate"`,
234/// `"warmup_steps"`) that downstream training code reads. `model_id` is a
235/// deterministic identifier the engine assigns when proposing the model
236/// — useful for tracking models across log files.
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct SpeechModelConfig {
239    /// Layers in execution order.
240    pub layers: Vec<SpeechLayerType>,
241    /// Free-form scalar hyperparameters keyed by name.
242    pub parameters: HashMap<String, f64>,
243    /// Engine-assigned model identifier.
244    pub model_id: String,
245}
246
247/// Evaluation result for a single speech model.
248///
249/// The semantics of every field are:
250///
251/// * `word_error_rate` — fraction of incorrectly recognised words in
252///   `[0, 1]`. Lower is better.
253/// * `latency_ms` — wall-clock inference latency on the target device.
254/// * `memory_mb` — peak resident memory during inference.
255/// * `param_count` — total trainable parameter count.
256#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
257pub struct SpeechModelEvaluation {
258    /// Fraction of incorrectly recognised words in `[0, 1]`. Lower is better.
259    pub word_error_rate: f64,
260    /// Wall-clock inference latency in milliseconds.
261    pub latency_ms: f64,
262    /// Peak resident memory in megabytes.
263    pub memory_mb: f64,
264    /// Total trainable parameter count.
265    pub param_count: u64,
266}
267
268/// The speech NAS engine.
269///
270/// `SpeechNasEngine` is the orchestration object: it owns the search
271/// space, the running history of evaluated models, and the current
272/// best-so-far record. It is intentionally non-generic over a numeric
273/// type (unlike [`crate::domain_specific_nas::DomainNASEngine`]) because
274/// speech evaluation metrics are inherently `f64`-valued.
275#[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
284/// Return the position constraint associated with a layer type.
285///
286/// This is the single source of truth for placement rules in the
287/// speech-NAS module — every proposal / mutation / crossover step uses
288/// it to decide whether a layer is legal at a given position.
289pub 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
297/// Probability of preferring a `StartOnly` layer for the first position
298/// when both `StartOnly` and `Any` candidates exist.
299const PREFER_START_PROB: f64 = 0.8;
300
301/// Probability of preferring an `EndOnly` layer for the last position
302/// when both `EndOnly` and `Any` candidates exist.
303const PREFER_END_PROB: f64 = 0.8;
304
305/// Probability of substituting a layer (vs. perturbing its hyperparameters)
306/// in [`SpeechNasEngine::mutate`].
307const SUBSTITUTE_PROB: f64 = 0.3;
308
309impl SpeechNasEngine {
310    /// Construct a new engine from an explicit search space.
311    ///
312    /// The engine starts with no evaluated models, no best record, and an
313    /// internal model counter at zero.
314    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    /// Construct a new engine using [`SpeechSearchSpace::default`].
325    pub fn with_default_search_space() -> Self {
326        Self::new(SpeechSearchSpace::default())
327    }
328
329    /// Borrow the search space.
330    pub fn search_space(&self) -> &SpeechSearchSpace {
331        &self.search_space
332    }
333
334    /// Borrow the evaluation history.
335    pub fn evaluated(&self) -> &[(SpeechModelConfig, SpeechModelEvaluation)] {
336        &self.evaluated
337    }
338
339    /// Return the best model recorded so far, if any.
340    pub fn best(&self) -> Option<&SpeechModelConfig> {
341        self.best_model.as_ref()
342    }
343
344    /// Return the evaluation associated with [`Self::best`].
345    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    /// Reset the engine to a freshly-constructed state.
354    ///
355    /// The search space is preserved. The history, best record, and
356    /// internal id counter are wiped.
357    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    // -------------------------------------------------------------------
365    // Sampling primitives
366    // -------------------------------------------------------------------
367
368    /// Allocate the next model identifier.
369    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    /// Validate that the configured search space can produce a valid model.
376    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        // Need at least one body-eligible layer (Any / Body) to fill
394        // middle positions for architectures of depth >= 3.
395        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    /// Pick one of the layers matching the supplied position constraint
410    /// (including `Any`), preferring the strict constraint with probability
411    /// `prefer_strict_prob` when both kinds of candidates exist.
412    ///
413    /// Returns an error only when *no* layer is legal at the position.
414    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    /// Pick a body-position layer (constraint `Body` or `Any`).
459    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    // -------------------------------------------------------------------
482    // Public NAS operations
483    // -------------------------------------------------------------------
484
485    /// Propose a fresh speech model configuration uniformly from the search space.
486    ///
487    /// Depth is sampled uniformly from `[min_depth, max_depth]`. The first
488    /// layer is drawn preferring [`LayerPositionConstraint::StartOnly`]
489    /// with probability `PREFER_START_PROB`; the last layer is drawn
490    /// preferring [`LayerPositionConstraint::EndOnly`] with probability
491    /// `PREFER_END_PROB`; middle layers are drawn from the body pool.
492    pub fn propose<R: Rng>(&mut self, rng: &mut Random<R>) -> Result<SpeechModelConfig> {
493        self.ensure_search_space_valid()?;
494
495        // Inclusive depth sample. The gen_range API in scirs2-core uses an
496        // exclusive upper bound, so add 1 to the max depth here.
497        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            // Degenerate: pick something legal at any position. Prefer the
504            // end (CTC) since a 1-layer model is essentially a head.
505            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            // First layer: prefer Mel front-end.
513            let first = self.pick_position_layer(
514                rng,
515                LayerPositionConstraint::StartOnly,
516                PREFER_START_PROB,
517            )?;
518            layers.push(first);
519
520            // Middle layers.
521            for _ in 1..(depth - 1) {
522                layers.push(self.pick_body_layer(rng)?);
523            }
524
525            // Last layer: prefer CTC decoder.
526            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    /// Mutate a model by either substituting a non-edge layer or perturbing
540    /// the hyperparameters of an existing one.
541    ///
542    /// With probability `SUBSTITUTE_PROB` the engine replaces a single
543    /// non-first / non-last layer with a different body-eligible layer.
544    /// Otherwise it perturbs a hyperparameter — `Conv1D::channels` is
545    /// scaled by ±25 %, `Dropout::p_milli` shifts by ±50, recurrent
546    /// hidden sizes shift by one bucket, etc.
547    ///
548    /// The first and last layers are never replaced; the positional
549    /// invariant (Mel-at-start, CTC-at-end) is therefore preserved.
550    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        // Decide between substitution and perturbation.
567        let do_substitute = rng.gen_range(0.0_f64..1.0) < SUBSTITUTE_PROB;
568
569        if do_substitute && n >= 3 {
570            // Pick a non-edge position.
571            let pos: usize = rng.gen_range(1..(n - 1));
572            let original = new_layers[pos];
573
574            // Try up to 8 draws to find a different layer.
575            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            // Perturb a single layer's hyperparameters. Restrict to non-edge
585            // layers when possible so the positional invariant cannot be
586            // accidentally broken by a perturbation that produces a
587            // different position class (e.g. a Mel filter bank with
588            // adjusted n_mels remains a Mel filter bank — but we still
589            // protect the edges as a defensive measure).
590            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    /// Apply a small hyperparameter perturbation to a single layer.
606    ///
607    /// The mutation is *bounded* — channels / hidden sizes / dropout
608    /// probabilities can drift but never explode — and it preserves the
609    /// position class of the original layer (a `Conv1D` stays a `Conv1D`,
610    /// not a `BiLSTM`).
611    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                // Shift in {-16, 0, +16} but stay > 0.
631                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                // Shift heads by ±1 (but stay >= 1) or dim by ±25 %.
659                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                // Drift p_milli by ±50 but clamp to [0, 900] (probability
694                // 0..0.9). Probabilities above 0.9 are degenerate.
695                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    /// Single-point crossover.
708    ///
709    /// Picks a split index `s ∈ [1, min(a.len, b.len) - 1]`, takes
710    /// `a.layers[..s]` from the first parent and `b.layers[s..]` from
711    /// the second, then runs the positional *repair* pass:
712    ///
713    /// * if the offspring's first layer is not a Mel filter bank, a
714    ///   randomly chosen Mel filter bank from the search space is
715    ///   prepended (or, if none is present, the first existing Mel
716    ///   filter bank in the parents).
717    /// * if the offspring's last layer is not a CTC decoder, one is
718    ///   appended.
719    ///
720    /// Because the inherited slices each came from a valid parent the
721    /// repair changes at most one layer per edge.
722    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            // Degenerate parents — fall through with split = 1 for the
738            // longer of the two, otherwise just append parent b.
739            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    /// Repair-and-finalise step shared by [`Self::crossover`].
756    fn finish_crossover(
757        &self,
758        a: &SpeechModelConfig,
759        b: &SpeechModelConfig,
760        mut layers: Vec<SpeechLayerType>,
761    ) -> Result<SpeechModelConfig> {
762        // Repair: ensure a Mel filter bank at position 0.
763        if !matches!(layers.first(), Some(SpeechLayerType::MelFilterBank { .. })) {
764            // Prefer to harvest a Mel filter bank from one of the parents
765            // so the spectral feature width stays consistent; fall back to
766            // the search space's first Mel filter bank if neither parent
767            // has one.
768            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        // Repair: ensure a CTC decoder at the last position.
791        if !matches!(layers.last(), Some(SpeechLayerType::CTCDecoder)) {
792            // CTC is parameter-free so we can always synthesise one.
793            layers.push(SpeechLayerType::CTCDecoder);
794        }
795
796        // Clamp to max depth if the repair overshot. We trim mid-body
797        // layers (preserving the Mel head and CTC tail) so the resulting
798        // model stays within the engine's depth budget.
799        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            // Body length to keep: max_depth - 2 (head + tail).
803            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        // Merge parent parameter maps (parent b wins on conflict — a
814        // simple, deterministic, well-defined recombination rule).
815        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    // -------------------------------------------------------------------
828    // Evaluation tracking
829    // -------------------------------------------------------------------
830
831    /// Record an evaluation result for a previously-proposed model.
832    ///
833    /// Validates that `word_error_rate ∈ [0, 1]`, `latency_ms ≥ 0`, and
834    /// `memory_mb ≥ 0`. Updates the best-so-far record when the new
835    /// evaluation has a strictly lower word-error rate than the current
836    /// best.
837    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    /// Compute the Pareto front over `(WER, latency_ms, memory_mb)`.
876    ///
877    /// A model is *dominated* if there exists another evaluated model
878    /// that is no worse on every axis and strictly better on at least
879    /// one. The returned slice borrows from [`Self::evaluated`] and is
880    /// guaranteed not to include any dominated model.
881    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    /// Return `true` iff `a` strictly dominates `b` on
902    /// `(WER, latency_ms, memory_mb)`.
903    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// -----------------------------------------------------------------------
915// Tests
916// -----------------------------------------------------------------------
917
918#[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        // Try multiple mutations to confirm at least one actually changes
1063        // the architecture (substitution is probabilistic).
1064        let mut saw_change = false;
1065        for _ in 0..30 {
1066            let mutated = engine.mutate(&model, &mut rng).expect("mutate");
1067            // Same depth.
1068            assert_eq!(mutated.layers.len(), model.layers.len());
1069            // Different id.
1070            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        // Three models:
1245        //   A: WER 0.15, latency 100, mem 60   (Pareto-optimal — best WER)
1246        //   B: WER 0.20, latency  50, mem 30   (Pareto-optimal — best lat/mem)
1247        //   C: WER 0.25, latency 150, mem 80   (dominated by A on every axis)
1248        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        // After reset, the id counter should also be zero again.
1401        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        // Three mutually non-dominated models: each is best on one axis.
1431        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        // Required for HashMap / HashSet membership in downstream tooling.
1503        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}