Skip to main content

optirs_nas/
progressive.rs

1// Progressive Neural Architecture Search
2//
3// Phase bookkeeping for a progressive search: which phase the search is in, how
4// complexity grows with the phase, and which architectures each phase produced.
5//
6// This module tracks progression; [`crate::search_strategies::ProgressiveNAS`] is
7// the strategy that *generates* architectures phase by phase. The two are
8// independent — a caller can drive this tracker from any strategy.
9//
10// # Attribution
11//
12// Every recorded evaluation is credited to the architecture that produced it, and
13// `best_per_phase` really is the best of each phase. It used to be neither:
14//
15// * `best_per_phase` was a three-slot FIFO. `record_architecture_evaluation`
16//   pushed the id and popped the oldest, so it held the three *most recent*
17//   architectures of the phase whatever they scored — a 0.01 result evicted a 0.99
18//   one, and `get_best_for_phase` returned the answer to a question nobody asked.
19// * `ProgressionRecord::best_performance` was documented as "best performance
20//   achieved" and set to the performance of the single architecture being
21//   recorded, so the field named the wrong quantity in every record.
22// * `analyze_performance_trend` regressed over the last ten performances of the
23//   *whole run*. Immediately after a phase transition that window straddles two
24//   phases, so an `Adaptive` progression decided whether to advance out of phase
25//   *n* using measurements largely taken in phase *n-1*.
26// * `update_search_phase` recomputed the phase from scratch for the budget- and
27//   time-based strategies, so a caller that passed a smaller `generation` (or a
28//   clock that had not yet crossed the next boundary after an adaptive advance)
29//   moved the search *backwards* into an earlier phase.
30// * `ProgressiveNAS::new` derived `BudgetBased(search_budget / 4)`, which is `0`
31//   for any budget under four, and `TimeBased` divided by
32//   `duration_per_phase.as_secs()`, which is `0` for any sub-second duration.
33//   Both then panicked with "attempt to divide by zero" on the first
34//   `update_search_phase` call.
35// * Completion was never signalled: the tracker reached `Final` and stayed there
36//   silently, with no way for a caller to learn the schedule was exhausted.
37//
38// Completion is reported through [`ProgressiveNAS::is_search_complete`] rather
39// than by returning `Err`, matching `search_strategies::progressive`: a normally
40// finished search is not a failure, and recording after completion keeps working.
41
42use std::fmt::Debug;
43
44use super::NASConfig;
45use crate::error::{OptimError, Result};
46use crate::numeric::{count_as, scalar_as};
47use scirs2_core::numeric::Float;
48use std::collections::HashMap;
49use std::time::Duration;
50
51/// Number of best-scoring architectures retained per phase.
52pub const BEST_PER_PHASE_CAPACITY: usize = 3;
53
54/// Number of recent performances the adaptive trend regression uses.
55pub const TREND_WINDOW: usize = 10;
56
57/// Progressive NAS configuration
58#[derive(Debug, Clone)]
59pub struct ProgressiveNAS<T: Float + Debug + Send + Sync + 'static> {
60    /// Current search phase
61    pub current_phase: SearchPhase,
62
63    /// Phase progression strategy
64    pub progression_strategy: ProgressionStrategy,
65
66    /// Complexity scheduler
67    pub complexity_scheduler: ComplexityScheduler<T>,
68
69    /// Architecture progression tracker
70    pub architecture_progression: ArchitectureProgression<T>,
71
72    /// Whether the schedule has been exhausted: the search reached
73    /// [`SearchPhase::Final`] and a further advance was requested.
74    search_complete: bool,
75}
76
77/// Search phases in progressive NAS
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
79pub enum SearchPhase {
80    /// Initial simple architectures
81    Initial,
82
83    /// Intermediate complexity
84    Intermediate,
85
86    /// Advanced architectures
87    Advanced,
88
89    /// Final optimization phase
90    Final,
91}
92
93impl SearchPhase {
94    /// Every phase in schedule order.
95    pub const ORDER: [SearchPhase; 4] = [
96        SearchPhase::Initial,
97        SearchPhase::Intermediate,
98        SearchPhase::Advanced,
99        SearchPhase::Final,
100    ];
101
102    /// Position of this phase in the schedule, `0` for [`SearchPhase::Initial`].
103    pub fn index(self) -> usize {
104        match self {
105            SearchPhase::Initial => 0,
106            SearchPhase::Intermediate => 1,
107            SearchPhase::Advanced => 2,
108            SearchPhase::Final => 3,
109        }
110    }
111
112    /// Phase at schedule position `index`, saturating at [`SearchPhase::Final`].
113    pub fn at(index: usize) -> SearchPhase {
114        *Self::ORDER.get(index).unwrap_or(&SearchPhase::Final)
115    }
116
117    /// The next phase, or `None` when this is already the last one.
118    pub fn next(self) -> Option<SearchPhase> {
119        Self::ORDER.get(self.index() + 1).copied()
120    }
121}
122
123/// Strategy for progressing through phases
124#[derive(Debug, Clone)]
125pub enum ProgressionStrategy {
126    /// Time-based progression
127    TimeBased(std::time::Duration),
128
129    /// Performance-based progression
130    PerformanceBased(f64),
131
132    /// Budget-based progression
133    BudgetBased(usize),
134
135    /// Adaptive progression
136    Adaptive,
137}
138
139/// Complexity scheduler for progressive search
140#[derive(Debug, Clone)]
141pub struct ComplexityScheduler<T: Float + Debug + Send + Sync + 'static> {
142    /// Current complexity level
143    pub current_complexity: T,
144
145    /// Maximum complexity
146    pub max_complexity: T,
147
148    /// Complexity increase rate
149    pub increase_rate: T,
150
151    /// Scheduling strategy
152    pub strategy: SchedulingStrategy,
153}
154
155/// Scheduling strategies for complexity increase
156#[derive(Debug, Clone)]
157pub enum SchedulingStrategy {
158    /// Linear increase
159    Linear,
160
161    /// Exponential increase
162    Exponential,
163
164    /// Step-wise increase
165    StepWise,
166
167    /// Adaptive based on performance
168    Adaptive,
169}
170
171/// Architecture progression tracker
172#[derive(Debug, Clone)]
173pub struct ArchitectureProgression<T: Float + Debug + Send + Sync + 'static> {
174    /// Progression history
175    pub history: Vec<ProgressionRecord<T>>,
176
177    /// The best-scoring architectures of each phase, best first, at most
178    /// [`BEST_PER_PHASE_CAPACITY`] per phase.
179    ///
180    /// Ranked by the performance recorded for the architecture, not by recency.
181    /// Use [`ArchitectureProgression::record_evaluation`] to populate it so the
182    /// score travels with the id.
183    pub best_per_phase: HashMap<SearchPhase, Vec<ScoredArchitecture<T>>>,
184
185    /// Performance trends
186    pub performance_trends: Vec<T>,
187}
188
189/// An architecture together with the performance it achieved.
190#[derive(Debug, Clone)]
191pub struct ScoredArchitecture<T: Float + Debug + Send + Sync + 'static> {
192    /// Identifier of the architecture that was evaluated.
193    pub architecture_id: String,
194
195    /// Performance it achieved.
196    pub performance: T,
197}
198
199/// Record of progression step
200#[derive(Debug, Clone)]
201pub struct ProgressionRecord<T: Float + Debug + Send + Sync + 'static> {
202    /// Phase when recorded
203    pub phase: SearchPhase,
204
205    /// Complexity level
206    pub complexity: T,
207
208    /// Performance of the architecture this record is about.
209    pub performance: T,
210
211    /// Best performance achieved in `phase` up to and including this record.
212    ///
213    /// This used to be set to the single architecture's own performance while
214    /// being documented as the best achieved, so a poor result overwrote the
215    /// phase's high-water mark in every reader's eyes.
216    pub best_performance: T,
217
218    /// Number of architectures evaluated
219    pub architectures_evaluated: usize,
220
221    /// Timestamp
222    pub timestamp: std::time::Instant,
223}
224
225impl<T: Float + Debug + Send + Sync + std::iter::Sum> ProgressiveNAS<T> {
226    /// Create new progressive NAS.
227    ///
228    /// The budget-derived phase length is floored at one: `search_budget / 4` is
229    /// `0` for any budget under four, and the previous code then divided by it on
230    /// the first [`ProgressiveNAS::update_search_phase`] call.
231    pub fn new(config: &NASConfig<T>) -> Result<Self> {
232        let progression_strategy = match config.search_budget {
233            budget if budget < 50 => ProgressionStrategy::TimeBased(Duration::from_secs(300)),
234            budget if budget < 200 => ProgressionStrategy::BudgetBased((budget / 4).max(1)),
235            _ => ProgressionStrategy::Adaptive,
236        };
237
238        Ok(Self {
239            current_phase: SearchPhase::Initial,
240            progression_strategy,
241            complexity_scheduler: ComplexityScheduler::new()?,
242            architecture_progression: ArchitectureProgression::new(),
243            search_complete: false,
244        })
245    }
246
247    /// Whether the schedule has been exhausted.
248    ///
249    /// Set once the search is in [`SearchPhase::Final`] and a further advance is
250    /// requested. Recording and phase updates keep working afterwards, so a caller
251    /// that ignores this still gets valid bookkeeping.
252    pub fn is_search_complete(&self) -> bool {
253        self.search_complete
254    }
255
256    /// Update search phase based on progress.
257    ///
258    /// Phases never move backwards: the budget- and time-based rules compute the
259    /// phase the elapsed budget/time *implies* and take the later of that and the
260    /// current phase. Recomputing unconditionally, as this used to, let a caller
261    /// passing a smaller `generation` — or a clock that had not yet crossed the
262    /// next boundary after an adaptive advance — walk the search back into an
263    /// earlier phase and re-lower the complexity budget.
264    pub fn update_search_phase(&mut self, generation: usize) -> Result<()> {
265        match self.progression_strategy {
266            ProgressionStrategy::BudgetBased(budget_per_phase) => {
267                if budget_per_phase == 0 {
268                    return Err(OptimError::InvalidConfig(
269                        "ProgressionStrategy::BudgetBased needs a non-zero budget per \
270                         phase; zero would make every generation belong to every phase"
271                            .to_string(),
272                    ));
273                }
274                self.advance_to_index(generation / budget_per_phase);
275            }
276            ProgressionStrategy::Adaptive => {
277                // Adaptive progression based on the trend *within the current
278                // phase*: a window spanning a transition measures the phase the
279                // search has already left.
280                if let Some(trend) = self.analyze_performance_trend()? {
281                    let epsilon: T = scalar_as(0.01, "adaptive progression threshold")?;
282                    if trend < epsilon {
283                        self.advance_phase();
284                    }
285                }
286            }
287            ProgressionStrategy::TimeBased(duration_per_phase) => {
288                let phase_seconds = duration_per_phase.as_secs();
289                if phase_seconds == 0 {
290                    return Err(OptimError::InvalidConfig(
291                        "ProgressionStrategy::TimeBased needs a phase duration of at \
292                         least one second; a sub-second duration divided by zero"
293                            .to_string(),
294                    ));
295                }
296                let elapsed = self
297                    .architecture_progression
298                    .history
299                    .first()
300                    .map(|first| first.timestamp.elapsed())
301                    .unwrap_or(Duration::from_secs(0));
302
303                let phases_elapsed = (elapsed.as_secs() / phase_seconds) as usize;
304                self.advance_to_index(phases_elapsed);
305            }
306            ProgressionStrategy::PerformanceBased(threshold) => {
307                if let Some(latest_performance) =
308                    self.architecture_progression.performance_trends.last()
309                {
310                    let threshold: T = scalar_as(threshold, "performance progression threshold")?;
311                    if *latest_performance > threshold {
312                        self.advance_phase();
313                    }
314                }
315            }
316        }
317
318        Ok(())
319    }
320
321    /// Move to the phase at schedule position `index` if it is later than the
322    /// current one; never backwards.
323    ///
324    /// An `index` past the last phase means the schedule has been consumed, which
325    /// latches completion. Completion is never un-latched: a caller that replays
326    /// an earlier generation is not un-finishing the search.
327    fn advance_to_index(&mut self, index: usize) {
328        let phase = SearchPhase::at(index);
329        if phase > self.current_phase {
330            self.current_phase = phase;
331        }
332        if index >= SearchPhase::ORDER.len() {
333            self.search_complete = true;
334        }
335    }
336
337    /// Advance to the next phase, or record completion if there is none.
338    fn advance_phase(&mut self) {
339        match self.current_phase.next() {
340            Some(next) => self.current_phase = next,
341            None => self.search_complete = true,
342        }
343    }
344
345    /// Least-squares slope of the last [`TREND_WINDOW`] performances *of the
346    /// current phase*, or `None` when the phase has not produced that many yet.
347    ///
348    /// The window used to be taken from the whole run's performance history, so
349    /// right after a transition it mixed two phases and the adaptive rule decided
350    /// whether to leave phase *n* from measurements taken in phase *n-1*.
351    fn analyze_performance_trend(&self) -> Result<Option<T>> {
352        let phase = self.current_phase;
353        let phase_performances: Vec<T> = self
354            .architecture_progression
355            .history
356            .iter()
357            .filter(|record| record.phase == phase)
358            .map(|record| record.performance)
359            .collect();
360
361        if phase_performances.len() < TREND_WINDOW {
362            return Ok(None);
363        }
364
365        let recent_trends = &phase_performances[phase_performances.len() - TREND_WINDOW..];
366
367        // Ordinary least squares over x = 0..n-1.
368        let n: T = count_as(recent_trends.len(), "trend window size")?;
369        let two: T = scalar_as(2.0, "trend regression constant")?;
370        let sum_x = n * (n - T::one()) / two;
371        let sum_y = recent_trends.iter().cloned().sum::<T>();
372        let mut sum_xy = T::zero();
373        let mut sum_x2 = T::zero();
374        for (i, &y) in recent_trends.iter().enumerate() {
375            let x: T = count_as(i, "trend regression index")?;
376            sum_xy = sum_xy + x * y;
377            sum_x2 = sum_x2 + x * x;
378        }
379
380        let denominator = n * sum_x2 - sum_x * sum_x;
381        if denominator == T::zero() {
382            // A single-point window has no slope; report "no trend" rather than
383            // dividing by zero.
384            return Ok(None);
385        }
386        Ok(Some((n * sum_xy - sum_x * sum_y) / denominator))
387    }
388
389    /// Get current search configuration based on phase
390    pub fn get_current_search_config(&self) -> SearchPhaseConfig<T> {
391        match self.current_phase {
392            SearchPhase::Initial => SearchPhaseConfig {
393                complexity_limit: scirs2_core::numeric::NumCast::from(0.25)
394                    .unwrap_or_else(|| T::zero()),
395                max_components: 2,
396                max_depth: 3,
397                exploration_factor: scirs2_core::numeric::NumCast::from(0.8)
398                    .unwrap_or_else(|| T::zero()),
399                mutation_rate: scirs2_core::numeric::NumCast::from(0.3)
400                    .unwrap_or_else(|| T::zero()),
401                population_diversity_weight: scirs2_core::numeric::NumCast::from(0.7)
402                    .unwrap_or_else(|| T::zero()),
403                conservative_search: true,
404            },
405            SearchPhase::Intermediate => SearchPhaseConfig {
406                complexity_limit: scirs2_core::numeric::NumCast::from(0.5)
407                    .unwrap_or_else(|| T::zero()),
408                max_components: 4,
409                max_depth: 5,
410                exploration_factor: scirs2_core::numeric::NumCast::from(0.6)
411                    .unwrap_or_else(|| T::zero()),
412                mutation_rate: scirs2_core::numeric::NumCast::from(0.2)
413                    .unwrap_or_else(|| T::zero()),
414                population_diversity_weight: scirs2_core::numeric::NumCast::from(0.5)
415                    .unwrap_or_else(|| T::zero()),
416                conservative_search: false,
417            },
418            SearchPhase::Advanced => SearchPhaseConfig {
419                complexity_limit: scirs2_core::numeric::NumCast::from(0.75)
420                    .unwrap_or_else(|| T::zero()),
421                max_components: 6,
422                max_depth: 7,
423                exploration_factor: scirs2_core::numeric::NumCast::from(0.4)
424                    .unwrap_or_else(|| T::zero()),
425                mutation_rate: scirs2_core::numeric::NumCast::from(0.15)
426                    .unwrap_or_else(|| T::zero()),
427                population_diversity_weight: scirs2_core::numeric::NumCast::from(0.3)
428                    .unwrap_or_else(|| T::zero()),
429                conservative_search: false,
430            },
431            SearchPhase::Final => SearchPhaseConfig {
432                complexity_limit: T::one(),
433                max_components: 8,
434                max_depth: 10,
435                exploration_factor: scirs2_core::numeric::NumCast::from(0.2)
436                    .unwrap_or_else(|| T::zero()),
437                mutation_rate: scirs2_core::numeric::NumCast::from(0.1)
438                    .unwrap_or_else(|| T::zero()),
439                population_diversity_weight: scirs2_core::numeric::NumCast::from(0.2)
440                    .unwrap_or_else(|| T::zero()),
441                conservative_search: false,
442            },
443        }
444    }
445
446    /// Record an architecture evaluation, crediting it to the phase the search is
447    /// in and to the architecture that produced it.
448    ///
449    /// An empty `architecture_id` is rejected: an unidentified result cannot be
450    /// credited to anything, and the previous code stored it anyway, so
451    /// `get_best_for_phase` handed back an empty string as a "best architecture".
452    pub fn record_architecture_evaluation(
453        &mut self,
454        architecture_id: String,
455        performance: T,
456        complexity: T,
457    ) -> Result<()> {
458        if architecture_id.is_empty() {
459            return Err(OptimError::InvalidParameter(
460                "a progression record needs the id of the architecture it is about; \
461                 an empty id cannot be credited to anything"
462                    .to_string(),
463            ));
464        }
465
466        self.architecture_progression.record_evaluation(
467            self.current_phase,
468            architecture_id,
469            performance,
470            complexity,
471        );
472
473        Ok(())
474    }
475}
476
477/// Configuration for each search phase
478#[derive(Debug, Clone)]
479pub struct SearchPhaseConfig<T: Float + Debug + Send + Sync + 'static> {
480    /// Maximum complexity allowed in this phase
481    pub complexity_limit: T,
482
483    /// Maximum number of components
484    pub max_components: usize,
485
486    /// Maximum architecture depth
487    pub max_depth: usize,
488
489    /// Exploration factor (0.0 = exploit, 1.0 = explore)
490    pub exploration_factor: T,
491
492    /// Mutation rate for genetic algorithms
493    pub mutation_rate: T,
494
495    /// Weight for population diversity
496    pub population_diversity_weight: T,
497
498    /// Whether to use conservative search strategies
499    pub conservative_search: bool,
500}
501
502impl<T: Float + Debug + Send + Sync + 'static> ComplexityScheduler<T> {
503    /// Create new complexity scheduler
504    pub fn new() -> Result<Self> {
505        Ok(Self {
506            current_complexity: scalar_as(0.1, "initial complexity")?,
507            max_complexity: T::one(),
508            increase_rate: scalar_as(0.1, "complexity increase rate")?,
509            strategy: SchedulingStrategy::Linear,
510        })
511    }
512
513    /// Fraction of [`ComplexityScheduler::max_complexity`] each phase is allowed.
514    fn phase_factor(phase: SearchPhase) -> Result<T> {
515        let steps: T = count_as(SearchPhase::ORDER.len(), "phase count")?;
516        let position: T = count_as(phase.index() + 1, "phase position")?;
517        Ok(position / steps)
518    }
519
520    /// Update complexity for `phase` and return the new level.
521    ///
522    /// Every [`SchedulingStrategy`] is implemented. `StepWise` and `Adaptive` used
523    /// to fall into a `_ => {}` arm that silently returned the previous value, so a
524    /// caller selecting either got a complexity budget frozen at its initial 0.1
525    /// for the whole search while the phases advanced around it. Fallible for the
526    /// same reason: keeping the previous level on a conversion failure would
527    /// reintroduce exactly that frozen budget, and there is no other value that is
528    /// not a guess.
529    pub fn update_complexity(&mut self, phase: SearchPhase) -> Result<T> {
530        match self.strategy {
531            // Complexity is a straight function of the phase: quarter, half,
532            // three-quarters, all of it for a four-phase schedule.
533            SchedulingStrategy::Linear => {
534                self.current_complexity = self.max_complexity * Self::phase_factor(phase)?;
535            }
536            // Geometric growth per call, capped at the maximum.
537            SchedulingStrategy::Exponential => {
538                self.current_complexity = self.current_complexity * (T::one() + self.increase_rate);
539                if self.current_complexity > self.max_complexity {
540                    self.current_complexity = self.max_complexity;
541                }
542            }
543            // One fixed step per phase transition, held flat inside a phase: the
544            // level is `initial + increase_rate * phase_index`, capped.
545            SchedulingStrategy::StepWise => {
546                let steps: T = count_as(phase.index(), "completed phase count")?;
547                let base: T = scalar_as(0.1, "step-wise base complexity")?;
548                let level = base + self.increase_rate * steps;
549                self.current_complexity = if level > self.max_complexity {
550                    self.max_complexity
551                } else {
552                    level
553                };
554            }
555            // Adaptive: grow towards the phase's allowance rather than jumping to
556            // it, so a phase transition does not hand the search its full budget in
557            // one step. Geometric approach with `increase_rate` as the step size.
558            SchedulingStrategy::Adaptive => {
559                let target = self.max_complexity * Self::phase_factor(phase)?;
560                let gap = target - self.current_complexity;
561                self.current_complexity = self.current_complexity + gap * self.increase_rate;
562                if self.current_complexity > self.max_complexity {
563                    self.current_complexity = self.max_complexity;
564                }
565            }
566        }
567
568        Ok(self.current_complexity)
569    }
570}
571
572impl<T: Float + Debug + Send + Sync + 'static> Default for ArchitectureProgression<T> {
573    fn default() -> Self {
574        Self::new()
575    }
576}
577
578impl<T: Float + Debug + Send + Sync + 'static> ArchitectureProgression<T> {
579    /// Create new architecture progression tracker
580    pub fn new() -> Self {
581        Self {
582            history: Vec::new(),
583            best_per_phase: HashMap::new(),
584            performance_trends: Vec::new(),
585        }
586    }
587
588    /// Record one evaluation: append the history entry, update the phase's
589    /// high-water mark and its best-scoring set.
590    pub fn record_evaluation(
591        &mut self,
592        phase: SearchPhase,
593        architecture_id: String,
594        performance: T,
595        complexity: T,
596    ) {
597        let best_in_phase = self.best_in_phase(phase).map_or(performance, |best| {
598            if performance > best {
599                performance
600            } else {
601                best
602            }
603        });
604
605        self.performance_trends.push(performance);
606        self.history.push(ProgressionRecord {
607            phase,
608            complexity,
609            performance,
610            best_performance: best_in_phase,
611            architectures_evaluated: self.history.len() + 1,
612            timestamp: std::time::Instant::now(),
613        });
614
615        let ranked = self.best_per_phase.entry(phase).or_default();
616        ranked.push(ScoredArchitecture {
617            architecture_id,
618            performance,
619        });
620        // Best first, ties broken by id so the retained set is deterministic
621        // rather than dependent on insertion order.
622        ranked.sort_by(|left, right| {
623            right
624                .performance
625                .partial_cmp(&left.performance)
626                .unwrap_or(std::cmp::Ordering::Equal)
627                .then_with(|| left.architecture_id.cmp(&right.architecture_id))
628        });
629        ranked.truncate(BEST_PER_PHASE_CAPACITY);
630    }
631
632    /// Record a pre-built progression step.
633    ///
634    /// Kept for callers that assemble the record themselves; it does *not*
635    /// maintain [`ArchitectureProgression::best_per_phase`], because a bare record
636    /// carries no architecture id to rank. Prefer
637    /// [`ArchitectureProgression::record_evaluation`].
638    pub fn record_step(&mut self, record: ProgressionRecord<T>) {
639        self.performance_trends.push(record.performance);
640        self.history.push(record);
641    }
642
643    /// Best performance recorded in `phase`, or `None` if it has none yet.
644    pub fn best_in_phase(&self, phase: SearchPhase) -> Option<T> {
645        self.best_per_phase
646            .get(&phase)
647            .and_then(|ranked| ranked.first())
648            .map(|entry| entry.performance)
649    }
650
651    /// The best-scoring architecture ids of `phase`, best first.
652    ///
653    /// This used to return the three *most recently recorded* ids of the phase,
654    /// whatever they scored, because the underlying vector was a FIFO.
655    pub fn get_best_for_phase(&self, phase: SearchPhase) -> Vec<String> {
656        self.best_per_phase
657            .get(&phase)
658            .map(|ranked| {
659                ranked
660                    .iter()
661                    .map(|entry| entry.architecture_id.clone())
662                    .collect()
663            })
664            .unwrap_or_default()
665    }
666
667    /// The best-scoring architectures of `phase` with their scores, best first.
668    pub fn best_scored_for_phase(&self, phase: SearchPhase) -> &[ScoredArchitecture<T>] {
669        self.best_per_phase
670            .get(&phase)
671            .map(|ranked| ranked.as_slice())
672            .unwrap_or(&[])
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679
680    fn config_with_budget(budget: usize) -> NASConfig<f64> {
681        NASConfig::<f64> {
682            search_budget: budget,
683            ..NASConfig::<f64>::default()
684        }
685    }
686
687    #[test]
688    fn test_progressive_nas_creation() {
689        let nas: ProgressiveNAS<f64> =
690            ProgressiveNAS::new(&NASConfig::<f64>::default()).expect("construction");
691        assert_eq!(nas.current_phase, SearchPhase::Initial);
692        assert!(!nas.is_search_complete());
693    }
694
695    #[test]
696    fn a_tiny_budget_does_not_produce_a_zero_length_phase() {
697        // `search_budget / 4` is 0 for any budget under four, and the first
698        // `update_search_phase` then divided by it.
699        for budget in [0, 1, 2, 3, 50, 51, 199] {
700            let mut nas: ProgressiveNAS<f64> =
701                ProgressiveNAS::new(&config_with_budget(budget)).expect("construction");
702            if let ProgressionStrategy::BudgetBased(per_phase) = nas.progression_strategy {
703                assert!(per_phase >= 1, "budget {budget} gave a zero-length phase");
704            }
705            nas.update_search_phase(7)
706                .expect("updating a phase must not divide by zero");
707        }
708    }
709
710    #[test]
711    fn a_sub_second_time_budget_is_an_error_not_a_panic() {
712        let mut nas: ProgressiveNAS<f64> =
713            ProgressiveNAS::new(&config_with_budget(10)).expect("construction");
714        nas.progression_strategy =
715            ProgressionStrategy::TimeBased(std::time::Duration::from_millis(250));
716        nas.record_architecture_evaluation("a".to_string(), 0.5, 0.1)
717            .expect("record");
718
719        let err = nas
720            .update_search_phase(1)
721            .expect_err("a zero-second phase length cannot be honoured");
722        assert!(matches!(err, OptimError::InvalidConfig(_)));
723
724        nas.progression_strategy = ProgressionStrategy::BudgetBased(0);
725        assert!(matches!(
726            nas.update_search_phase(1),
727            Err(OptimError::InvalidConfig(_))
728        ));
729    }
730
731    #[test]
732    fn budget_based_phases_advance_and_never_regress() {
733        let mut nas: ProgressiveNAS<f64> =
734            ProgressiveNAS::new(&config_with_budget(100)).expect("construction");
735        nas.progression_strategy = ProgressionStrategy::BudgetBased(10);
736
737        nas.update_search_phase(0).expect("update");
738        assert_eq!(nas.current_phase, SearchPhase::Initial);
739        nas.update_search_phase(10).expect("update");
740        assert_eq!(nas.current_phase, SearchPhase::Intermediate);
741        nas.update_search_phase(25).expect("update");
742        assert_eq!(nas.current_phase, SearchPhase::Advanced);
743        nas.update_search_phase(99).expect("update");
744        assert_eq!(nas.current_phase, SearchPhase::Final);
745
746        // A smaller generation used to walk the search backwards.
747        nas.update_search_phase(0).expect("update");
748        assert_eq!(
749            nas.current_phase,
750            SearchPhase::Final,
751            "phases must not regress"
752        );
753        assert!(
754            nas.is_search_complete(),
755            "staying in Final after the schedule is exhausted must be reported"
756        );
757    }
758
759    #[test]
760    fn performance_based_progression_advances_once_per_call_and_then_completes() {
761        let mut nas: ProgressiveNAS<f64> =
762            ProgressiveNAS::new(&config_with_budget(10)).expect("construction");
763        nas.progression_strategy = ProgressionStrategy::PerformanceBased(0.5);
764
765        nas.record_architecture_evaluation("a".to_string(), 0.9, 0.1)
766            .expect("record");
767        for expected in [
768            SearchPhase::Intermediate,
769            SearchPhase::Advanced,
770            SearchPhase::Final,
771        ] {
772            nas.update_search_phase(0).expect("update");
773            assert_eq!(nas.current_phase, expected);
774            assert!(!nas.is_search_complete());
775        }
776
777        nas.update_search_phase(0).expect("update");
778        assert_eq!(nas.current_phase, SearchPhase::Final);
779        assert!(nas.is_search_complete());
780    }
781
782    #[test]
783    fn the_adaptive_trend_only_sees_the_current_phase() {
784        let mut nas: ProgressiveNAS<f64> =
785            ProgressiveNAS::new(&config_with_budget(10)).expect("construction");
786        nas.progression_strategy = ProgressionStrategy::Adaptive;
787
788        // A steeply improving phase 0: ten records with a strong upward trend.
789        for step in 0..TREND_WINDOW {
790            nas.record_architecture_evaluation(format!("p0_{step}"), step as f64, 0.1)
791                .expect("record");
792        }
793        nas.update_search_phase(0).expect("update");
794        assert_eq!(
795            nas.current_phase,
796            SearchPhase::Initial,
797            "a strongly improving phase must not be abandoned"
798        );
799
800        // Move to phase 1 and record a *flat* run there. If the window still
801        // straddled phases it would still see phase 0's steep climb and refuse to
802        // advance.
803        nas.current_phase = SearchPhase::Intermediate;
804        for step in 0..TREND_WINDOW {
805            nas.record_architecture_evaluation(format!("p1_{step}"), 1.0, 0.1)
806                .expect("record");
807        }
808        nas.update_search_phase(0).expect("update");
809        assert_eq!(
810            nas.current_phase,
811            SearchPhase::Advanced,
812            "a flat current phase must trigger an advance"
813        );
814    }
815
816    #[test]
817    fn best_per_phase_keeps_the_best_not_the_most_recent() {
818        let mut nas: ProgressiveNAS<f64> =
819            ProgressiveNAS::new(&config_with_budget(10)).expect("construction");
820
821        for (id, score) in [
822            ("great", 0.99),
823            ("good", 0.80),
824            ("ok", 0.60),
825            ("bad", 0.10),
826            ("awful", 0.01),
827        ] {
828            nas.record_architecture_evaluation(id.to_string(), score, 0.1)
829                .expect("record");
830        }
831
832        let best = nas
833            .architecture_progression
834            .get_best_for_phase(SearchPhase::Initial);
835        assert_eq!(
836            best,
837            vec!["great".to_string(), "good".to_string(), "ok".to_string()],
838            "the FIFO used to return the three most recent ids instead"
839        );
840        assert_eq!(best.len(), BEST_PER_PHASE_CAPACITY);
841        assert_eq!(
842            nas.architecture_progression
843                .best_in_phase(SearchPhase::Initial),
844            Some(0.99)
845        );
846
847        // Records are credited to the phase that was current when they arrived.
848        nas.current_phase = SearchPhase::Advanced;
849        nas.record_architecture_evaluation("late".to_string(), 0.5, 0.2)
850            .expect("record");
851        assert_eq!(
852            nas.architecture_progression
853                .get_best_for_phase(SearchPhase::Advanced),
854            vec!["late".to_string()]
855        );
856        assert!(!nas
857            .architecture_progression
858            .get_best_for_phase(SearchPhase::Initial)
859            .contains(&"late".to_string()));
860    }
861
862    #[test]
863    fn the_record_high_water_mark_is_the_phase_best_not_the_latest_score() {
864        let mut nas: ProgressiveNAS<f64> =
865            ProgressiveNAS::new(&config_with_budget(10)).expect("construction");
866        nas.record_architecture_evaluation("first".to_string(), 0.9, 0.1)
867            .expect("record");
868        nas.record_architecture_evaluation("second".to_string(), 0.2, 0.1)
869            .expect("record");
870
871        let history = &nas.architecture_progression.history;
872        assert_eq!(history.len(), 2);
873        assert!((history[1].performance - 0.2).abs() < 1e-12);
874        assert!(
875            (history[1].best_performance - 0.9).abs() < 1e-12,
876            "a worse result must not lower the phase's high-water mark"
877        );
878    }
879
880    #[test]
881    fn an_unidentified_evaluation_is_rejected() {
882        let mut nas: ProgressiveNAS<f64> =
883            ProgressiveNAS::new(&config_with_budget(10)).expect("construction");
884        let err = nas
885            .record_architecture_evaluation(String::new(), 0.5, 0.1)
886            .expect_err("an empty architecture id cannot be credited");
887        assert!(matches!(err, OptimError::InvalidParameter(_)));
888        assert!(nas.architecture_progression.history.is_empty());
889    }
890
891    #[test]
892    fn test_complexity_scheduler_linear() {
893        let mut scheduler = ComplexityScheduler::<f64>::new().expect("construction");
894
895        let initial = scheduler
896            .update_complexity(SearchPhase::Initial)
897            .expect("phase factors are representable in f64");
898        let intermediate = scheduler
899            .update_complexity(SearchPhase::Intermediate)
900            .expect("phase factors are representable in f64");
901        let advanced = scheduler
902            .update_complexity(SearchPhase::Advanced)
903            .expect("phase factors are representable in f64");
904        let final_level = scheduler
905            .update_complexity(SearchPhase::Final)
906            .expect("phase factors are representable in f64");
907
908        assert!(initial < intermediate);
909        assert!(intermediate < advanced);
910        assert!(advanced < final_level);
911        assert!((final_level - 1.0).abs() < 1e-12);
912    }
913
914    #[test]
915    fn every_scheduling_strategy_actually_schedules() {
916        // `StepWise` and `Adaptive` used to fall into a `_ => {}` arm and return
917        // the untouched initial 0.1 forever.
918        let mut stepwise = ComplexityScheduler::<f64>::new().expect("construction");
919        stepwise.strategy = SchedulingStrategy::StepWise;
920        let levels: Vec<f64> = SearchPhase::ORDER
921            .iter()
922            .map(|&phase| {
923                stepwise
924                    .update_complexity(phase)
925                    .expect("phase factors are representable in f64")
926            })
927            .collect();
928        for window in levels.windows(2) {
929            assert!(
930                window[1] > window[0],
931                "step-wise complexity did not grow: {levels:?}"
932            );
933        }
934        assert!(levels.iter().all(|level| *level <= 1.0));
935
936        let mut adaptive = ComplexityScheduler::<f64>::new().expect("construction");
937        adaptive.strategy = SchedulingStrategy::Adaptive;
938        let start = adaptive.current_complexity;
939        let after = adaptive
940            .update_complexity(SearchPhase::Final)
941            .expect("phase factors are representable in f64");
942        assert!(after > start, "adaptive complexity did not move");
943        // It approaches the target rather than jumping to it.
944        assert!(after < 1.0);
945        for _ in 0..500 {
946            adaptive
947                .update_complexity(SearchPhase::Final)
948                .expect("phase factors are representable in f64");
949        }
950        assert!((adaptive.current_complexity - 1.0).abs() < 1e-6);
951
952        let mut exponential = ComplexityScheduler::<f64>::new().expect("construction");
953        exponential.strategy = SchedulingStrategy::Exponential;
954        for _ in 0..500 {
955            exponential
956                .update_complexity(SearchPhase::Initial)
957                .expect("phase factors are representable in f64");
958        }
959        assert!((exponential.current_complexity - 1.0).abs() < 1e-12);
960    }
961
962    #[test]
963    fn test_architecture_progression() {
964        let mut progression = ArchitectureProgression::<f64>::new();
965        progression.record_evaluation(SearchPhase::Initial, "a".to_string(), 0.8, 0.1);
966
967        assert_eq!(progression.history.len(), 1);
968        assert_eq!(progression.performance_trends.len(), 1);
969        assert_eq!(progression.history[0].architectures_evaluated, 1);
970        assert_eq!(
971            progression
972                .best_scored_for_phase(SearchPhase::Initial)
973                .len(),
974            1
975        );
976        assert!(progression
977            .best_scored_for_phase(SearchPhase::Advanced)
978            .is_empty());
979    }
980}