Skip to main content

optirs_nas/evaluation/
benchmark.rs

1//! Benchmark suite for evaluating optimizer architectures
2//!
3//! Provides comprehensive benchmark tests and test functions.
4//!
5//! # Custom benchmarks
6//!
7//! `CustomBenchmark`, `CustomBenchmarkConfig`, `ProblemDefinition` and
8//! `CustomEvaluator` used to be declared here, and [`BenchmarkSuite`] held a
9//! `custom_benchmarks` vector of them. Nothing could register one (there was no
10//! adder) and nothing could run one: `CustomEvaluator` described an evaluator with
11//! an `EvaluatorType` tag and a parameter map — it carried no callable, so the
12//! suite had no way to obtain a value from it. The types are gone rather than left
13//! as a public vocabulary for a feature the suite cannot execute; running
14//! caller-supplied problems needs an executable evaluator trait first.
15//!
16//! Result caching lives one level up, in [`crate::evaluation::EvaluationCache`],
17//! which is bounded and has an eviction policy. The suite's own `results_cache`
18//! field was never read or written and would have double-counted the empirical
19//! score history that drives percentile ranks.
20
21use scirs2_core::numeric::Float;
22use std::collections::HashMap;
23use std::fmt::Debug;
24use std::time::{Duration, Instant, SystemTime};
25
26use super::types::*;
27use crate::error::{OptimError, Result};
28use crate::{EvaluationConfig, OptimizerArchitecture, ResourceUsage};
29
30/// Test function for benchmarks
31#[derive(Debug, Clone)]
32pub struct TestFunction<T: Float + Debug + Send + Sync + 'static> {
33    /// Function type
34    pub function_type: TestFunctionType,
35
36    /// Function parameters
37    pub parameters: HashMap<String, T>,
38
39    /// Dimensionality
40    pub dimensions: usize,
41
42    /// Evaluation budget
43    pub max_evaluations: usize,
44
45    /// Target performance
46    pub target_performance: Option<T>,
47}
48
49/// Standard benchmark test
50#[derive(Debug, Clone)]
51pub struct StandardBenchmark<T: Float + Debug + Send + Sync + 'static> {
52    /// Benchmark name
53    pub name: String,
54
55    /// Benchmark type
56    pub benchmark_type: BenchmarkType,
57
58    /// Test function
59    pub test_function: TestFunction<T>,
60
61    /// Expected performance range
62    pub expected_range: (T, T),
63
64    /// Difficulty level
65    pub difficulty: DifficultyLevel,
66
67    /// Resource requirements
68    pub resource_requirements: ResourceRequirements,
69}
70
71/// Benchmark metadata
72#[derive(Debug, Clone)]
73pub struct BenchmarkMetadata {
74    /// Suite name
75    pub name: String,
76
77    /// Version
78    pub version: String,
79
80    /// Description
81    pub description: String,
82
83    /// Creation date
84    pub created_at: SystemTime,
85
86    /// Last updated
87    pub updated_at: SystemTime,
88
89    /// Author information
90    pub author: String,
91
92    /// License
93    pub license: String,
94}
95
96/// Benchmark results
97#[derive(Debug, Clone)]
98pub struct BenchmarkResults<T: Float + Debug + Send + Sync + 'static> {
99    /// Individual test results
100    pub test_results: Vec<TestResult<T>>,
101
102    /// Overall score
103    pub overall_score: T,
104
105    /// Performance ranking
106    pub ranking: PerformanceRanking,
107
108    /// Statistical summary
109    pub statistical_summary: StatisticalSummary<T>,
110
111    /// Resource usage summary
112    pub resource_summary: ResourceSummary<T>,
113}
114
115/// Individual test result
116#[derive(Debug, Clone)]
117pub struct TestResult<T: Float + Debug + Send + Sync + 'static> {
118    /// Test name
119    pub test_name: String,
120
121    /// Score
122    pub score: T,
123
124    /// Normalized score
125    pub normalized_score: T,
126
127    /// Percentile rank
128    pub percentile_rank: T,
129
130    /// Execution time
131    pub execution_time: Duration,
132
133    /// Resource usage
134    pub resource_usage: ResourceUsage<T>,
135
136    /// Additional metrics
137    pub metrics: HashMap<String, T>,
138}
139
140/// Comprehensive benchmark suite
141#[derive(Debug)]
142pub struct BenchmarkSuite<T: Float + Debug + Send + Sync + 'static> {
143    /// Standard benchmarks
144    standard_benchmarks: Vec<StandardBenchmark<T>>,
145
146    /// Suite identity: name, version, description, authorship and licence.
147    /// Readable through [`BenchmarkSuite::metadata`].
148    metadata: BenchmarkMetadata,
149
150    /// Empirical distribution of normalized scores observed per benchmark name.
151    ///
152    /// Populated as architectures are benchmarked and used to compute a *real*
153    /// percentile rank for each new result (the fraction of recorded runs the
154    /// new run is at least as good as, the new run included).
155    score_history: HashMap<String, Vec<f64>>,
156
157    /// Number of optimizer steps executed per benchmark run. Derived from the
158    /// evaluation configuration's epoch budget in [`BenchmarkSuite::initialize`].
159    steps_per_benchmark: usize,
160}
161
162/// Default number of optimizer steps executed per benchmark when the
163/// evaluation configuration does not specify a usable epoch budget.
164const DEFAULT_STEPS_PER_BENCHMARK: usize = 60;
165
166/// Upper bound on the number of optimizer steps executed per benchmark.
167///
168/// Architecture search evaluates hundreds of candidates, each running every
169/// registered benchmark; capping the inner loop keeps a full search bounded
170/// while still being long enough for the optimizers to separate.
171const MAX_STEPS_PER_BENCHMARK: usize = 400;
172
173impl<T: Float + Debug + Default + Send + Sync> BenchmarkSuite<T> {
174    pub(crate) fn new() -> Result<Self> {
175        Ok(Self {
176            standard_benchmarks: Vec::new(),
177            metadata: BenchmarkMetadata {
178                name: "Standard Benchmark Suite".to_string(),
179                version: env!("CARGO_PKG_VERSION").to_string(),
180                description: "Optimizer evaluation suite over classical \
181                              continuous-optimization test functions"
182                    .to_string(),
183                created_at: SystemTime::now(),
184                updated_at: SystemTime::now(),
185                author: "COOLJAPAN OU (Team KitaSan)".to_string(),
186                license: "Apache-2.0".to_string(),
187            },
188            score_history: HashMap::new(),
189            steps_per_benchmark: DEFAULT_STEPS_PER_BENCHMARK,
190        })
191    }
192
193    pub(crate) fn initialize(&mut self, config: &EvaluationConfig) -> Result<()> {
194        // Drive the inner optimization loop from the configured epoch budget so
195        // callers can trade evaluation fidelity against search wall time.
196        self.steps_per_benchmark = (config.epochs as usize).clamp(1, MAX_STEPS_PER_BENCHMARK);
197
198        // Initialize standard benchmarks
199        self.standard_benchmarks.clear();
200        self.add_standard_benchmarks()?;
201        Ok(())
202    }
203
204    /// Number of optimizer steps executed per benchmark run.
205    pub fn steps_per_benchmark(&self) -> usize {
206        self.steps_per_benchmark
207    }
208
209    /// Identity of this benchmark suite: name, version, description, author and
210    /// licence.
211    pub fn metadata(&self) -> &BenchmarkMetadata {
212        &self.metadata
213    }
214
215    /// Registered standard benchmarks.
216    pub fn standard_benchmarks(&self) -> &[StandardBenchmark<T>] {
217        &self.standard_benchmarks
218    }
219
220    fn add_standard_benchmarks(&mut self) -> Result<()> {
221        // Add Rosenbrock function benchmark
222        self.standard_benchmarks.push(StandardBenchmark {
223            name: "Rosenbrock".to_string(),
224            benchmark_type: BenchmarkType::NonConvexOptimization,
225            test_function: TestFunction {
226                function_type: TestFunctionType::Rosenbrock,
227                parameters: HashMap::new(),
228                dimensions: 10,
229                max_evaluations: 1000,
230                target_performance: Some(
231                    scirs2_core::numeric::NumCast::from(1e-6).unwrap_or_else(|| T::zero()),
232                ),
233            },
234            expected_range: (
235                scirs2_core::numeric::NumCast::from(1e-8).unwrap_or_else(|| T::zero()),
236                scirs2_core::numeric::NumCast::from(1e-2).unwrap_or_else(|| T::zero()),
237            ),
238            difficulty: DifficultyLevel::Medium,
239            resource_requirements: ResourceRequirements {
240                memory_mb: 100,
241                cpu_cores: 1,
242                gpu_memory_mb: None,
243                max_runtime_seconds: 300,
244                storage_mb: 10,
245            },
246        });
247
248        // Add Quadratic benchmark
249        self.standard_benchmarks.push(StandardBenchmark {
250            name: "Quadratic".to_string(),
251            benchmark_type: BenchmarkType::ConvergenceSpeed,
252            test_function: TestFunction {
253                function_type: TestFunctionType::Quadratic,
254                parameters: HashMap::new(),
255                dimensions: 20,
256                max_evaluations: 500,
257                target_performance: Some(
258                    scirs2_core::numeric::NumCast::from(1e-8).unwrap_or_else(|| T::zero()),
259                ),
260            },
261            expected_range: (
262                scirs2_core::numeric::NumCast::from(1e-10).unwrap_or_else(|| T::zero()),
263                scirs2_core::numeric::NumCast::from(1e-4).unwrap_or_else(|| T::zero()),
264            ),
265            difficulty: DifficultyLevel::Easy,
266            resource_requirements: ResourceRequirements {
267                memory_mb: 50,
268                cpu_cores: 1,
269                gpu_memory_mb: None,
270                max_runtime_seconds: 120,
271                storage_mb: 5,
272            },
273        });
274
275        // Add Rastrigin benchmark (highly multimodal)
276        self.standard_benchmarks.push(StandardBenchmark {
277            name: "Rastrigin".to_string(),
278            benchmark_type: BenchmarkType::NonConvexOptimization,
279            test_function: TestFunction {
280                function_type: TestFunctionType::Rastrigin,
281                parameters: HashMap::new(),
282                dimensions: 10,
283                max_evaluations: 1000,
284                target_performance: Some(
285                    scirs2_core::numeric::NumCast::from(1e-4).unwrap_or_else(|| T::zero()),
286                ),
287            },
288            expected_range: (
289                scirs2_core::numeric::NumCast::from(1e-6).unwrap_or_else(|| T::zero()),
290                scirs2_core::numeric::NumCast::from(1e1).unwrap_or_else(|| T::zero()),
291            ),
292            difficulty: DifficultyLevel::Hard,
293            resource_requirements: ResourceRequirements {
294                memory_mb: 100,
295                cpu_cores: 1,
296                gpu_memory_mb: None,
297                max_runtime_seconds: 300,
298                storage_mb: 10,
299            },
300        });
301
302        // Add Sphere benchmark (smooth convex baseline)
303        self.standard_benchmarks.push(StandardBenchmark {
304            name: "Sphere".to_string(),
305            benchmark_type: BenchmarkType::ConvergenceSpeed,
306            test_function: TestFunction {
307                function_type: TestFunctionType::Sphere,
308                parameters: HashMap::new(),
309                dimensions: 20,
310                max_evaluations: 500,
311                target_performance: Some(
312                    scirs2_core::numeric::NumCast::from(1e-10).unwrap_or_else(|| T::zero()),
313                ),
314            },
315            expected_range: (
316                scirs2_core::numeric::NumCast::from(1e-12).unwrap_or_else(|| T::zero()),
317                scirs2_core::numeric::NumCast::from(1e-2).unwrap_or_else(|| T::zero()),
318            ),
319            difficulty: DifficultyLevel::Easy,
320            resource_requirements: ResourceRequirements {
321                memory_mb: 50,
322                cpu_cores: 1,
323                gpu_memory_mb: None,
324                max_runtime_seconds: 120,
325                storage_mb: 5,
326            },
327        });
328
329        Ok(())
330    }
331
332    /// Run every registered standard benchmark against `architecture`.
333    ///
334    /// Each benchmark instantiates the concrete optimizer described by the
335    /// architecture and actually runs it for [`Self::steps_per_benchmark`]
336    /// gradient steps on the benchmark's test function, reporting the objective
337    /// value it achieved.
338    pub(crate) fn run_benchmarks(
339        &mut self,
340        architecture: &OptimizerArchitecture<T>,
341    ) -> Result<Vec<TestResult<T>>> {
342        let mut results = Vec::with_capacity(self.standard_benchmarks.len());
343
344        // Index-based iteration: `run_single_benchmark` records the observed
345        // score into `self.score_history`, so it needs `&mut self`.
346        for idx in 0..self.standard_benchmarks.len() {
347            let benchmark = self.standard_benchmarks[idx].clone();
348            let result = self.run_single_benchmark(&benchmark, architecture)?;
349            results.push(result);
350        }
351
352        Ok(results)
353    }
354
355    /// Execute a single benchmark: build the candidate optimizer from the
356    /// architecture, minimize the test function for a fixed step budget, and
357    /// report the achieved objective together with a normalized score, an
358    /// empirical percentile rank and measured execution time.
359    fn run_single_benchmark(
360        &mut self,
361        benchmark: &StandardBenchmark<T>,
362        architecture: &OptimizerArchitecture<T>,
363    ) -> Result<TestResult<T>> {
364        let function_type = &benchmark.test_function.function_type;
365        let dimensions = benchmark.test_function.dimensions.max(1);
366
367        // Deterministic starting point: depends only on the test function and
368        // dimensionality, so every candidate architecture is compared on
369        // exactly the same problem instance and repeated runs reproduce.
370        let mut params = initial_point(function_type, dimensions);
371        let initial_objective = objective_value(function_type, &params)?;
372
373        let spec = OptimizerSpec::from_architecture(architecture);
374        let mut optimizer = CandidateOptimizer::new(spec.clone(), params.len());
375
376        let start_time = Instant::now();
377        let mut best_objective = initial_objective;
378
379        for _ in 0..self.steps_per_benchmark {
380            let gradient = objective_gradient(function_type, &params)?;
381            optimizer.step(&mut params, &gradient);
382
383            // A diverging optimizer can produce non-finite iterates; stop there
384            // and keep the best objective observed so far rather than
385            // propagating NaN into the score.
386            if params.iter().any(|v| !v.is_finite()) {
387                break;
388            }
389
390            let value = objective_value(function_type, &params)?;
391            if value < best_objective {
392                best_objective = value;
393            }
394        }
395
396        let execution_time = start_time.elapsed();
397
398        // Normalized score: fraction of the initial objective that was removed.
399        // 1.0 means the optimizer drove the objective to zero, 0.0 means it made
400        // no progress (or diverged). Bounded and comparable across functions.
401        let normalized = if initial_objective > 0.0 && initial_objective.is_finite() {
402            ((initial_objective - best_objective) / initial_objective).clamp(0.0, 1.0)
403        } else {
404            0.0
405        };
406
407        // Empirical percentile rank against all runs recorded for this
408        // benchmark so far, this run included.
409        let history = self
410            .score_history
411            .entry(benchmark.name.clone())
412            .or_default();
413        history.push(normalized);
414        let total = history.len();
415        let at_or_below = history.iter().filter(|&&s| s <= normalized).count();
416        let percentile = at_or_below as f64 / total as f64;
417
418        // Honest resource accounting. Only wall-clock time and the analytic
419        // working-set size of the optimizer are actually known here; GPU,
420        // network, disk, energy and cost are not measured and are reported as
421        // zero rather than fabricated.
422        let working_set_bytes =
423            (dimensions * spec.state_slots_per_parameter() * std::mem::size_of::<f64>()) as f64;
424        let memory_gb = working_set_bytes / 1.0e9;
425        let elapsed_secs = execution_time.as_secs_f64();
426
427        let mut metrics = HashMap::new();
428        metrics.insert(
429            "initial_objective".to_string(),
430            to_t::<T>(initial_objective),
431        );
432        metrics.insert("final_objective".to_string(), to_t::<T>(best_objective));
433        metrics.insert(
434            "steps".to_string(),
435            to_t::<T>(self.steps_per_benchmark as f64),
436        );
437        metrics.insert("learning_rate".to_string(), to_t::<T>(spec.learning_rate));
438
439        Ok(TestResult {
440            test_name: benchmark.name.clone(),
441            score: to_t::<T>(best_objective),
442            normalized_score: to_t::<T>(normalized),
443            percentile_rank: to_t::<T>(percentile),
444            execution_time,
445            resource_usage: ResourceUsage {
446                memory_gb: to_t::<T>(memory_gb),
447                cpu_time_seconds: to_t::<T>(elapsed_secs),
448                gpu_time_seconds: T::zero(),
449                energy_kwh: T::zero(),
450                network_io_gb: T::zero(),
451                disk_io_gb: T::zero(),
452                peak_memory_gb: to_t::<T>(memory_gb),
453                efficiency_score: to_t::<T>(normalized / (1.0 + elapsed_secs)),
454                cost_usd: T::zero(),
455                network_gb: T::zero(),
456            },
457            metrics,
458        })
459    }
460}
461
462/// Convert a finite `f64` into `T`, falling back to zero.
463fn to_t<T: Float>(value: f64) -> T {
464    scirs2_core::numeric::NumCast::from(value).unwrap_or_else(T::zero)
465}
466
467// ---------------------------------------------------------------------------
468// Test functions
469// ---------------------------------------------------------------------------
470
471/// Deterministic starting point for a test function of the given dimension.
472///
473/// The classical literature start points are used where they exist
474/// (Rosenbrock's `(-1.2, 1, -1.2, 1, ...)`), otherwise a fixed, reproducible
475/// offset from the optimum that leaves a non-trivial amount of work to do.
476fn initial_point(function_type: &TestFunctionType, dimensions: usize) -> Vec<f64> {
477    match function_type {
478        TestFunctionType::Rosenbrock => (0..dimensions)
479            .map(|i| if i % 2 == 0 { -1.2 } else { 1.0 })
480            .collect(),
481        TestFunctionType::Rastrigin => (0..dimensions)
482            .map(|i| 2.5 - 0.1 * ((i % 7) as f64))
483            .collect(),
484        TestFunctionType::Ackley => (0..dimensions)
485            .map(|i| 3.0 - 0.2 * ((i % 5) as f64))
486            .collect(),
487        TestFunctionType::Beale => vec![1.0, 1.0],
488        // Quadratic / Sphere and anything else: a fixed non-zero start.
489        _ => (0..dimensions)
490            .map(|i| 1.0 + 0.5 * ((i % 4) as f64))
491            .collect(),
492    }
493}
494
495/// Objective value of a supported continuous test function.
496///
497/// Returns [`OptimError::NotImplemented`] for function types that have no
498/// analytic implementation, so a caller never silently benchmarks against a
499/// fabricated score.
500fn objective_value(function_type: &TestFunctionType, x: &[f64]) -> Result<f64> {
501    if x.is_empty() {
502        return Err(OptimError::EvaluationError(
503            "test function evaluated on an empty parameter vector".to_string(),
504        ));
505    }
506
507    let value = match function_type {
508        // f(x) = sum_i i-weighted x_i^2 — an ill-conditioned quadratic bowl.
509        TestFunctionType::Quadratic => x
510            .iter()
511            .enumerate()
512            .map(|(i, &xi)| (1.0 + i as f64) * xi * xi)
513            .sum(),
514
515        // f(x) = sum_i x_i^2 — the isotropic sphere.
516        TestFunctionType::Sphere => x.iter().map(|&xi| xi * xi).sum(),
517
518        // f(x) = sum_{i<n-1} [100 (x_{i+1} - x_i^2)^2 + (1 - x_i)^2]
519        TestFunctionType::Rosenbrock => {
520            let mut acc = 0.0;
521            for i in 0..x.len().saturating_sub(1) {
522                let t = x[i + 1] - x[i] * x[i];
523                let u = 1.0 - x[i];
524                acc += 100.0 * t * t + u * u;
525            }
526            acc
527        }
528
529        // f(x) = 10 n + sum_i [x_i^2 - 10 cos(2 pi x_i)]
530        TestFunctionType::Rastrigin => {
531            let two_pi = 2.0 * std::f64::consts::PI;
532            10.0 * x.len() as f64
533                + x.iter()
534                    .map(|&xi| xi * xi - 10.0 * (two_pi * xi).cos())
535                    .sum::<f64>()
536        }
537
538        // Standard Ackley function (a = 20, b = 0.2, c = 2 pi).
539        TestFunctionType::Ackley => {
540            let n = x.len() as f64;
541            let two_pi = 2.0 * std::f64::consts::PI;
542            let sum_sq = x.iter().map(|&xi| xi * xi).sum::<f64>() / n;
543            let sum_cos = x.iter().map(|&xi| (two_pi * xi).cos()).sum::<f64>() / n;
544            -20.0 * (-0.2 * sum_sq.sqrt()).exp() - sum_cos.exp() + 20.0 + std::f64::consts::E
545        }
546
547        // Beale function (2-D only).
548        TestFunctionType::Beale => {
549            if x.len() < 2 {
550                return Err(OptimError::EvaluationError(
551                    "Beale test function requires two dimensions".to_string(),
552                ));
553            }
554            let (a, b) = (x[0], x[1]);
555            let t1 = 1.5 - a + a * b;
556            let t2 = 2.25 - a + a * b * b;
557            let t3 = 2.625 - a + a * b * b * b;
558            t1 * t1 + t2 * t2 + t3 * t3
559        }
560
561        other => {
562            return Err(OptimError::NotImplemented(format!(
563                "test function {:?} has no analytic implementation",
564                other
565            )))
566        }
567    };
568
569    Ok(value)
570}
571
572/// Analytic gradient of a supported continuous test function.
573fn objective_gradient(function_type: &TestFunctionType, x: &[f64]) -> Result<Vec<f64>> {
574    if x.is_empty() {
575        return Err(OptimError::EvaluationError(
576            "test function differentiated on an empty parameter vector".to_string(),
577        ));
578    }
579
580    let gradient = match function_type {
581        TestFunctionType::Quadratic => x
582            .iter()
583            .enumerate()
584            .map(|(i, &xi)| 2.0 * (1.0 + i as f64) * xi)
585            .collect(),
586
587        TestFunctionType::Sphere => x.iter().map(|&xi| 2.0 * xi).collect(),
588
589        TestFunctionType::Rosenbrock => {
590            let n = x.len();
591            let mut g = vec![0.0; n];
592            for i in 0..n.saturating_sub(1) {
593                let t = x[i + 1] - x[i] * x[i];
594                g[i] += -400.0 * x[i] * t - 2.0 * (1.0 - x[i]);
595                g[i + 1] += 200.0 * t;
596            }
597            g
598        }
599
600        TestFunctionType::Rastrigin => {
601            let two_pi = 2.0 * std::f64::consts::PI;
602            x.iter()
603                .map(|&xi| 2.0 * xi + 10.0 * two_pi * (two_pi * xi).sin())
604                .collect()
605        }
606
607        TestFunctionType::Ackley => {
608            let n = x.len() as f64;
609            let two_pi = 2.0 * std::f64::consts::PI;
610            let mean_sq = x.iter().map(|&xi| xi * xi).sum::<f64>() / n;
611            let root = mean_sq.sqrt();
612            let mean_cos = x.iter().map(|&xi| (two_pi * xi).cos()).sum::<f64>() / n;
613            let exp1 = (-0.2 * root).exp();
614            let exp2 = mean_cos.exp();
615
616            x.iter()
617                .map(|&xi| {
618                    // d/dxi of -20 exp(-0.2 sqrt(mean(x^2)))
619                    let first = if root > 1e-12 {
620                        4.0 * exp1 * xi / (n * root)
621                    } else {
622                        0.0
623                    };
624                    // d/dxi of -exp(mean(cos(2 pi x)))
625                    let second = exp2 * two_pi * (two_pi * xi).sin() / n;
626                    first + second
627                })
628                .collect()
629        }
630
631        TestFunctionType::Beale => {
632            if x.len() < 2 {
633                return Err(OptimError::EvaluationError(
634                    "Beale test function requires two dimensions".to_string(),
635                ));
636            }
637            let (a, b) = (x[0], x[1]);
638            let t1 = 1.5 - a + a * b;
639            let t2 = 2.25 - a + a * b * b;
640            let t3 = 2.625 - a + a * b * b * b;
641            let da = 2.0 * t1 * (b - 1.0) + 2.0 * t2 * (b * b - 1.0) + 2.0 * t3 * (b * b * b - 1.0);
642            let db = 2.0 * t1 * a + 2.0 * t2 * (2.0 * a * b) + 2.0 * t3 * (3.0 * a * b * b);
643            vec![da, db]
644        }
645
646        other => {
647            return Err(OptimError::NotImplemented(format!(
648                "test function {:?} has no analytic gradient",
649                other
650            )))
651        }
652    };
653
654    Ok(gradient)
655}
656
657// ---------------------------------------------------------------------------
658// Candidate optimizer construction
659// ---------------------------------------------------------------------------
660
661/// Optimizer family a candidate architecture resolves to.
662#[derive(Debug, Clone, Copy, PartialEq, Eq)]
663enum OptimizerFamily {
664    Sgd,
665    Adam,
666    AdamW,
667    RmsProp,
668    AdaGrad,
669    Lion,
670    RAdam,
671}
672
673/// Concrete, runnable description of the optimizer encoded by an architecture.
674#[derive(Debug, Clone)]
675struct OptimizerSpec {
676    family: OptimizerFamily,
677    learning_rate: f64,
678    beta1: f64,
679    beta2: f64,
680    epsilon: f64,
681    weight_decay: f64,
682    momentum: f64,
683}
684
685impl OptimizerSpec {
686    /// Resolve the architecture's leading recognised component into an
687    /// optimizer family and read its hyperparameters.
688    ///
689    /// Hyperparameters are looked up in the architecture's `hyperparameters`
690    /// map first and then in `parameters`, so architectures produced by either
691    /// convention are honoured. Values outside a numerically sane band are
692    /// clamped so a pathological candidate cannot make the benchmark diverge
693    /// into non-finite arithmetic before the loop's own guard fires.
694    fn from_architecture<T: Float + Debug + Send + Sync + 'static>(
695        architecture: &OptimizerArchitecture<T>,
696    ) -> Self {
697        let family = architecture
698            .components
699            .iter()
700            .find_map(|c| family_from_name(c))
701            .unwrap_or(OptimizerFamily::Sgd);
702
703        let lookup = |keys: &[&str], default: f64| -> f64 {
704            for key in keys {
705                if let Some(value) = architecture
706                    .hyperparameters
707                    .get(*key)
708                    .or_else(|| architecture.parameters.get(*key))
709                {
710                    if let Some(v) = value.to_f64() {
711                        if v.is_finite() {
712                            return v;
713                        }
714                    }
715                }
716            }
717            default
718        };
719
720        let default_lr = match family {
721            OptimizerFamily::Sgd => 1e-2,
722            OptimizerFamily::Lion => 1e-4,
723            _ => 1e-3,
724        };
725
726        Self {
727            family,
728            learning_rate: lookup(&["learning_rate", "lr"], default_lr).clamp(1e-8, 1.0),
729            beta1: lookup(&["beta1", "momentum"], 0.9).clamp(0.0, 0.9999),
730            beta2: lookup(&["beta2", "decay", "rho"], 0.999).clamp(0.0, 0.999999),
731            epsilon: lookup(&["epsilon", "eps"], 1e-8).clamp(1e-16, 1e-1),
732            weight_decay: lookup(&["weight_decay", "l2"], 0.0).clamp(0.0, 1.0),
733            momentum: lookup(&["momentum", "beta1"], 0.9).clamp(0.0, 0.9999),
734        }
735    }
736
737    /// Number of `f64` slots the optimizer keeps per model parameter
738    /// (the parameter vector itself plus its persistent optimizer state).
739    /// Used for the analytic working-set estimate reported as memory usage.
740    fn state_slots_per_parameter(&self) -> usize {
741        match self.family {
742            OptimizerFamily::AdaGrad => 2, // params + accumulated squares
743            OptimizerFamily::RmsProp => 2, // params + squared-gradient EMA
744            OptimizerFamily::Lion => 2,    // params + momentum
745            OptimizerFamily::Sgd => {
746                if self.momentum > 0.0 {
747                    2
748                } else {
749                    1
750                }
751            }
752            // params + first and second moments
753            OptimizerFamily::Adam | OptimizerFamily::AdamW | OptimizerFamily::RAdam => 3,
754        }
755    }
756}
757
758/// Map a component name (the `Debug` rendering of a component type) onto an
759/// optimizer family. Matching is case-insensitive and order-sensitive so
760/// `AdamW` is not swallowed by the `Adam` prefix.
761fn family_from_name(name: &str) -> Option<OptimizerFamily> {
762    let lower = name.to_ascii_lowercase();
763    if lower.contains("adamw") {
764        Some(OptimizerFamily::AdamW)
765    } else if lower.contains("radam") {
766        Some(OptimizerFamily::RAdam)
767    } else if lower.contains("adagrad") {
768        Some(OptimizerFamily::AdaGrad)
769    } else if lower.contains("rmsprop") {
770        Some(OptimizerFamily::RmsProp)
771    } else if lower.contains("lion") {
772        Some(OptimizerFamily::Lion)
773    } else if lower.contains("adam") {
774        Some(OptimizerFamily::Adam)
775    } else if lower.contains("sgd") || lower.contains("momentum") || lower.contains("nesterov") {
776        Some(OptimizerFamily::Sgd)
777    } else {
778        None
779    }
780}
781
782/// A runnable optimizer instance carrying its persistent state.
783///
784/// The update rules are implemented here rather than delegated so that the
785/// benchmark harness is hermetic: a NAS score depends only on the candidate
786/// architecture and the test function, never on a sibling crate's internals,
787/// and a given architecture reproduces bit-for-bit across releases.
788///
789/// All rules follow the published formulations:
790/// * SGD with (heavy-ball) momentum and L2 weight decay,
791/// * Adam (Kingma & Ba, 2015) with bias correction,
792/// * AdamW (Loshchilov & Hutter, 2019) with decoupled weight decay,
793/// * RMSprop (Tieleman & Hinton, 2012),
794/// * AdaGrad (Duchi et al., 2011),
795/// * Lion (Chen et al., 2023),
796/// * RAdam (Liu et al., 2020) with the variance-rectification term.
797#[derive(Debug, Clone)]
798struct CandidateOptimizer {
799    spec: OptimizerSpec,
800    /// First moment / momentum buffer.
801    m: Vec<f64>,
802    /// Second moment / squared-gradient accumulator.
803    v: Vec<f64>,
804    /// Step counter (1-based once the first step has been taken).
805    t: u32,
806}
807
808impl CandidateOptimizer {
809    fn new(spec: OptimizerSpec, dimensions: usize) -> Self {
810        Self {
811            spec,
812            m: vec![0.0; dimensions],
813            v: vec![0.0; dimensions],
814            t: 0,
815        }
816    }
817
818    /// Apply one in-place optimizer update to `params`.
819    fn step(&mut self, params: &mut [f64], gradients: &[f64]) {
820        self.t = self.t.saturating_add(1);
821        let t = self.t as f64;
822
823        let lr = self.spec.learning_rate;
824        let b1 = self.spec.beta1;
825        let b2 = self.spec.beta2;
826        let eps = self.spec.epsilon;
827        let wd = self.spec.weight_decay;
828
829        match self.spec.family {
830            OptimizerFamily::Sgd => {
831                let mu = self.spec.momentum;
832                for i in 0..params.len() {
833                    let g = gradients[i] + wd * params[i];
834                    self.m[i] = mu * self.m[i] + g;
835                    params[i] -= lr * self.m[i];
836                }
837            }
838
839            OptimizerFamily::Adam => {
840                let bc1 = 1.0 - b1.powf(t);
841                let bc2 = 1.0 - b2.powf(t);
842                for i in 0..params.len() {
843                    let g = gradients[i] + wd * params[i];
844                    self.m[i] = b1 * self.m[i] + (1.0 - b1) * g;
845                    self.v[i] = b2 * self.v[i] + (1.0 - b2) * g * g;
846                    let m_hat = self.m[i] / bc1.max(f64::MIN_POSITIVE);
847                    let v_hat = self.v[i] / bc2.max(f64::MIN_POSITIVE);
848                    params[i] -= lr * m_hat / (v_hat.max(0.0).sqrt() + eps);
849                }
850            }
851
852            OptimizerFamily::AdamW => {
853                let bc1 = 1.0 - b1.powf(t);
854                let bc2 = 1.0 - b2.powf(t);
855                for i in 0..params.len() {
856                    // Decoupled weight decay: applied to the parameter, not the
857                    // gradient, so it does not enter the moment estimates.
858                    params[i] -= lr * wd * params[i];
859
860                    let g = gradients[i];
861                    self.m[i] = b1 * self.m[i] + (1.0 - b1) * g;
862                    self.v[i] = b2 * self.v[i] + (1.0 - b2) * g * g;
863                    let m_hat = self.m[i] / bc1.max(f64::MIN_POSITIVE);
864                    let v_hat = self.v[i] / bc2.max(f64::MIN_POSITIVE);
865                    params[i] -= lr * m_hat / (v_hat.max(0.0).sqrt() + eps);
866                }
867            }
868
869            OptimizerFamily::RmsProp => {
870                // `beta2` doubles as RMSprop's decay factor `rho`.
871                let rho = b2.clamp(0.5, 0.9999);
872                for i in 0..params.len() {
873                    let g = gradients[i] + wd * params[i];
874                    self.v[i] = rho * self.v[i] + (1.0 - rho) * g * g;
875                    params[i] -= lr * g / (self.v[i].max(0.0).sqrt() + eps);
876                }
877            }
878
879            OptimizerFamily::AdaGrad => {
880                for i in 0..params.len() {
881                    let g = gradients[i] + wd * params[i];
882                    self.v[i] += g * g;
883                    params[i] -= lr * g / (self.v[i].max(0.0).sqrt() + eps);
884                }
885            }
886
887            OptimizerFamily::Lion => {
888                for i in 0..params.len() {
889                    let g = gradients[i];
890                    // Update direction is the sign of an interpolated momentum.
891                    let interpolated = b1 * self.m[i] + (1.0 - b1) * g;
892                    let direction = if interpolated > 0.0 {
893                        1.0
894                    } else if interpolated < 0.0 {
895                        -1.0
896                    } else {
897                        0.0
898                    };
899                    params[i] -= lr * (direction + wd * params[i]);
900                    // Momentum is updated with the second interpolation factor.
901                    self.m[i] = b2 * self.m[i] + (1.0 - b2) * g;
902                }
903            }
904
905            OptimizerFamily::RAdam => {
906                let bc1 = 1.0 - b1.powf(t);
907                let b2_t = b2.powf(t);
908                let bc2 = 1.0 - b2_t;
909                // Maximum length of the approximated SMA and its value at step t.
910                let rho_inf = 2.0 / (1.0 - b2).max(f64::MIN_POSITIVE) - 1.0;
911                let rho_t = rho_inf - 2.0 * t * b2_t / bc2.max(f64::MIN_POSITIVE);
912
913                // Rectification term; only defined once the variance estimate is
914                // tractable (rho_t > 4), otherwise fall back to plain SGD on the
915                // bias-corrected first moment.
916                let rectification = if rho_t > 4.0 {
917                    let numerator = (rho_t - 4.0) * (rho_t - 2.0) * rho_inf;
918                    let denominator = (rho_inf - 4.0) * (rho_inf - 2.0) * rho_t;
919                    if denominator > 0.0 {
920                        Some((numerator / denominator).max(0.0).sqrt())
921                    } else {
922                        None
923                    }
924                } else {
925                    None
926                };
927
928                for i in 0..params.len() {
929                    let g = gradients[i] + wd * params[i];
930                    self.m[i] = b1 * self.m[i] + (1.0 - b1) * g;
931                    self.v[i] = b2 * self.v[i] + (1.0 - b2) * g * g;
932                    let m_hat = self.m[i] / bc1.max(f64::MIN_POSITIVE);
933
934                    match rectification {
935                        Some(r) => {
936                            let v_hat = (self.v[i] / bc2.max(f64::MIN_POSITIVE)).max(0.0).sqrt();
937                            params[i] -= lr * r * m_hat / (v_hat + eps);
938                        }
939                        None => params[i] -= lr * m_hat,
940                    }
941                }
942            }
943        }
944    }
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950
951    fn architecture(component: &str, hyper: &[(&str, f64)]) -> OptimizerArchitecture<f64> {
952        let mut hyperparameters = HashMap::new();
953        for (k, v) in hyper {
954            hyperparameters.insert((*k).to_string(), *v);
955        }
956        OptimizerArchitecture {
957            components: vec![component.to_string()],
958            parameters: HashMap::new(),
959            connections: Vec::new(),
960            metadata: HashMap::new(),
961            hyperparameters,
962            architecture_id: format!("{}_test", component),
963        }
964    }
965
966    fn suite() -> BenchmarkSuite<f64> {
967        let mut suite = BenchmarkSuite::<f64>::new().expect("suite");
968        let config = EvaluationConfig {
969            epochs: 40,
970            ..EvaluationConfig::default()
971        };
972        suite.initialize(&config).expect("initialize");
973        suite
974    }
975
976    #[test]
977    fn test_benchmark_suite_creation() {
978        let suite = BenchmarkSuite::<f64>::new();
979        assert!(suite.is_ok());
980    }
981
982    #[test]
983    fn test_standard_benchmarks_registered() {
984        let suite = suite();
985        let names: Vec<&str> = suite
986            .standard_benchmarks()
987            .iter()
988            .map(|b| b.name.as_str())
989            .collect();
990        assert!(names.contains(&"Rosenbrock"));
991        assert!(names.contains(&"Quadratic"));
992        assert!(names.contains(&"Rastrigin"));
993        assert!(names.contains(&"Sphere"));
994        assert_eq!(suite.steps_per_benchmark(), 40);
995    }
996
997    #[test]
998    fn test_objective_values_at_known_optima() {
999        // Sphere and Quadratic have their minimum at the origin.
1000        assert!(
1001            objective_value(&TestFunctionType::Sphere, &[0.0; 5])
1002                .expect("sphere")
1003                .abs()
1004                < 1e-12
1005        );
1006        assert!(
1007            objective_value(&TestFunctionType::Quadratic, &[0.0; 5])
1008                .expect("quadratic")
1009                .abs()
1010                < 1e-12
1011        );
1012        // Rosenbrock's minimum is at the all-ones vector.
1013        assert!(
1014            objective_value(&TestFunctionType::Rosenbrock, &[1.0; 6])
1015                .expect("rosenbrock")
1016                .abs()
1017                < 1e-12
1018        );
1019        // Rastrigin's minimum is at the origin.
1020        assert!(
1021            objective_value(&TestFunctionType::Rastrigin, &[0.0; 4])
1022                .expect("rastrigin")
1023                .abs()
1024                < 1e-12
1025        );
1026        // Beale's minimum is at (3, 0.5).
1027        assert!(
1028            objective_value(&TestFunctionType::Beale, &[3.0, 0.5])
1029                .expect("beale")
1030                .abs()
1031                < 1e-12
1032        );
1033    }
1034
1035    #[test]
1036    fn test_gradients_match_finite_differences() {
1037        let point = [0.7_f64, -0.4, 1.3, 0.2];
1038        for function_type in [
1039            TestFunctionType::Sphere,
1040            TestFunctionType::Quadratic,
1041            TestFunctionType::Rosenbrock,
1042            TestFunctionType::Rastrigin,
1043            TestFunctionType::Ackley,
1044        ] {
1045            let analytic = objective_gradient(&function_type, &point).expect("gradient");
1046            let h = 1e-6;
1047            for i in 0..point.len() {
1048                let mut plus = point;
1049                let mut minus = point;
1050                plus[i] += h;
1051                minus[i] -= h;
1052                let numeric = (objective_value(&function_type, &plus).expect("f+")
1053                    - objective_value(&function_type, &minus).expect("f-"))
1054                    / (2.0 * h);
1055                assert!(
1056                    (analytic[i] - numeric).abs() < 1e-3,
1057                    "{:?} gradient[{}]: analytic {} vs numeric {}",
1058                    function_type,
1059                    i,
1060                    analytic[i],
1061                    numeric
1062                );
1063            }
1064        }
1065    }
1066
1067    #[test]
1068    fn test_unimplemented_test_function_is_reported() {
1069        let err = objective_value(&TestFunctionType::NeuralNetworkTraining, &[1.0]);
1070        assert!(matches!(err, Err(OptimError::NotImplemented(_))));
1071    }
1072
1073    #[test]
1074    fn test_benchmarks_actually_optimize() {
1075        let mut suite = suite();
1076        let results = suite
1077            .run_benchmarks(&architecture("Adam", &[("learning_rate", 0.1)]))
1078            .expect("run benchmarks");
1079
1080        assert_eq!(results.len(), 4);
1081        for result in &results {
1082            // A working optimizer reduces the objective from the start point.
1083            assert!(
1084                result.normalized_score > 0.0,
1085                "{} made no progress",
1086                result.test_name
1087            );
1088            assert!(result.normalized_score <= 1.0);
1089            assert!(result.score.is_finite());
1090            assert!(result.percentile_rank > 0.0 && result.percentile_rank <= 1.0);
1091        }
1092    }
1093
1094    #[test]
1095    fn test_different_architectures_produce_different_scores() {
1096        let mut suite = suite();
1097        let fast = suite
1098            .run_benchmarks(&architecture("Adam", &[("learning_rate", 0.2)]))
1099            .expect("fast");
1100        let slow = suite
1101            .run_benchmarks(&architecture("SGD", &[("learning_rate", 1e-6)]))
1102            .expect("slow");
1103
1104        // The well-tuned candidate must beat the crippled one on the smooth
1105        // convex benchmark; a benchmark that ignored the architecture could not
1106        // distinguish them.
1107        let fast_sphere = fast
1108            .iter()
1109            .find(|r| r.test_name == "Sphere")
1110            .expect("sphere")
1111            .normalized_score;
1112        let slow_sphere = slow
1113            .iter()
1114            .find(|r| r.test_name == "Sphere")
1115            .expect("sphere")
1116            .normalized_score;
1117        assert!(
1118            fast_sphere > slow_sphere,
1119            "expected {} > {}",
1120            fast_sphere,
1121            slow_sphere
1122        );
1123    }
1124
1125    #[test]
1126    fn test_component_type_alone_changes_the_score() {
1127        // The architectures produced by the live `RandomSearch` currently carry
1128        // an empty hyperparameter map, so the *only* signal available is the
1129        // component type. Scores must still differ: each family has its own
1130        // default learning rate and its own update rule.
1131        let mut suite = suite();
1132        let adam = suite
1133            .run_benchmarks(&architecture("Adam", &[]))
1134            .expect("adam");
1135        let sgd = suite
1136            .run_benchmarks(&architecture("SGD", &[]))
1137            .expect("sgd");
1138        let rmsprop = suite
1139            .run_benchmarks(&architecture("RMSprop", &[]))
1140            .expect("rmsprop");
1141
1142        let score_of = |results: &[TestResult<f64>], name: &str| -> f64 {
1143            results
1144                .iter()
1145                .find(|r| r.test_name == name)
1146                .map(|r| r.normalized_score)
1147                .unwrap_or(f64::NAN)
1148        };
1149
1150        for benchmark in ["Sphere", "Rosenbrock"] {
1151            let a = score_of(&adam, benchmark);
1152            let s = score_of(&sgd, benchmark);
1153            let r = score_of(&rmsprop, benchmark);
1154            assert!(
1155                (a - s).abs() > 1e-9,
1156                "{}: Adam and SGD must differ ({} vs {})",
1157                benchmark,
1158                a,
1159                s
1160            );
1161            assert!(
1162                (a - r).abs() > 1e-9,
1163                "{}: Adam and RMSprop must differ ({} vs {})",
1164                benchmark,
1165                a,
1166                r
1167            );
1168        }
1169    }
1170
1171    #[test]
1172    fn test_unrecognised_component_falls_back_to_sgd() {
1173        // `ComponentType::Custom` renders as "Custom" through `format!("{:?}")`
1174        // and matches no family; the deliberate fallback is plain SGD so an
1175        // exotic candidate is still benchmarked rather than skipped.
1176        assert_eq!(family_from_name("Custom"), None);
1177        let spec = OptimizerSpec::from_architecture(&architecture("Custom", &[]));
1178        assert_eq!(spec.family, OptimizerFamily::Sgd);
1179    }
1180
1181    #[test]
1182    fn test_identical_architecture_reproduces() {
1183        let mut suite_a = suite();
1184        let mut suite_b = suite();
1185        let arch = architecture("AdamW", &[("learning_rate", 0.05), ("beta1", 0.85)]);
1186
1187        let a = suite_a.run_benchmarks(&arch).expect("a");
1188        let b = suite_b.run_benchmarks(&arch).expect("b");
1189
1190        assert_eq!(a.len(), b.len());
1191        for (ra, rb) in a.iter().zip(b.iter()) {
1192            assert_eq!(ra.test_name, rb.test_name);
1193            assert!(
1194                (ra.score - rb.score).abs() < 1e-12,
1195                "{} not reproducible: {} vs {}",
1196                ra.test_name,
1197                ra.score,
1198                rb.score
1199            );
1200        }
1201    }
1202
1203    #[test]
1204    fn test_optimizer_family_resolution() {
1205        assert_eq!(family_from_name("AdamW"), Some(OptimizerFamily::AdamW));
1206        assert_eq!(family_from_name("Adam"), Some(OptimizerFamily::Adam));
1207        assert_eq!(family_from_name("RAdam"), Some(OptimizerFamily::RAdam));
1208        assert_eq!(family_from_name("SGD"), Some(OptimizerFamily::Sgd));
1209        assert_eq!(family_from_name("RMSprop"), Some(OptimizerFamily::RmsProp));
1210        assert_eq!(family_from_name("Unrelated"), None);
1211    }
1212}