1use 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
51pub const BEST_PER_PHASE_CAPACITY: usize = 3;
53
54pub const TREND_WINDOW: usize = 10;
56
57#[derive(Debug, Clone)]
59pub struct ProgressiveNAS<T: Float + Debug + Send + Sync + 'static> {
60 pub current_phase: SearchPhase,
62
63 pub progression_strategy: ProgressionStrategy,
65
66 pub complexity_scheduler: ComplexityScheduler<T>,
68
69 pub architecture_progression: ArchitectureProgression<T>,
71
72 search_complete: bool,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
79pub enum SearchPhase {
80 Initial,
82
83 Intermediate,
85
86 Advanced,
88
89 Final,
91}
92
93impl SearchPhase {
94 pub const ORDER: [SearchPhase; 4] = [
96 SearchPhase::Initial,
97 SearchPhase::Intermediate,
98 SearchPhase::Advanced,
99 SearchPhase::Final,
100 ];
101
102 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 pub fn at(index: usize) -> SearchPhase {
114 *Self::ORDER.get(index).unwrap_or(&SearchPhase::Final)
115 }
116
117 pub fn next(self) -> Option<SearchPhase> {
119 Self::ORDER.get(self.index() + 1).copied()
120 }
121}
122
123#[derive(Debug, Clone)]
125pub enum ProgressionStrategy {
126 TimeBased(std::time::Duration),
128
129 PerformanceBased(f64),
131
132 BudgetBased(usize),
134
135 Adaptive,
137}
138
139#[derive(Debug, Clone)]
141pub struct ComplexityScheduler<T: Float + Debug + Send + Sync + 'static> {
142 pub current_complexity: T,
144
145 pub max_complexity: T,
147
148 pub increase_rate: T,
150
151 pub strategy: SchedulingStrategy,
153}
154
155#[derive(Debug, Clone)]
157pub enum SchedulingStrategy {
158 Linear,
160
161 Exponential,
163
164 StepWise,
166
167 Adaptive,
169}
170
171#[derive(Debug, Clone)]
173pub struct ArchitectureProgression<T: Float + Debug + Send + Sync + 'static> {
174 pub history: Vec<ProgressionRecord<T>>,
176
177 pub best_per_phase: HashMap<SearchPhase, Vec<ScoredArchitecture<T>>>,
184
185 pub performance_trends: Vec<T>,
187}
188
189#[derive(Debug, Clone)]
191pub struct ScoredArchitecture<T: Float + Debug + Send + Sync + 'static> {
192 pub architecture_id: String,
194
195 pub performance: T,
197}
198
199#[derive(Debug, Clone)]
201pub struct ProgressionRecord<T: Float + Debug + Send + Sync + 'static> {
202 pub phase: SearchPhase,
204
205 pub complexity: T,
207
208 pub performance: T,
210
211 pub best_performance: T,
217
218 pub architectures_evaluated: usize,
220
221 pub timestamp: std::time::Instant,
223}
224
225impl<T: Float + Debug + Send + Sync + std::iter::Sum> ProgressiveNAS<T> {
226 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 pub fn is_search_complete(&self) -> bool {
253 self.search_complete
254 }
255
256 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 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 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 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 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 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 return Ok(None);
385 }
386 Ok(Some((n * sum_xy - sum_x * sum_y) / denominator))
387 }
388
389 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 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#[derive(Debug, Clone)]
479pub struct SearchPhaseConfig<T: Float + Debug + Send + Sync + 'static> {
480 pub complexity_limit: T,
482
483 pub max_components: usize,
485
486 pub max_depth: usize,
488
489 pub exploration_factor: T,
491
492 pub mutation_rate: T,
494
495 pub population_diversity_weight: T,
497
498 pub conservative_search: bool,
500}
501
502impl<T: Float + Debug + Send + Sync + 'static> ComplexityScheduler<T> {
503 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 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 pub fn update_complexity(&mut self, phase: SearchPhase) -> Result<T> {
530 match self.strategy {
531 SchedulingStrategy::Linear => {
534 self.current_complexity = self.max_complexity * Self::phase_factor(phase)?;
535 }
536 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 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 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 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 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 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 pub fn record_step(&mut self, record: ProgressionRecord<T>) {
639 self.performance_trends.push(record.performance);
640 self.history.push(record);
641 }
642
643 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 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 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 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 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 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 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 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 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 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}