Skip to main content

optirs_learned/meta_learning/
framework.rs

1//! Meta-learning framework and its subsystems.
2//!
3//! [`MetaLearningFramework`] wires a concrete meta-learner to the task
4//! sampler, the held-out validator, the adaptation engine and the transfer /
5//! continual / multi-task / few-shot subsystems. Every subsystem here computes
6//! its reported numbers from data it actually saw; see the individual doc
7//! comments for what each figure means.
8
9use crate::error::{OptimError, Result};
10use scirs2_core::ndarray::Array1;
11use scirs2_core::numeric::Float;
12use std::collections::HashMap;
13use std::fmt::Debug;
14
15use super::functions::MetaLearner;
16use super::types::*;
17
18/// Task distribution manager.
19///
20/// Samples meta-training batches from the caller's task pool honouring
21/// [`TaskSamplingStrategy`]. Sampling is deterministic for a given seed and
22/// call index, so repeated runs reproduce the same batches.
23pub struct TaskDistributionManager<T: Float + Debug + Send + Sync + 'static> {
24    config: MetaLearningConfig,
25    /// Base RNG seed
26    seed: u64,
27    /// Number of batches drawn so far (mixed into the seed and used to advance
28    /// the curriculum)
29    draws: usize,
30    _phantom: std::marker::PhantomData<T>,
31}
32impl<T: Float + Debug + Send + Sync + 'static + Default + Clone> TaskDistributionManager<T> {
33    pub fn new(config: &MetaLearningConfig) -> Result<Self> {
34        Ok(Self {
35            config: config.clone(),
36            seed: 0x5EED_1234_ABCD_0001,
37            draws: 0,
38            _phantom: std::marker::PhantomData,
39        })
40    }
41
42    /// Override the base sampling seed.
43    pub fn with_seed(mut self, seed: u64) -> Self {
44        self.seed = seed;
45        self
46    }
47
48    /// The sampling strategy in force.
49    pub fn strategy(&self) -> TaskSamplingStrategy {
50        self.config.task_sampling_strategy
51    }
52
53    /// Draw a batch of tasks from `tasks`.
54    ///
55    /// Returns `Err` when the pool is empty or `batch_size` is zero — an empty
56    /// pool used to yield default (empty) tasks whose losses were `0/0`.
57    pub fn sample_task_batch(
58        &mut self,
59        tasks: &[MetaTask<T>],
60        batch_size: usize,
61    ) -> Result<Vec<MetaTask<T>>> {
62        if tasks.is_empty() {
63            return Err(OptimError::InsufficientData(
64                "cannot sample a task batch from an empty task pool".to_string(),
65            ));
66        }
67        if batch_size == 0 {
68            return Err(OptimError::InvalidConfig(
69                "task batch size must be greater than zero".to_string(),
70            ));
71        }
72
73        let draw = self.draws;
74        self.draws += 1;
75        let mut rng = scirs2_core::random::Random::seed(self.seed.wrapping_add(draw as u64));
76
77        let indices: Vec<usize> = match self.config.task_sampling_strategy {
78            TaskSamplingStrategy::Uniform => (0..batch_size)
79                .map(|_| rng.random_range(0..tasks.len()))
80                .collect(),
81            TaskSamplingStrategy::Curriculum => {
82                // Easiest first, advancing one batch per draw and wrapping.
83                let order = Self::order_by_difficulty(tasks, false);
84                (0..batch_size)
85                    .map(|i| order[(draw * batch_size + i) % order.len()])
86                    .collect()
87            }
88            TaskSamplingStrategy::DifficultyBased => {
89                Self::sample_weighted_by_difficulty(tasks, batch_size, &mut rng)
90            }
91            TaskSamplingStrategy::DiversityBased => Self::greedy_diverse(tasks, batch_size),
92            TaskSamplingStrategy::ActiveLearning | TaskSamplingStrategy::Adversarial => {
93                // Hardest first: the tasks the current meta-parameters are most
94                // likely to be wrong about.
95                let order = Self::order_by_difficulty(tasks, true);
96                (0..batch_size).map(|i| order[i % order.len()]).collect()
97            }
98        };
99
100        let mut batch = Vec::with_capacity(indices.len());
101        for idx in indices {
102            let task = tasks.get(idx).ok_or_else(|| {
103                OptimError::ComputationError(format!("sampled task index {idx} out of range"))
104            })?;
105            batch.push(task.clone());
106        }
107        Ok(batch)
108    }
109
110    /// Task indices ordered by difficulty (ascending, or descending when
111    /// `hardest_first`). NaN difficulties sort as equal.
112    fn order_by_difficulty(tasks: &[MetaTask<T>], hardest_first: bool) -> Vec<usize> {
113        let mut order: Vec<usize> = (0..tasks.len()).collect();
114        order.sort_by(|&a, &b| {
115            let ord = tasks[a]
116                .difficulty
117                .partial_cmp(&tasks[b].difficulty)
118                .unwrap_or(std::cmp::Ordering::Equal);
119            if hardest_first {
120                ord.reverse()
121            } else {
122                ord
123            }
124        });
125        order
126    }
127
128    fn sample_weighted_by_difficulty<R: scirs2_core::random::Rng>(
129        tasks: &[MetaTask<T>],
130        batch_size: usize,
131        rng: &mut scirs2_core::random::Random<R>,
132    ) -> Vec<usize> {
133        // Weights are difficulties shifted to be strictly positive.
134        let mut weights: Vec<f64> = tasks
135            .iter()
136            .map(|t| t.difficulty.to_f64().unwrap_or(0.0))
137            .collect();
138        let min = weights.iter().copied().fold(f64::INFINITY, f64::min);
139        let shift = if min.is_finite() && min < 0.0 {
140            -min
141        } else {
142            0.0
143        };
144        for w in weights.iter_mut() {
145            if !w.is_finite() {
146                *w = 0.0;
147            }
148            *w = *w + shift + 1e-9;
149        }
150        let total: f64 = weights.iter().sum();
151        (0..batch_size)
152            .map(|_| {
153                let mut target = rng.random_range(0.0..total);
154                for (i, w) in weights.iter().enumerate() {
155                    target -= *w;
156                    if target <= 0.0 {
157                        return i;
158                    }
159                }
160                weights.len() - 1
161            })
162            .collect()
163    }
164
165    /// Greedy farthest-point selection over per-task mean support features.
166    fn greedy_diverse(tasks: &[MetaTask<T>], batch_size: usize) -> Vec<usize> {
167        let signatures: Vec<Vec<f64>> = tasks.iter().map(Self::task_signature).collect();
168        let mut chosen: Vec<usize> = Vec::with_capacity(batch_size);
169        chosen.push(0);
170        while chosen.len() < batch_size {
171            let mut best = 0usize;
172            let mut best_dist = -1.0f64;
173            for (i, sig) in signatures.iter().enumerate() {
174                let d = chosen
175                    .iter()
176                    .map(|&c| Self::signature_distance(sig, &signatures[c]))
177                    .fold(f64::INFINITY, f64::min);
178                if d > best_dist {
179                    best_dist = d;
180                    best = i;
181                }
182            }
183            chosen.push(best);
184            if chosen.len() >= batch_size {
185                break;
186            }
187        }
188        chosen.truncate(batch_size);
189        chosen
190    }
191
192    fn task_signature(task: &MetaTask<T>) -> Vec<f64> {
193        let mut acc: Vec<f64> = Vec::new();
194        for feat in &task.support_set.features {
195            if acc.len() < feat.len() {
196                acc.resize(feat.len(), 0.0);
197            }
198            for (i, v) in feat.iter().enumerate() {
199                acc[i] += v.to_f64().unwrap_or(0.0);
200            }
201        }
202        let n = task.support_set.features.len().max(1) as f64;
203        for a in acc.iter_mut() {
204            *a /= n;
205        }
206        acc
207    }
208
209    fn signature_distance(a: &[f64], b: &[f64]) -> f64 {
210        let n = a.len().max(b.len());
211        let mut acc = 0.0;
212        for i in 0..n {
213            let d = a.get(i).copied().unwrap_or(0.0) - b.get(i).copied().unwrap_or(0.0);
214            acc += d * d;
215        }
216        acc.sqrt()
217    }
218}
219/// Continual learning system backed by
220/// [`crate::continual_learning::ElasticWeightConsolidation`].
221pub struct ContinualLearningSystem<T: Float + Debug + Send + Sync + 'static> {
222    settings: ContinualLearningSettings,
223    /// Gradient steps taken per task in the sequence
224    steps_per_task: usize,
225    /// Forgetting measured by the most recent sequence
226    last_forgetting: T,
227}
228impl<
229        T: Float
230            + Debug
231            + Send
232            + Sync
233            + 'static
234            + Default
235            + Clone
236            + std::iter::Sum
237            + scirs2_core::ndarray::ScalarOperand,
238    > ContinualLearningSystem<T>
239{
240    pub fn new(settings: &ContinualLearningSettings) -> Result<Self> {
241        Ok(Self {
242            settings: settings.clone(),
243            steps_per_task: 20,
244            last_forgetting: T::zero(),
245        })
246    }
247
248    /// Override the number of gradient steps taken per task.
249    pub fn with_steps_per_task(mut self, steps: usize) -> Result<Self> {
250        if steps == 0 {
251            return Err(OptimError::InvalidConfig(
252                "steps_per_task must be greater than zero".to_string(),
253            ));
254        }
255        self.steps_per_task = steps;
256        Ok(self)
257    }
258    /// Learn a sequence of tasks with elastic weight consolidation.
259    ///
260    /// Delegates to [`crate::continual_learning::ElasticWeightConsolidation`]:
261    /// each task is trained with SGD on the shared linear model plus the EWC
262    /// penalty gradient, then consolidated (Fisher diagonal + anchor).
263    /// Forgetting is measured for real by re-evaluating every earlier task
264    /// after the sequence finishes.
265    pub fn learn_sequence(
266        &mut self,
267        sequence: &[MetaTask<T>],
268        meta_parameters: &mut HashMap<String, Array1<T>>,
269    ) -> Result<ContinualLearningResult<T>> {
270        if sequence.is_empty() {
271            return Err(OptimError::InsufficientData(
272                "continual learning requires at least one task".to_string(),
273            ));
274        }
275
276        let lambda: T = scirs2_core::numeric::NumCast::from(
277            1.0 - self.settings.plasticity_stability_balance.clamp(0.0, 1.0),
278        )
279        .unwrap_or_else(T::zero);
280        let uses_ewc = self
281            .settings
282            .anti_forgetting_strategies
283            .iter()
284            .any(|s| matches!(s, AntiForgettingStrategy::ElasticWeightConsolidation));
285
286        let mut ewc = crate::continual_learning::ElasticWeightConsolidation::<T>::new(lambda)
287            .with_num_samples(self.settings.memory_replay.replay_frequency.max(2));
288
289        let lr: T = scirs2_core::numeric::NumCast::from(0.05).unwrap_or_else(T::zero);
290        let mut sequence_results = Vec::with_capacity(sequence.len());
291        let mut first_losses: Vec<T> = Vec::with_capacity(sequence.len());
292        let mut total_steps = 0usize;
293
294        for (task_idx, task) in sequence.iter().enumerate() {
295            let before = crate::meta_learning::linear_model::mse_loss(
296                &task.support_set.features,
297                &task.support_set.targets,
298                meta_parameters,
299            )?;
300
301            for _ in 0..self.steps_per_task {
302                let mut grads = crate::meta_learning::linear_model::mse_gradients(
303                    &task.support_set.features,
304                    &task.support_set.targets,
305                    meta_parameters,
306                )?;
307                if uses_ewc && task_idx > 0 {
308                    let penalty = ewc.ewc_gradient(meta_parameters)?;
309                    for (name, g) in grads.iter_mut() {
310                        if let Some(p) = penalty.get(name) {
311                            let n = g.len().min(p.len());
312                            for i in 0..n {
313                                g[i] = g[i] + p[i];
314                            }
315                        }
316                    }
317                }
318                crate::meta_learning::linear_model::descend(meta_parameters, &grads, lr)?;
319                total_steps += 1;
320            }
321
322            let after = crate::meta_learning::linear_model::mse_loss(
323                &task.support_set.features,
324                &task.support_set.targets,
325                meta_parameters,
326            )?;
327            first_losses.push(after);
328
329            if uses_ewc {
330                // Fisher information from per-sample gradients of this task.
331                let features = task.support_set.features.clone();
332                let targets = task.support_set.targets.clone();
333                ewc.compute_fisher_diagonal(meta_parameters, |params, sample_idx| {
334                    let i = sample_idx % features.len().max(1);
335                    let feats = features.get(i).cloned().ok_or_else(|| {
336                        OptimError::InsufficientData("empty support set".to_string())
337                    })?;
338                    let target = targets.get(i).copied().ok_or_else(|| {
339                        OptimError::InsufficientData("empty target set".to_string())
340                    })?;
341                    crate::meta_learning::linear_model::mse_gradients(&[feats], &[target], params)
342                })?;
343                ewc.consolidate(meta_parameters)?;
344            }
345
346            let mut metrics = HashMap::new();
347            metrics.insert("loss_before".to_string(), before);
348            metrics.insert("loss_after".to_string(), after);
349            sequence_results.push(TaskResult {
350                task_id: task.id.clone(),
351                loss: after,
352                metrics,
353            });
354        }
355
356        // Forgetting: how much each earlier task's loss regressed once the
357        // whole sequence has been learned.
358        let mut regressions: Vec<T> = Vec::with_capacity(sequence.len());
359        for (idx, task) in sequence.iter().enumerate() {
360            let now = crate::meta_learning::linear_model::mse_loss(
361                &task.support_set.features,
362                &task.support_set.targets,
363                meta_parameters,
364            )?;
365            if let Some(right_after) = first_losses.get(idx) {
366                regressions.push((now - *right_after).max(T::zero()));
367            }
368        }
369        let forgetting_measure =
370            crate::meta_learning::metrics::mean_of(&regressions).unwrap_or_else(T::zero);
371        self.last_forgetting = forgetting_measure;
372
373        let improvement = sequence_results
374            .iter()
375            .filter_map(|r| r.metrics.get("loss_before").map(|b| *b - r.loss))
376            .fold(T::zero(), |a, b| a + b);
377        let steps_t = T::from(total_steps.max(1)).unwrap_or_else(T::one);
378        let adaptation_efficiency = improvement / steps_t;
379
380        Ok(ContinualLearningResult {
381            sequence_results,
382            forgetting_measure,
383            adaptation_efficiency,
384        })
385    }
386
387    /// Forgetting measured by the most recent [`Self::learn_sequence`] call.
388    ///
389    /// Zero before any sequence has been learned.
390    pub fn forgetting_measure(&self) -> T {
391        self.last_forgetting
392    }
393}
394/// Few-shot learner over the shared linear task model.
395///
396/// Adapts the meta-parameters on the support set with analytic-gradient SGD,
397/// then scores the query set. Every reported figure is measured.
398pub struct FewShotLearner<T: Float + Debug + Send + Sync + 'static> {
399    settings: FewShotSettings,
400    /// Gradient steps taken on the support set
401    adaptation_steps: usize,
402    /// Support-set learning rate
403    learning_rate: T,
404    /// Accuracy of every episode learned so far
405    episode_accuracies: Vec<T>,
406}
407impl<
408        T: Float
409            + Debug
410            + Send
411            + Sync
412            + 'static
413            + Default
414            + Clone
415            + std::iter::Sum
416            + scirs2_core::ndarray::ScalarOperand,
417    > FewShotLearner<T>
418{
419    pub fn new(settings: &FewShotSettings) -> Result<Self> {
420        Ok(Self {
421            settings: settings.clone(),
422            adaptation_steps: settings.num_shots.max(1) * 4,
423            learning_rate: scirs2_core::numeric::NumCast::from(0.05).unwrap_or_else(T::zero),
424            episode_accuracies: Vec::new(),
425        })
426    }
427
428    /// The configured few-shot algorithm.
429    pub fn algorithm(&self) -> FewShotAlgorithm {
430        self.settings.algorithm
431    }
432
433    /// Adapt on `support_set` and score `query_set`.
434    pub fn learn(
435        &mut self,
436        support_set: &TaskDataset<T>,
437        query_set: &TaskDataset<T>,
438        meta_parameters: &HashMap<String, Array1<T>>,
439    ) -> Result<FewShotResult<T>> {
440        if support_set.features.is_empty() {
441            return Err(OptimError::InsufficientData(
442                "few-shot learning requires a non-empty support set".to_string(),
443            ));
444        }
445        if query_set.features.is_empty() {
446            return Err(OptimError::InsufficientData(
447                "few-shot learning requires a non-empty query set".to_string(),
448            ));
449        }
450
451        let mut adapted = meta_parameters.clone();
452        for _ in 0..self.adaptation_steps {
453            let grads = crate::meta_learning::linear_model::mse_gradients(
454                &support_set.features,
455                &support_set.targets,
456                &adapted,
457            )?;
458            crate::meta_learning::linear_model::descend(&mut adapted, &grads, self.learning_rate)?;
459        }
460
461        let predictions =
462            crate::meta_learning::linear_model::predict_all(&query_set.features, &adapted)?;
463        // Few-shot benchmarks are classification: score by label match. When
464        // the targets are not integral labels, fall back to clamped R^2.
465        let accuracy =
466            crate::meta_learning::linear_model::label_accuracy(&predictions, &query_set.targets)
467                .or_else(|| {
468                    crate::meta_learning::linear_model::r_squared(&predictions, &query_set.targets)
469                        .map(|r| r.max(T::zero()).min(T::one()))
470                })
471                .unwrap_or_else(T::zero);
472
473        // Per-sample uncertainty: the absolute residual, largest where the
474        // adapted model is least sure.
475        let uncertainty_estimates: Vec<T> = predictions
476            .iter()
477            .zip(query_set.targets.iter())
478            .map(|(p, y)| (*p - *y).abs())
479            .collect();
480        let mean_uncertainty =
481            crate::meta_learning::metrics::mean_of(&uncertainty_estimates).unwrap_or_else(T::zero);
482        let confidence = T::one() / (T::one() + mean_uncertainty);
483
484        self.episode_accuracies.push(accuracy);
485
486        Ok(FewShotResult {
487            accuracy,
488            confidence,
489            adaptation_steps: self.adaptation_steps,
490            uncertainty_estimates,
491        })
492    }
493
494    /// Mean accuracy across every episode learned so far.
495    ///
496    /// Zero before any episode has been learned.
497    pub fn average_performance(&self) -> T {
498        crate::meta_learning::metrics::mean_of(&self.episode_accuracies).unwrap_or_else(T::zero)
499    }
500}
501/// Meta-optimization tracker.
502///
503/// Records what meta-training actually did: how many epochs ran, how many
504/// tasks were consumed, the loss trajectory, and the best parameters seen.
505pub struct MetaOptimizationTracker<T: Float + Debug + Send + Sync + 'static> {
506    /// Number of recorded epochs
507    step_count: usize,
508    /// Number of tasks actually consumed
509    tasks_seen: usize,
510    /// Training loss per recorded epoch
511    training_losses: Vec<f64>,
512    /// Validation loss per recorded epoch
513    validation_losses: Vec<f64>,
514    /// Best meta-parameters seen so far
515    best_parameters: Option<MetaParameters<T>>,
516}
517impl<T: Float + Debug + Send + Sync + 'static + Default + Clone> MetaOptimizationTracker<T> {
518    pub fn new() -> Self {
519        Self {
520            step_count: 0,
521            tasks_seen: 0,
522            training_losses: Vec::new(),
523            validation_losses: Vec::new(),
524            best_parameters: None,
525        }
526    }
527
528    /// Record one meta-training epoch. `tasks_in_epoch` is the number of tasks
529    /// the epoch actually consumed.
530    pub fn record_epoch(
531        &mut self,
532        _epoch: usize,
533        training_result: &TrainingResult,
534        validation_result: &ValidationResult,
535        tasks_in_epoch: usize,
536    ) -> Result<()> {
537        self.step_count += 1;
538        self.tasks_seen += tasks_in_epoch;
539        self.training_losses.push(training_result.training_loss);
540        self.validation_losses
541            .push(validation_result.validation_loss);
542        Ok(())
543    }
544
545    pub fn update_best_parameters(&mut self, metaparameters: &MetaParameters<T>) -> Result<()> {
546        self.best_parameters = Some(metaparameters.clone());
547        Ok(())
548    }
549
550    /// The best meta-parameters recorded so far, if any.
551    pub fn best_parameters(&self) -> Option<&MetaParameters<T>> {
552        self.best_parameters.as_ref()
553    }
554
555    /// Number of epochs recorded.
556    pub fn epochs_recorded(&self) -> usize {
557        self.step_count
558    }
559
560    /// Number of tasks actually consumed across all recorded epochs.
561    pub fn total_tasks_seen(&self) -> usize {
562        self.tasks_seen
563    }
564
565    /// Mean training-loss improvement per epoch.
566    ///
567    /// Returns zero until at least two epochs have been recorded, because a
568    /// single epoch provides no improvement to measure.
569    pub fn adaptation_efficiency(&self) -> T {
570        if self.training_losses.len() < 2 {
571            return T::zero();
572        }
573        let first = self.training_losses.first().copied().unwrap_or(0.0);
574        let last = self.training_losses.last().copied().unwrap_or(0.0);
575        let epochs = (self.training_losses.len() - 1) as f64;
576        T::from((first - last) / epochs).unwrap_or_else(T::zero)
577    }
578
579    /// Validation loss trajectory (one entry per recorded epoch).
580    pub fn validation_losses(&self) -> &[f64] {
581        &self.validation_losses
582    }
583}
584/// Meta-Learning Framework for Learned Optimizers
585pub struct MetaLearningFramework<T: Float + Debug + Send + Sync + 'static> {
586    /// Meta-learning configuration
587    config: MetaLearningConfig,
588    /// Meta-learner implementation
589    meta_learner: Box<dyn MetaLearner<T> + Send + Sync>,
590    /// Task distribution manager
591    task_manager: TaskDistributionManager<T>,
592    /// Meta-validation system
593    meta_validator: MetaValidator<T>,
594    /// Adaptation engine
595    adaptation_engine: AdaptationEngine<T>,
596    /// Transfer learning manager
597    transfer_manager: TransferLearningManager<T>,
598    /// Continual learning system
599    continual_learner: ContinualLearningSystem<T>,
600    /// Multi-task coordinator
601    multitask_coordinator: MultiTaskCoordinator<T>,
602    /// Meta-optimization tracker
603    meta_tracker: MetaOptimizationTracker<T>,
604    /// Few-shot learning specialist
605    few_shot_learner: FewShotLearner<T>,
606}
607impl<
608        T: Float
609            + Default
610            + Clone
611            + Send
612            + Sync
613            + std::iter::Sum
614            + for<'a> std::iter::Sum<&'a T>
615            + scirs2_core::ndarray::ScalarOperand
616            + std::fmt::Debug,
617    > MetaLearningFramework<T>
618{
619    /// Create a new meta-learning framework
620    pub fn new(config: MetaLearningConfig) -> Result<Self> {
621        let meta_learner = Self::create_meta_learner(&config)?;
622        let task_manager = TaskDistributionManager::new(&config)?;
623        let meta_validator = MetaValidator::new(&config)?;
624        let adaptation_engine = AdaptationEngine::new(&config)?;
625        let transfer_manager = TransferLearningManager::new(&config.transfer_settings)?;
626        let continual_learner = ContinualLearningSystem::new(&config.continual_settings)?;
627        let multitask_coordinator = MultiTaskCoordinator::new(&config.multitask_settings)?;
628        let meta_tracker = MetaOptimizationTracker::new();
629        let few_shot_learner = FewShotLearner::new(&config.few_shot_settings)?;
630        Ok(Self {
631            config,
632            meta_learner,
633            task_manager,
634            meta_validator,
635            adaptation_engine,
636            transfer_manager,
637            continual_learner,
638            multitask_coordinator,
639            meta_tracker,
640            few_shot_learner,
641        })
642    }
643    /// Build the meta-learner named by `config.algorithm`.
644    ///
645    /// Only the algorithms that have a real implementation are constructed;
646    /// every other variant returns [`OptimError::InvalidConfig`] rather than
647    /// silently substituting a different algorithm.
648    fn create_meta_learner(
649        config: &MetaLearningConfig,
650    ) -> Result<Box<dyn MetaLearner<T> + Send + Sync>> {
651        let inner_lr: T = scirs2_core::numeric::NumCast::from(config.inner_learning_rate)
652            .ok_or_else(|| {
653                OptimError::InvalidConfig("inner learning rate is not representable".to_string())
654            })?;
655        let outer_lr: T = scirs2_core::numeric::NumCast::from(config.meta_learning_rate)
656            .ok_or_else(|| {
657                OptimError::InvalidConfig("meta learning rate is not representable".to_string())
658            })?;
659
660        match config.algorithm {
661            MetaLearningAlgorithm::MAML | MetaLearningAlgorithm::FOMAML => {
662                let maml_config = MAMLConfig {
663                    // FOMAML is MAML with the second-order term dropped.
664                    second_order: matches!(config.algorithm, MetaLearningAlgorithm::MAML)
665                        && config.second_order,
666                    inner_lr,
667                    outer_lr,
668                    inner_steps: config.inner_steps,
669                    allow_unused: true,
670                    gradient_clip: Some(config.gradient_clip),
671                };
672                Ok(Box::new(MAMLLearner::<T, scirs2_core::ndarray::Ix1>::new(
673                    maml_config,
674                )?))
675            }
676            MetaLearningAlgorithm::Reptile => Ok(Box::new(
677                crate::meta_learning::reptile_learner::ReptileLearner::new(outer_lr, inner_lr)
678                    .with_inner_steps(config.inner_steps),
679            )),
680            MetaLearningAlgorithm::MetaSGD => Ok(Box::new(
681                crate::meta_learning::meta_sgd_learner::MetaSGDLearner::new(inner_lr)
682                    .with_alpha_lr(outer_lr)
683                    .with_outer_lr(outer_lr)
684                    .with_inner_steps(config.inner_steps),
685            )),
686            other => Err(OptimError::InvalidConfig(format!(
687                "meta-learning algorithm {other:?} is not implemented; \
688                 available algorithms are MAML, FOMAML, Reptile and MetaSGD"
689            ))),
690        }
691    }
692    /// The meta-learning algorithm the dispatched learner actually implements.
693    pub fn algorithm(&self) -> MetaLearningAlgorithm {
694        self.meta_learner.get_algorithm()
695    }
696
697    /// The configuration this framework was built from.
698    pub fn config(&self) -> &MetaLearningConfig {
699        &self.config
700    }
701
702    /// Perform meta-training.
703    ///
704    /// **Update contract:** the meta-learner owns the meta-update. Its
705    /// `meta_train_step` writes the new meta-parameters and reports the
706    /// gradient it used; this loop does not apply that gradient a second time.
707    pub async fn meta_train(
708        &mut self,
709        tasks: Vec<MetaTask<T>>,
710        num_epochs: usize,
711    ) -> Result<MetaTrainingResults<T>> {
712        if tasks.is_empty() {
713            return Err(OptimError::InsufficientData(
714                "meta-training requires a non-empty task pool".to_string(),
715            ));
716        }
717        let meta_params_raw = self.initialize_meta_parameters(&tasks)?;
718        let mut meta_parameters = MetaParameters {
719            parameters: meta_params_raw,
720            metadata: HashMap::new(),
721        };
722        let mut training_history = Vec::new();
723        let mut best_performance = T::neg_infinity();
724        let batch_size = self.config.task_batch_size;
725        for epoch in 0..num_epochs {
726            let task_batch = self.task_manager.sample_task_batch(&tasks, batch_size)?;
727            let batch_len = task_batch.len();
728            // The learner applies the meta-update itself; see the contract above.
729            let training_result = self
730                .meta_learner
731                .meta_train_step(&task_batch, &mut meta_parameters.parameters)?;
732            let validation_result =
733                self.meta_validator
734                    .validate(&meta_parameters, &tasks, &mut *self.meta_learner)?;
735            let mut training_metrics = HashMap::new();
736            training_metrics.insert(
737                "gradient_alignment".to_string(),
738                training_result
739                    .metrics
740                    .gradient_alignment
741                    .to_f64()
742                    .unwrap_or(0.0),
743            );
744            training_metrics.insert(
745                "adaptation_speed".to_string(),
746                training_result
747                    .metrics
748                    .avg_adaptation_speed
749                    .to_f64()
750                    .unwrap_or(0.0),
751            );
752            let training_result_simple = TrainingResult {
753                training_loss: training_result.meta_loss.to_f64().unwrap_or(f64::NAN),
754                metrics: training_metrics,
755                steps: epoch,
756            };
757            self.meta_tracker.record_epoch(
758                epoch,
759                &training_result_simple,
760                &validation_result,
761                batch_len,
762            )?;
763            let current_performance =
764                T::from(-validation_result.validation_loss).unwrap_or_default();
765            if current_performance > best_performance {
766                best_performance = current_performance;
767                self.meta_tracker.update_best_parameters(&meta_parameters)?;
768            }
769            let mut task_specific_metrics = HashMap::new();
770            for (name, value) in &validation_result.metrics {
771                if let Some(v) = T::from(*value) {
772                    task_specific_metrics.insert(name.clone(), v);
773                }
774            }
775            // Generalization gap: held-out loss minus meta-training loss.
776            let generalization_gap = T::from(validation_result.validation_loss)
777                .unwrap_or_else(T::zero)
778                - training_result.meta_loss;
779            let meta_validation_result = MetaValidationResult {
780                performance: current_performance,
781                adaptation_speed: training_result.metrics.avg_adaptation_speed,
782                generalization_gap,
783                task_specific_metrics,
784            };
785            training_history.push(MetaTrainingEpoch {
786                epoch,
787                training_result,
788                validation_result: meta_validation_result,
789                meta_parameters: meta_parameters.parameters.clone(),
790            });
791            if self.should_early_stop(&training_history) {
792                break;
793            }
794        }
795        let total_epochs = training_history.len();
796        Ok(MetaTrainingResults {
797            final_parameters: meta_parameters.parameters,
798            training_history,
799            best_performance,
800            total_epochs,
801        })
802    }
803    /// Adapt to new task
804    pub fn adapt_to_task(
805        &mut self,
806        task: &MetaTask<T>,
807        meta_parameters: &HashMap<String, Array1<T>>,
808    ) -> Result<TaskAdaptationResult<T>> {
809        self.adaptation_engine.adapt(
810            task,
811            meta_parameters,
812            &mut *self.meta_learner,
813            self.config.inner_steps,
814        )
815    }
816    /// Perform few-shot learning
817    pub fn few_shot_learning(
818        &mut self,
819        support_set: &TaskDataset<T>,
820        query_set: &TaskDataset<T>,
821        meta_parameters: &HashMap<String, Array1<T>>,
822    ) -> Result<FewShotResult<T>> {
823        self.few_shot_learner
824            .learn(support_set, query_set, meta_parameters)
825    }
826    /// Transfer learning to new domain
827    pub fn transfer_to_domain(
828        &mut self,
829        source_tasks: &[MetaTask<T>],
830        target_tasks: &[MetaTask<T>],
831        meta_parameters: &HashMap<String, Array1<T>>,
832    ) -> Result<TransferLearningResult<T>> {
833        self.transfer_manager
834            .transfer(source_tasks, target_tasks, meta_parameters)
835    }
836    /// Continual learning across task sequence
837    pub fn continual_learning(
838        &mut self,
839        task_sequence: &[MetaTask<T>],
840        meta_parameters: &mut HashMap<String, Array1<T>>,
841    ) -> Result<ContinualLearningResult<T>> {
842        self.continual_learner
843            .learn_sequence(task_sequence, meta_parameters)
844    }
845    /// Multi-task learning
846    pub fn multi_task_learning(
847        &mut self,
848        tasks: &[MetaTask<T>],
849        meta_parameters: &mut HashMap<String, Array1<T>>,
850    ) -> Result<MultiTaskResult<T>> {
851        self.multitask_coordinator
852            .learn_simultaneously(tasks, meta_parameters)
853    }
854    /// Initialise the meta-parameters of the shared linear task model.
855    ///
856    /// The weight vector is sized from the widest support feature vector in the
857    /// pool and initialised with small deterministic pseudo-random values
858    /// (Xavier-style scale `sqrt(1 / fan_in)`); a zero initialisation would put
859    /// every task at the same stationary starting point.
860    pub fn initialize_meta_parameters(
861        &self,
862        tasks: &[MetaTask<T>],
863    ) -> Result<HashMap<String, Array1<T>>> {
864        let feature_dim = tasks
865            .iter()
866            .flat_map(|t| {
867                t.support_set
868                    .features
869                    .iter()
870                    .chain(t.query_set.features.iter())
871            })
872            .map(|f| f.len())
873            .max()
874            .unwrap_or(0);
875        if feature_dim == 0 {
876            return Err(OptimError::InsufficientData(
877                "cannot size meta-parameters: no task carries any features".to_string(),
878            ));
879        }
880
881        let scale = 1.0 / (feature_dim as f64).sqrt();
882        let mut rng = scirs2_core::random::Random::seed(0x1234_5678_9ABC_DEF0);
883        let mut weights = Array1::zeros(feature_dim);
884        for i in 0..feature_dim {
885            let v: f64 = rng.random_range(-scale..scale);
886            weights[i] = scirs2_core::numeric::NumCast::from(v).unwrap_or_else(T::zero);
887        }
888
889        let mut parameters = HashMap::new();
890        parameters.insert(
891            crate::meta_learning::linear_model::WEIGHTS_KEY.to_string(),
892            weights,
893        );
894        parameters.insert(
895            crate::meta_learning::linear_model::BIAS_KEY.to_string(),
896            Array1::zeros(1),
897        );
898        Ok(parameters)
899    }
900
901    /// Stop when the held-out validation loss has plateaued.
902    ///
903    /// The decision uses the *real* validation losses recorded by the
904    /// meta-validator; the old implementation compared a constant to itself and
905    /// therefore stopped every run at epoch 10.
906    fn should_early_stop(&self, history: &[MetaTrainingEpoch<T>]) -> bool {
907        const WINDOW: usize = 5;
908        if history.len() < 2 * WINDOW {
909            return false;
910        }
911        let recent: Vec<T> = history
912            .iter()
913            .rev()
914            .take(WINDOW)
915            .map(|epoch| epoch.validation_result.performance)
916            .collect();
917        let max_recent = recent.iter().fold(T::neg_infinity(), |a, &b| a.max(b));
918        let min_recent = recent.iter().fold(T::infinity(), |a, &b| a.min(b));
919        if !max_recent.is_finite() || !min_recent.is_finite() {
920            return false;
921        }
922        let spread = max_recent - min_recent;
923        let scale = max_recent.abs().max(T::one());
924        let threshold: T = scirs2_core::numeric::NumCast::from(1e-6).unwrap_or_else(T::zero);
925        spread / scale < threshold
926    }
927    /// Get meta-learning statistics
928    pub fn get_meta_learning_statistics(&self) -> MetaLearningStatistics<T> {
929        MetaLearningStatistics {
930            algorithm: self.config.algorithm,
931            total_tasks_seen: self.meta_tracker.total_tasks_seen(),
932            adaptation_efficiency: self.meta_tracker.adaptation_efficiency(),
933            transfer_success_rate: self.transfer_manager.success_rate(),
934            forgetting_measure: self.continual_learner.forgetting_measure(),
935            multitask_interference: self.multitask_coordinator.interference_measure(),
936            few_shot_performance: self.few_shot_learner.average_performance(),
937        }
938    }
939}
940/// Transfer learning manager backed by
941/// [`crate::cross_domain_transfer::CrossDomainTransfer`].
942pub struct TransferLearningManager<T: Float + Debug + Send + Sync + 'static> {
943    settings: TransferLearningSettings,
944    /// Number of transfers attempted so far
945    attempts: usize,
946    /// Number of transfers that improved the target-task loss
947    successes: usize,
948    _phantom: std::marker::PhantomData<T>,
949}
950impl<
951        T: Float
952            + Debug
953            + Send
954            + Sync
955            + 'static
956            + Default
957            + Clone
958            + std::iter::Sum
959            + scirs2_core::ndarray::ScalarOperand,
960    > TransferLearningManager<T>
961{
962    pub fn new(settings: &TransferLearningSettings) -> Result<Self> {
963        Ok(Self {
964            settings: settings.clone(),
965            attempts: 0,
966            successes: 0,
967            _phantom: std::marker::PhantomData,
968        })
969    }
970
971    /// Whether the configured settings request domain adaptation.
972    pub fn domain_adaptation_enabled(&self) -> bool {
973        self.settings.domain_adaptation
974    }
975
976    /// Transfer from a set of source tasks to a set of target tasks.
977    ///
978    /// Both task groups are summarised as domains (mean support features and
979    /// the fitted linear parameters), registered with the real
980    /// [`crate::cross_domain_transfer::CrossDomainTransfer`] engine, and the
981    /// transferred parameters are evaluated on the target tasks. Every number
982    /// reported here is measured, not assumed.
983    pub fn transfer(
984        &mut self,
985        source_tasks: &[MetaTask<T>],
986        target_tasks: &[MetaTask<T>],
987        meta_parameters: &HashMap<String, Array1<T>>,
988    ) -> Result<TransferLearningResult<T>> {
989        if source_tasks.is_empty() || target_tasks.is_empty() {
990            return Err(OptimError::InsufficientData(
991                "transfer requires at least one source and one target task".to_string(),
992            ));
993        }
994        let weights = meta_parameters
995            .get(crate::meta_learning::linear_model::WEIGHTS_KEY)
996            .ok_or_else(|| {
997                OptimError::InvalidConfig(
998                    "transfer requires a 'weights' entry in the meta-parameters".to_string(),
999                )
1000            })?;
1001
1002        let source_repr = Self::group_signature(source_tasks, weights.len())?;
1003        let target_repr = Self::group_signature(target_tasks, weights.len())?;
1004
1005        let mut engine = crate::cross_domain_transfer::CrossDomainTransfer::<T>::new(weights.len());
1006        engine.register_domain(crate::cross_domain_transfer::DomainKnowledge {
1007            domain_name: "source".to_string(),
1008            shared_representation: source_repr,
1009            domain_specific_params: weights.clone(),
1010            performance_history: Vec::new(),
1011        })?;
1012        engine.register_domain(crate::cross_domain_transfer::DomainKnowledge {
1013            domain_name: "target".to_string(),
1014            shared_representation: target_repr,
1015            domain_specific_params: weights.clone(),
1016            performance_history: Vec::new(),
1017        })?;
1018
1019        let similarity = engine.compute_domain_similarity("source", "target")?;
1020        let result = engine.transfer("source", "target")?;
1021
1022        let mut transferred = meta_parameters.clone();
1023        transferred.insert(
1024            crate::meta_learning::linear_model::WEIGHTS_KEY.to_string(),
1025            result.transferred_params.clone(),
1026        );
1027
1028        let source_before = Self::group_loss(source_tasks, meta_parameters)?;
1029        let source_after = Self::group_loss(source_tasks, &transferred)?;
1030        let target_before = Self::group_loss(target_tasks, meta_parameters)?;
1031        let target_after = Self::group_loss(target_tasks, &transferred)?;
1032
1033        self.attempts += 1;
1034        if target_after < target_before {
1035            self.successes += 1;
1036        }
1037
1038        let relative = |before: T, after: T| -> T {
1039            if before > T::zero() {
1040                ((before - after) / before).max(T::zero()).min(T::one())
1041            } else {
1042                T::zero()
1043            }
1044        };
1045
1046        Ok(TransferLearningResult {
1047            transfer_efficiency: relative(target_before, target_after),
1048            // Cosine similarity mapped from [-1, 1] to [0, 1].
1049            domain_adaptation_score: (similarity + T::one()) / (T::one() + T::one()),
1050            // Retention: how much of the source performance survived.
1051            source_task_retention: if source_after > T::zero() {
1052                (source_before / source_after).min(T::one())
1053            } else {
1054                T::one()
1055            },
1056            target_task_performance: T::one() / (T::one() + target_after),
1057        })
1058    }
1059
1060    /// Fraction of transfers that improved the target-task loss.
1061    ///
1062    /// Returns zero before any transfer has been attempted.
1063    pub fn success_rate(&self) -> T {
1064        if self.attempts == 0 {
1065            return T::zero();
1066        }
1067        let a = T::from(self.attempts).unwrap_or_else(T::one);
1068        T::from(self.successes).unwrap_or_else(T::zero) / a
1069    }
1070
1071    /// Mean support-feature vector across a group of tasks, resized to `dim`.
1072    fn group_signature(tasks: &[MetaTask<T>], dim: usize) -> Result<Array1<T>> {
1073        let mut acc: Array1<T> = Array1::zeros(dim);
1074        let mut count = 0usize;
1075        for task in tasks {
1076            for feat in &task.support_set.features {
1077                let n = dim.min(feat.len());
1078                for i in 0..n {
1079                    acc[i] = acc[i] + feat[i];
1080                }
1081                count += 1;
1082            }
1083        }
1084        if count == 0 {
1085            return Err(OptimError::InsufficientData(
1086                "task group has no support samples".to_string(),
1087            ));
1088        }
1089        let c = T::from(count).unwrap_or_else(T::one);
1090        Ok(acc.mapv(|v| v / c))
1091    }
1092
1093    /// Mean query loss of a group of tasks at the given parameters.
1094    fn group_loss(tasks: &[MetaTask<T>], parameters: &HashMap<String, Array1<T>>) -> Result<T> {
1095        let mut total = T::zero();
1096        let mut count = 0usize;
1097        for task in tasks {
1098            if task.query_set.features.is_empty() {
1099                continue;
1100            }
1101            total = total
1102                + crate::meta_learning::linear_model::mse_loss(
1103                    &task.query_set.features,
1104                    &task.query_set.targets,
1105                    parameters,
1106                )?;
1107            count += 1;
1108        }
1109        if count == 0 {
1110            return Err(OptimError::InsufficientData(
1111                "task group has no query samples".to_string(),
1112            ));
1113        }
1114        let c = T::from(count).unwrap_or_else(T::one);
1115        Ok(total / c)
1116    }
1117}
1118/// Multi-task coordinator.
1119///
1120/// Trains one shared parameter vector on all tasks at once, combining the
1121/// per-task gradients according to [`TaskWeightingStrategy`] and
1122/// [`GradientBalancingMethod`], and measures the real gradient interference
1123/// between tasks.
1124pub struct MultiTaskCoordinator<T: Float + Debug + Send + Sync + 'static> {
1125    settings: MultiTaskSettings,
1126    /// Joint gradient steps per `learn_simultaneously` call
1127    steps: usize,
1128    /// Interference measured by the most recent call
1129    last_interference: T,
1130}
1131impl<
1132        T: Float
1133            + Debug
1134            + Send
1135            + Sync
1136            + 'static
1137            + Default
1138            + Clone
1139            + std::iter::Sum
1140            + scirs2_core::ndarray::ScalarOperand,
1141    > MultiTaskCoordinator<T>
1142{
1143    pub fn new(settings: &MultiTaskSettings) -> Result<Self> {
1144        Ok(Self {
1145            settings: settings.clone(),
1146            steps: 20,
1147            last_interference: T::zero(),
1148        })
1149    }
1150
1151    /// Override the number of joint gradient steps.
1152    pub fn with_steps(mut self, steps: usize) -> Result<Self> {
1153        if steps == 0 {
1154            return Err(OptimError::InvalidConfig(
1155                "multi-task steps must be greater than zero".to_string(),
1156            ));
1157        }
1158        self.steps = steps;
1159        Ok(self)
1160    }
1161
1162    pub fn learn_simultaneously(
1163        &mut self,
1164        tasks: &[MetaTask<T>],
1165        meta_parameters: &mut HashMap<String, Array1<T>>,
1166    ) -> Result<MultiTaskResult<T>> {
1167        if tasks.is_empty() {
1168            return Err(OptimError::InsufficientData(
1169                "multi-task learning requires at least one task".to_string(),
1170            ));
1171        }
1172
1173        let lr: T = scirs2_core::numeric::NumCast::from(0.05).unwrap_or_else(T::zero);
1174        let start = std::time::Instant::now();
1175        let mut last_gradients: Vec<HashMap<String, Array1<T>>> = Vec::new();
1176        let mut initial_losses: Vec<T> = Vec::with_capacity(tasks.len());
1177        for task in tasks {
1178            initial_losses.push(crate::meta_learning::linear_model::mse_loss(
1179                &task.support_set.features,
1180                &task.support_set.targets,
1181                meta_parameters,
1182            )?);
1183        }
1184
1185        for _ in 0..self.steps {
1186            let mut per_task: Vec<HashMap<String, Array1<T>>> = Vec::with_capacity(tasks.len());
1187            let mut losses: Vec<T> = Vec::with_capacity(tasks.len());
1188            for task in tasks {
1189                losses.push(crate::meta_learning::linear_model::mse_loss(
1190                    &task.support_set.features,
1191                    &task.support_set.targets,
1192                    meta_parameters,
1193                )?);
1194                per_task.push(crate::meta_learning::linear_model::mse_gradients(
1195                    &task.support_set.features,
1196                    &task.support_set.targets,
1197                    meta_parameters,
1198                )?);
1199            }
1200
1201            let weights = self.task_weights(&losses, &per_task)?;
1202            let mut combined: HashMap<String, Array1<T>> = meta_parameters
1203                .iter()
1204                .map(|(name, p)| (name.clone(), Array1::zeros(p.len())))
1205                .collect();
1206            for (grad, weight) in per_task.iter().zip(weights.iter()) {
1207                for (name, g) in grad {
1208                    if let Some(acc) = combined.get_mut(name) {
1209                        let n = acc.len().min(g.len());
1210                        for i in 0..n {
1211                            acc[i] = acc[i] + *weight * g[i];
1212                        }
1213                    }
1214                }
1215            }
1216            crate::meta_learning::linear_model::descend(meta_parameters, &combined, lr)?;
1217            last_gradients = per_task;
1218        }
1219
1220        // Interference: mean pairwise *conflict* between task gradients,
1221        // i.e. (1 - cosine)/2 in [0, 1]. Orthogonal tasks score 0.5, opposed
1222        // tasks score 1.
1223        let mut conflicts: Vec<T> = Vec::new();
1224        for i in 0..last_gradients.len() {
1225            for j in (i + 1)..last_gradients.len() {
1226                if let Some(c) = crate::meta_learning::linear_model::map_cosine(
1227                    &last_gradients[i],
1228                    &last_gradients[j],
1229                ) {
1230                    conflicts.push((T::one() - c) / (T::one() + T::one()));
1231                }
1232            }
1233        }
1234        self.last_interference =
1235            crate::meta_learning::metrics::mean_of(&conflicts).unwrap_or_else(T::zero);
1236
1237        let mut task_results = Vec::with_capacity(tasks.len());
1238        let mut converged = true;
1239        for (idx, task) in tasks.iter().enumerate() {
1240            let loss = crate::meta_learning::linear_model::mse_loss(
1241                &task.support_set.features,
1242                &task.support_set.targets,
1243                meta_parameters,
1244            )?;
1245            if let Some(before) = initial_losses.get(idx) {
1246                if loss >= *before {
1247                    converged = false;
1248                }
1249            }
1250            let mut metrics = HashMap::new();
1251            if let Some(before) = initial_losses.get(idx) {
1252                metrics.insert("loss_before".to_string(), *before);
1253            }
1254            metrics.insert("loss_after".to_string(), loss);
1255            task_results.push(TaskResult {
1256                task_id: task.id.clone(),
1257                loss,
1258                metrics,
1259            });
1260        }
1261
1262        let elapsed = T::from(start.elapsed().as_secs_f64()).unwrap_or_else(T::zero);
1263        Ok(MultiTaskResult {
1264            task_results,
1265            coordination_overhead: elapsed,
1266            convergence_status: if converged {
1267                "converged".to_string()
1268            } else {
1269                "not_converged".to_string()
1270            },
1271        })
1272    }
1273
1274    /// Per-task gradient weights implied by the configured strategies.
1275    fn task_weights(
1276        &self,
1277        losses: &[T],
1278        gradients: &[HashMap<String, Array1<T>>],
1279    ) -> Result<Vec<T>> {
1280        let n = T::from(losses.len().max(1)).unwrap_or_else(T::one);
1281        let raw: Vec<T> = match self.settings.task_weighting {
1282            TaskWeightingStrategy::Uniform => vec![T::one(); losses.len()],
1283            TaskWeightingStrategy::PerformanceBased | TaskWeightingStrategy::Adaptive => {
1284                // Focus on the tasks that are currently doing worst.
1285                losses.iter().map(|l| l.max(T::zero())).collect()
1286            }
1287            TaskWeightingStrategy::UncertaintyBased => {
1288                // Homoscedastic-uncertainty weighting: 1 / (1 + loss).
1289                losses
1290                    .iter()
1291                    .map(|l| T::one() / (T::one() + l.max(T::zero())))
1292                    .collect()
1293            }
1294            TaskWeightingStrategy::GradientMagnitude | TaskWeightingStrategy::Learned => gradients
1295                .iter()
1296                .map(crate::meta_learning::linear_model::map_norm)
1297                .collect(),
1298        };
1299
1300        let total = raw.iter().copied().fold(T::zero(), |a, b| a + b);
1301        let normalised: Vec<T> = if total > T::zero() {
1302            raw.iter().map(|w| *w / total).collect()
1303        } else {
1304            vec![T::one() / n; losses.len()]
1305        };
1306
1307        Ok(match self.settings.gradient_balancing {
1308            // GradNorm-style rescaling keeps every task's contribution at a
1309            // comparable magnitude; the other methods use the weights directly.
1310            GradientBalancingMethod::GradNorm => {
1311                let norms: Vec<T> = gradients
1312                    .iter()
1313                    .map(crate::meta_learning::linear_model::map_norm)
1314                    .collect();
1315                let mean_norm =
1316                    crate::meta_learning::metrics::mean_of(&norms).unwrap_or_else(T::one);
1317                normalised
1318                    .iter()
1319                    .zip(norms.iter())
1320                    .map(|(w, nrm)| {
1321                        if *nrm > T::zero() {
1322                            *w * mean_norm / *nrm
1323                        } else {
1324                            *w
1325                        }
1326                    })
1327                    .collect()
1328            }
1329            _ => normalised,
1330        })
1331    }
1332
1333    /// Gradient interference measured by the most recent call, in `[0, 1]`.
1334    ///
1335    /// Zero before any multi-task step has run.
1336    pub fn interference_measure(&self) -> T {
1337        self.last_interference
1338    }
1339}
1340/// Meta-validation system for meta-learning.
1341///
1342/// Holds out a deterministic slice of the task pool and measures the *real*
1343/// post-adaptation query loss of the current meta-parameters on it.
1344pub struct MetaValidator<T: Float + Debug + Send + Sync + 'static> {
1345    config: MetaLearningConfig,
1346    /// Fraction of the task pool reserved for validation
1347    holdout_fraction: f64,
1348    _phantom: std::marker::PhantomData<T>,
1349}
1350impl<
1351        T: Float
1352            + Debug
1353            + Send
1354            + Sync
1355            + 'static
1356            + Default
1357            + Clone
1358            + std::iter::Sum
1359            + scirs2_core::ndarray::ScalarOperand,
1360    > MetaValidator<T>
1361{
1362    pub fn new(config: &MetaLearningConfig) -> Result<Self> {
1363        Ok(Self {
1364            config: config.clone(),
1365            holdout_fraction: 0.25,
1366            _phantom: std::marker::PhantomData,
1367        })
1368    }
1369
1370    /// Override the held-out fraction (must be in `(0, 1)`).
1371    pub fn with_holdout_fraction(mut self, fraction: f64) -> Result<Self> {
1372        if !(fraction > 0.0 && fraction < 1.0) {
1373            return Err(OptimError::InvalidConfig(
1374                "holdout fraction must lie strictly between 0 and 1".to_string(),
1375            ));
1376        }
1377        self.holdout_fraction = fraction;
1378        Ok(self)
1379    }
1380
1381    /// Indices of the held-out tasks: the deterministic tail of the pool.
1382    fn holdout_range(&self, len: usize) -> std::ops::Range<usize> {
1383        if len == 0 {
1384            return 0..0;
1385        }
1386        let count = ((len as f64) * self.holdout_fraction).ceil() as usize;
1387        let count = count.clamp(1, len);
1388        (len - count)..len
1389    }
1390
1391    /// Validate the current meta-parameters against held-out tasks.
1392    ///
1393    /// Each held-out task is adapted with the learner's own inner loop, then
1394    /// scored on its query set. The reported loss is the mean of those query
1395    /// losses — never a constant.
1396    pub fn validate(
1397        &self,
1398        meta_parameters: &MetaParameters<T>,
1399        tasks: &[MetaTask<T>],
1400        meta_learner: &mut dyn MetaLearner<T>,
1401    ) -> Result<ValidationResult> {
1402        let range = self.holdout_range(tasks.len());
1403        let holdout = tasks.get(range).unwrap_or(&[]);
1404        if holdout.is_empty() {
1405            return Err(OptimError::InsufficientData(
1406                "no held-out tasks available for meta-validation".to_string(),
1407            ));
1408        }
1409
1410        let mut total = 0.0f64;
1411        let mut pre_total = 0.0f64;
1412        let mut counted = 0usize;
1413        for task in holdout {
1414            if task.query_set.features.is_empty() || task.support_set.features.is_empty() {
1415                continue;
1416            }
1417            let pre = crate::meta_learning::linear_model::mse_loss(
1418                &task.query_set.features,
1419                &task.query_set.targets,
1420                &meta_parameters.parameters,
1421            )?;
1422            let adaptation = meta_learner.adapt_to_task(
1423                task,
1424                &meta_parameters.parameters,
1425                self.config.inner_steps,
1426            )?;
1427            let query = meta_learner.evaluate_query_set(task, &adaptation.adapted_parameters)?;
1428            total += query.query_loss.to_f64().unwrap_or(f64::NAN);
1429            pre_total += pre.to_f64().unwrap_or(f64::NAN);
1430            counted += 1;
1431        }
1432        if counted == 0 {
1433            return Err(OptimError::InsufficientData(
1434                "every held-out task was empty".to_string(),
1435            ));
1436        }
1437
1438        let validation_loss = total / counted as f64;
1439        let pre_loss = pre_total / counted as f64;
1440        let mut metrics = std::collections::HashMap::new();
1441        metrics.insert("held_out_tasks".to_string(), counted as f64);
1442        metrics.insert("pre_adaptation_loss".to_string(), pre_loss);
1443        metrics.insert("post_adaptation_loss".to_string(), validation_loss);
1444
1445        Ok(ValidationResult {
1446            is_valid: validation_loss.is_finite(),
1447            validation_loss,
1448            metrics,
1449        })
1450    }
1451}
1452/// Adaptation engine for meta-learning
1453pub struct AdaptationEngine<T: Float + Debug + Send + Sync + 'static> {
1454    config: MetaLearningConfig,
1455    _phantom: std::marker::PhantomData<T>,
1456}
1457impl<T: Float + Debug + Send + Sync + 'static + Default + Clone> AdaptationEngine<T> {
1458    pub fn new(config: &MetaLearningConfig) -> Result<Self> {
1459        Ok(Self {
1460            config: config.clone(),
1461            _phantom: std::marker::PhantomData,
1462        })
1463    }
1464    /// Adapt to a task by delegating to the meta-learner's own inner loop.
1465    ///
1466    /// `inner_steps` of zero falls back to the configured `inner_steps`.
1467    pub fn adapt(
1468        &mut self,
1469        task: &MetaTask<T>,
1470        meta_parameters: &HashMap<String, Array1<T>>,
1471        meta_learner: &mut dyn MetaLearner<T>,
1472        inner_steps: usize,
1473    ) -> Result<TaskAdaptationResult<T>> {
1474        let steps = if inner_steps == 0 {
1475            self.config.inner_steps
1476        } else {
1477            inner_steps
1478        };
1479        if steps == 0 {
1480            return Err(OptimError::InvalidConfig(
1481                "adaptation requires at least one inner step".to_string(),
1482            ));
1483        }
1484        meta_learner.adapt_to_task(task, meta_parameters, steps)
1485    }
1486
1487    /// The adaptation strategies this engine was configured with.
1488    pub fn strategies(&self) -> &[AdaptationStrategy] {
1489        &self.config.adaptation_strategies
1490    }
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495    use super::*;
1496    use crate::meta_learning::linear_model;
1497
1498    fn basis(x: f64) -> Array1<f64> {
1499        let u = x / 3.0;
1500        Array1::from_vec(vec![1.0, u, u * u, u * u * u])
1501    }
1502
1503    fn linear_task(id: usize, slope: f64, intercept: f64) -> MetaTask<f64> {
1504        let mut support_features = Vec::new();
1505        let mut support_targets = Vec::new();
1506        let mut query_features = Vec::new();
1507        let mut query_targets = Vec::new();
1508        for k in 0..10 {
1509            let x = -3.0 + 6.0 * (k as f64) / 9.0;
1510            let y = slope * x + intercept;
1511            if k % 2 == 0 {
1512                support_features.push(basis(x));
1513                support_targets.push(y);
1514            } else {
1515                query_features.push(basis(x));
1516                query_targets.push(y);
1517            }
1518        }
1519        MetaTask {
1520            id: format!("task_{id}"),
1521            support_set: TaskDataset {
1522                features: support_features,
1523                targets: support_targets,
1524                weights: Vec::new(),
1525                metadata: DatasetMetadata::default(),
1526            },
1527            query_set: TaskDataset {
1528                features: query_features,
1529                targets: query_targets,
1530                weights: Vec::new(),
1531                metadata: DatasetMetadata::default(),
1532            },
1533            metadata: TaskMetadata::default(),
1534            difficulty: slope.abs(),
1535            domain: format!("domain_{}", id % 2),
1536            task_type: TaskType::Regression,
1537        }
1538    }
1539
1540    fn task_pool(n: usize) -> Vec<MetaTask<f64>> {
1541        (0..n)
1542            .map(|i| linear_task(i, 0.2 + 0.3 * i as f64, -1.0 + 0.4 * i as f64))
1543            .collect()
1544    }
1545
1546    fn params(dim: usize) -> HashMap<String, Array1<f64>> {
1547        let mut p = HashMap::new();
1548        p.insert(linear_model::WEIGHTS_KEY.to_string(), Array1::zeros(dim));
1549        p.insert(linear_model::BIAS_KEY.to_string(), Array1::zeros(1));
1550        p
1551    }
1552
1553    /// F10: batches come from the caller's pool, honour the batch size, and an
1554    /// empty pool is an error rather than ten default (empty) tasks.
1555    #[test]
1556    fn test_sample_task_batch_uses_real_tasks() {
1557        let pool = task_pool(5);
1558        let config = MetaLearningConfig::default();
1559        let mut manager = TaskDistributionManager::<f64>::new(&config).expect("manager");
1560
1561        // No `.min(10)` cap: asking for 20 yields 20.
1562        let batch = manager.sample_task_batch(&pool, 20).expect("batch");
1563        assert_eq!(batch.len(), 20);
1564        for task in &batch {
1565            assert!(pool.iter().any(|t| t.id == task.id), "unknown task sampled");
1566            assert!(!task.support_set.features.is_empty(), "empty support set");
1567        }
1568
1569        assert!(manager.sample_task_batch(&[], 4).is_err());
1570        assert!(manager.sample_task_batch(&pool, 0).is_err());
1571    }
1572
1573    /// F10: the sampling strategy actually changes which tasks are drawn.
1574    #[test]
1575    fn test_sampling_strategies_differ() {
1576        let pool = task_pool(6);
1577
1578        let easiest = MetaLearningConfig {
1579            task_sampling_strategy: TaskSamplingStrategy::Curriculum,
1580            ..MetaLearningConfig::default()
1581        };
1582        let mut curriculum =
1583            TaskDistributionManager::<f64>::new(&easiest).expect("curriculum manager");
1584        let first = curriculum.sample_task_batch(&pool, 2).expect("batch");
1585
1586        let hardest = MetaLearningConfig {
1587            task_sampling_strategy: TaskSamplingStrategy::Adversarial,
1588            ..MetaLearningConfig::default()
1589        };
1590        let mut adversarial =
1591            TaskDistributionManager::<f64>::new(&hardest).expect("adversarial manager");
1592        let hard = adversarial.sample_task_batch(&pool, 2).expect("batch");
1593
1594        // Curriculum starts with the easiest task, adversarial with the hardest.
1595        assert_ne!(first[0].id, hard[0].id);
1596        assert!(first[0].difficulty < hard[0].difficulty);
1597    }
1598
1599    /// F12: the requested algorithm is the one that gets built, and
1600    /// unimplemented algorithms are refused instead of silently substituted.
1601    #[test]
1602    fn test_meta_learner_dispatch() {
1603        let cases = [
1604            (MetaLearningAlgorithm::MAML, MetaLearningAlgorithm::FOMAML),
1605            (MetaLearningAlgorithm::FOMAML, MetaLearningAlgorithm::FOMAML),
1606            (
1607                MetaLearningAlgorithm::Reptile,
1608                MetaLearningAlgorithm::Reptile,
1609            ),
1610            (
1611                MetaLearningAlgorithm::MetaSGD,
1612                MetaLearningAlgorithm::MetaSGD,
1613            ),
1614        ];
1615        for (requested, expected) in cases {
1616            let config = MetaLearningConfig {
1617                algorithm: requested,
1618                ..MetaLearningConfig::default()
1619            };
1620            let framework = MetaLearningFramework::<f64>::new(config).expect("framework");
1621            assert_eq!(
1622                format!("{:?}", framework.algorithm()),
1623                format!("{expected:?}"),
1624                "dispatch for {requested:?}"
1625            );
1626        }
1627
1628        // Second-order MAML reports MAML.
1629        let config = MetaLearningConfig {
1630            second_order: true,
1631            ..MetaLearningConfig::default()
1632        };
1633        let framework = MetaLearningFramework::<f64>::new(config).expect("framework");
1634        assert!(matches!(framework.algorithm(), MetaLearningAlgorithm::MAML));
1635
1636        for unimplemented in [
1637            MetaLearningAlgorithm::ProtoNet,
1638            MetaLearningAlgorithm::MatchingNet,
1639            MetaLearningAlgorithm::IMaml,
1640            MetaLearningAlgorithm::L2L,
1641        ] {
1642            let config = MetaLearningConfig {
1643                algorithm: unimplemented,
1644                ..MetaLearningConfig::default()
1645            };
1646            assert!(
1647                MetaLearningFramework::<f64>::new(config).is_err(),
1648                "{unimplemented:?} must be refused, not silently replaced by MAML"
1649            );
1650        }
1651    }
1652
1653    /// F13: the adaptation engine runs the learner's inner loop.
1654    #[test]
1655    fn test_adaptation_engine_delegates() {
1656        let config = MetaLearningConfig::default();
1657        let mut engine = AdaptationEngine::<f64>::new(&config).expect("engine");
1658        let mut learner = crate::meta_learning::reptile_learner::ReptileLearner::new(0.1f64, 0.3);
1659        let task = linear_task(0, 0.7, -0.2);
1660        let start = params(4);
1661
1662        let result = engine.adapt(&task, &start, &mut learner, 6).expect("adapt");
1663        assert_eq!(result.adaptation_trajectory.len(), 6);
1664        let moved = result.adapted_parameters[linear_model::WEIGHTS_KEY]
1665            .iter()
1666            .any(|w| w.abs() > 0.0);
1667        assert!(moved, "adaptation must move the parameters");
1668        let baseline = linear_model::mse_loss(
1669            &task.support_set.features,
1670            &task.support_set.targets,
1671            &start,
1672        )
1673        .expect("loss");
1674        assert!(result.final_loss < baseline);
1675    }
1676
1677    /// F14: the validator measures a real held-out loss.
1678    #[test]
1679    fn test_validator_measures_real_holdout_loss() {
1680        let pool = task_pool(8);
1681        let config = MetaLearningConfig::default();
1682        let validator = MetaValidator::<f64>::new(&config).expect("validator");
1683        let mut learner = crate::meta_learning::reptile_learner::ReptileLearner::new(0.1f64, 0.3);
1684
1685        let good = MetaParameters {
1686            parameters: params(4),
1687            metadata: HashMap::new(),
1688        };
1689        let mut bad_params = params(4);
1690        if let Some(w) = bad_params.get_mut(linear_model::WEIGHTS_KEY) {
1691            w.fill(50.0);
1692        }
1693        let bad = MetaParameters {
1694            parameters: bad_params,
1695            metadata: HashMap::new(),
1696        };
1697
1698        let good_result = validator
1699            .validate(&good, &pool, &mut learner)
1700            .expect("validate good");
1701        let bad_result = validator
1702            .validate(&bad, &pool, &mut learner)
1703            .expect("validate bad");
1704
1705        assert!(
1706            bad_result.validation_loss > good_result.validation_loss,
1707            "the validator must respond to the parameters: good={}, bad={}",
1708            good_result.validation_loss,
1709            bad_result.validation_loss
1710        );
1711        assert!(good_result.metrics.contains_key("held_out_tasks"));
1712        assert!(validator.validate(&good, &[], &mut learner).is_err());
1713    }
1714
1715    /// F14: continual learning really trains and really measures forgetting.
1716    #[test]
1717    fn test_continual_learning_uses_ewc() {
1718        let config = MetaLearningConfig::default();
1719        let mut system =
1720            ContinualLearningSystem::<f64>::new(&config.continual_settings).expect("system");
1721        assert_eq!(system.forgetting_measure(), 0.0);
1722
1723        let sequence = task_pool(3);
1724        let mut parameters = params(4);
1725        let result = system
1726            .learn_sequence(&sequence, &mut parameters)
1727            .expect("sequence");
1728
1729        assert_eq!(result.sequence_results.len(), 3);
1730        for task_result in &result.sequence_results {
1731            let before = task_result.metrics["loss_before"];
1732            assert!(
1733                task_result.loss < before,
1734                "task {} did not improve: {} -> {}",
1735                task_result.task_id,
1736                before,
1737                task_result.loss
1738            );
1739        }
1740        assert!(result.forgetting_measure >= 0.0);
1741        assert!(system.forgetting_measure().is_finite());
1742        assert!(
1743            ContinualLearningSystem::<f64>::new(&config.continual_settings)
1744                .expect("system")
1745                .learn_sequence(&[], &mut parameters)
1746                .is_err()
1747        );
1748    }
1749
1750    /// F14: transfer really runs through the cross-domain engine.
1751    #[test]
1752    fn test_transfer_manager_delegates() {
1753        let config = MetaLearningConfig::default();
1754        let mut manager =
1755            TransferLearningManager::<f64>::new(&config.transfer_settings).expect("manager");
1756        assert_eq!(manager.success_rate(), 0.0);
1757
1758        let source = vec![linear_task(0, 1.0, 0.0), linear_task(1, 1.1, 0.1)];
1759        let target = vec![linear_task(2, -1.0, 2.0)];
1760        let mut parameters = params(4);
1761        if let Some(w) = parameters.get_mut(linear_model::WEIGHTS_KEY) {
1762            w.fill(0.3);
1763        }
1764
1765        let result = manager
1766            .transfer(&source, &target, &parameters)
1767            .expect("transfer");
1768        assert!(result.domain_adaptation_score.is_finite());
1769        assert!((0.0..=1.0).contains(&result.domain_adaptation_score));
1770        assert!(manager.transfer(&[], &target, &parameters).is_err());
1771    }
1772
1773    /// F14: the tracker counts what actually happened.
1774    #[test]
1775    fn test_tracker_counts_are_real() {
1776        let mut tracker = MetaOptimizationTracker::<f64>::new();
1777        assert_eq!(tracker.total_tasks_seen(), 0);
1778        assert_eq!(tracker.adaptation_efficiency(), 0.0);
1779
1780        for (epoch, loss) in [(0usize, 1.0f64), (1, 0.5), (2, 0.25)] {
1781            let training = TrainingResult {
1782                training_loss: loss,
1783                metrics: HashMap::new(),
1784                steps: epoch,
1785            };
1786            let validation = ValidationResult {
1787                is_valid: true,
1788                validation_loss: loss,
1789                metrics: HashMap::new(),
1790            };
1791            tracker
1792                .record_epoch(epoch, &training, &validation, 4)
1793                .expect("record");
1794        }
1795
1796        assert_eq!(tracker.epochs_recorded(), 3);
1797        // 3 epochs x 4 tasks -- not "epochs * 10".
1798        assert_eq!(tracker.total_tasks_seen(), 12);
1799        approx::assert_abs_diff_eq!(tracker.adaptation_efficiency(), 0.375, epsilon = 1e-12);
1800        assert_eq!(tracker.validation_losses().len(), 3);
1801    }
1802
1803    /// F14: few-shot learning adapts and scores for real.
1804    #[test]
1805    fn test_few_shot_learner_is_real() {
1806        let config = MetaLearningConfig::default();
1807        let mut learner = FewShotLearner::<f64>::new(&config.few_shot_settings).expect("learner");
1808        assert_eq!(learner.average_performance(), 0.0);
1809
1810        let task = linear_task(0, 0.5, 0.0);
1811        let result = learner
1812            .learn(&task.support_set, &task.query_set, &params(4))
1813            .expect("few-shot");
1814        assert_eq!(
1815            result.uncertainty_estimates.len(),
1816            task.query_set.features.len()
1817        );
1818        assert!(result.confidence > 0.0 && result.confidence <= 1.0);
1819        assert!(learner.average_performance().is_finite());
1820
1821        let empty = TaskDataset::<f64>::default();
1822        assert!(learner.learn(&empty, &task.query_set, &params(4)).is_err());
1823    }
1824
1825    /// F14: multi-task learning trains the shared parameters and measures real
1826    /// gradient interference.
1827    #[test]
1828    fn test_multi_task_coordinator_is_real() {
1829        let config = MetaLearningConfig::default();
1830        let mut coordinator =
1831            MultiTaskCoordinator::<f64>::new(&config.multitask_settings).expect("coordinator");
1832        assert_eq!(coordinator.interference_measure(), 0.0);
1833
1834        let tasks = vec![linear_task(0, 1.0, 0.0), linear_task(1, -1.0, 0.0)];
1835        let mut parameters = params(4);
1836        let result = coordinator
1837            .learn_simultaneously(&tasks, &mut parameters)
1838            .expect("multi-task");
1839        assert_eq!(result.task_results.len(), 2);
1840        let interference = coordinator.interference_measure();
1841        assert!(
1842            (0.0..=1.0).contains(&interference),
1843            "interference out of range: {interference}"
1844        );
1845        // Opposed tasks must register as conflicting (cosine below zero).
1846        assert!(interference > 0.5, "opposed tasks should conflict");
1847        assert!(coordinator
1848            .learn_simultaneously(&[], &mut parameters)
1849            .is_err());
1850    }
1851
1852    /// The framework's meta-parameter initialiser sizes itself from the tasks
1853    /// and is not all zeros.
1854    #[test]
1855    fn test_initialize_meta_parameters_is_sized_and_nonzero() {
1856        let pool = task_pool(3);
1857        let framework =
1858            MetaLearningFramework::<f64>::new(MetaLearningConfig::default()).expect("framework");
1859        let initial = framework
1860            .initialize_meta_parameters(&pool)
1861            .expect("initialise");
1862        assert_eq!(initial[linear_model::WEIGHTS_KEY].len(), 4);
1863        assert!(initial[linear_model::WEIGHTS_KEY]
1864            .iter()
1865            .any(|w| w.abs() > 0.0));
1866        assert!(framework.initialize_meta_parameters(&[]).is_err());
1867    }
1868}