Skip to main content

optirs_core/streaming/adaptive_streaming/
meta_learning.rs

1// Meta-learning and experience management for adaptive streaming
2//
3// This module provides sophisticated meta-learning capabilities that learn from
4// optimization experiences to improve future adaptation decisions, including
5// experience replay, transfer learning, and adaptive strategy selection.
6
7use super::config::*;
8use super::meta_bandit::{arm_index_for, arm_table, state_features, BanditArm, FeatureScaler};
9use super::meta_transfer::TransferLearning;
10use super::optimizer::{Adaptation, AdaptationPriority, AdaptationType, StreamingDataPoint};
11use super::performance::PerformanceTracker;
12
13pub use super::meta_transfer::{
14    DomainAdaptation, TransferMetrics, TransferStrategy, MIN_TRANSFER_SIMILARITY,
15};
16
17use crate::utils::{scalar_or, try_scalar_str};
18use scirs2_core::numeric::Float;
19use scirs2_core::random::thread_rng;
20use std::collections::{HashMap, VecDeque};
21use std::time::{Duration, Instant};
22
23/// Type of meta-model used for decision making
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum MetaModelType {
26    NeuralNetwork,
27    LinearRegression,
28    RandomForest,
29    GradientBoosting,
30    SupportVectorMachine,
31}
32
33/// Meta-learning system for streaming optimization
34pub struct MetaLearner<A: Float + Send + Sync> {
35    /// Meta-learning configuration
36    config: MetaLearningConfig,
37    /// Experience buffer for learning
38    experience_buffer: ExperienceBuffer<A>,
39    /// Meta-model for decision making
40    meta_model: MetaModel<A>,
41    /// Strategy selection system
42    strategy_selector: StrategySelector<A>,
43    /// Transfer learning system
44    transfer_learning: TransferLearning<A>,
45    /// Meta-learning statistics
46    statistics: MetaLearningStatistics<A>,
47    /// Learning rate adaptation
48    learning_rate_adapter: LearningRateAdapter<A>,
49    /// Instant the current episode began (real clock for episode duration).
50    episode_start: Instant,
51    /// First reward recorded in the current episode.
52    episode_initial_performance: Option<A>,
53    /// Number of experiences recorded in the current episode.
54    episode_adaptation_count: usize,
55    /// Externally supplied resource-state signal, set via
56    /// [`MetaLearner::update_context_signals`]. Empty means "not reported".
57    context_resource_state: Vec<A>,
58    /// Externally supplied drift-indicator signal.
59    context_drift_indicators: Vec<A>,
60}
61
62/// Type alias for experience replay functionality
63pub type ExperienceReplay<A> = ExperienceBuffer<A>;
64
65/// Experience buffer for storing and managing learning experiences
66pub struct ExperienceBuffer<A: Float + Send + Sync> {
67    /// Buffer configuration
68    config: ExperienceReplayConfig,
69    /// Stored experiences
70    experiences: VecDeque<MetaExperience<A>>,
71    /// Priority queue for prioritized replay
72    priority_queue: VecDeque<(MetaExperience<A>, A)>,
73    /// Experience importance sampling
74    importance_weights: HashMap<usize, A>,
75    /// Maximum retained experiences, from
76    /// `MetaLearningConfig::experience_buffer_size`.
77    capacity: usize,
78}
79
80/// Meta-learning experience representation
81#[derive(Debug, Clone)]
82pub struct MetaExperience<A: Float + Send + Sync> {
83    /// Unique experience ID
84    pub id: u64,
85    /// State when experience occurred
86    pub state: MetaState<A>,
87    /// Action taken
88    pub action: MetaAction<A>,
89    /// Reward received
90    pub reward: A,
91    /// Next state after action
92    pub next_state: Option<MetaState<A>>,
93    /// Experience timestamp
94    pub timestamp: Instant,
95    /// Episode context
96    pub episode_context: EpisodeContext<A>,
97    /// Experience priority for replay
98    pub priority: A,
99    /// Number of times replayed
100    pub replay_count: usize,
101}
102
103/// Meta-state representation
104#[derive(Debug, Clone)]
105pub struct MetaState<A: Float + Send + Sync> {
106    /// Performance metrics at this state
107    pub performance_metrics: Vec<A>,
108    /// Resource state
109    pub resource_state: Vec<A>,
110    /// Drift indicators
111    pub drift_indicators: Vec<A>,
112    /// Adaptation history length
113    pub adaptation_history: usize,
114    /// State timestamp
115    pub timestamp: Instant,
116}
117
118/// Meta-action representation
119#[derive(Debug, Clone)]
120pub struct MetaAction<A: Float + Send + Sync> {
121    /// Adaptation magnitudes applied
122    pub adaptation_magnitudes: Vec<A>,
123    /// Types of adaptations
124    pub adaptation_types: Vec<AdaptationType>,
125    /// Learning rate change
126    pub learning_rate_change: A,
127    /// Buffer size change
128    pub buffer_size_change: A,
129    /// Action timestamp
130    pub timestamp: Instant,
131}
132
133/// Episode context for meta-learning
134#[derive(Debug, Clone)]
135pub struct EpisodeContext<A: Float + Send + Sync> {
136    /// Episode ID
137    pub episode_id: u64,
138    /// Episode start time
139    pub start_time: Instant,
140    /// Episode duration
141    pub duration: Duration,
142    /// Initial performance
143    pub initial_performance: A,
144    /// Final performance
145    pub final_performance: A,
146    /// Number of adaptations in episode
147    pub adaptation_count: usize,
148    /// Episode outcome classification
149    pub outcome: EpisodeOutcome,
150}
151
152/// Episode outcome classifications
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum EpisodeOutcome {
155    /// Significant improvement
156    Success,
157    /// Moderate improvement
158    PartialSuccess,
159    /// No significant change
160    Neutral,
161    /// Performance degradation
162    Failure,
163    /// Severe performance degradation
164    CriticalFailure,
165}
166
167/// Meta-model for decision making
168pub struct MetaModel<A: Float + Send + Sync> {
169    /// Model parameters
170    parameters: MetaModelParameters<A>,
171    /// Training history
172    training_history: VecDeque<TrainingEpisode<A>>,
173    /// Model performance metrics
174    performance_metrics: ModelPerformanceMetrics<A>,
175    /// Feature importance
176    feature_importance: Vec<A>,
177    /// Contextual-bandit arms: the concrete adaptations the model can
178    /// recommend, each with its own online linear reward model.
179    arms: Vec<BanditArm<A>>,
180    /// Number of training steps applied.
181    training_steps: usize,
182    /// Running feature statistics used to standardise the state vector.
183    feature_scaler: FeatureScaler<A>,
184}
185
186/// Meta-model parameters
187#[derive(Debug, Clone)]
188pub struct MetaModelParameters<A: Float + Send + Sync> {
189    /// Weight matrices for neural networks
190    pub weights: Vec<Vec<A>>,
191    /// Bias vectors
192    pub biases: Vec<A>,
193    /// Learning rate
194    pub learning_rate: A,
195    /// Regularization parameters
196    pub regularization: RegularizationParams<A>,
197    /// Optimization parameters
198    pub optimization: OptimizationParams<A>,
199}
200
201/// Regularization parameters
202#[derive(Debug, Clone)]
203pub struct RegularizationParams<A: Float + Send + Sync> {
204    /// L1 regularization strength
205    pub l1_lambda: A,
206    /// L2 regularization strength
207    pub l2_lambda: A,
208    /// Dropout rate
209    pub dropout_rate: A,
210    /// Early stopping patience
211    pub early_stopping_patience: usize,
212}
213
214/// Optimization parameters
215#[derive(Debug, Clone)]
216pub struct OptimizationParams<A: Float + Send + Sync> {
217    /// Momentum coefficient
218    pub momentum: A,
219    /// Adam beta1 parameter
220    pub beta1: A,
221    /// Adam beta2 parameter
222    pub beta2: A,
223    /// Epsilon for numerical stability
224    pub epsilon: A,
225    /// Gradient clipping threshold
226    pub grad_clip_threshold: A,
227}
228
229/// Training episode for meta-model
230#[derive(Debug, Clone)]
231pub struct TrainingEpisode<A: Float + Send + Sync> {
232    /// Episode ID
233    pub episode_id: u64,
234    /// Training loss
235    pub training_loss: A,
236    /// Validation loss
237    pub validation_loss: A,
238    /// Training accuracy
239    pub training_accuracy: A,
240    /// Validation accuracy
241    pub validation_accuracy: A,
242    /// Episode duration
243    pub duration: Duration,
244    /// Timestamp
245    pub timestamp: Instant,
246}
247
248/// Model performance metrics
249#[derive(Debug, Clone)]
250pub struct ModelPerformanceMetrics<A: Float + Send + Sync> {
251    /// Prediction accuracy
252    pub prediction_accuracy: A,
253    /// Decision quality
254    pub decision_quality: A,
255    /// Adaptation effectiveness
256    pub adaptation_effectiveness: A,
257    /// Transfer learning success rate
258    pub transfer_success_rate: A,
259    /// Generalization performance
260    pub generalization_performance: A,
261}
262
263/// Strategy selection system
264pub struct StrategySelector<A: Float + Send + Sync> {
265    /// Available strategies
266    strategies: HashMap<String, AdaptationStrategy<A>>,
267    /// Strategy performance history
268    strategy_performance: HashMap<String, StrategyPerformance<A>>,
269    /// Strategy selection policy
270    selection_policy: SelectionPolicy,
271    /// Exploration parameters
272    exploration_params: ExplorationParams<A>,
273}
274
275/// Adaptation strategy representation
276#[derive(Debug, Clone)]
277pub struct AdaptationStrategy<A: Float + Send + Sync> {
278    /// Strategy name
279    pub name: String,
280    /// Strategy parameters
281    pub parameters: HashMap<String, A>,
282    /// Strategy type
283    pub strategy_type: StrategyType,
284    /// Applicability conditions
285    pub conditions: Vec<StrategyCondition<A>>,
286    /// Expected outcomes
287    pub expected_outcomes: Vec<A>,
288}
289
290/// Strategy types
291#[derive(Debug, Clone)]
292pub enum StrategyType {
293    /// Conservative strategy (small changes)
294    Conservative,
295    /// Aggressive strategy (large changes)
296    Aggressive,
297    /// Balanced strategy
298    Balanced,
299    /// Reactive strategy (responds to changes)
300    Reactive,
301    /// Proactive strategy (anticipates changes)
302    Proactive,
303    /// Custom strategy
304    Custom(String),
305}
306
307/// Strategy applicability conditions
308#[derive(Debug, Clone)]
309pub struct StrategyCondition<A: Float + Send + Sync> {
310    /// Condition type
311    pub condition_type: ConditionType,
312    /// Threshold value
313    pub threshold: A,
314    /// Operator for comparison
315    pub operator: ComparisonOperator,
316    /// Weight in decision making
317    pub weight: A,
318}
319
320/// Condition types for strategy selection
321#[derive(Debug, Clone)]
322pub enum ConditionType {
323    /// Performance threshold
324    Performance,
325    /// Resource utilization
326    ResourceUtilization,
327    /// Data quality
328    DataQuality,
329    /// Drift detection
330    DriftDetection,
331    /// Time-based condition
332    Temporal,
333    /// Custom condition
334    Custom(String),
335}
336
337/// Comparison operators
338#[derive(Debug, Clone)]
339pub enum ComparisonOperator {
340    /// Greater than
341    GreaterThan,
342    /// Less than
343    LessThan,
344    /// Equal to
345    EqualTo,
346    /// Between values
347    Between(f64, f64),
348    /// In set of values
349    InSet(Vec<f64>),
350}
351
352/// Strategy performance tracking
353#[derive(Debug, Clone)]
354pub struct StrategyPerformance<A: Float + Send + Sync> {
355    /// Number of times used
356    pub usage_count: usize,
357    /// Success rate
358    pub success_rate: A,
359    /// Average improvement
360    pub avg_improvement: A,
361    /// Best improvement achieved
362    pub best_improvement: A,
363    /// Worst outcome
364    pub worst_outcome: A,
365    /// Recent performance trend
366    pub recent_trend: TrendDirection,
367    /// Context-specific performance
368    pub context_performance: HashMap<String, A>,
369}
370
371/// Trend direction for strategy performance
372#[derive(Debug, Clone, PartialEq, Eq)]
373pub enum TrendDirection {
374    /// Improving trend
375    Improving,
376    /// Declining trend
377    Declining,
378    /// Stable trend
379    Stable,
380    /// Oscillating trend
381    Oscillating,
382}
383
384/// Strategy selection policies
385#[derive(Debug, Clone)]
386pub enum SelectionPolicy {
387    /// Epsilon-greedy selection
388    EpsilonGreedy { epsilon: f64 },
389    /// Upper Confidence Bound
390    UCB { confidence_parameter: f64 },
391    /// Thompson sampling
392    ThompsonSampling,
393    /// Softmax selection
394    Softmax { temperature: f64 },
395    /// Context-aware selection
396    ContextAware,
397    /// Multi-armed bandit
398    MultiArmedBandit,
399}
400
401/// Exploration parameters
402#[derive(Debug, Clone)]
403pub struct ExplorationParams<A: Float + Send + Sync> {
404    /// Exploration rate
405    pub exploration_rate: A,
406    /// Exploration decay
407    pub exploration_decay: A,
408    /// Minimum exploration rate
409    pub min_exploration_rate: A,
410    /// Curiosity bonus weight
411    pub curiosity_weight: A,
412    /// Novelty bonus weight
413    pub novelty_weight: A,
414}
415
416/// Learning rate adaptation system
417pub struct LearningRateAdapter<A: Float + Send + Sync> {
418    /// Current learning rate
419    current_rate: A,
420    /// Learning rate history
421    rate_history: VecDeque<A>,
422    /// Rate bounds
423    min_rate: A,
424    max_rate: A,
425}
426
427/// Learning rate adaptation strategies
428#[derive(Debug, Clone)]
429pub enum LearningRateStrategy {
430    /// Fixed learning rate
431    Fixed,
432    /// Step decay
433    StepDecay { decay_factor: f64, step_size: usize },
434    /// Exponential decay
435    ExponentialDecay { decay_rate: f64 },
436    /// Performance-based adaptation
437    PerformanceBased,
438    /// Cyclical learning rates
439    Cyclical {
440        min_lr: f64,
441        max_lr: f64,
442        cycle_length: usize,
443    },
444    /// Adaptive learning rate (Adam-style)
445    Adaptive,
446}
447
448/// Meta-learning statistics
449#[derive(Debug, Clone)]
450pub struct MetaLearningStatistics<A: Float + Send + Sync> {
451    /// Total experiences collected
452    pub total_experiences: usize,
453    /// Model training episodes
454    pub training_episodes: usize,
455    /// Average reward per episode
456    pub avg_reward_per_episode: A,
457    /// Best episode reward
458    pub best_episode_reward: A,
459    /// Learning progress
460    pub learning_progress: A,
461    /// Strategy selection accuracy
462    pub strategy_selection_accuracy: A,
463    /// Transfer learning success rate
464    pub transfer_success_rate: A,
465    /// Experience replay effectiveness
466    pub replay_effectiveness: A,
467}
468
469impl<A: Float + Default + Clone + std::iter::Sum + Send + Sync + std::fmt::Debug> MetaLearner<A> {
470    /// Creates a new meta-learner
471    pub fn new(config: &StreamingConfig) -> Result<Self, String> {
472        let meta_config = config.meta_learning_config.clone();
473
474        let experience_buffer = ExperienceBuffer::new(
475            &meta_config.replay_config,
476            meta_config.experience_buffer_size,
477        );
478        let meta_model = MetaModel::new(meta_config.model_complexity.clone())?;
479        let strategy_selector = StrategySelector::new();
480        let transfer_learning = TransferLearning::new();
481        let learning_rate_adapter = LearningRateAdapter::new(meta_config.meta_learning_rate);
482
483        let statistics = MetaLearningStatistics {
484            total_experiences: 0,
485            training_episodes: 0,
486            avg_reward_per_episode: A::zero(),
487            best_episode_reward: A::zero(),
488            learning_progress: A::zero(),
489            strategy_selection_accuracy: A::zero(),
490            transfer_success_rate: A::zero(),
491            replay_effectiveness: A::zero(),
492        };
493
494        Ok(Self {
495            config: meta_config,
496            experience_buffer,
497            meta_model,
498            strategy_selector,
499            transfer_learning,
500            statistics,
501            learning_rate_adapter,
502            episode_start: Instant::now(),
503            episode_initial_performance: None,
504            episode_adaptation_count: 0,
505            context_resource_state: Vec::new(),
506            context_drift_indicators: Vec::new(),
507        })
508    }
509
510    /// Supplies the resource and drift signals the meta-learner cannot observe
511    /// for itself.
512    ///
513    /// ML5: `extract_meta_state` used to fill `resource_state` with the
514    /// constants `[0.5, 0.3]` and `drift_indicators` with `[0.1]`, so the
515    /// bandit's context vector was two thirds fabricated and identical on every
516    /// call. The owning optimizer knows the real values and reports them here.
517    pub fn update_context_signals(&mut self, resource_state: Vec<A>, drift_indicators: Vec<A>) {
518        self.context_resource_state = resource_state;
519        self.context_drift_indicators = drift_indicators;
520    }
521
522    /// Updates the meta-learner with new experience
523    pub fn update_experience(
524        &mut self,
525        state: MetaState<A>,
526        action: MetaAction<A>,
527        reward: A,
528    ) -> Result<(), String> {
529        // `state`/`action` are moved into the experience below, so the priority
530        // is computed first.
531        let priority = self.calculate_experience_priority(&state, &action, reward);
532        let experience = MetaExperience {
533            id: self.generate_experience_id(),
534            state,
535            action,
536            reward,
537            next_state: None, // Will be filled in next update
538            timestamp: Instant::now(),
539            episode_context: self.create_episode_context(reward)?,
540            priority,
541            replay_count: 0,
542        };
543
544        // Add to experience buffer
545        self.experience_buffer.add_experience(experience)?;
546
547        // Real per-episode bookkeeping.
548        if self.episode_initial_performance.is_none() {
549            self.episode_initial_performance = Some(reward);
550        }
551        self.episode_adaptation_count = self.episode_adaptation_count.saturating_add(1);
552
553        // Update statistics
554        self.statistics.total_experiences += 1;
555
556        // Trigger learning if enough experiences collected. Two independent
557        // cadences apply (CF1): `update_frequency` is the meta-model's own
558        // training interval, and `ExperienceReplayConfig::replay_frequency` is
559        // how often stored experience is replayed. `replay_frequency` had no
560        // reader at all, so replay silently inherited `update_frequency`.
561        let experiences = self.statistics.total_experiences;
562        let update_due = self.config.update_frequency > 0
563            && experiences.is_multiple_of(self.config.update_frequency);
564        let replay_due = self.config.replay_config.replay_frequency > 0
565            && experiences.is_multiple_of(self.config.replay_config.replay_frequency);
566        if update_due || replay_due {
567            self.trigger_learning()?;
568        }
569
570        Ok(())
571    }
572
573    /// Generates unique experience ID
574    fn generate_experience_id(&self) -> u64 {
575        self.statistics.total_experiences as u64 + 1
576    }
577
578    /// Creates episode context for experience.
579    ///
580    /// ML5: `duration`, `initial_performance` and `adaptation_count` were the
581    /// fabricated constants `60s`, `0.5` and `1`, and `start_time` was stamped
582    /// as "now" for every experience so the duration could never be anything
583    /// else. All four are now measured from real episode state: the episode's
584    /// actual start `Instant`, the first reward recorded in the episode, and the
585    /// number of experiences the episode has accumulated.
586    fn create_episode_context(&self, reward: A) -> Result<EpisodeContext<A>, String> {
587        let convert = |value: f64| -> Result<A, String> {
588            A::from(value).ok_or_else(|| format!("{value} is not representable"))
589        };
590        let outcome = if reward > convert(0.8)? {
591            EpisodeOutcome::Success
592        } else if reward > convert(0.5)? {
593            EpisodeOutcome::PartialSuccess
594        } else if reward > convert(0.2)? {
595            EpisodeOutcome::Neutral
596        } else if reward > convert(-0.2)? {
597            EpisodeOutcome::Failure
598        } else {
599            EpisodeOutcome::CriticalFailure
600        };
601
602        Ok(EpisodeContext {
603            episode_id: self.statistics.training_episodes as u64,
604            start_time: self.episode_start,
605            // Real elapsed time since the current episode began.
606            duration: self.episode_start.elapsed(),
607            // The first reward recorded in this episode; for the first
608            // experience of an episode that is this reward itself.
609            initial_performance: self.episode_initial_performance.unwrap_or(reward),
610            final_performance: reward,
611            // Real count of experiences accumulated in this episode, including
612            // the one being created.
613            adaptation_count: self.episode_adaptation_count + 1,
614            outcome,
615        })
616    }
617
618    /// Calculates priority for experience replay
619    fn calculate_experience_priority(
620        &self,
621        state: &MetaState<A>,
622        action: &MetaAction<A>,
623        reward: A,
624    ) -> A {
625        // Every branch returns a strictly positive priority: a zero priority
626        // would make the experience unsamplable under prioritized replay and
627        // would break the importance-sampling normalisation.
628        let floor = scalar_or(1e-6, A::zero());
629        let priority = match self.config.replay_config.priority_method {
630            // Temporal-difference error: how wrong the meta-model's current
631            // value estimate for this state/action was. This is the canonical
632            // PER priority and is only available because the model can score
633            // the pair it just acted on.
634            PriorityMethod::TDError => {
635                match self.meta_model.estimate_reward(state, action) {
636                    Some(predicted) => (reward - predicted).abs(),
637                    // No estimate yet (untrained arm): treat it as maximally
638                    // surprising so it gets replayed, which is what PER does
639                    // for unvisited transitions.
640                    None => A::one(),
641                }
642            }
643            // Surprise relative to the rewards seen so far: |r - mean| in units
644            // of the running average magnitude.
645            PriorityMethod::Surprise => {
646                let mean = self.statistics.avg_reward_per_episode;
647                let scale = mean.abs().max(A::one());
648                (reward - mean).abs() / scale
649            }
650            // Magnitude of the adaptation that was actually applied.
651            PriorityMethod::GradientMagnitude => action
652                .adaptation_magnitudes
653                .iter()
654                .fold(A::zero(), |acc, m| acc + m.abs()),
655            // Improvement over the best episode reward observed so far.
656            PriorityMethod::LossImprovement => {
657                (reward - self.statistics.best_episode_reward).max(A::zero())
658            }
659            // Uniform random priority: the PER ablation baseline.
660            PriorityMethod::Random => scalar_or(thread_rng().gen_range(0.0..1.0), A::one()),
661        };
662        priority.max(floor)
663    }
664
665    /// Test-only: records one synthetic experience so buffer bounds can be
666    /// exercised without standing up a whole optimizer.
667    #[cfg(test)]
668    pub(crate) fn record_probe_experience_for_test(&mut self, reward: A) -> Result<(), String> {
669        let state = MetaState {
670            performance_metrics: vec![reward],
671            resource_state: vec![A::one()],
672            drift_indicators: vec![A::zero()],
673            adaptation_history: 0,
674            timestamp: Instant::now(),
675        };
676        let action = MetaAction {
677            adaptation_magnitudes: vec![reward],
678            adaptation_types: vec![AdaptationType::LearningRate],
679            learning_rate_change: reward,
680            buffer_size_change: A::zero(),
681            timestamp: Instant::now(),
682        };
683        self.update_experience(state, action, reward)
684    }
685
686    /// Test-only count of retained experiences.
687    #[cfg(test)]
688    pub(crate) fn experience_count_for_test(&self) -> usize {
689        self.experience_buffer.experiences.len()
690    }
691
692    /// Characteristic vector describing the domain this learner is currently
693    /// training on: the reported resource state followed by the drift
694    /// indicators, i.e. exactly the context signals the owning optimizer
695    /// publishes through [`Self::update_context_signals`].
696    fn target_domain_characteristics(&self) -> Vec<A> {
697        let mut characteristics = self.context_resource_state.clone();
698        characteristics.extend(self.context_drift_indicators.iter().copied());
699        characteristics
700    }
701
702    /// Registers a source domain whose experiences may be replayed into this
703    /// learner when transfer learning is enabled.
704    ///
705    /// Returns an error when `MetaLearningConfig::enable_transfer_learning` is
706    /// off, rather than accepting the source and never using it (CF1: that flag
707    /// previously had no reader at all, so transfer learning was neither on nor
708    /// off — it simply did not exist).
709    pub fn register_transfer_source(
710        &mut self,
711        source_id: String,
712        experiences: Vec<MetaExperience<A>>,
713        source_characteristics: Vec<A>,
714    ) -> Result<(), String> {
715        if !self.config.enable_transfer_learning {
716            return Err(
717                "MetaLearningConfig::enable_transfer_learning is disabled, so no transfer \
718                 source can be registered"
719                    .to_string(),
720            );
721        }
722        self.transfer_learning
723            .register_source(source_id, experiences, source_characteristics);
724        Ok(())
725    }
726
727    /// Measured transfer-learning outcomes, or `None` when transfer learning is
728    /// disabled.
729    pub fn transfer_metrics(&self) -> Option<&TransferMetrics<A>> {
730        self.config
731            .enable_transfer_learning
732            .then(|| self.transfer_learning.metrics())
733    }
734
735    /// Triggers meta-learning update
736    fn trigger_learning(&mut self) -> Result<(), String> {
737        // Sample experiences for training
738        let mut training_batch = self
739            .experience_buffer
740            .sample_batch(self.config.replay_config.batch_size)?;
741
742        // Augment with similarity-weighted source-domain experiences when
743        // transfer learning is enabled and a source has been registered (CF1).
744        if self.config.enable_transfer_learning && self.transfer_learning.source_domain_count() > 0
745        {
746            let reward_before = self.meta_model.performance_metrics.prediction_accuracy;
747            let target_characteristics = self.target_domain_characteristics();
748            let transferred = self.transfer_learning.select_transfer_batch(
749                target_characteristics,
750                self.config.replay_config.batch_size,
751            );
752            if !transferred.is_empty() {
753                training_batch.extend(transferred);
754                self.meta_model.train_on_batch(&training_batch)?;
755                // fall through to the weighted pass below for the local batch
756                let reward_after = self.meta_model.performance_metrics.prediction_accuracy;
757                self.transfer_learning
758                    .record_transfer_outcome(reward_before, reward_after);
759                self.statistics.transfer_success_rate = self
760                    .transfer_learning
761                    .metrics()
762                    .success_rate
763                    .unwrap_or_else(A::zero);
764            }
765        }
766
767        // Train meta-model, applying the importance-sampling weights recorded by
768        // the sampling step above when the correction is enabled.
769        let weights: HashMap<u64, A> = training_batch
770            .iter()
771            .filter_map(|experience| {
772                self.experience_buffer
773                    .importance_weight(experience.id)
774                    .map(|weight| (experience.id, weight))
775            })
776            .collect();
777        self.meta_model
778            .train_on_weighted_batch(&training_batch, |id| {
779                weights.get(&id).copied().unwrap_or_else(A::one)
780            })?;
781
782        // Update strategy selection
783        self.strategy_selector
784            .update_from_experiences(&training_batch)?;
785
786        // Update statistics from the batch that was actually trained on, so the
787        // reward figures are measurements rather than initial zeros.
788        self.statistics.training_episodes += 1;
789        if !training_batch.is_empty() {
790            let count = A::from(training_batch.len())
791                .ok_or_else(|| "batch size is not representable".to_string())?;
792            let total = training_batch
793                .iter()
794                .fold(A::zero(), |acc, experience| acc + experience.reward);
795            let mean = total / count;
796            self.statistics.avg_reward_per_episode = mean;
797            for experience in &training_batch {
798                if experience.reward > self.statistics.best_episode_reward {
799                    self.statistics.best_episode_reward = experience.reward;
800                }
801            }
802            // Learning progress is the model's measured prediction accuracy —
803            // how well it has learned to anticipate reward.
804            self.statistics.learning_progress =
805                self.meta_model.performance_metrics.prediction_accuracy;
806            self.statistics.strategy_selection_accuracy =
807                self.meta_model.performance_metrics.decision_quality;
808            self.statistics.replay_effectiveness = mean;
809        }
810
811        // A training round closes the current episode: start a fresh one so the
812        // next episode's duration and adaptation count are measured from here.
813        self.episode_start = Instant::now();
814        self.episode_initial_performance = None;
815        self.episode_adaptation_count = 0;
816
817        Ok(())
818    }
819
820    /// Recommends adaptations based on current state.
821    ///
822    /// `_current_data` is accepted for signature stability but is not read: the
823    /// meta-state the bandit consumes is `[performance, resource, drift]` with a
824    /// layout the feature scaler in [`super::meta_bandit`] is fitted against,
825    /// and per-batch data characteristics have no slot in it. The owning
826    /// optimizer already derives its data statistics separately
827    /// (`compute_data_statistics`), and resource/drift signals reach the
828    /// meta-learner through [`Self::update_context_signals`]. Feeding data
829    /// characteristics into the bandit would require widening `MetaState` and
830    /// refitting the scaler, which is a design change rather than a wiring fix.
831    pub fn recommend_adaptations(
832        &mut self,
833        _current_data: &[StreamingDataPoint<A>],
834        performance_tracker: &PerformanceTracker<A>,
835    ) -> Result<Vec<Adaptation<A>>, String> {
836        // Extract current meta-state
837        let current_state = self.extract_meta_state(performance_tracker)?;
838
839        // Use meta-model to predict best action
840        let predicted_action = self.meta_model.predict_action(&current_state)?;
841
842        // Select appropriate strategy
843        let strategy = self.strategy_selector.select_strategy(&current_state)?;
844
845        // Generate adaptations based on prediction and strategy
846        let adaptations =
847            self.generate_adaptations_from_prediction(&predicted_action, &strategy)?;
848
849        Ok(adaptations)
850    }
851
852    /// Extracts meta-state from current situation
853    /// Deliberately takes no data batch: `MetaState`'s feature layout
854    /// (performance, resource and drift signals) is what the bandit's feature
855    /// scaler is fitted against and has no slot for per-batch data
856    /// characteristics, so a batch argument could only be discarded.
857    fn extract_meta_state(
858        &self,
859        performance_tracker: &PerformanceTracker<A>,
860    ) -> Result<MetaState<A>, String> {
861        // Get recent performance
862        let recent_performance = performance_tracker.get_recent_performance(5);
863        let performance_metrics = if !recent_performance.is_empty() {
864            vec![
865                recent_performance[0].loss,
866                recent_performance[0].accuracy.unwrap_or(A::zero()),
867                recent_performance[0].convergence_rate.unwrap_or(A::zero()),
868            ]
869        } else {
870            vec![A::zero(), A::zero(), A::zero()]
871        };
872
873        // Resource and drift signals as reported by the owning optimizer. An
874        // empty vector honestly means "not reported" rather than a stand-in
875        // value that the bandit would learn a weight for.
876        let resource_state = self.context_resource_state.clone();
877        let drift_indicators = self.context_drift_indicators.clone();
878
879        Ok(MetaState {
880            performance_metrics,
881            resource_state,
882            drift_indicators,
883            adaptation_history: self.statistics.total_experiences,
884            timestamp: Instant::now(),
885        })
886    }
887
888    /// Generates adaptations from model prediction
889    fn generate_adaptations_from_prediction(
890        &self,
891        predicted_action: &MetaAction<A>,
892        _strategy: &AdaptationStrategy<A>,
893    ) -> Result<Vec<Adaptation<A>>, String> {
894        let mut adaptations = Vec::new();
895
896        // Generate adaptations based on predicted action
897        for (i, &magnitude) in predicted_action.adaptation_magnitudes.iter().enumerate() {
898            if magnitude.abs() > try_scalar_str::<A, _>(0.05)? {
899                // Minimum threshold
900                let adaptation_type = if i < predicted_action.adaptation_types.len() {
901                    predicted_action.adaptation_types[i].clone()
902                } else {
903                    AdaptationType::LearningRate // Default
904                };
905
906                let adaptation = Adaptation {
907                    adaptation_type,
908                    magnitude,
909                    target_component: "meta_learner".to_string(),
910                    parameters: std::collections::HashMap::new(),
911                    priority: if magnitude.abs() > try_scalar_str::<A, _>(0.3)? {
912                        AdaptationPriority::High
913                    } else {
914                        AdaptationPriority::Normal
915                    },
916                    timestamp: Instant::now(),
917                };
918
919                adaptations.push(adaptation);
920            }
921        }
922
923        Ok(adaptations)
924    }
925
926    /// Applies adaptation to meta-learning system
927    pub fn apply_adaptation(&mut self, adaptation: &Adaptation<A>) -> Result<(), String> {
928        match adaptation.adaptation_type {
929            AdaptationType::MetaLearning => {
930                // Adjust meta-learning parameters
931                let new_rate = self.learning_rate_adapter.current_rate + adaptation.magnitude;
932                self.learning_rate_adapter.update_rate(new_rate)?;
933            }
934            _ => {
935                // Handle other adaptation types
936            }
937        }
938
939        Ok(())
940    }
941
942    /// Gets meta-learning effectiveness score
943    pub fn get_effectiveness_score(&self) -> f32 {
944        self.statistics.learning_progress.to_f32().unwrap_or(0.0)
945    }
946
947    /// Gets diagnostic information
948    pub fn get_diagnostics(&self) -> MetaLearningDiagnostics {
949        MetaLearningDiagnostics {
950            total_experiences: self.statistics.total_experiences,
951            training_episodes: self.statistics.training_episodes,
952            current_learning_rate: self
953                .learning_rate_adapter
954                .current_rate
955                .to_f64()
956                .unwrap_or(0.0),
957            model_accuracy: self
958                .meta_model
959                .performance_metrics
960                .prediction_accuracy
961                .to_f64()
962                .unwrap_or(0.0),
963            strategy_count: self.strategy_selector.strategies.len(),
964            transfer_success_rate: self
965                .statistics
966                .transfer_success_rate
967                .to_f64()
968                .unwrap_or(0.0),
969        }
970    }
971}
972
973/// Exponent `beta` of the prioritized-replay importance-sampling correction
974/// (Schaul et al., "Prioritized Experience Replay", ICLR 2016). A full
975/// annealing schedule needs a training-progress signal the buffer does not
976/// have, so the fully-corrected value is used.
977const IMPORTANCE_SAMPLING_BETA: f64 = 1.0;
978
979impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> ExperienceBuffer<A> {
980    /// `capacity` comes from `MetaLearningConfig::experience_buffer_size` (CF1).
981    /// Both the main buffer and the priority queue used to be hardcoded to
982    /// 10 000 / 1 000 entries, so configuring a 1 000-experience buffer had no
983    /// effect whatsoever.
984    fn new(config: &ExperienceReplayConfig, capacity: usize) -> Self {
985        let capacity = capacity.max(1);
986        Self {
987            config: config.clone(),
988            experiences: VecDeque::with_capacity(capacity.min(64 * 1024)),
989            priority_queue: VecDeque::new(),
990            importance_weights: HashMap::new(),
991            capacity,
992        }
993    }
994
995    fn add_experience(&mut self, experience: MetaExperience<A>) -> Result<(), String> {
996        // Add to main buffer, bounded by the configured capacity (CF1).
997        while self.experiences.len() >= self.capacity {
998            if let Some(evicted) = self.experiences.pop_front() {
999                self.importance_weights.remove(&(evicted.id as usize));
1000            } else {
1001                break;
1002            }
1003        }
1004        self.experiences.push_back(experience.clone());
1005
1006        // Add to priority queue if using prioritized replay
1007        if self.config.enable_prioritized_replay {
1008            let priority = experience.priority;
1009            self.priority_queue.push_back((experience, priority));
1010
1011            // Highest priority first.
1012            self.priority_queue
1013                .make_contiguous()
1014                .sort_by(|a, b| crate::utils::total_order(&b.1, &a.1));
1015
1016            // The priority queue is a view over the buffer, so it is bounded by
1017            // the same configured capacity rather than a separate constant.
1018            while self.priority_queue.len() > self.capacity {
1019                self.priority_queue.pop_back();
1020            }
1021        }
1022
1023        Ok(())
1024    }
1025
1026    /// Importance-sampling weight for a sampled experience under prioritized
1027    /// replay: `w_i = (1 / (N * P(i)))^beta`, normalised by the largest weight
1028    /// in the batch so the maximum is 1 and the correction only ever scales
1029    /// updates down.
1030    ///
1031    /// Only computed when `ExperienceReplayConfig::importance_sampling` is set
1032    /// (CF1); that field previously had no reader, and `importance_weights` was
1033    /// a `#[allow(dead_code)]`-adjacent field nothing ever wrote, so
1034    /// prioritized replay ran with its sampling bias entirely uncorrected.
1035    fn record_importance_weights(&mut self, batch: &[MetaExperience<A>], total_priority: A) {
1036        self.importance_weights.clear();
1037        if !self.config.importance_sampling || batch.is_empty() || total_priority <= A::zero() {
1038            return;
1039        }
1040        let n = match A::from(self.experiences.len().max(1)) {
1041            Some(n) => n,
1042            None => return,
1043        };
1044        let beta = match A::from(IMPORTANCE_SAMPLING_BETA) {
1045            Some(beta) => beta,
1046            None => return,
1047        };
1048
1049        let mut raw: Vec<(usize, A)> = Vec::with_capacity(batch.len());
1050        let mut max_weight = A::zero();
1051        for experience in batch {
1052            let probability = experience.priority / total_priority;
1053            if probability <= A::zero() {
1054                continue;
1055            }
1056            let weight = (A::one() / (n * probability)).powf(beta);
1057            if weight > max_weight {
1058                max_weight = weight;
1059            }
1060            raw.push((experience.id as usize, weight));
1061        }
1062        if max_weight <= A::zero() {
1063            return;
1064        }
1065        for (id, weight) in raw {
1066            self.importance_weights.insert(id, weight / max_weight);
1067        }
1068    }
1069
1070    /// Normalised importance-sampling weight recorded for `experience_id` by the
1071    /// most recent [`Self::sample_batch`] call, if importance sampling is on.
1072    fn importance_weight(&self, experience_id: u64) -> Option<A> {
1073        self.importance_weights
1074            .get(&(experience_id as usize))
1075            .copied()
1076    }
1077
1078    fn sample_batch(&mut self, batch_size: usize) -> Result<Vec<MetaExperience<A>>, String> {
1079        if self.experiences.is_empty() {
1080            return Ok(Vec::new());
1081        }
1082
1083        let mut batch = Vec::with_capacity(batch_size);
1084        let total_priority: A = self.experiences.iter().map(|e| e.priority).sum();
1085
1086        if self.config.enable_prioritized_replay && !self.priority_queue.is_empty() {
1087            // Sample from priority queue
1088            for _ in 0..batch_size.min(self.priority_queue.len()) {
1089                if let Some((experience, _)) = self.priority_queue.pop_front() {
1090                    batch.push(experience);
1091                }
1092            }
1093        } else {
1094            // Random sampling
1095            for _ in 0..batch_size.min(self.experiences.len()) {
1096                let idx = thread_rng().gen_range(0..self.experiences.len());
1097                if let Some(experience) = self.experiences.get(idx) {
1098                    batch.push(experience.clone());
1099                }
1100            }
1101        }
1102
1103        self.record_importance_weights(&batch, total_priority);
1104        Ok(batch)
1105    }
1106}
1107
1108impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> MetaModel<A> {
1109    fn new(complexity: MetaModelComplexity) -> Result<Self, String> {
1110        let parameters = match complexity {
1111            MetaModelComplexity::Low => MetaModelParameters {
1112                weights: vec![vec![try_scalar_str::<A, _>(0.1)?; 10]; 2],
1113                biases: vec![A::zero(); 10],
1114                // Normalised-LMS step size (stable for 0 < mu < 2), not an
1115                // unnormalised SGD rate.
1116                learning_rate: A::from(0.5).unwrap_or_else(A::one),
1117                regularization: RegularizationParams {
1118                    l1_lambda: try_scalar_str::<A, _>(0.001)?,
1119                    l2_lambda: try_scalar_str::<A, _>(0.001)?,
1120                    dropout_rate: try_scalar_str::<A, _>(0.1)?,
1121                    early_stopping_patience: 10,
1122                },
1123                optimization: OptimizationParams {
1124                    momentum: try_scalar_str::<A, _>(0.9)?,
1125                    beta1: try_scalar_str::<A, _>(0.9)?,
1126                    beta2: try_scalar_str::<A, _>(0.999)?,
1127                    epsilon: try_scalar_str::<A, _>(1e-8)?,
1128                    grad_clip_threshold: try_scalar_str::<A, _>(1.0)?,
1129                },
1130            },
1131            _ => MetaModelParameters {
1132                weights: vec![vec![try_scalar_str::<A, _>(0.1)?; 50]; 3],
1133                biases: vec![A::zero(); 50],
1134                // A larger model gets a more conservative NLMS step size.
1135                learning_rate: A::from(0.3).unwrap_or_else(A::one),
1136                regularization: RegularizationParams {
1137                    l1_lambda: try_scalar_str::<A, _>(0.0001)?,
1138                    l2_lambda: try_scalar_str::<A, _>(0.0001)?,
1139                    dropout_rate: try_scalar_str::<A, _>(0.2)?,
1140                    early_stopping_patience: 20,
1141                },
1142                optimization: OptimizationParams {
1143                    momentum: try_scalar_str::<A, _>(0.9)?,
1144                    beta1: try_scalar_str::<A, _>(0.9)?,
1145                    beta2: try_scalar_str::<A, _>(0.999)?,
1146                    epsilon: try_scalar_str::<A, _>(1e-8)?,
1147                    grad_clip_threshold: try_scalar_str::<A, _>(1.0)?,
1148                },
1149            },
1150        };
1151
1152        let arms: Vec<BanditArm<A>> = arm_table()
1153            .into_iter()
1154            .map(|(prefix, adaptation_type, magnitude)| {
1155                BanditArm::new(format!("{prefix}:{magnitude}"), adaptation_type, magnitude)
1156            })
1157            .collect();
1158
1159        Ok(Self {
1160            parameters,
1161            training_history: VecDeque::with_capacity(1000),
1162            performance_metrics: ModelPerformanceMetrics {
1163                // All five metrics start at zero: nothing has been measured yet,
1164                // and a 0.5 seed would read as "50% accurate" before the model
1165                // has seen a single experience.
1166                prediction_accuracy: A::zero(),
1167                decision_quality: A::zero(),
1168                adaptation_effectiveness: A::zero(),
1169                transfer_success_rate: A::zero(),
1170                generalization_performance: A::zero(),
1171            },
1172            feature_importance: Vec::new(),
1173            arms,
1174            training_steps: 0,
1175            feature_scaler: FeatureScaler::default(),
1176        })
1177    }
1178
1179    /// Trains the contextual bandit on a batch of observed experiences.
1180    ///
1181    /// ML2: this used to compute the batch's mean reward, nudge the learning
1182    /// rate by ±1%, and assign that mean reward directly to
1183    /// `prediction_accuracy` — no model parameter was ever touched, so nothing
1184    /// was learned and "accuracy" was a relabelled reward.
1185    ///
1186    /// Each experience now performs a real stochastic-gradient step on the
1187    /// squared error between the arm's predicted reward and the observed
1188    /// reward, and `prediction_accuracy` is measured from the *pre-update*
1189    /// prediction error — a genuine held-out-by-one-step accuracy.
1190    fn train_on_batch(&mut self, batch: &[MetaExperience<A>]) -> Result<(), String> {
1191        self.train_on_weighted_batch(batch, |_| A::one())
1192    }
1193
1194    /// Train on `batch`, scaling each experience's update by
1195    /// `weight_of(experience_id)`.
1196    ///
1197    /// This is what makes `ExperienceReplayConfig::importance_sampling` real:
1198    /// prioritized replay deliberately over-samples high-priority transitions,
1199    /// which biases the gradient, and the importance-sampling weight
1200    /// `w_i = (1/(N*P(i)))^beta` corrects for exactly that over-sampling
1201    /// (Schaul et al., "Prioritized Experience Replay", ICLR 2016). Computing
1202    /// the weight without applying it to the update would leave the bias in
1203    /// place.
1204    fn train_on_weighted_batch(
1205        &mut self,
1206        batch: &[MetaExperience<A>],
1207        weight_of: impl Fn(u64) -> A,
1208    ) -> Result<(), String> {
1209        if batch.is_empty() {
1210            return Ok(());
1211        }
1212
1213        let training_started = Instant::now();
1214
1215        // Fold the batch into the feature scaler before training on it, so every
1216        // experience in the batch is standardised against the same statistics.
1217        for experience in batch {
1218            self.feature_scaler
1219                .observe(&state_features(&experience.state));
1220        }
1221
1222        let mut squared_error_total = A::zero();
1223        let mut scale_total = A::zero();
1224        let mut trained = 0usize;
1225
1226        for experience in batch {
1227            let features = self
1228                .feature_scaler
1229                .standardize(&state_features(&experience.state));
1230            let arm = match arm_index_for(&experience.action) {
1231                Some(index) => index,
1232                // An experience whose action does not correspond to any arm the
1233                // model can take carries no gradient for it.
1234                None => continue,
1235            };
1236
1237            let predicted = self.arms[arm].predict(&features);
1238            let error = predicted - experience.reward;
1239            squared_error_total = squared_error_total + error * error;
1240            scale_total = scale_total + experience.reward.abs();
1241
1242            // Importance-sampling correction: scale this transition's step by its
1243            // weight (1.0 when the correction is disabled).
1244            let learning_rate = self.parameters.learning_rate * weight_of(experience.id);
1245            let l2 = self.parameters.regularization.l2_lambda;
1246            self.arms[arm].sgd_step(&features, error, learning_rate, l2);
1247            self.arms[arm].observe(experience.reward);
1248            trained += 1;
1249        }
1250
1251        if trained == 0 {
1252            return Ok(());
1253        }
1254
1255        let count =
1256            A::from(trained).ok_or_else(|| format!("batch size {trained} is not representable"))?;
1257        let rmse = (squared_error_total / count).sqrt();
1258        let mean_scale = (scale_total / count)
1259            .max(A::from(1e-8).ok_or_else(|| "1e-8 is not representable".to_string())?);
1260
1261        // Accuracy as one minus the normalised prediction error, clamped into
1262        // [0, 1]. This is a real measurement of how well the model predicts
1263        // reward, not the reward itself.
1264        let accuracy = (A::one() - (rmse / mean_scale).min(A::one())).max(A::zero());
1265        self.performance_metrics.prediction_accuracy = accuracy;
1266
1267        // Decision quality: the fraction of experiences whose reward beat the
1268        // running average, which is what "did the chosen action help" means
1269        // with the data available here.
1270        let average_reward = self.average_observed_reward();
1271        let better = batch
1272            .iter()
1273            .filter(|experience| experience.reward > average_reward)
1274            .count();
1275        if let Some(fraction) = A::from(better as f64 / batch.len() as f64) {
1276            self.performance_metrics.decision_quality = fraction;
1277        }
1278
1279        // Track training history so the field is real state, not dead weight.
1280        self.training_steps += 1;
1281        if self.training_history.len() >= 1000 {
1282            self.training_history.pop_front();
1283        }
1284        self.training_history.push_back(TrainingEpisode {
1285            episode_id: self.training_steps as u64,
1286            training_loss: rmse,
1287            // No held-out split exists in a streaming setting, so validation and
1288            // training figures are the same measurement; they are not two
1289            // independently-invented numbers.
1290            validation_loss: rmse,
1291            training_accuracy: accuracy,
1292            validation_accuracy: accuracy,
1293            duration: training_started.elapsed(),
1294            timestamp: Instant::now(),
1295        });
1296
1297        // Real learning-rate schedule: anneal towards a floor once the model is
1298        // already accurate (fine-tuning), hold otherwise. The floor matters —
1299        // an unbounded decay would eventually freeze learning entirely, which is
1300        // indistinguishable from not learning at all.
1301        if accuracy > A::from(0.9).unwrap_or_else(A::one) {
1302            let decay = A::from(0.99).unwrap_or_else(A::one);
1303            let floor = A::from(NLMS_STEP_FLOOR).unwrap_or_else(A::zero);
1304            self.parameters.learning_rate = (self.parameters.learning_rate * decay).max(floor);
1305        }
1306
1307        // Feature importance is the mean absolute weight across arms — a real,
1308        // if simple, attribution.
1309        self.feature_importance = self.mean_absolute_weights();
1310
1311        Ok(())
1312    }
1313
1314    /// Selects an action for the given state using the learned bandit.
1315    ///
1316    /// ML1: this used to return the constants `[0.1, -0.05]` with
1317    /// `learning_rate_change: 0.01` and `buffer_size_change: 5.0` for every
1318    /// state — `state` was accepted and never read, so the meta-learner
1319    /// recommended the identical adaptation forever.
1320    ///
1321    /// The action is now the arm with the highest predicted reward for this
1322    /// state's feature vector, so a different state genuinely yields a
1323    /// different recommendation.
1324    fn predict_action(&self, state: &MetaState<A>) -> Result<MetaAction<A>, String> {
1325        let features = self.feature_scaler.standardize(&state_features(state));
1326        if self.arms.is_empty() {
1327            return Err("meta-model has no action arms".to_string());
1328        }
1329
1330        let mut best_index = 0usize;
1331        let mut best_value = self.arms[0].predict(&features);
1332        for (index, arm) in self.arms.iter().enumerate().skip(1) {
1333            let value = arm.predict(&features);
1334            if value > best_value {
1335                best_value = value;
1336                best_index = index;
1337            }
1338        }
1339
1340        let arm = &self.arms[best_index];
1341        let magnitude = arm
1342            .magnitude()
1343            .ok_or_else(|| "arm magnitude is not representable".to_string())?;
1344
1345        let (learning_rate_change, buffer_size_change) = match arm.adaptation_type {
1346            AdaptationType::LearningRate => (magnitude, A::zero()),
1347            AdaptationType::BufferSize => (A::zero(), magnitude),
1348            _ => (A::zero(), A::zero()),
1349        };
1350
1351        Ok(MetaAction {
1352            adaptation_magnitudes: vec![magnitude],
1353            adaptation_types: vec![arm.adaptation_type.clone()],
1354            learning_rate_change,
1355            buffer_size_change,
1356            timestamp: Instant::now(),
1357        })
1358    }
1359
1360    /// The model's current value estimate for taking `action` in `state`.
1361    ///
1362    /// Returns `None` when the action does not map to a known arm or that arm
1363    /// has never been trained, so callers can distinguish "no estimate yet" from
1364    /// "estimated zero" — the difference matters for the temporal-difference
1365    /// replay priority, where an untrained arm must count as maximally
1366    /// surprising rather than perfectly predicted.
1367    fn estimate_reward(&self, state: &MetaState<A>, action: &MetaAction<A>) -> Option<A> {
1368        let index = arm_index_for(action)?;
1369        let arm = self.arms.get(index)?;
1370        if arm.pulls == 0 {
1371            return None;
1372        }
1373        let features = self.feature_scaler.standardize(&state_features(state));
1374        Some(arm.predict(&features))
1375    }
1376
1377    /// Mean reward observed across every arm, or zero before any observation.
1378    fn average_observed_reward(&self) -> A {
1379        let total_pulls: usize = self.arms.iter().map(|arm| arm.pulls).sum();
1380        if total_pulls == 0 {
1381            return A::zero();
1382        }
1383        let Some(count) = A::from(total_pulls) else {
1384            return A::zero();
1385        };
1386        let total: A = self
1387            .arms
1388            .iter()
1389            .fold(A::zero(), |acc, arm| acc + arm.reward_total);
1390        total / count
1391    }
1392
1393    /// Mean absolute weight per feature across all arms.
1394    fn mean_absolute_weights(&self) -> Vec<A> {
1395        let width = self
1396            .arms
1397            .iter()
1398            .map(|arm| arm.weights.len())
1399            .max()
1400            .unwrap_or(0);
1401        let Some(arm_count) = A::from(self.arms.len().max(1)) else {
1402            return Vec::new();
1403        };
1404        (0..width)
1405            .map(|index| {
1406                let total = self.arms.iter().fold(A::zero(), |acc, arm| {
1407                    acc + arm
1408                        .weights
1409                        .get(index)
1410                        .map(|w| w.abs())
1411                        .unwrap_or_else(A::zero)
1412                });
1413                total / arm_count
1414            })
1415            .collect()
1416    }
1417
1418    /// Number of times each arm has been trained on, keyed by arm label.
1419    pub fn arm_pull_counts(&self) -> Vec<(String, usize)> {
1420        self.arms
1421            .iter()
1422            .map(|arm| (arm.label.clone(), arm.pulls))
1423            .collect()
1424    }
1425}
1426
1427// The contextual-bandit arms, the feature standardiser and the state/action
1428// encoding live in `super::meta_bandit`.
1429
1430impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StrategySelector<A> {
1431    fn new() -> Self {
1432        let mut strategies = HashMap::new();
1433
1434        // Add default strategies
1435        strategies.insert(
1436            "conservative".to_string(),
1437            AdaptationStrategy {
1438                name: "conservative".to_string(),
1439                parameters: HashMap::new(),
1440                strategy_type: StrategyType::Conservative,
1441                conditions: Vec::new(),
1442                expected_outcomes: vec![scalar_or(0.05, A::zero())],
1443            },
1444        );
1445
1446        strategies.insert(
1447            "aggressive".to_string(),
1448            AdaptationStrategy {
1449                name: "aggressive".to_string(),
1450                parameters: HashMap::new(),
1451                strategy_type: StrategyType::Aggressive,
1452                // Prior expected improvement, used only until the arm has been
1453                // used at least once and has a measured average.
1454                expected_outcomes: vec![A::from(0.2).unwrap_or_else(A::zero)],
1455                conditions: Vec::new(),
1456            },
1457        );
1458
1459        Self {
1460            strategies,
1461            strategy_performance: HashMap::new(),
1462            selection_policy: SelectionPolicy::EpsilonGreedy { epsilon: 0.1 },
1463            exploration_params: ExplorationParams {
1464                exploration_rate: scalar_or(0.1, A::zero()),
1465                exploration_decay: scalar_or(0.99, A::zero()),
1466                min_exploration_rate: scalar_or(0.01, A::zero()),
1467                curiosity_weight: scalar_or(0.1, A::zero()),
1468                novelty_weight: scalar_or(0.1, A::zero()),
1469            },
1470        }
1471    }
1472
1473    /// Selects a strategy for the given state using the configured policy.
1474    ///
1475    /// ML3: this used to look up the literal key `"balanced"`, which the
1476    /// constructor never inserted — so the lookup always missed and the fallback
1477    /// `self.strategies.values().next()` returned an arbitrary `HashMap` entry,
1478    /// which is not even deterministic across runs. The `_state` argument,
1479    /// `selection_policy` and `exploration_params` were all unread.
1480    ///
1481    /// Selection is now genuinely policy-driven over the measured per-strategy
1482    /// performance, and the ordering is deterministic (ties broken by name)
1483    /// rather than dependent on hash iteration order.
1484    fn select_strategy(&self, state: &MetaState<A>) -> Result<AdaptationStrategy<A>, String> {
1485        if self.strategies.is_empty() {
1486            return Err("No strategies available".to_string());
1487        }
1488
1489        // Deterministic candidate order.
1490        let mut names: Vec<&String> = self.strategies.keys().collect();
1491        names.sort();
1492
1493        // Value of each arm: its measured average improvement, or the strategy's
1494        // declared expected outcome while it has never been used (which is the
1495        // only prior available).
1496        let scored: Vec<(&String, A, usize)> = names
1497            .iter()
1498            .map(|name| {
1499                let (value, usage) = match self.strategy_performance.get(*name) {
1500                    Some(performance) if performance.usage_count > 0 => {
1501                        (performance.avg_improvement, performance.usage_count)
1502                    }
1503                    _ => {
1504                        let prior = self
1505                            .strategies
1506                            .get(*name)
1507                            .and_then(|strategy| strategy.expected_outcomes.first().copied())
1508                            .unwrap_or_else(A::zero);
1509                        (prior, 0)
1510                    }
1511                };
1512                (*name, value, usage)
1513            })
1514            .collect();
1515
1516        let chosen = match &self.selection_policy {
1517            SelectionPolicy::EpsilonGreedy { epsilon } => {
1518                let effective_epsilon = self
1519                    .exploration_params
1520                    .exploration_rate
1521                    .to_f64()
1522                    .unwrap_or(*epsilon)
1523                    .max(
1524                        self.exploration_params
1525                            .min_exploration_rate
1526                            .to_f64()
1527                            .unwrap_or(0.0),
1528                    )
1529                    .clamp(0.0, 1.0);
1530                if thread_rng().gen_range(0.0..1.0) < effective_epsilon {
1531                    // Explore: prefer the least-used arm, which is the
1532                    // information-maximising choice.
1533                    scored
1534                        .iter()
1535                        .min_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(b.0)))
1536                        .map(|entry| entry.0)
1537                } else {
1538                    best_by_value(&scored)
1539                }
1540            }
1541            SelectionPolicy::UCB {
1542                confidence_parameter,
1543            } => {
1544                let total_usage: usize = scored.iter().map(|entry| entry.2).sum();
1545                let total = (total_usage.max(1) as f64).ln();
1546                let c = *confidence_parameter;
1547                scored
1548                    .iter()
1549                    .max_by(|a, b| {
1550                        let score_a = ucb_score(a.1, a.2, total, c);
1551                        let score_b = ucb_score(b.1, b.2, total, c);
1552                        score_a
1553                            .partial_cmp(&score_b)
1554                            .unwrap_or(std::cmp::Ordering::Equal)
1555                            .then_with(|| b.0.cmp(a.0))
1556                    })
1557                    .map(|entry| entry.0)
1558            }
1559            SelectionPolicy::Softmax { temperature } => {
1560                let temperature = temperature.abs().max(1e-6);
1561                let values: Vec<f64> = scored
1562                    .iter()
1563                    .map(|entry| entry.1.to_f64().unwrap_or(0.0) / temperature)
1564                    .collect();
1565                let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1566                let exponentials: Vec<f64> =
1567                    values.iter().map(|value| (value - max).exp()).collect();
1568                let total: f64 = exponentials.iter().sum();
1569                if total <= 0.0 {
1570                    best_by_value(&scored)
1571                } else {
1572                    let mut draw = thread_rng().gen_range(0.0..total);
1573                    let mut selected = scored.last().map(|entry| entry.0);
1574                    for (entry, weight) in scored.iter().zip(exponentials.iter()) {
1575                        draw -= *weight;
1576                        if draw <= 0.0 {
1577                            selected = Some(entry.0);
1578                            break;
1579                        }
1580                    }
1581                    selected
1582                }
1583            }
1584            SelectionPolicy::ThompsonSampling | SelectionPolicy::MultiArmedBandit => {
1585                // Sample each arm's value perturbed by a scale that shrinks as
1586                // the arm accumulates evidence — the defining behaviour of
1587                // posterior sampling.
1588                scored
1589                    .iter()
1590                    .map(|entry| {
1591                        let scale = 1.0 / ((entry.2 as f64) + 1.0).sqrt();
1592                        let noise = thread_rng().gen_range(-1.0..1.0) * scale;
1593                        (entry.0, entry.1.to_f64().unwrap_or(0.0) + noise)
1594                    })
1595                    .max_by(|a, b| {
1596                        a.1.partial_cmp(&b.1)
1597                            .unwrap_or(std::cmp::Ordering::Equal)
1598                            .then_with(|| b.0.cmp(a.0))
1599                    })
1600                    .map(|entry| entry.0)
1601            }
1602            SelectionPolicy::ContextAware => {
1603                // Score arms by their measured performance *in this state's
1604                // context bucket*, falling back to the global value.
1605                let context_key = context_key_for(state);
1606                scored
1607                    .iter()
1608                    .max_by(|a, b| {
1609                        let value_a = self
1610                            .strategy_performance
1611                            .get(a.0)
1612                            .and_then(|p| p.context_performance.get(&context_key).copied())
1613                            .unwrap_or(a.1);
1614                        let value_b = self
1615                            .strategy_performance
1616                            .get(b.0)
1617                            .and_then(|p| p.context_performance.get(&context_key).copied())
1618                            .unwrap_or(b.1);
1619                        value_a
1620                            .partial_cmp(&value_b)
1621                            .unwrap_or(std::cmp::Ordering::Equal)
1622                            .then_with(|| b.0.cmp(a.0))
1623                    })
1624                    .map(|entry| entry.0)
1625            }
1626        };
1627
1628        let name = chosen.ok_or_else(|| "strategy selection produced no candidate".to_string())?;
1629        self.strategies
1630            .get(name)
1631            .cloned()
1632            .ok_or_else(|| format!("selected strategy '{name}' is not registered"))
1633    }
1634
1635    /// Updates per-strategy reward statistics from observed experiences.
1636    ///
1637    /// ML4: this was an unconditional `Ok(())`, so `strategy_performance` stayed
1638    /// permanently empty and every policy above would have had nothing to choose
1639    /// on. Each experience now moves the usage count, running average
1640    /// improvement, best/worst outcomes, success rate, per-context performance
1641    /// and trend of the strategy that produced it, and decays the exploration
1642    /// rate towards its floor.
1643    fn update_from_experiences(&mut self, experiences: &[MetaExperience<A>]) -> Result<(), String> {
1644        if experiences.is_empty() {
1645            return Ok(());
1646        }
1647
1648        let success_threshold =
1649            A::from(0.5).ok_or_else(|| "0.5 is not representable".to_string())?;
1650
1651        for experience in experiences {
1652            let strategy_name = strategy_name_for(&experience.action);
1653            if !self.strategies.contains_key(&strategy_name) {
1654                continue;
1655            }
1656            let context_key = context_key_for(&experience.state);
1657            let reward = experience.reward;
1658
1659            let entry = self
1660                .strategy_performance
1661                .entry(strategy_name)
1662                .or_insert_with(|| StrategyPerformance {
1663                    usage_count: 0,
1664                    success_rate: A::zero(),
1665                    avg_improvement: A::zero(),
1666                    best_improvement: reward,
1667                    worst_outcome: reward,
1668                    recent_trend: TrendDirection::Stable,
1669                    context_performance: HashMap::new(),
1670                });
1671
1672            let previous_average = entry.avg_improvement;
1673            entry.usage_count = entry.usage_count.saturating_add(1);
1674            let count = A::from(entry.usage_count)
1675                .ok_or_else(|| "usage count is not representable".to_string())?;
1676
1677            // Incremental mean.
1678            entry.avg_improvement = previous_average + (reward - previous_average) / count;
1679
1680            // Incremental success rate over the same counter.
1681            let success = if reward > success_threshold {
1682                A::one()
1683            } else {
1684                A::zero()
1685            };
1686            entry.success_rate = entry.success_rate + (success - entry.success_rate) / count;
1687
1688            if reward > entry.best_improvement {
1689                entry.best_improvement = reward;
1690            }
1691            if reward < entry.worst_outcome {
1692                entry.worst_outcome = reward;
1693            }
1694
1695            entry.recent_trend = if entry.avg_improvement > previous_average {
1696                TrendDirection::Improving
1697            } else if entry.avg_improvement < previous_average {
1698                TrendDirection::Declining
1699            } else {
1700                TrendDirection::Stable
1701            };
1702
1703            // Per-context running mean.
1704            let context_entry = entry
1705                .context_performance
1706                .entry(context_key)
1707                .or_insert_with(|| reward);
1708            let smoothing = A::from(0.2).unwrap_or_else(A::one);
1709            *context_entry = smoothing * reward + (A::one() - smoothing) * *context_entry;
1710        }
1711
1712        // Real exploration decay: each learning round moves the rate towards its
1713        // configured floor.
1714        let decayed =
1715            self.exploration_params.exploration_rate * self.exploration_params.exploration_decay;
1716        self.exploration_params.exploration_rate =
1717            decayed.max(self.exploration_params.min_exploration_rate);
1718
1719        Ok(())
1720    }
1721
1722    /// Measured performance of a named strategy, if it has been used.
1723    pub fn strategy_performance_for(&self, name: &str) -> Option<&StrategyPerformance<A>> {
1724        self.strategy_performance.get(name)
1725    }
1726
1727    /// Current exploration rate.
1728    pub fn exploration_rate(&self) -> A {
1729        self.exploration_params.exploration_rate
1730    }
1731}
1732
1733/// Picks the highest-valued arm, breaking ties by name for determinism.
1734fn best_by_value<'a, A: Float + Send + Sync>(
1735    scored: &'a [(&'a String, A, usize)],
1736) -> Option<&'a String> {
1737    scored
1738        .iter()
1739        .max_by(|a, b| {
1740            a.1.partial_cmp(&b.1)
1741                .unwrap_or(std::cmp::Ordering::Equal)
1742                .then_with(|| b.0.cmp(a.0))
1743        })
1744        .map(|entry| entry.0)
1745}
1746
1747/// UCB1 score: mean value plus an exploration bonus that shrinks as the arm is
1748/// used more.
1749fn ucb_score<A: Float + Send + Sync>(
1750    value: A,
1751    usage: usize,
1752    log_total_usage: f64,
1753    confidence: f64,
1754) -> f64 {
1755    let mean = value.to_f64().unwrap_or(0.0);
1756    if usage == 0 {
1757        // An untried arm has an unbounded bonus, so it is always tried first.
1758        return f64::INFINITY;
1759    }
1760    mean + confidence * (log_total_usage / usage as f64).sqrt()
1761}
1762
1763/// Maps a meta-state onto a coarse context bucket key.
1764///
1765/// The key is derived from the sign and magnitude band of the loss and the drift
1766/// level, so states that call for the same kind of response share a bucket.
1767fn context_key_for<A: Float + Send + Sync>(state: &MetaState<A>) -> String {
1768    let loss = state
1769        .performance_metrics
1770        .first()
1771        .and_then(|value| value.to_f64())
1772        .unwrap_or(0.0);
1773    let drift = state
1774        .drift_indicators
1775        .first()
1776        .and_then(|value| value.to_f64())
1777        .unwrap_or(0.0);
1778    let loss_band = if loss <= 0.1 {
1779        "loss:low"
1780    } else if loss <= 1.0 {
1781        "loss:mid"
1782    } else {
1783        "loss:high"
1784    };
1785    let drift_band = if drift < 0.5 {
1786        "drift:none"
1787    } else if drift < 1.5 {
1788        "drift:warning"
1789    } else {
1790        "drift:active"
1791    };
1792    format!("{loss_band}|{drift_band}")
1793}
1794
1795/// Maps an action onto the strategy whose aggressiveness it matches.
1796///
1797/// This is how an observed experience is attributed back to a strategy: the
1798/// magnitude of the adaptation that was applied tells us whether a conservative
1799/// or aggressive strategy produced it.
1800fn strategy_name_for<A: Float + Send + Sync>(action: &MetaAction<A>) -> String {
1801    let magnitude = action
1802        .adaptation_magnitudes
1803        .first()
1804        .and_then(|value| value.to_f64())
1805        .map(f64::abs)
1806        .unwrap_or(0.0);
1807    if magnitude >= AGGRESSIVE_MAGNITUDE_THRESHOLD {
1808        "aggressive".to_string()
1809    } else {
1810        "conservative".to_string()
1811    }
1812}
1813
1814/// Magnitude at or above which an adaptation is attributed to the aggressive
1815/// strategy.
1816const AGGRESSIVE_MAGNITUDE_THRESHOLD: f64 = 0.15;
1817
1818/// Lower bound on the meta-model's normalised-LMS step size, so the accuracy
1819/// annealing schedule cannot decay learning to a standstill.
1820const NLMS_STEP_FLOOR: f64 = 0.01;
1821
1822impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> LearningRateAdapter<A> {
1823    fn new(initial_rate: f64) -> Self {
1824        Self {
1825            current_rate: scalar_or(initial_rate, A::zero()),
1826            rate_history: VecDeque::with_capacity(100),
1827            min_rate: scalar_or(1e-6, A::zero()),
1828            max_rate: scalar_or(0.1, A::zero()),
1829        }
1830    }
1831
1832    fn update_rate(&mut self, new_rate: A) -> Result<(), String> {
1833        self.current_rate = new_rate.max(self.min_rate).min(self.max_rate);
1834
1835        if self.rate_history.len() >= 100 {
1836            self.rate_history.pop_front();
1837        }
1838        self.rate_history.push_back(self.current_rate);
1839
1840        Ok(())
1841    }
1842}
1843
1844/// Diagnostic information for meta-learning
1845#[derive(Debug, Clone)]
1846pub struct MetaLearningDiagnostics {
1847    pub total_experiences: usize,
1848    pub training_episodes: usize,
1849    pub current_learning_rate: f64,
1850    pub model_accuracy: f64,
1851    pub strategy_count: usize,
1852    pub transfer_success_rate: f64,
1853}
1854
1855#[cfg(test)]
1856#[path = "meta_learning_regression_tests.rs"]
1857mod regression_tests;