1use scirs2_core::ndarray::{Array1, Array2};
6use scirs2_core::numeric::{Float, NumCast};
7use std::collections::HashMap;
8use std::fmt::Debug;
9use std::sync::Mutex;
10use std::time::Instant;
11
12use super::types::*;
13use crate::error::{OptimError, Result};
14use crate::nas_engine::results::EvaluationResults;
15use crate::{EvaluationConfig, EvaluationMetric, OptimizerArchitecture};
16
17const FEATURE_DIM: usize = 12;
22
23#[derive(Debug)]
46pub struct PerformancePredictor<T: Float + Debug + Send + Sync + 'static> {
47 predictor_model: PredictorModel<T>,
49
50 training_data: PredictorTrainingData<T>,
52
53 confidence_level: T,
55
56 learning_rate: T,
58
59 ridge_lambda: T,
61
62 pending_features: Mutex<Vec<Array1<T>>>,
74}
75
76#[derive(Debug)]
78pub struct PredictorModel<T: Float + Debug + Send + Sync + 'static> {
79 model_type: PredictorModelType,
81
82 parameters: ModelParameters<T>,
84
85 training_state: ModelTrainingState<T>,
87}
88
89#[derive(Debug)]
94pub struct ModelParameters<T: Float + Debug + Send + Sync + 'static> {
95 weights: Vec<Array2<T>>,
97}
98
99#[derive(Debug)]
101pub struct ModelTrainingState<T: Float + Debug + Send + Sync + 'static> {
102 current_epoch: usize,
104
105 loss_history: Vec<T>,
107}
108
109#[derive(Debug)]
111pub struct PredictorTrainingData<T: Float + Debug + Send + Sync + 'static> {
112 architecture_features: Vec<Array1<T>>,
114
115 performance_targets: Vec<T>,
117}
118
119impl<T: Float + Debug + Default + Send + Sync> PerformancePredictor<T> {
122 const DEFAULT_CONFIDENCE_LEVEL: f64 = 0.95;
124
125 pub fn new(_config: &EvaluationConfig) -> Result<Self> {
126 Ok(Self {
127 predictor_model: PredictorModel::new(),
128 training_data: PredictorTrainingData::new(),
129 confidence_level: Self::scalar(Self::DEFAULT_CONFIDENCE_LEVEL),
130 learning_rate: Self::scalar(0.05),
131 ridge_lambda: Self::scalar(1e-4),
132 pending_features: Mutex::new(Vec::new()),
133 })
134 }
135
136 pub fn model_type(&self) -> &PredictorModelType {
138 &self.predictor_model.model_type
139 }
140
141 pub fn epochs_trained(&self) -> usize {
143 self.predictor_model.training_state.current_epoch
144 }
145
146 pub fn training_loss_history(&self) -> &[T] {
148 &self.predictor_model.training_state.loss_history
149 }
150
151 pub fn observation_count(&self) -> usize {
153 self.num_observations()
154 }
155
156 pub fn training_rmse(&self) -> Option<T> {
163 let targets = &self.training_data.performance_targets;
164 let features = &self.training_data.architecture_features;
165 let n = targets.len().min(features.len());
166 if n == 0 {
167 return None;
168 }
169 let mut sum_sq = T::zero();
170 for i in 0..n {
171 let error = Self::sigmoid(self.raw_score(&features[i])) - targets[i];
172 sum_sq = sum_sq + error * error;
173 }
174 let count: T = NumCast::from(n as f64)?;
175 Some((sum_sq / count).sqrt())
176 }
177
178 pub fn confidence_level(&self) -> T {
180 self.confidence_level
181 }
182
183 pub fn set_confidence_level(&mut self, level: T) -> Result<()> {
189 if !(level > T::zero() && level < T::one()) {
190 return Err(OptimError::InvalidParameter(format!(
191 "confidence level must lie strictly inside (0, 1), got {:?}",
192 level
193 )));
194 }
195 self.confidence_level = level;
196 Ok(())
197 }
198
199 fn scalar(value: f64) -> T {
201 NumCast::from(value).unwrap_or_else(T::zero)
202 }
203
204 fn sigmoid(raw: T) -> T {
206 let one = T::one();
207 if raw >= T::zero() {
208 let z = (-raw).exp();
209 one / (one + z)
210 } else {
211 let z = raw.exp();
212 z / (one + z)
213 }
214 }
215
216 fn clamp_unit(value: T) -> T {
218 value.max(T::zero()).min(T::one())
219 }
220
221 fn structure_signature(architecture: &OptimizerArchitecture<T>) -> T {
225 let mut entries: Vec<&str> = architecture.components.iter().map(|s| s.as_str()).collect();
228 entries.sort_unstable();
229
230 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
231 for entry in entries {
232 for byte in entry.as_bytes() {
233 hash ^= *byte as u64;
234 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
235 }
236 hash ^= 0x1f;
238 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
239 }
240
241 let bucket = (hash >> 40) as f64;
243 let max = ((1u64 << 24) - 1) as f64;
244 Self::scalar(bucket / max)
245 }
246
247 fn extract_features(&self, architecture: &OptimizerArchitecture<T>) -> Array1<T> {
263 let mut features = Array1::zeros(FEATURE_DIM);
264
265 features[0] = T::one();
267
268 let n_components = architecture.components.len();
271 features[1] = Self::saturating_count(n_components);
272
273 let mut distinct: Vec<&str> = architecture.components.iter().map(|s| s.as_str()).collect();
275 distinct.sort_unstable();
276 distinct.dedup();
277 features[2] = Self::saturating_count(distinct.len());
278
279 let numeric_values: Vec<f64> = architecture
284 .parameters
285 .values()
286 .chain(architecture.hyperparameters.values())
287 .map(|v| v.to_f64().unwrap_or(0.0))
288 .filter(|v| v.is_finite())
289 .collect();
290
291 features[3] = Self::saturating_count(numeric_values.len());
293
294 if numeric_values.is_empty() {
296 features[4] = T::zero();
297 features[5] = T::zero();
298 features[6] = T::zero();
299 features[7] = T::zero();
300 } else {
301 let mut sum = 0.0_f64;
302 let mut max = f64::NEG_INFINITY;
303 let mut min = f64::INFINITY;
304 for &v in &numeric_values {
305 sum += v;
306 if v > max {
307 max = v;
308 }
309 if v < min {
310 min = v;
311 }
312 }
313 let count = numeric_values.len() as f64;
314 features[4] = Self::scalar(sum / count);
315 features[5] = Self::scalar((sum.abs()).tanh()); features[6] = Self::scalar(max);
317 features[7] = Self::scalar(min);
318 }
319
320 features[8] = Self::structure_signature(architecture);
322
323 features[9] = Self::saturating_count(architecture.architecture_id.len());
325
326 if n_components == 0 {
328 features[10] = T::zero();
329 } else {
330 const KEYWORDS: [&str; 7] = [
331 "adam", "sgd", "momentum", "rmsprop", "adagrad", "adamw", "lamb",
332 ];
333 let mut hits = 0usize;
334 for entry in &architecture.components {
335 let lower = entry.to_ascii_lowercase();
336 if KEYWORDS.iter().any(|kw| lower.contains(kw)) {
337 hits += 1;
338 }
339 }
340 features[10] = Self::scalar(hits as f64 / n_components as f64);
341 }
342
343 let lr = ["learning_rate", "lr"]
345 .iter()
346 .find_map(|key| {
347 architecture
348 .hyperparameters
349 .get(*key)
350 .or_else(|| architecture.parameters.get(*key))
351 })
352 .and_then(|v| v.to_f64())
353 .filter(|v| v.is_finite())
354 .unwrap_or(0.0);
355 features[11] = Self::scalar(lr);
356
357 features
358 }
359
360 fn saturating_count(n: usize) -> T {
363 let nf = n as f64;
364 Self::scalar(nf / (nf + 1.0))
365 }
366
367 fn raw_score(&self, features: &Array1<T>) -> T {
369 let weights = &self.predictor_model.parameters.weights[0];
370 let mut acc = T::zero();
371 for i in 0..FEATURE_DIM {
372 acc = acc + weights[[i, 0]] * features[i];
373 }
374 acc
375 }
376
377 fn num_observations(&self) -> usize {
379 self.training_data.performance_targets.len()
380 }
381
382 fn confidence_half_width(&self) -> T {
389 let n = self.num_observations();
390 let base = Self::scalar(0.25);
391 let denom = Self::scalar((1.0 + n as f64).sqrt());
392 let spread = base / denom;
393 let scale = T::one() + self.confidence_level;
395 spread * scale
396 }
397
398 pub fn predict_performance(
399 &self,
400 architecture: &OptimizerArchitecture<T>,
401 ) -> Result<EvaluationResults<T>> {
402 let start_time = Instant::now();
403
404 let features = self.extract_features(architecture);
406
407 let raw = self.raw_score(&features);
409 let overall_score = Self::clamp_unit(Self::sigmoid(raw));
410
411 let half_width = self.confidence_half_width();
413 let lower = Self::clamp_unit(overall_score - half_width);
414 let upper = Self::clamp_unit(overall_score + half_width);
415
416 if let Ok(mut pending) = self.pending_features.lock() {
420 pending.push(features);
421 }
422
423 let mut metric_scores: HashMap<EvaluationMetric, T> = HashMap::new();
425 metric_scores.insert(EvaluationMetric::FinalPerformance, overall_score);
426
427 let mut confidence_intervals: HashMap<EvaluationMetric, (T, T)> = HashMap::new();
428 confidence_intervals.insert(EvaluationMetric::FinalPerformance, (lower, upper));
429
430 Ok(EvaluationResults {
431 metric_scores,
432 overall_score,
433 confidence_intervals,
434 evaluation_time: start_time.elapsed(),
435 success: true,
436 error_message: None,
437 cv_results: None,
438 benchmark_results: HashMap::new(),
439 training_trajectory: Vec::new(),
440 })
441 }
442
443 pub fn update_with_results(&mut self, results: &[EvaluationResults<T>]) -> Result<()> {
444 let mut pairs: Vec<(Array1<T>, T)> = Vec::new();
450 {
451 let mut pending = self.pending_features.lock().map_err(|_| {
452 OptimError::EvaluationError("predictor feature buffer poisoned".into())
453 })?;
454 for result in results {
455 if !result.success {
456 continue;
457 }
458 if pending.is_empty() {
459 break;
460 }
461 let features = pending.remove(0);
462 let target = Self::clamp_unit(result.overall_score);
463 pairs.push((features, target));
464 }
465 }
466
467 let lr = self.learning_rate;
468 let lambda = self.ridge_lambda;
469
470 for (features, target) in pairs {
471 let raw = self.raw_score(&features);
474 let prediction = Self::sigmoid(raw);
475 let error = prediction - target;
476
477 {
478 let weights = &mut self.predictor_model.parameters.weights[0];
479 for i in 0..FEATURE_DIM {
480 let grad = error * features[i] + lambda * weights[[i, 0]];
481 weights[[i, 0]] = weights[[i, 0]] - lr * grad;
482 }
483 }
484
485 let loss = error * error;
487 self.predictor_model.training_state.loss_history.push(loss);
488 self.predictor_model.training_state.current_epoch += 1;
489
490 self.training_data.architecture_features.push(features);
493 self.training_data.performance_targets.push(target);
494 }
495
496 Ok(())
497 }
498}
499
500impl<T: Float + Debug + Default + Send + Sync> PredictorModel<T> {
501 fn new() -> Self {
509 Self {
510 model_type: PredictorModelType::LinearRegression,
511 parameters: ModelParameters {
512 weights: vec![Array2::zeros((FEATURE_DIM, 1))],
513 },
514 training_state: ModelTrainingState {
515 current_epoch: 0,
516 loss_history: Vec::new(),
517 },
518 }
519 }
520}
521
522impl<T: Float + Debug + Default + Send + Sync> PredictorTrainingData<T> {
523 fn new() -> Self {
524 Self {
525 architecture_features: Vec::new(),
526 performance_targets: Vec::new(),
527 }
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534
535 fn make_config() -> EvaluationConfig {
537 EvaluationConfig {
538 epochs: 1,
539 batch_size: 8,
540 learning_rate: 0.01,
541 performance_prediction: true,
542 }
543 }
544
545 fn make_architecture(id: &str) -> OptimizerArchitecture<f64> {
547 let mut parameters = HashMap::new();
548 parameters.insert("learning_rate".to_string(), 0.01_f64);
549 parameters.insert("momentum".to_string(), 0.9_f64);
550 parameters.insert("weight_decay".to_string(), 0.0001_f64);
551
552 OptimizerArchitecture {
553 architecture_id: id.to_string(),
554 parameters,
555 components: vec![
556 "AdamW".to_string(),
557 "CosineSchedule".to_string(),
558 "GradientClipping".to_string(),
559 ],
560 connections: Vec::new(),
561 metadata: HashMap::new(),
562 hyperparameters: HashMap::new(),
563 }
564 }
565
566 #[test]
567 fn test_feature_vector_is_fixed_length_and_deterministic() {
568 let predictor = PerformancePredictor::<f64>::new(&make_config())
569 .expect("predictor construction should succeed");
570 let architecture = make_architecture("arch-features");
571
572 let f1 = predictor.extract_features(&architecture);
573 let f2 = predictor.extract_features(&architecture);
574
575 assert_eq!(f1.len(), FEATURE_DIM);
576 for i in 0..FEATURE_DIM {
578 assert!((f1[i] - f2[i]).abs() < 1e-12);
579 }
580 assert!((f1[0] - 1.0).abs() < 1e-12);
582 for i in 0..FEATURE_DIM {
584 assert!(f1[i].is_finite());
585 }
586 }
587
588 #[test]
589 fn test_predict_returns_valid_results() {
590 let predictor = PerformancePredictor::<f64>::new(&make_config())
591 .expect("predictor construction should succeed");
592 let architecture = make_architecture("arch-predict");
593
594 let results = predictor
595 .predict_performance(&architecture)
596 .expect("prediction should succeed");
597
598 assert!(results.overall_score >= 0.0 && results.overall_score <= 1.0);
600 assert!(results.success);
601 assert!(results.error_message.is_none());
602
603 assert!(!results.metric_scores.is_empty());
605 let predicted = results
606 .metric_scores
607 .get(&EvaluationMetric::FinalPerformance)
608 .copied()
609 .expect("FinalPerformance metric should be present");
610 assert!((predicted - results.overall_score).abs() < 1e-12);
611
612 let (lo, hi) = results
614 .confidence_intervals
615 .get(&EvaluationMetric::FinalPerformance)
616 .copied()
617 .expect("confidence interval should be present");
618 assert!(lo >= 0.0 && hi <= 1.0);
619 assert!(lo <= hi);
620
621 assert!(results.cv_results.is_none());
623 assert!(results.benchmark_results.is_empty());
624 assert!(results.training_trajectory.is_empty());
625 }
626
627 #[test]
628 fn test_confidence_interval_narrows_with_more_data() {
629 let mut predictor = PerformancePredictor::<f64>::new(&make_config())
630 .expect("predictor construction should succeed");
631 let architecture = make_architecture("arch-uncertainty");
632
633 let wide = predictor.confidence_half_width();
634
635 for _ in 0..25 {
637 let _ = predictor
638 .predict_performance(&architecture)
639 .expect("prediction should succeed");
640 let result = predictor
641 .predict_performance(&architecture)
642 .expect("prediction should succeed");
643 predictor
644 .update_with_results(std::slice::from_ref(&result))
645 .expect("update should succeed");
646 }
647
648 let narrow = predictor.confidence_half_width();
649 assert!(narrow < wide);
651 }
652
653 #[test]
654 fn test_model_learns_toward_high_target() {
655 let mut predictor = PerformancePredictor::<f64>::new(&make_config())
656 .expect("predictor construction should succeed");
657 let architecture = make_architecture("arch-learn");
658
659 let baseline = predictor
661 .predict_performance(&architecture)
662 .expect("prediction should succeed")
663 .overall_score;
664
665 let high_target = 0.95_f64;
666
667 for _ in 0..200 {
672 let mut result = predictor
673 .predict_performance(&architecture)
674 .expect("prediction should succeed");
675 result.overall_score = high_target;
676 predictor
677 .update_with_results(std::slice::from_ref(&result))
678 .expect("update should succeed");
679 }
680
681 let learned = predictor
682 .predict_performance(&architecture)
683 .expect("prediction should succeed")
684 .overall_score;
685
686 assert!(
688 learned > baseline + 0.05,
689 "prediction did not increase: baseline={baseline}, learned={learned}"
690 );
691 assert!(
694 (high_target - learned).abs() < 0.25,
695 "prediction did not converge toward target: learned={learned}, target={high_target}"
696 );
697 assert!(predictor.num_observations() >= 200);
699 }
700
701 #[test]
702 fn test_update_skips_failed_results() {
703 let mut predictor = PerformancePredictor::<f64>::new(&make_config())
704 .expect("predictor construction should succeed");
705 let architecture = make_architecture("arch-failed");
706
707 let mut result = predictor
708 .predict_performance(&architecture)
709 .expect("prediction should succeed");
710 result.success = false;
711 result.error_message = Some("synthetic failure".to_string());
712 result.overall_score = 0.99;
713
714 predictor
715 .update_with_results(std::slice::from_ref(&result))
716 .expect("update should succeed");
717
718 assert_eq!(predictor.num_observations(), 0);
720 }
721
722 #[test]
723 fn test_recorded_training_state_is_readable_and_consistent() {
724 let mut predictor = PerformancePredictor::<f64>::new(&make_config())
725 .expect("predictor construction should succeed");
726 let architecture = make_architecture("arch-state");
727
728 assert_eq!(
731 predictor.model_type(),
732 &PredictorModelType::LinearRegression
733 );
734 assert_eq!(predictor.epochs_trained(), 0);
735 assert!(predictor.training_loss_history().is_empty());
736 assert_eq!(predictor.observation_count(), 0);
737 assert!(predictor.training_rmse().is_none());
738
739 for _ in 0..40 {
740 let mut result = predictor
741 .predict_performance(&architecture)
742 .expect("prediction should succeed");
743 result.overall_score = 0.9;
744 predictor
745 .update_with_results(std::slice::from_ref(&result))
746 .expect("update should succeed");
747 }
748
749 assert_eq!(predictor.epochs_trained(), 40);
751 assert_eq!(predictor.training_loss_history().len(), 40);
752 assert_eq!(predictor.observation_count(), 40);
753
754 let rmse = predictor
755 .training_rmse()
756 .expect("training RMSE is defined once pairs exist");
757 assert!(rmse.is_finite());
758 assert!((0.0..=1.0).contains(&rmse));
759 assert!(rmse < 0.4, "training did not reduce the fit error: {rmse}");
762 }
763
764 #[test]
765 fn test_confidence_level_is_validated_and_widens_the_interval() {
766 let mut predictor = PerformancePredictor::<f64>::new(&make_config())
767 .expect("predictor construction should succeed");
768
769 assert!((predictor.confidence_level() - 0.95).abs() < 1e-12);
770
771 let wide = predictor.confidence_half_width();
772 predictor
773 .set_confidence_level(0.5)
774 .expect("0.5 is a valid confidence level");
775 let narrow = predictor.confidence_half_width();
776 assert!(
777 narrow < wide,
778 "a lower confidence level must not widen the interval: {narrow} vs {wide}"
779 );
780
781 for bad in [0.0, 1.0, -0.5, 1.5, f64::NAN] {
783 assert!(
784 predictor.set_confidence_level(bad).is_err(),
785 "confidence level {bad} must be rejected"
786 );
787 }
788 assert!((predictor.confidence_level() - 0.5).abs() < 1e-12);
790 }
791}