Skip to main content

optirs_core/
self_tuning.rs

1// Self-tuning optimization strategies
2//
3// This module provides adaptive optimization strategies that automatically
4// tune hyperparameters, select optimizers, and adjust configurations based
5// on training dynamics and problem characteristics.
6
7use crate::error::{OptimError, Result};
8use crate::optimizers::*;
9use crate::utils::{scalar_or, total_order, try_f64, try_scalar};
10use scirs2_core::ndarray::{Array, Dimension, ScalarOperand};
11use scirs2_core::numeric::Float;
12use scirs2_core::random::thread_rng;
13use std::collections::{HashMap, VecDeque};
14use std::fmt::Debug;
15use std::time::{Duration, Instant};
16
17/// Configuration for self-tuning optimization
18#[derive(Debug, Clone)]
19pub struct SelfTuningConfig {
20    /// Window size for performance evaluation
21    pub evaluation_window: usize,
22
23    /// Minimum improvement threshold for parameter updates
24    pub improvement_threshold: f64,
25
26    /// Maximum number of optimizer switches per epoch
27    pub max_switches_per_epoch: usize,
28
29    /// Enable automatic learning rate adjustment
30    pub auto_lr_adjustment: bool,
31
32    /// Enable automatic optimizer selection
33    pub auto_optimizer_selection: bool,
34
35    /// Enable automatic batch size tuning
36    pub auto_batch_size_tuning: bool,
37
38    /// Warmup period before starting adaptations
39    pub warmup_steps: usize,
40
41    /// Exploration probability for optimizer selection
42    pub exploration_rate: f64,
43
44    /// Decay rate for exploration
45    pub exploration_decay: f64,
46
47    /// Performance metric to optimize
48    pub target_metric: TargetMetric,
49
50    /// Minimum wall-clock time that must pass between two optimizer switches.
51    ///
52    /// Switching optimizers throws away momentum/accumulator state that has
53    /// not transferred, so back-to-back switches can cost more than they gain.
54    /// `Duration::ZERO` disables the throttle and restores step-count-only
55    /// gating.
56    pub min_adaptation_interval: Duration,
57}
58
59impl Default for SelfTuningConfig {
60    fn default() -> Self {
61        Self {
62            evaluation_window: 100,
63            improvement_threshold: 0.01,
64            max_switches_per_epoch: 3,
65            auto_lr_adjustment: true,
66            auto_optimizer_selection: true,
67            auto_batch_size_tuning: false,
68            warmup_steps: 1000,
69            exploration_rate: 0.1,
70            exploration_decay: 0.99,
71            target_metric: TargetMetric::Loss,
72            min_adaptation_interval: Duration::from_secs(1),
73        }
74    }
75}
76
77/// Target optimization metric
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub enum TargetMetric {
80    /// Minimize loss
81    Loss,
82    /// Maximize accuracy
83    Accuracy,
84    /// Minimize convergence time
85    ConvergenceTime,
86    /// Maximize training throughput
87    Throughput,
88    /// Custom metric (user-defined)
89    Custom,
90}
91
92/// Performance statistics for tracking optimization progress
93#[derive(Debug, Clone)]
94pub struct PerformanceStats {
95    /// Current loss value
96    pub loss: f64,
97
98    /// Current accuracy (if available)
99    pub accuracy: Option<f64>,
100
101    /// Gradient norm
102    pub gradient_norm: f64,
103
104    /// Training throughput (samples/second)
105    pub throughput: f64,
106
107    /// Memory usage (MB)
108    pub memory_usage: f64,
109
110    /// Wall clock time for this step
111    pub step_time: Duration,
112
113    /// Learning rate used
114    pub learning_rate: f64,
115
116    /// Optimizer type used
117    pub optimizer_type: String,
118
119    /// Custom metrics
120    pub custom_metrics: HashMap<String, f64>,
121}
122
123/// Adaptive optimizer that automatically tunes hyperparameters
124pub struct SelfTuningOptimizer<A: Float, D: Dimension> {
125    /// Configuration
126    config: SelfTuningConfig,
127
128    /// Current active optimizer
129    current_optimizer: Box<dyn OptimizerTrait<A, D>>,
130
131    /// Available optimizer candidates
132    optimizer_candidates: Vec<OptimizerCandidate<A, D>>,
133
134    /// Performance history
135    performance_history: VecDeque<PerformanceStats>,
136
137    /// Hyperparameter search state
138    search_state: HyperparameterSearchState,
139
140    /// Optimizer selection strategy
141    selection_strategy: OptimizerSelectionStrategy,
142
143    /// Index into `optimizer_candidates` of the optimizer currently running.
144    ///
145    /// Without it there was no way to attribute an observed performance back
146    /// to the candidate that produced it, which is why every candidate's
147    /// `average_reward` stayed pinned at its initial `0.0`.
148    current_candidate_idx: usize,
149
150    /// Current step count
151    step_count: usize,
152
153    /// Number of optimizer switches in current epoch
154    switches_this_epoch: usize,
155
156    /// Best performance seen so far
157    best_performance: Option<f64>,
158
159    /// Time of last adaptation
160    last_adaptation_time: Instant,
161
162    /// Exploration state for multi-armed bandit
163    bandit_state: BanditState,
164}
165
166/// Optimizer candidate with its configuration
167struct OptimizerCandidate<A: Float, D: Dimension> {
168    /// Name/identifier
169    name: String,
170
171    /// Factory function to create the optimizer
172    factory: Box<dyn Fn() -> Box<dyn OptimizerTrait<A, D>>>,
173
174    /// Rewards observed while this candidate was the active optimizer, most
175    /// recent last and bounded by the configured evaluation window.
176    performance_history: Vec<f64>,
177
178    /// Usage count
179    usage_count: usize,
180
181    /// Mean of `performance_history`, in *reward* orientation: always
182    /// higher-is-better, with loss-like target metrics negated. Selection
183    /// maximises this, so storing the raw metric would make the tuner prefer
184    /// the *worst* optimizer whenever the target metric is a loss.
185    average_reward: f64,
186
187    /// 95% confidence interval around `average_reward`, as
188    /// `(lower, upper)`. Width feeds the bandit's exploration bonus.
189    confidence_interval: (f64, f64),
190}
191
192/// Hyperparameter search state
193#[derive(Debug)]
194struct HyperparameterSearchState {
195    /// Current learning rate
196    learning_rate: f64,
197
198    /// Learning rate search bounds
199    lr_bounds: (f64, f64),
200
201    /// Current batch size
202    batch_size: usize,
203
204    /// Batch size search bounds
205    batch_size_bounds: (usize, usize),
206
207    /// Number of search iterations (reported optimization steps) folded into
208    /// the search state so far.
209    search_iterations: usize,
210
211    /// Target-metric values observed for the configurations tried so far, most
212    /// recent last, bounded to a few evaluation windows.
213    observed_metrics: Vec<f64>,
214
215    /// Best hyperparameters found: the learning rate and batch size in force
216    /// when the best target-metric value so far was observed.
217    best_hyperparameters: HashMap<String, f64>,
218}
219
220/// Public snapshot of the hyperparameter-search state.
221#[derive(Debug, Clone)]
222pub struct HyperparameterSearchSummary {
223    /// Reported optimization steps folded into the search state.
224    pub search_iterations: usize,
225    /// Number of retained target-metric observations.
226    pub observations: usize,
227    /// Learning rate currently in force.
228    pub learning_rate: f64,
229    /// Inclusive learning-rate search bounds.
230    pub lr_bounds: (f64, f64),
231    /// Batch size currently in force.
232    pub batch_size: usize,
233    /// Inclusive batch-size search bounds.
234    pub batch_size_bounds: (usize, usize),
235    /// Best configuration observed so far, keyed by hyperparameter name plus a
236    /// `target_metric` entry holding the metric value it achieved.
237    pub best_hyperparameters: HashMap<String, f64>,
238}
239
240/// Optimizer selection strategies.
241///
242/// Every variant below has a working implementation in
243/// [`SelfTuningOptimizer`]; this enum is public so a caller can actually
244/// choose between them via
245/// [`SelfTuningOptimizer::set_selection_strategy`]. It used to be private
246/// with the constructor hard-coding `MultiArmedBandit { UCB1 }`, which left
247/// three fully-implemented strategies unreachable.
248#[derive(Debug, Clone)]
249pub enum OptimizerSelectionStrategy {
250    /// Multi-armed bandit approach
251    MultiArmedBandit {
252        /// Bandit algorithm type
253        algorithm: BanditAlgorithm,
254    },
255
256    /// Performance-based selection
257    PerformanceBased {
258        /// Minimum performance difference for switching
259        min_difference: f64,
260    },
261
262    /// Round-robin testing
263    RoundRobin {
264        /// Current optimizer index
265        current_index: usize,
266    },
267
268    /// Meta-learning based selection
269    MetaLearning {
270        /// Problem characteristics
271        problem_features: Vec<f64>,
272        /// Learned optimizer mappings
273        optimizer_mappings: HashMap<String, f64>,
274    },
275}
276
277/// Multi-armed bandit algorithms.
278///
279/// All four are implemented by `select_optimizer_bandit`; public so
280/// [`OptimizerSelectionStrategy::MultiArmedBandit`] can be built with any of
281/// them.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum BanditAlgorithm {
284    /// Explore uniformly at random with probability `exploration_rate`,
285    /// otherwise take the current best reward estimate.
286    EpsilonGreedy,
287    /// Deterministic upper-confidence-bound selection (Auer et al. 2002).
288    UCB1,
289    /// Sample each arm from its estimated reward interval and take the best
290    /// draw.
291    ThompsonSampling,
292    /// Contextual UCB variant.
293    LinUCB,
294}
295
296/// Multi-armed bandit state
297#[derive(Debug)]
298struct BanditState {
299    /// Reward estimates for each optimizer
300    reward_estimates: Vec<f64>,
301
302    /// Confidence bounds
303    confidence_bounds: Vec<f64>,
304
305    /// Selection counts
306    selection_counts: Vec<usize>,
307
308    /// Total selections
309    total_selections: usize,
310
311    /// Exploration parameter
312    exploration_param: f64,
313}
314
315/// Trait for optimizer implementations that can be used with self-tuning
316pub trait OptimizerTrait<A: Float + ScalarOperand + Debug, D: Dimension>: Send + Sync {
317    /// Get optimizer name
318    fn name(&self) -> &str;
319
320    /// Perform optimization step
321    fn step(&mut self, params: &mut [Array<A, D>], grads: &[Array<A, D>]) -> Result<()>;
322
323    /// Get current learning rate
324    fn learning_rate(&self) -> A;
325
326    /// Set learning rate
327    fn set_learning_rate(&mut self, lr: A);
328
329    /// Get optimizer state for serialization
330    fn get_state(&self) -> HashMap<String, Vec<u8>>;
331
332    /// Set optimizer state from serialization
333    fn set_state(&mut self, state: HashMap<String, Vec<u8>>) -> Result<()>;
334
335    /// Clone the optimizer
336    fn clone_optimizer(&self) -> Box<dyn OptimizerTrait<A, D>>;
337}
338
339impl<
340        A: Float + ScalarOperand + Debug + Send + Sync + 'static + scirs2_core::numeric::FromPrimitive,
341        D: Dimension + 'static,
342    > SelfTuningOptimizer<A, D>
343{
344    /// Create new self-tuning optimizer
345    pub fn new(config: SelfTuningConfig) -> Result<Self> {
346        let mut optimizer_candidates = Vec::new();
347
348        // Add default optimizer candidates
349        optimizer_candidates.push(OptimizerCandidate {
350            name: "Adam".to_string(),
351            factory: Box::new(|| Box::new(AdamOptimizerWrapper::new(0.001, 0.9, 0.999, 1e-8, 0.0))),
352            performance_history: Vec::new(),
353            usage_count: 0,
354            average_reward: 0.0,
355            confidence_interval: (0.0, 0.0),
356        });
357
358        optimizer_candidates.push(OptimizerCandidate {
359            name: "SGD".to_string(),
360            factory: Box::new(|| Box::new(SGDOptimizerWrapper::new(0.01, 0.9, 0.0))),
361            performance_history: Vec::new(),
362            usage_count: 0,
363            average_reward: 0.0,
364            confidence_interval: (0.0, 0.0),
365        });
366
367        optimizer_candidates.push(OptimizerCandidate {
368            name: "AdamW".to_string(),
369            factory: Box::new(|| {
370                Box::new(AdamWOptimizerWrapper::new(0.001, 0.9, 0.999, 1e-8, 0.01))
371            }),
372            performance_history: Vec::new(),
373            usage_count: 0,
374            average_reward: 0.0,
375            confidence_interval: (0.0, 0.0),
376        });
377
378        // Start with Adam as default
379        let current_optimizer = (optimizer_candidates[0].factory)();
380
381        let search_state = HyperparameterSearchState {
382            learning_rate: 0.001,
383            lr_bounds: (1e-6, 1.0),
384            batch_size: 32,
385            batch_size_bounds: (8, 512),
386            search_iterations: 0,
387            observed_metrics: Vec::new(),
388            best_hyperparameters: HashMap::new(),
389        };
390
391        let selection_strategy = OptimizerSelectionStrategy::MultiArmedBandit {
392            algorithm: BanditAlgorithm::UCB1,
393        };
394
395        let bandit_state = BanditState {
396            reward_estimates: vec![0.0; optimizer_candidates.len()],
397            confidence_bounds: vec![1.0; optimizer_candidates.len()],
398            selection_counts: vec![0; optimizer_candidates.len()],
399            total_selections: 0,
400            exploration_param: 2.0,
401        };
402
403        Ok(Self {
404            config,
405            current_optimizer,
406            optimizer_candidates,
407            performance_history: VecDeque::new(),
408            search_state,
409            selection_strategy,
410            current_candidate_idx: 0,
411            step_count: 0,
412            switches_this_epoch: 0,
413            best_performance: None,
414            last_adaptation_time: Instant::now(),
415            bandit_state,
416        })
417    }
418
419    /// Add a custom optimizer candidate
420    pub fn add_optimizer_candidate<F>(&mut self, name: String, factory: F)
421    where
422        F: Fn() -> Box<dyn OptimizerTrait<A, D>> + 'static,
423    {
424        self.optimizer_candidates.push(OptimizerCandidate {
425            name,
426            factory: Box::new(factory),
427            performance_history: Vec::new(),
428            usage_count: 0,
429            average_reward: 0.0,
430            confidence_interval: (0.0, 0.0),
431        });
432
433        // Update bandit state
434        self.bandit_state.reward_estimates.push(0.0);
435        self.bandit_state.confidence_bounds.push(1.0);
436        self.bandit_state.selection_counts.push(0);
437    }
438
439    /// Choose how the next optimizer is selected.
440    ///
441    /// All four [`OptimizerSelectionStrategy`] variants are implemented; before
442    /// this setter existed the constructor's `MultiArmedBandit { UCB1 }` was
443    /// the only reachable one.
444    pub fn set_selection_strategy(&mut self, strategy: OptimizerSelectionStrategy) {
445        self.selection_strategy = strategy;
446    }
447
448    /// The strategy currently in force.
449    pub fn selection_strategy(&self) -> &OptimizerSelectionStrategy {
450        &self.selection_strategy
451    }
452
453    /// Perform optimization step with automatic tuning
454    pub fn step(
455        &mut self,
456        params: &mut [Array<A, D>],
457        grads: &[Array<A, D>],
458        stats: PerformanceStats,
459    ) -> Result<()> {
460        self.step_count += 1;
461
462        // Record performance
463        self.performance_history.push_back(stats.clone());
464        if self.performance_history.len() > self.config.evaluation_window {
465            self.performance_history.pop_front();
466        }
467
468        // Perform optimization step
469        self.current_optimizer.step(params, grads)?;
470
471        // Attribute this step's observation to the optimizer that produced it,
472        // *before* any adaptation can switch which optimizer is current.
473        // Without this every candidate's reward estimate stayed at its initial
474        // 0.0, so `PerformanceBased` selection ranked a constant and the bandit
475        // chose between identical arms forever; recording it after the switch
476        // would credit the incoming optimizer with the outgoing one's result,
477        // which is worse than not recording at all -- it would systematically
478        // reward whichever optimizer was switched *to* after a bad step.
479        self.record_candidate_performance(&stats);
480
481        // Self-tuning adaptations
482        if self.step_count > self.config.warmup_steps {
483            self.maybe_adapt_optimizer(&stats)?;
484            self.maybe_adapt_learning_rate(&stats)?;
485            self.maybe_adapt_hyperparameters(&stats)?;
486        }
487
488        // Update best performance
489        if let Some(performance) = self.extract_performance_metric(&stats) {
490            let improved = match self.best_performance {
491                None => true,
492                Some(best) => self.is_better_performance(performance, best),
493            };
494            if improved {
495                self.best_performance = Some(performance);
496            }
497        }
498
499        Ok(())
500    }
501
502    /// Check if we should adapt the optimizer
503    fn maybe_adapt_optimizer(&mut self, stats: &PerformanceStats) -> Result<()> {
504        if !self.config.auto_optimizer_selection {
505            return Ok(());
506        }
507
508        if self.switches_this_epoch >= self.config.max_switches_per_epoch {
509            return Ok(());
510        }
511
512        // Respect the cool-down since the last switch. `last_adaptation_time`
513        // was recorded but never consulted, so the only limit on switching was
514        // the per-epoch count.
515        if self.switches_this_epoch > 0
516            && self.last_adaptation_time.elapsed() < self.config.min_adaptation_interval
517        {
518            return Ok(());
519        }
520
521        let should_adapt = self.should_adapt_optimizer(stats);
522
523        if should_adapt {
524            self.adapt_optimizer(stats)?;
525            self.switches_this_epoch += 1;
526        }
527
528        Ok(())
529    }
530
531    /// Determine if optimizer should be adapted
532    fn should_adapt_optimizer(&self, stats: &PerformanceStats) -> bool {
533        if self.performance_history.len() < self.config.evaluation_window / 2 {
534            return false;
535        }
536
537        // Check for performance degradation or stagnation. The freshest
538        // observation is the `stats` just reported, which is not yet in
539        // `performance_history`; including it is what makes this decision react
540        // to the current step rather than lagging a full window behind it.
541        let mut recent_performance: Vec<f64> = self
542            .performance_history
543            .iter()
544            .rev()
545            .take(self.config.evaluation_window / 4)
546            .filter_map(|s| self.extract_performance_metric(s))
547            .collect();
548        if let Some(current) = self.extract_performance_metric(stats) {
549            recent_performance.insert(0, current);
550        }
551
552        let older_performance: Vec<f64> = self
553            .performance_history
554            .iter()
555            .rev()
556            .skip(self.config.evaluation_window / 4)
557            .take(self.config.evaluation_window / 4)
558            .filter_map(|s| self.extract_performance_metric(s))
559            .collect();
560
561        if recent_performance.is_empty() || older_performance.is_empty() {
562            return false;
563        }
564
565        let recent_avg = recent_performance.iter().sum::<f64>() / recent_performance.len() as f64;
566        let older_avg = older_performance.iter().sum::<f64>() / older_performance.len() as f64;
567
568        // Check for stagnation or degradation
569        match self.config.target_metric {
570            TargetMetric::Loss => {
571                (recent_avg - older_avg).abs() < self.config.improvement_threshold
572                    || recent_avg > older_avg
573            }
574            TargetMetric::Accuracy | TargetMetric::Throughput => {
575                (recent_avg - older_avg).abs() < self.config.improvement_threshold
576                    || recent_avg < older_avg
577            }
578            _ => false,
579        }
580    }
581
582    /// Adapt the optimizer based on performance
583    fn adapt_optimizer(&mut self, stats: &PerformanceStats) -> Result<()> {
584        let new_optimizer_idx = match &self.selection_strategy {
585            OptimizerSelectionStrategy::MultiArmedBandit { algorithm } => {
586                self.select_optimizer_bandit(*algorithm)
587            }
588            OptimizerSelectionStrategy::PerformanceBased { .. } => {
589                self.select_optimizer_performance_based()
590            }
591            // Advance from the optimizer that is actually running. The
592            // strategy's own `current_index` was never written back, so this
593            // used to return the same successor on every call and round-robin
594            // never got past the second candidate.
595            OptimizerSelectionStrategy::RoundRobin { .. } => {
596                (self.current_candidate_idx + 1) % self.optimizer_candidates.len()
597            }
598            OptimizerSelectionStrategy::MetaLearning { .. } => {
599                self.select_optimizer_meta_learning(stats)
600            }
601        };
602
603        // Switch to new optimizer
604        if new_optimizer_idx < self.optimizer_candidates.len() {
605            let current_lr = self.current_optimizer.learning_rate();
606            let current_state = self.current_optimizer.get_state();
607
608            self.current_optimizer = (self.optimizer_candidates[new_optimizer_idx].factory)();
609            self.current_optimizer.set_learning_rate(current_lr);
610
611            // Try to transfer compatible state
612            if self.current_optimizer.set_state(current_state).is_err() {
613                // State transfer failed, continue with fresh state
614            }
615
616            // Update usage statistics
617            self.optimizer_candidates[new_optimizer_idx].usage_count += 1;
618            self.current_candidate_idx = new_optimizer_idx;
619            self.last_adaptation_time = Instant::now();
620            // Keep the strategy's own cursor in step with reality so
621            // `selection_strategy()` reports where the rotation actually is.
622            if let OptimizerSelectionStrategy::RoundRobin { current_index } =
623                &mut self.selection_strategy
624            {
625                *current_index = new_optimizer_idx;
626            }
627        }
628
629        Ok(())
630    }
631
632    /// Select optimizer using multi-armed bandit
633    fn select_optimizer_bandit(&mut self, algorithm: BanditAlgorithm) -> usize {
634        match algorithm {
635            BanditAlgorithm::UCB1 => self.select_ucb1(),
636            BanditAlgorithm::EpsilonGreedy => self.select_epsilon_greedy(),
637            BanditAlgorithm::ThompsonSampling => self.select_thompson_sampling(),
638            BanditAlgorithm::LinUCB => self.select_linucb(),
639        }
640    }
641
642    /// UCB1 optimizer selection
643    fn select_ucb1(&self) -> usize {
644        if self.bandit_state.total_selections == 0 {
645            return 0;
646        }
647
648        let mut best_score = f64::NEG_INFINITY;
649        let mut best_idx = 0;
650
651        for i in 0..self.optimizer_candidates.len() {
652            let ucb_score = if self.bandit_state.selection_counts[i] == 0 {
653                f64::INFINITY
654            } else {
655                let mean_reward = self.bandit_state.reward_estimates[i];
656                let confidence = (self.bandit_state.exploration_param
657                    * (self.bandit_state.total_selections as f64).ln()
658                    / self.bandit_state.selection_counts[i] as f64)
659                    .sqrt();
660                mean_reward + confidence
661            };
662
663            if ucb_score > best_score {
664                best_score = ucb_score;
665                best_idx = i;
666            }
667        }
668
669        best_idx
670    }
671
672    /// Epsilon-greedy optimizer selection
673    fn select_epsilon_greedy(&self) -> usize {
674        let mut rng = thread_rng();
675
676        if scalar_or(rng.random::<f64>(), A::zero())
677            < scalar_or(self.config.exploration_rate, A::zero())
678        {
679            // Explore: random selection
680            rng.gen_range(0..self.optimizer_candidates.len())
681        } else {
682            // Exploit: best performing optimizer
683            self.bandit_state
684                .reward_estimates
685                .iter()
686                .enumerate()
687                .max_by(|a, b| total_order(a.1, b.1))
688                .map(|(idx, _)| idx)
689                .unwrap_or(0)
690        }
691    }
692
693    /// Thompson sampling optimizer selection
694    fn select_thompson_sampling(&self) -> usize {
695        // Simplified Thompson sampling - in practice would use Beta distributions
696        let mut rng = thread_rng();
697
698        let mut best_sample = f64::NEG_INFINITY;
699        let mut best_idx = 0;
700
701        for (i, _) in self.optimizer_candidates.iter().enumerate() {
702            let mean = self.bandit_state.reward_estimates[i];
703            let std = self.bandit_state.confidence_bounds[i];
704            let sample = rng.gen_range(mean - std..mean + std);
705
706            if sample > best_sample {
707                best_sample = sample;
708                best_idx = i;
709            }
710        }
711
712        best_idx
713    }
714
715    /// LinUCB optimizer selection (contextual bandit)
716    fn select_linucb(&self) -> usize {
717        // Simplified LinUCB - would use contextual features in practice
718        self.select_ucb1()
719    }
720
721    /// Performance-based optimizer selection
722    fn select_optimizer_performance_based(&self) -> usize {
723        self.optimizer_candidates
724            .iter()
725            .enumerate()
726            .max_by(|a, b| total_order(&a.1.average_reward, &b.1.average_reward))
727            .map(|(idx, _)| idx)
728            .unwrap_or(0)
729    }
730
731    /// Feature vector describing the current optimization problem, in the same
732    /// order as `OptimizerSelectionStrategy::MetaLearning::problem_features`:
733    /// `[loss, gradient_norm, throughput, memory_usage, learning_rate]`.
734    fn problem_feature_vector(stats: &PerformanceStats) -> [f64; 5] {
735        [
736            stats.loss,
737            stats.gradient_norm,
738            stats.throughput,
739            stats.memory_usage,
740            stats.learning_rate,
741        ]
742    }
743
744    /// Meta-learning based optimizer selection.
745    ///
746    /// The strategy carries a learned `optimizer_mappings` table (optimizer name
747    /// to expected quality) that was fitted on a problem described by
748    /// `problem_features`. That table is only trustworthy for problems that
749    /// resemble the one it was fitted on, so the current problem's features are
750    /// compared to the stored ones by cosine similarity and the learned ranking
751    /// is used only above a similarity threshold; otherwise selection falls back
752    /// to the measured `average_reward` of each candidate.
753    ///
754    /// This replaces a stub that ignored `stats` and unconditionally returned
755    /// candidate 0, which made `MetaLearning` silently equivalent to "never
756    /// switch away from the first optimizer".
757    fn select_optimizer_meta_learning(&self, stats: &PerformanceStats) -> usize {
758        /// Minimum cosine similarity between the current problem and the one the
759        /// mappings were learned on before the learned ranking is trusted.
760        const SIMILARITY_THRESHOLD: f64 = 0.9;
761
762        let OptimizerSelectionStrategy::MetaLearning {
763            problem_features,
764            optimizer_mappings,
765        } = &self.selection_strategy
766        else {
767            return self.select_optimizer_performance_based();
768        };
769        if optimizer_mappings.is_empty() {
770            return self.select_optimizer_performance_based();
771        }
772
773        let current = Self::problem_feature_vector(stats);
774        let shared = problem_features.len().min(current.len());
775        let (mut dot, mut norm_stored, mut norm_current) = (0.0, 0.0, 0.0);
776        for i in 0..shared {
777            dot += problem_features[i] * current[i];
778            norm_stored += problem_features[i] * problem_features[i];
779            norm_current += current[i] * current[i];
780        }
781        let similarity = if norm_stored > 0.0 && norm_current > 0.0 {
782            dot / (norm_stored.sqrt() * norm_current.sqrt())
783        } else {
784            // No usable feature vector on one side: the mappings cannot be
785            // shown to apply here.
786            0.0
787        };
788        if similarity < SIMILARITY_THRESHOLD {
789            return self.select_optimizer_performance_based();
790        }
791
792        self.optimizer_candidates
793            .iter()
794            .enumerate()
795            .filter_map(|(idx, candidate)| {
796                optimizer_mappings
797                    .get(&candidate.name)
798                    .map(|score| (idx, *score))
799            })
800            .max_by(|a, b| total_order(&a.1, &b.1))
801            .map(|(idx, _)| idx)
802            .unwrap_or_else(|| self.select_optimizer_performance_based())
803    }
804
805    /// Adapt learning rate based on performance
806    fn maybe_adapt_learning_rate(&mut self, stats: &PerformanceStats) -> Result<()> {
807        if !self.config.auto_lr_adjustment {
808            return Ok(());
809        }
810
811        // Simple adaptive learning rate based on gradient norm
812        let current_lr = try_f64(self.current_optimizer.learning_rate())?;
813        let gradient_norm = stats.gradient_norm;
814
815        let new_lr = if gradient_norm > 10.0 {
816            // Large gradients - reduce learning rate
817            current_lr * 0.9
818        } else if gradient_norm < 0.1 {
819            // Small gradients - increase learning rate
820            current_lr * 1.1
821        } else {
822            current_lr
823        };
824
825        let clamped_lr = new_lr
826            .max(self.search_state.lr_bounds.0)
827            .min(self.search_state.lr_bounds.1);
828
829        if (clamped_lr - current_lr).abs() > current_lr * 0.01 {
830            self.current_optimizer
831                .set_learning_rate(try_scalar::<A, _>(clamped_lr)?);
832            self.search_state.learning_rate = clamped_lr;
833        }
834
835        Ok(())
836    }
837
838    /// Record the reported statistics against the active hyperparameter search
839    /// trial.
840    ///
841    /// Only the *observation* half of hyperparameter search is implemented here:
842    /// the metric for the current configuration is appended to the search
843    /// state's history so a search driver has real data to work from. Proposing
844    /// the next configuration (Bayesian optimization / grid / successive
845    /// halving) is not implemented — see `HyperparameterSearchState` and the
846    /// unimplemented `SearchStrategy` variants — so nothing is mutated behind
847    /// the caller's back and no configuration change is fabricated.
848    fn maybe_adapt_hyperparameters(&mut self, stats: &PerformanceStats) -> Result<()> {
849        let Some(metric) = self.extract_performance_metric(stats) else {
850            return Ok(());
851        };
852
853        let improved = match self.best_observed_metric() {
854            Some(best) => self.metric_is_better(metric, best),
855            None => true,
856        };
857
858        self.search_state.observed_metrics.push(metric);
859        let cap = self.config.evaluation_window.max(1) * 4;
860        if self.search_state.observed_metrics.len() > cap {
861            self.search_state.observed_metrics.remove(0);
862        }
863        self.search_state.search_iterations += 1;
864
865        if improved {
866            self.search_state
867                .best_hyperparameters
868                .insert("learning_rate".to_string(), self.search_state.learning_rate);
869            self.search_state.best_hyperparameters.insert(
870                "batch_size".to_string(),
871                self.search_state.batch_size as f64,
872            );
873            self.search_state
874                .best_hyperparameters
875                .insert("target_metric".to_string(), metric);
876        }
877
878        Ok(())
879    }
880
881    /// Whether `candidate` is a better target-metric value than `incumbent`,
882    /// respecting the configured metric's direction.
883    fn metric_is_better(&self, candidate: f64, incumbent: f64) -> bool {
884        match self.config.target_metric {
885            TargetMetric::Loss => candidate < incumbent,
886            _ => candidate > incumbent,
887        }
888    }
889
890    /// Best target-metric value recorded by the hyperparameter search so far.
891    fn best_observed_metric(&self) -> Option<f64> {
892        self.search_state
893            .best_hyperparameters
894            .get("target_metric")
895            .copied()
896    }
897
898    /// Snapshot of the hyperparameter-search state: the number of reported
899    /// steps folded in, the best configuration observed, and the batch-size
900    /// search bounds the (not yet implemented) proposal step would respect.
901    pub fn hyperparameter_search_summary(&self) -> HyperparameterSearchSummary {
902        HyperparameterSearchSummary {
903            search_iterations: self.search_state.search_iterations,
904            observations: self.search_state.observed_metrics.len(),
905            learning_rate: self.search_state.learning_rate,
906            lr_bounds: self.search_state.lr_bounds,
907            batch_size: self.search_state.batch_size,
908            batch_size_bounds: self.search_state.batch_size_bounds,
909            best_hyperparameters: self.search_state.best_hyperparameters.clone(),
910        }
911    }
912
913    /// Extract performance metric from stats
914    fn extract_performance_metric(&self, stats: &PerformanceStats) -> Option<f64> {
915        match self.config.target_metric {
916            TargetMetric::Loss => Some(stats.loss),
917            TargetMetric::Accuracy => stats.accuracy,
918            TargetMetric::Throughput => Some(stats.throughput),
919            TargetMetric::ConvergenceTime => Some(stats.step_time.as_secs_f64()),
920            TargetMetric::Custom => stats.custom_metrics.values().next().copied(),
921        }
922    }
923
924    /// Whether the target metric is one where a *smaller* value is better.
925    fn lower_is_better(&self) -> bool {
926        matches!(
927            self.config.target_metric,
928            TargetMetric::Loss | TargetMetric::ConvergenceTime
929        )
930    }
931
932    /// The observed metric expressed as a reward: always higher-is-better, so
933    /// that every selection rule can simply maximise it.
934    fn reward_from_metric(&self, metric: f64) -> f64 {
935        if self.lower_is_better() {
936            -metric
937        } else {
938            metric
939        }
940    }
941
942    /// Fold this step's observation into the active candidate's statistics and
943    /// the bandit's estimate of that arm.
944    ///
945    /// This is the update that makes `performance_history`, `average_reward`,
946    /// `confidence_interval`, `reward_estimates` and `confidence_bounds`
947    /// carry real measurements instead of their initial constants.
948    fn record_candidate_performance(&mut self, stats: &PerformanceStats) {
949        let Some(metric) = self.extract_performance_metric(stats) else {
950            return;
951        };
952        if !metric.is_finite() {
953            return;
954        }
955        let reward = self.reward_from_metric(metric);
956
957        let window = self.config.evaluation_window.max(1);
958        let idx = self.current_candidate_idx;
959        let Some(candidate) = self.optimizer_candidates.get_mut(idx) else {
960            return;
961        };
962
963        candidate.performance_history.push(reward);
964        if candidate.performance_history.len() > window {
965            let excess = candidate.performance_history.len() - window;
966            candidate.performance_history.drain(0..excess);
967        }
968
969        let samples = candidate.performance_history.len();
970        let count = samples as f64;
971        let mean = candidate.performance_history.iter().sum::<f64>() / count;
972        // Sample standard error; a single observation has no spread to report,
973        // so its interval is a point.
974        let half_width = if samples > 1 {
975            let variance = candidate
976                .performance_history
977                .iter()
978                .map(|&r| (r - mean) * (r - mean))
979                .sum::<f64>()
980                / (count - 1.0);
981            1.96 * (variance / count).sqrt()
982        } else {
983            0.0
984        };
985
986        candidate.average_reward = mean;
987        candidate.confidence_interval = (mean - half_width, mean + half_width);
988
989        // Mirror into the bandit arms, which select on exactly these numbers.
990        if let Some(estimate) = self.bandit_state.reward_estimates.get_mut(idx) {
991            *estimate = mean;
992        }
993        if let Some(bound) = self.bandit_state.confidence_bounds.get_mut(idx) {
994            // A never-measured arm keeps its optimistic initial bound so it
995            // still gets explored; a measured one reports its real spread.
996            *bound = if samples > 1 { half_width } else { 1.0 };
997        }
998    }
999
1000    /// Check if performance is better
1001    fn is_better_performance(&self, new_perf: f64, oldperf: f64) -> bool {
1002        match self.config.target_metric {
1003            TargetMetric::Loss | TargetMetric::ConvergenceTime => new_perf < oldperf,
1004            TargetMetric::Accuracy | TargetMetric::Throughput => new_perf > oldperf,
1005            TargetMetric::Custom => new_perf > oldperf, // Assume higher is better for custom
1006        }
1007    }
1008
1009    /// Reset epoch counters
1010    pub fn reset_epoch(&mut self) {
1011        self.switches_this_epoch = 0;
1012    }
1013
1014    /// Get current optimizer information
1015    pub fn get_optimizer_info(&self) -> OptimizerInfo {
1016        OptimizerInfo {
1017            name: self.current_optimizer.name().to_string(),
1018            // A learning rate with no `f64` image cannot be reported; `NaN`
1019            // marks it as unavailable rather than panicking an info getter.
1020            learning_rate: try_f64(self.current_optimizer.learning_rate()).unwrap_or(f64::NAN),
1021            step_count: self.step_count,
1022            switches_this_epoch: self.switches_this_epoch,
1023            performance_window_size: self.performance_history.len(),
1024            best_performance: self.best_performance,
1025        }
1026    }
1027
1028    /// Get optimization statistics
1029    pub fn get_statistics(&self) -> SelfTuningStatistics {
1030        let optimizer_usage: HashMap<String, usize> = self
1031            .optimizer_candidates
1032            .iter()
1033            .map(|c| (c.name.clone(), c.usage_count))
1034            .collect();
1035
1036        SelfTuningStatistics {
1037            total_steps: self.step_count,
1038            total_optimizer_switches: self
1039                .optimizer_candidates
1040                .iter()
1041                .map(|c| c.usage_count)
1042                .sum(),
1043            optimizer_usage,
1044            current_learning_rate: self.search_state.learning_rate,
1045            average_step_time: self
1046                .performance_history
1047                .iter()
1048                .map(|s| s.step_time.as_secs_f64())
1049                .sum::<f64>()
1050                / self.performance_history.len().max(1) as f64,
1051            exploration_rate: self.config.exploration_rate,
1052        }
1053    }
1054}
1055
1056/// Information about current optimizer state
1057#[derive(Debug, Clone)]
1058pub struct OptimizerInfo {
1059    pub name: String,
1060    pub learning_rate: f64,
1061    pub step_count: usize,
1062    pub switches_this_epoch: usize,
1063    pub performance_window_size: usize,
1064    pub best_performance: Option<f64>,
1065}
1066
1067/// Statistics about self-tuning optimization
1068#[derive(Debug, Clone)]
1069pub struct SelfTuningStatistics {
1070    pub total_steps: usize,
1071    pub total_optimizer_switches: usize,
1072    pub optimizer_usage: HashMap<String, usize>,
1073    pub current_learning_rate: f64,
1074    pub average_step_time: f64,
1075    pub exploration_rate: f64,
1076}
1077
1078// Wrapper implementations for existing optimizers
1079struct AdamOptimizerWrapper<A: Float + ScalarOperand + Debug, D: Dimension> {
1080    inner: crate::optimizers::Adam<A>,
1081    _phantom: std::marker::PhantomData<D>,
1082}
1083
1084impl<A: Float + ScalarOperand + Debug + Send + Sync, D: Dimension + Send + Sync>
1085    AdamOptimizerWrapper<A, D>
1086{
1087    fn new(_lr: f64, beta1: f64, beta2: f64, eps: f64, weightdecay: f64) -> Self {
1088        Self {
1089            inner: crate::optimizers::Adam::new_with_config(
1090                scalar_or(_lr, A::zero()),
1091                scalar_or(beta1, A::zero()),
1092                scalar_or(beta2, A::zero()),
1093                scalar_or(eps, A::zero()),
1094                scalar_or(weightdecay, A::zero()),
1095            ),
1096            _phantom: std::marker::PhantomData,
1097        }
1098    }
1099}
1100
1101impl<A: Float + ScalarOperand + Debug + Send + Sync + 'static, D: Dimension + 'static>
1102    OptimizerTrait<A, D> for AdamOptimizerWrapper<A, D>
1103{
1104    fn name(&self) -> &str {
1105        "Adam"
1106    }
1107
1108    fn step(&mut self, params: &mut [Array<A, D>], grads: &[Array<A, D>]) -> Result<()> {
1109        if params.len() != grads.len() {
1110            return Err(crate::error::OptimError::InvalidParameter(
1111                "Mismatched number of parameters and gradients".to_string(),
1112            ));
1113        }
1114
1115        for (param, grad) in params.iter_mut().zip(grads.iter()) {
1116            let updated = self.inner.step(param, grad)?;
1117            *param = updated;
1118        }
1119        Ok(())
1120    }
1121
1122    fn learning_rate(&self) -> A {
1123        self.inner.learning_rate()
1124    }
1125
1126    fn set_learning_rate(&mut self, lr: A) {
1127        <crate::optimizers::Adam<A> as crate::optimizers::Optimizer<A, D>>::set_learning_rate(
1128            &mut self.inner,
1129            lr,
1130        );
1131    }
1132
1133    /// Returns an empty map: the wrapped optimizer does not expose its internal
1134    /// moment/accumulator arrays, so there is genuinely nothing to serialize.
1135    /// This is reported honestly rather than emitting a partial snapshot that
1136    /// would silently lose state on restore.
1137    fn get_state(&self) -> HashMap<String, Vec<u8>> {
1138        HashMap::new()
1139    }
1140
1141    fn set_state(&mut self, state: HashMap<String, Vec<u8>>) -> Result<()> {
1142        if state.is_empty() {
1143            return Ok(());
1144        }
1145        Err(OptimError::UnsupportedOperation(format!(
1146            "{} does not expose serializable moment state, so a {}-entry state \
1147             snapshot cannot be restored; the optimizer starts from a fresh state",
1148            self.name(),
1149            state.len()
1150        )))
1151    }
1152
1153    fn clone_optimizer(&self) -> Box<dyn OptimizerTrait<A, D>> {
1154        Box::new(AdamOptimizerWrapper {
1155            inner: self.inner.clone(),
1156            _phantom: std::marker::PhantomData,
1157        })
1158    }
1159}
1160
1161struct SGDOptimizerWrapper<A: Float + ScalarOperand + Debug, D: Dimension> {
1162    inner: crate::optimizers::SGD<A>,
1163    _phantom: std::marker::PhantomData<D>,
1164}
1165
1166impl<A: Float + ScalarOperand + Debug + Send + Sync, D: Dimension + Send + Sync>
1167    SGDOptimizerWrapper<A, D>
1168{
1169    /// Build an SGD wrapper.
1170    ///
1171    /// There is deliberately no `nesterov` parameter: `crate::optimizers::SGD`
1172    /// implements classical (heavy-ball) momentum only, with no Nesterov
1173    /// look-ahead term, so a flag here could not be honoured. It previously
1174    /// accepted `nesterov: bool` and discarded it, which advertised support
1175    /// that does not exist.
1176    fn new(lr: f64, momentum: f64, weightdecay: f64) -> Self {
1177        Self {
1178            inner: crate::optimizers::SGD::new_with_config(
1179                scalar_or(lr, A::zero()),
1180                scalar_or(momentum, A::zero()),
1181                scalar_or(weightdecay, A::zero()),
1182            ),
1183            _phantom: std::marker::PhantomData,
1184        }
1185    }
1186}
1187
1188impl<A: Float + ScalarOperand + Debug + Send + Sync + 'static, D: Dimension + 'static>
1189    OptimizerTrait<A, D> for SGDOptimizerWrapper<A, D>
1190{
1191    fn name(&self) -> &str {
1192        "SGD"
1193    }
1194
1195    fn step(&mut self, params: &mut [Array<A, D>], grads: &[Array<A, D>]) -> Result<()> {
1196        if params.len() != grads.len() {
1197            return Err(crate::error::OptimError::InvalidParameter(
1198                "Mismatched number of parameters and gradients".to_string(),
1199            ));
1200        }
1201
1202        for (param, grad) in params.iter_mut().zip(grads.iter()) {
1203            let updated = self.inner.step(param, grad)?;
1204            *param = updated;
1205        }
1206        Ok(())
1207    }
1208
1209    fn learning_rate(&self) -> A {
1210        self.inner.learning_rate()
1211    }
1212
1213    fn set_learning_rate(&mut self, lr: A) {
1214        <crate::optimizers::SGD<A> as crate::optimizers::Optimizer<A, D>>::set_learning_rate(
1215            &mut self.inner,
1216            lr,
1217        );
1218    }
1219
1220    /// Returns an empty map: the wrapped optimizer does not expose its internal
1221    /// moment/accumulator arrays, so there is genuinely nothing to serialize.
1222    /// This is reported honestly rather than emitting a partial snapshot that
1223    /// would silently lose state on restore.
1224    fn get_state(&self) -> HashMap<String, Vec<u8>> {
1225        HashMap::new()
1226    }
1227
1228    fn set_state(&mut self, state: HashMap<String, Vec<u8>>) -> Result<()> {
1229        if state.is_empty() {
1230            return Ok(());
1231        }
1232        Err(OptimError::UnsupportedOperation(format!(
1233            "{} does not expose serializable moment state, so a {}-entry state \
1234             snapshot cannot be restored; the optimizer starts from a fresh state",
1235            self.name(),
1236            state.len()
1237        )))
1238    }
1239
1240    fn clone_optimizer(&self) -> Box<dyn OptimizerTrait<A, D>> {
1241        Box::new(SGDOptimizerWrapper {
1242            inner: self.inner.clone(),
1243            _phantom: std::marker::PhantomData,
1244        })
1245    }
1246}
1247
1248struct AdamWOptimizerWrapper<A: Float + ScalarOperand + Debug, D: Dimension> {
1249    inner: crate::optimizers::AdamW<A>,
1250    _phantom: std::marker::PhantomData<D>,
1251}
1252
1253impl<A: Float + ScalarOperand + Debug + Send + Sync, D: Dimension + Send + Sync>
1254    AdamWOptimizerWrapper<A, D>
1255{
1256    fn new(_lr: f64, beta1: f64, beta2: f64, eps: f64, weightdecay: f64) -> Self {
1257        Self {
1258            inner: crate::optimizers::AdamW::new_with_config(
1259                scalar_or(_lr, A::zero()),
1260                scalar_or(beta1, A::zero()),
1261                scalar_or(beta2, A::zero()),
1262                scalar_or(eps, A::zero()),
1263                scalar_or(weightdecay, A::zero()),
1264            ),
1265            _phantom: std::marker::PhantomData,
1266        }
1267    }
1268}
1269
1270impl<A: Float + ScalarOperand + Debug + Send + Sync + 'static, D: Dimension + 'static>
1271    OptimizerTrait<A, D> for AdamWOptimizerWrapper<A, D>
1272{
1273    fn name(&self) -> &str {
1274        "AdamW"
1275    }
1276
1277    fn step(&mut self, params: &mut [Array<A, D>], grads: &[Array<A, D>]) -> Result<()> {
1278        if params.len() != grads.len() {
1279            return Err(crate::error::OptimError::InvalidParameter(
1280                "Mismatched number of parameters and gradients".to_string(),
1281            ));
1282        }
1283
1284        for (param, grad) in params.iter_mut().zip(grads.iter()) {
1285            let updated = self.inner.step(param, grad)?;
1286            *param = updated;
1287        }
1288        Ok(())
1289    }
1290
1291    fn learning_rate(&self) -> A {
1292        self.inner.learning_rate()
1293    }
1294
1295    fn set_learning_rate(&mut self, lr: A) {
1296        <crate::optimizers::AdamW<A> as crate::optimizers::Optimizer<A, D>>::set_learning_rate(
1297            &mut self.inner,
1298            lr,
1299        );
1300    }
1301
1302    /// Returns an empty map: the wrapped optimizer does not expose its internal
1303    /// moment/accumulator arrays, so there is genuinely nothing to serialize.
1304    /// This is reported honestly rather than emitting a partial snapshot that
1305    /// would silently lose state on restore.
1306    fn get_state(&self) -> HashMap<String, Vec<u8>> {
1307        HashMap::new()
1308    }
1309
1310    fn set_state(&mut self, state: HashMap<String, Vec<u8>>) -> Result<()> {
1311        if state.is_empty() {
1312            return Ok(());
1313        }
1314        Err(OptimError::UnsupportedOperation(format!(
1315            "{} does not expose serializable moment state, so a {}-entry state \
1316             snapshot cannot be restored; the optimizer starts from a fresh state",
1317            self.name(),
1318            state.len()
1319        )))
1320    }
1321
1322    fn clone_optimizer(&self) -> Box<dyn OptimizerTrait<A, D>> {
1323        Box::new(AdamWOptimizerWrapper {
1324            inner: self.inner.clone(),
1325            _phantom: std::marker::PhantomData,
1326        })
1327    }
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332    use super::*;
1333    use scirs2_core::ndarray::Array1;
1334    use std::time::Duration;
1335
1336    #[test]
1337    fn test_self_tuning_config_default() {
1338        let config = SelfTuningConfig::default();
1339        assert_eq!(config.evaluation_window, 100);
1340        assert!(config.auto_lr_adjustment);
1341        assert!(config.auto_optimizer_selection);
1342    }
1343
1344    #[test]
1345    fn test_self_tuning_optimizer_creation() {
1346        let config = SelfTuningConfig::default();
1347        let optimizer: Result<SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1>> =
1348            SelfTuningOptimizer::new(config);
1349        assert!(optimizer.is_ok());
1350    }
1351
1352    #[test]
1353    fn test_performance_stats() {
1354        let stats = PerformanceStats {
1355            loss: 0.5,
1356            accuracy: Some(0.9),
1357            gradient_norm: 1.2,
1358            throughput: 100.0,
1359            memory_usage: 1024.0,
1360            step_time: Duration::from_millis(50),
1361            learning_rate: 0.001,
1362            optimizer_type: "Adam".to_string(),
1363            custom_metrics: HashMap::new(),
1364        };
1365
1366        assert_eq!(stats.loss, 0.5);
1367        assert_eq!(stats.accuracy, Some(0.9));
1368    }
1369
1370    #[test]
1371    fn test_optimizer_step() {
1372        let config = SelfTuningConfig::default();
1373        let mut optimizer: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1374            SelfTuningOptimizer::new(config).expect("default config must construct");
1375
1376        let mut params = vec![Array1::zeros(10)];
1377        let grads = vec![Array1::ones(10)];
1378
1379        let stats = PerformanceStats {
1380            loss: 1.0,
1381            accuracy: None,
1382            gradient_norm: 1.0,
1383            throughput: 50.0,
1384            memory_usage: 512.0,
1385            step_time: Duration::from_millis(10),
1386            learning_rate: 0.001,
1387            optimizer_type: "Adam".to_string(),
1388            custom_metrics: HashMap::new(),
1389        };
1390
1391        let result = optimizer.step(&mut params, &grads, stats);
1392        assert!(result.is_ok());
1393
1394        let info = optimizer.get_optimizer_info();
1395        assert_eq!(info.name, "Adam");
1396        assert_eq!(info.step_count, 1);
1397    }
1398
1399    #[test]
1400    fn test_bandit_selection() {
1401        let config = SelfTuningConfig::default();
1402        let optimizer: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1403            SelfTuningOptimizer::new(config).expect("default config must construct");
1404
1405        let selection = optimizer.select_ucb1();
1406        assert!(selection < optimizer.optimizer_candidates.len());
1407    }
1408
1409    #[test]
1410    fn test_performance_metric_extraction() {
1411        let config = SelfTuningConfig {
1412            target_metric: TargetMetric::Loss,
1413            ..Default::default()
1414        };
1415        let optimizer: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1416            SelfTuningOptimizer::new(config).expect("default config must construct");
1417
1418        let stats = PerformanceStats {
1419            loss: 0.8,
1420            accuracy: Some(0.85),
1421            gradient_norm: 1.1,
1422            throughput: 75.0,
1423            memory_usage: 800.0,
1424            step_time: Duration::from_millis(20),
1425            learning_rate: 0.001,
1426            optimizer_type: "Adam".to_string(),
1427            custom_metrics: HashMap::new(),
1428        };
1429
1430        let metric = optimizer.extract_performance_metric(&stats);
1431        assert_eq!(metric, Some(0.8));
1432    }
1433
1434    #[test]
1435    fn test_statistics() {
1436        let config = SelfTuningConfig::default();
1437        let optimizer: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1438            SelfTuningOptimizer::new(config).expect("default config must construct");
1439
1440        let stats = optimizer.get_statistics();
1441        assert_eq!(stats.total_steps, 0);
1442        assert!(stats.optimizer_usage.contains_key("Adam"));
1443    }
1444    // ------------------------------------------------- candidate accounting --
1445
1446    fn stats_with_loss(loss: f64) -> PerformanceStats {
1447        PerformanceStats {
1448            loss,
1449            accuracy: None,
1450            gradient_norm: 1.0,
1451            throughput: 50.0,
1452            memory_usage: 512.0,
1453            step_time: Duration::from_millis(10),
1454            learning_rate: 0.001,
1455            optimizer_type: "Adam".to_string(),
1456            custom_metrics: HashMap::new(),
1457        }
1458    }
1459
1460    /// The active candidate's reward statistics must track the observations
1461    /// that were actually reported. They used to be pinned at their initial
1462    /// `0.0` forever, which made every selection rule rank a constant.
1463    #[test]
1464    fn observed_performance_reaches_the_active_candidate() {
1465        let mut optimizer: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1466            SelfTuningOptimizer::new(SelfTuningConfig::default())
1467                .expect("default config must construct");
1468        let mut params = vec![Array1::zeros(4)];
1469        let grads = vec![Array1::ones(4)];
1470
1471        for loss in [1.0, 0.8, 0.6, 0.4] {
1472            optimizer
1473                .step(&mut params, &grads, stats_with_loss(loss))
1474                .expect("step");
1475        }
1476
1477        let active = &optimizer.optimizer_candidates[optimizer.current_candidate_idx];
1478        assert_eq!(
1479            active.performance_history.len(),
1480            4,
1481            "every reported observation must be attributed to the active candidate"
1482        );
1483        // Target metric is Loss (lower is better), so rewards are negated:
1484        // mean of -1.0, -0.8, -0.6, -0.4 is -0.7.
1485        assert!(
1486            (active.average_reward - (-0.7)).abs() < 1e-12,
1487            "average reward must be the mean of the negated losses, got {}",
1488            active.average_reward
1489        );
1490        assert_ne!(
1491            active.average_reward, 0.0,
1492            "regression: candidate statistics are still frozen at their initial 0.0"
1493        );
1494        let (lo, hi) = active.confidence_interval;
1495        assert!(
1496            lo < active.average_reward && active.average_reward < hi,
1497            "the confidence interval must bracket the mean, got ({lo}, {hi})"
1498        );
1499        assert!(
1500            (optimizer.bandit_state.reward_estimates[optimizer.current_candidate_idx] - (-0.7))
1501                .abs()
1502                < 1e-12,
1503            "the bandit arm must see the same estimate as the candidate"
1504        );
1505    }
1506
1507    /// Rewards must be higher-is-better regardless of the target metric,
1508    /// otherwise `PerformanceBased` selection would prefer the *worst*
1509    /// optimizer whenever the target metric is a loss.
1510    #[test]
1511    fn reward_orientation_follows_the_target_metric() {
1512        let loss_tuner: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1513            SelfTuningOptimizer::new(SelfTuningConfig {
1514                target_metric: TargetMetric::Loss,
1515                ..Default::default()
1516            })
1517            .expect("construct");
1518        assert_eq!(loss_tuner.reward_from_metric(0.3), -0.3);
1519
1520        let acc_tuner: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1521            SelfTuningOptimizer::new(SelfTuningConfig {
1522                target_metric: TargetMetric::Accuracy,
1523                ..Default::default()
1524            })
1525            .expect("construct");
1526        assert_eq!(acc_tuner.reward_from_metric(0.3), 0.3);
1527    }
1528
1529    /// A non-finite observation must not poison the running statistics.
1530    #[test]
1531    fn non_finite_observations_are_ignored() {
1532        let mut optimizer: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1533            SelfTuningOptimizer::new(SelfTuningConfig::default()).expect("construct");
1534        let mut params = vec![Array1::zeros(2)];
1535        let grads = vec![Array1::ones(2)];
1536
1537        optimizer
1538            .step(&mut params, &grads, stats_with_loss(1.0))
1539            .expect("step");
1540        optimizer
1541            .step(&mut params, &grads, stats_with_loss(f64::NAN))
1542            .expect("step");
1543
1544        let active = &optimizer.optimizer_candidates[optimizer.current_candidate_idx];
1545        assert_eq!(active.performance_history.len(), 1);
1546        assert!(active.average_reward.is_finite());
1547    }
1548
1549    /// All four selection strategies must be reachable by a caller. Only
1550    /// `MultiArmedBandit { UCB1 }` used to be constructible.
1551    #[test]
1552    fn every_selection_strategy_is_reachable() {
1553        let mut optimizer: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1554            SelfTuningOptimizer::new(SelfTuningConfig::default()).expect("construct");
1555
1556        for strategy in [
1557            OptimizerSelectionStrategy::MultiArmedBandit {
1558                algorithm: BanditAlgorithm::EpsilonGreedy,
1559            },
1560            OptimizerSelectionStrategy::MultiArmedBandit {
1561                algorithm: BanditAlgorithm::ThompsonSampling,
1562            },
1563            OptimizerSelectionStrategy::MultiArmedBandit {
1564                algorithm: BanditAlgorithm::LinUCB,
1565            },
1566            OptimizerSelectionStrategy::PerformanceBased {
1567                min_difference: 0.01,
1568            },
1569            OptimizerSelectionStrategy::RoundRobin { current_index: 0 },
1570            OptimizerSelectionStrategy::MetaLearning {
1571                problem_features: vec![0.0; 5],
1572                optimizer_mappings: HashMap::new(),
1573            },
1574        ] {
1575            optimizer.set_selection_strategy(strategy);
1576            let picked = optimizer.adapt_optimizer(&stats_with_loss(1.0));
1577            assert!(picked.is_ok(), "strategy must be usable end to end");
1578            assert!(optimizer.current_candidate_idx < optimizer.optimizer_candidates.len());
1579        }
1580    }
1581
1582    /// `PerformanceBased` selection must pick the candidate with the best
1583    /// measured reward, which is only possible now that rewards are recorded.
1584    #[test]
1585    fn performance_based_selection_picks_the_best_measured_candidate() {
1586        let mut optimizer: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1587            SelfTuningOptimizer::new(SelfTuningConfig::default()).expect("construct");
1588        // Candidate 1 measured better (less negative reward) than 0 and 2.
1589        optimizer.optimizer_candidates[0].average_reward = -1.0;
1590        optimizer.optimizer_candidates[1].average_reward = -0.1;
1591        optimizer.optimizer_candidates[2].average_reward = -0.5;
1592
1593        assert_eq!(optimizer.select_optimizer_performance_based(), 1);
1594    }
1595    /// An observation must be credited to the optimizer that produced it, not
1596    /// to whichever optimizer a switch on the same step happened to select.
1597    ///
1598    /// `record_candidate_performance` used to run *after* `maybe_adapt_optimizer`,
1599    /// so on every switching step the incoming optimizer was handed the outgoing
1600    /// one's result — systematically rewarding whichever optimizer was switched
1601    /// *to* after a bad step, which is exactly backwards.
1602    #[test]
1603    fn an_observation_is_credited_to_the_optimizer_that_produced_it() {
1604        let mut optimizer: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1605            SelfTuningOptimizer::new(SelfTuningConfig {
1606                warmup_steps: 0,
1607                evaluation_window: 4,
1608                max_switches_per_epoch: 10,
1609                min_adaptation_interval: Duration::ZERO,
1610                improvement_threshold: 10.0, // any step counts as "stagnating"
1611                ..Default::default()
1612            })
1613            .expect("construct");
1614        // Round-robin makes every adaptation move to a different candidate, so
1615        // a mis-ordered recording is guaranteed to land on the wrong one.
1616        optimizer
1617            .set_selection_strategy(OptimizerSelectionStrategy::RoundRobin { current_index: 0 });
1618
1619        let mut params = vec![Array1::zeros(3)];
1620        let grads = vec![Array1::ones(3)];
1621
1622        // Each step carries a unique loss, so each reward identifies its step.
1623        // Run until a step actually switches optimizers, then assert on that
1624        // step: that is the only step where the ordering is observable.
1625        let mut switched_on: Option<(usize, usize, f64)> = None;
1626        for i in 0..40 {
1627            let active_before = optimizer.current_candidate_idx;
1628            let loss = 1.0 + i as f64;
1629            optimizer
1630                .step(&mut params, &grads, stats_with_loss(loss))
1631                .expect("step");
1632            if optimizer.current_candidate_idx != active_before {
1633                switched_on = Some((active_before, optimizer.current_candidate_idx, -loss));
1634                break;
1635            }
1636        }
1637
1638        let (produced_by, switched_to, reward) =
1639            switched_on.expect("no switch occurred, so the ordering is not under test");
1640        assert_ne!(produced_by, switched_to);
1641        assert_eq!(
1642            optimizer.optimizer_candidates[produced_by]
1643                .performance_history
1644                .last()
1645                .copied(),
1646            Some(reward),
1647            "the observation must sit on the candidate that was active when it \
1648             was measured (candidate {produced_by}), not on the one switched to"
1649        );
1650        assert_ne!(
1651            optimizer.optimizer_candidates[switched_to]
1652                .performance_history
1653                .last()
1654                .copied(),
1655            Some(reward),
1656            "the candidate switched *to* (candidate {switched_to}) must not be \
1657             credited with the outgoing optimizer's result"
1658        );
1659    }
1660}