Skip to main content

optirs_learned/transformer_based_optimizer/
meta_learning.rs

1// Meta-learning components for transformer-based optimization
2
3use super::config::{ActivationFunction, MetaLearningConfig};
4use super::feedforward::FeedForwardNetwork;
5use crate::common::cast_positive;
6use crate::error::Result;
7use scirs2_core::ndarray::{Array1, Array2};
8use scirs2_core::numeric::Float;
9use std::collections::VecDeque;
10use std::fmt::Debug;
11use std::time::Instant;
12
13/// Meta-learning strategy types
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub enum MetaLearningStrategy {
16    /// Model-Agnostic Meta-Learning (MAML)
17    MAML,
18    /// First-Order MAML (FOMAML)
19    FOMAML,
20    /// Reptile algorithm
21    Reptile,
22    /// Gradient-based meta-learning
23    GradientBased,
24    /// Memory-augmented networks
25    MemoryAugmented,
26}
27
28/// Transformer meta-learning implementation
29pub struct TransformerMetaLearning<
30    T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static,
31> {
32    /// Meta-learning strategy
33    strategy: MetaLearningStrategy,
34
35    /// Configuration
36    config: MetaLearningConfig<T>,
37
38    /// Meta-optimizer for outer loop
39    meta_optimizer: MetaOptimizer<T>,
40
41    /// Task adaptation network
42    adaptation_network: AdaptationNetwork<T>,
43
44    /// Memory bank for storing task experiences
45    memory_bank: MemoryBank<T>,
46
47    /// Performance tracker
48    performance_tracker: PerformanceTracker<T>,
49
50    /// Current meta-learning state
51    meta_state: MetaState<T>,
52}
53
54impl<
55        T: Float
56            + Debug
57            + scirs2_core::ndarray::ScalarOperand
58            + Send
59            + Sync
60            + 'static
61            + scirs2_core::ndarray::ScalarOperand,
62    > TransformerMetaLearning<T>
63{
64    /// Create new transformer meta-learning component
65    pub fn new(config: &super::config::TransformerBasedOptimizerConfig<T>) -> Result<Self> {
66        let meta_config = config.meta_learning_config.clone();
67        let strategy = MetaLearningStrategy::MAML; // Default strategy
68
69        let meta_optimizer = MetaOptimizer::new(&meta_config)?;
70        let adaptation_network =
71            AdaptationNetwork::new(config.model_dimension, config.feedforward_dimension)?;
72        let memory_bank = MemoryBank::new(1000, config.model_dimension)?;
73        let performance_tracker = PerformanceTracker::new();
74        let meta_state = MetaState::new(config.model_dimension)?;
75
76        Ok(Self {
77            strategy,
78            config: meta_config,
79            meta_optimizer,
80            adaptation_network,
81            memory_bank,
82            performance_tracker,
83            meta_state,
84        })
85    }
86
87    /// Reject a task batch whose support/query arrays do not cover every task.
88    ///
89    /// Every `*_step` used to index `support_data[i]` / `query_data[i]` directly,
90    /// which panicked on a short slice; this turns that into a typed error.
91    fn validate_batch_shapes(
92        tasks: &[TaskBatch<T>],
93        support_data: &[Array2<T>],
94        query_data: &[Array2<T>],
95    ) -> Result<()> {
96        if tasks.is_empty() {
97            return Err(crate::error::OptimError::InsufficientData(
98                "meta step requires at least one task".to_string(),
99            ));
100        }
101        if support_data.len() < tasks.len() || query_data.len() < tasks.len() {
102            return Err(crate::error::OptimError::InvalidConfig(format!(
103                "meta step got {} tasks but {} support and {} query batches",
104                tasks.len(),
105                support_data.len(),
106                query_data.len()
107            )));
108        }
109        Ok(())
110    }
111
112    /// Perform meta-learning step
113    pub fn meta_step(
114        &mut self,
115        tasks: &[TaskBatch<T>],
116        support_data: &[Array2<T>],
117        query_data: &[Array2<T>],
118    ) -> Result<MetaLearningResult<T>> {
119        match self.strategy {
120            MetaLearningStrategy::MAML => self.maml_step(tasks, support_data, query_data),
121            MetaLearningStrategy::FOMAML => self.fomaml_step(tasks, support_data, query_data),
122            MetaLearningStrategy::Reptile => self.reptile_step(tasks, support_data, query_data),
123            MetaLearningStrategy::GradientBased => {
124                self.gradient_based_step(tasks, support_data, query_data)
125            }
126            MetaLearningStrategy::MemoryAugmented => {
127                self.memory_augmented_step(tasks, support_data, query_data)
128            }
129        }
130    }
131
132    /// MAML meta-learning step.
133    ///
134    /// Runs `inner_steps` real gradient-descent steps on each task's support
135    /// set (gradients from [`Self::compute_gradients`], not a constant), then
136    /// forms the outer-loop meta-gradient from the **query** loss at the
137    /// adapted parameters.
138    ///
139    /// When `MetaLearningConfig::first_order` is set, the meta-gradient is the
140    /// FOMAML approximation `∇L_q(θ')`. Otherwise the exact second-order MAML
141    /// meta-gradient is used: because the per-task objective is quadratic (see
142    /// [`Self::compute_task_loss`]) the support Hessian `H_s` is constant, so
143    ///
144    /// ```text
145    /// ∇_θ L_q(θ_k) = (I − α·H_s)^k · ∇L_q(θ_k)
146    /// ```
147    ///
148    /// holds exactly and is evaluated with `k` Hessian-vector products.
149    ///
150    /// # Errors
151    /// Returns `Err` when `tasks` is empty or `support_data`/`query_data` are
152    /// shorter than `tasks`.
153    fn maml_step(
154        &mut self,
155        tasks: &[TaskBatch<T>],
156        support_data: &[Array2<T>],
157        query_data: &[Array2<T>],
158    ) -> Result<MetaLearningResult<T>> {
159        Self::validate_batch_shapes(tasks, support_data, query_data)?;
160        let start_time = Instant::now();
161        let mut total_loss = T::zero();
162        let mut task_adaptations = Vec::new();
163
164        for (i, task) in tasks.iter().enumerate() {
165            // Inner adaptation loop
166            let mut adapted_params = self.meta_state.get_parameters().clone();
167            let support_loss_before =
168                self.compute_task_loss(&adapted_params, &support_data[i], task)?;
169
170            for _inner_step in 0..self.config.inner_steps {
171                // Real analytic gradient of the support loss at the current θ.
172                let gradients = self.compute_gradients(&adapted_params, &support_data[i], task)?;
173
174                // Update parameters
175                for (param, grad) in adapted_params.iter_mut().zip(gradients.iter()) {
176                    *param = *param - self.config.inner_learning_rate * (*grad);
177                }
178            }
179
180            // Evaluate on query set
181            let query_loss = self.compute_task_loss(&adapted_params, &query_data[i], task)?;
182            total_loss = total_loss + query_loss;
183
184            // Outer-loop gradient: ∇L_q at the adapted parameters, optionally
185            // back-propagated through the inner updates (exact, see doc above).
186            let mut query_gradient =
187                self.compute_gradients(&adapted_params, &query_data[i], task)?;
188            if !self.config.first_order {
189                let alpha = self.config.inner_learning_rate;
190                for _ in 0..self.config.inner_steps {
191                    let hv =
192                        self.hessian_vector_product(&query_gradient, &support_data[i], task)?;
193                    for (g, &h) in query_gradient.iter_mut().zip(hv.iter()) {
194                        *g = *g - alpha * h;
195                    }
196                }
197            }
198
199            task_adaptations.push(TaskAdaptation {
200                task_id: task.id.clone(),
201                adapted_parameters: adapted_params,
202                support_loss: support_loss_before,
203                query_loss,
204                query_gradient,
205                adaptation_steps: self.config.inner_steps,
206            });
207        }
208
209        // Meta-update
210        let meta_loss = total_loss / T::from(tasks.len()).unwrap_or_else(|| T::one());
211        let meta_gradients = self.compute_meta_gradients(&task_adaptations)?;
212        self.meta_optimizer
213            .update(&mut self.meta_state, &meta_gradients)?;
214
215        // Update memory bank
216        for (i, adaptation) in task_adaptations.iter().enumerate() {
217            self.memory_bank.store_experience(
218                &tasks[i],
219                &adaptation.adapted_parameters,
220                adaptation.query_loss,
221            )?;
222        }
223
224        let result = MetaLearningResult {
225            meta_loss: meta_loss.to_f64().unwrap_or(0.0),
226            task_adaptations,
227            computation_time: start_time.elapsed(),
228            convergence_rate: self.estimate_convergence_rate()?,
229        };
230
231        self.performance_tracker.record_meta_step(result.clone());
232        Ok(result)
233    }
234
235    /// First-order MAML step (FOMAML)
236    fn fomaml_step(
237        &mut self,
238        tasks: &[TaskBatch<T>],
239        support_data: &[Array2<T>],
240        query_data: &[Array2<T>],
241    ) -> Result<MetaLearningResult<T>> {
242        // Simplified version of MAML that ignores second-order derivatives
243        // Similar to MAML but uses first-order approximation for efficiency
244        self.maml_step(tasks, support_data, query_data)
245    }
246
247    /// Reptile meta-learning step
248    fn reptile_step(
249        &mut self,
250        tasks: &[TaskBatch<T>],
251        support_data: &[Array2<T>],
252        query_data: &[Array2<T>],
253    ) -> Result<MetaLearningResult<T>> {
254        Self::validate_batch_shapes(tasks, support_data, query_data)?;
255        let start_time = Instant::now();
256        let mut parameter_updates = Vec::new();
257        let mut total_loss = T::zero();
258
259        for (i, task) in tasks.iter().enumerate() {
260            // Adapt on support set
261            let mut adapted_params = self.meta_state.get_parameters().clone();
262
263            for _ in 0..self.config.inner_steps {
264                let gradients = self.compute_gradients(&adapted_params, &support_data[i], task)?;
265
266                for (param, grad) in adapted_params.iter_mut().zip(gradients.iter()) {
267                    *param = *param - self.config.inner_learning_rate * (*grad);
268                }
269            }
270
271            // Compute parameter difference for meta-update
272            let original_params = self.meta_state.get_parameters();
273            let param_diff: Vec<T> = adapted_params
274                .iter()
275                .zip(original_params.iter())
276                .map(|(adapted, original)| *adapted - *original)
277                .collect();
278
279            parameter_updates.push(param_diff);
280
281            // Evaluate on query set
282            let query_loss = self.compute_task_loss(&adapted_params, &query_data[i], task)?;
283            total_loss = total_loss + query_loss;
284        }
285
286        // Meta-update: move towards average of adapted parameters
287        let mut meta_update = vec![T::zero(); self.meta_state.get_parameters().len()];
288        for param_update in &parameter_updates {
289            for (i, &update) in param_update.iter().enumerate() {
290                meta_update[i] = meta_update[i] + update;
291            }
292        }
293
294        let num_tasks: T = cast_positive(tasks.len(), "task batch size")?;
295        for update in meta_update.iter_mut() {
296            *update = *update / num_tasks;
297        }
298
299        self.meta_state
300            .update_parameters(&meta_update, self.config.meta_learning_rate)?;
301
302        let result = MetaLearningResult {
303            meta_loss: (total_loss / num_tasks).to_f64().unwrap_or(0.0),
304            task_adaptations: Vec::new(), // Reptile doesn't track individual adaptations
305            computation_time: start_time.elapsed(),
306            convergence_rate: self.estimate_convergence_rate()?,
307        };
308
309        self.performance_tracker.record_meta_step(result.clone());
310        Ok(result)
311    }
312
313    /// Gradient-based meta-learning step
314    fn gradient_based_step(
315        &mut self,
316        tasks: &[TaskBatch<T>],
317        support_data: &[Array2<T>],
318        query_data: &[Array2<T>],
319    ) -> Result<MetaLearningResult<T>> {
320        // Implement gradient-based meta-learning with direct optimization
321        Self::validate_batch_shapes(tasks, support_data, query_data)?;
322        let start_time = Instant::now();
323        let mut total_loss = T::zero();
324
325        for (i, task) in tasks.iter().enumerate() {
326            // Use adaptation network to predict good initialization
327            let context_embedding = self.adaptation_network.encode_task_context(task)?;
328            let predicted_params = self
329                .adaptation_network
330                .predict_parameters(&context_embedding)?;
331
332            // Fine-tune predicted parameters
333            let mut adapted_params = predicted_params;
334            for _ in 0..self.config.inner_steps {
335                let gradients = self.compute_gradients(&adapted_params, &support_data[i], task)?;
336
337                for (param, grad) in adapted_params.iter_mut().zip(gradients.iter()) {
338                    *param = *param - self.config.inner_learning_rate * (*grad);
339                }
340            }
341
342            let query_loss = self.compute_task_loss(&adapted_params, &query_data[i], task)?;
343            total_loss = total_loss + query_loss;
344        }
345
346        let result = MetaLearningResult {
347            meta_loss: (total_loss / T::from(tasks.len()).unwrap_or_else(|| T::one()))
348                .to_f64()
349                .unwrap_or(0.0),
350            task_adaptations: Vec::new(),
351            computation_time: start_time.elapsed(),
352            convergence_rate: self.estimate_convergence_rate()?,
353        };
354
355        Ok(result)
356    }
357
358    /// Memory-augmented meta-learning step
359    fn memory_augmented_step(
360        &mut self,
361        tasks: &[TaskBatch<T>],
362        support_data: &[Array2<T>],
363        query_data: &[Array2<T>],
364    ) -> Result<MetaLearningResult<T>> {
365        Self::validate_batch_shapes(tasks, support_data, query_data)?;
366        let start_time = Instant::now();
367        let mut total_loss = T::zero();
368
369        for (i, task) in tasks.iter().enumerate() {
370            // Retrieve relevant experiences from memory
371            let relevant_experiences = self.memory_bank.retrieve_similar_experiences(task, 5)?;
372
373            // Use memory to initialize adaptation
374            let memory_guided_params = self.initialize_from_memory(&relevant_experiences)?;
375
376            let mut adapted_params = memory_guided_params;
377            for _ in 0..self.config.inner_steps {
378                let gradients = self.compute_gradients(&adapted_params, &support_data[i], task)?;
379
380                for (param, grad) in adapted_params.iter_mut().zip(gradients.iter()) {
381                    *param = *param - self.config.inner_learning_rate * (*grad);
382                }
383            }
384
385            let query_loss = self.compute_task_loss(&adapted_params, &query_data[i], task)?;
386            total_loss = total_loss + query_loss;
387
388            // Store experience
389            self.memory_bank
390                .store_experience(task, &adapted_params, query_loss)?;
391        }
392
393        let result = MetaLearningResult {
394            meta_loss: (total_loss / T::from(tasks.len()).unwrap_or_else(|| T::one()))
395                .to_f64()
396                .unwrap_or(0.0),
397            task_adaptations: Vec::new(),
398            computation_time: start_time.elapsed(),
399            convergence_rate: self.estimate_convergence_rate()?,
400        };
401
402        Ok(result)
403    }
404
405    /// Generate optimization update using meta-learned parameters
406    pub fn generate_update(
407        &mut self,
408        transformer_output: &Array2<T>,
409        current_parameters: &Array1<T>,
410    ) -> Result<Array1<T>> {
411        // Use adaptation network to generate parameter updates
412        let update = self
413            .adaptation_network
414            .generate_parameter_update(transformer_output, current_parameters)?;
415
416        // Apply meta-learned scaling
417        let scaled_update = self.apply_meta_scaling(&update)?;
418
419        Ok(scaled_update)
420    }
421
422    /// Update meta-learning state from loss
423    pub fn update_from_loss(&mut self, loss: T) -> Result<()> {
424        self.meta_state.update_loss_history(loss);
425        self.performance_tracker
426            .record_loss(loss.to_f64().unwrap_or(0.0));
427        Ok(())
428    }
429
430    /// Total learnable parameters held by the adaptation networks.
431    pub fn parameter_count(&self) -> usize {
432        self.adaptation_network.parameter_count()
433    }
434
435    /// Mutable access to the outer-loop optimizer (e.g. to enable momentum).
436    pub fn meta_optimizer_mut(&mut self) -> &mut MetaOptimizer<T> {
437        &mut self.meta_optimizer
438    }
439
440    /// Set meta-learning strategy
441    pub fn set_strategy(&mut self, strategy: MetaLearningStrategy) {
442        self.strategy = strategy;
443    }
444
445    /// Get current strategy
446    pub fn get_strategy(&self) -> MetaLearningStrategy {
447        self.strategy
448    }
449
450    /// Per-task objective actually optimized by every inner loop in this module.
451    ///
452    /// # Task model
453    ///
454    /// A task batch is a design matrix `data` of shape `(n, c)`: the **last**
455    /// column holds the regression target `y`, the remaining `c - 1` columns
456    /// hold the features `x`. The adapted parameter vector `θ` (length `p`) is
457    /// read as a bias plus weights:
458    ///
459    /// ```text
460    /// pred_i = θ[0] + Σ_{j<f} θ[j+1] · x_i[j],   f = min(c-1, p-1)
461    /// L(θ)   = (1/n) Σ_i (pred_i - y_i)²  +  (λ/p) Σ_{k≥1} θ[k]²
462    /// ```
463    ///
464    /// `λ` is taken from `task.complexity` (clamped at zero): a more complex
465    /// task is regularized more strongly. That is the one place the task
466    /// descriptor enters the objective, and it is why two different
467    /// `TaskBatch`es over the same data produce different losses and different
468    /// gradients.
469    ///
470    /// The objective is quadratic in `θ`, which is what makes the exact
471    /// second-order MAML meta-gradient in [`Self::maml_step`] computable in
472    /// closed form (see [`Self::hessian_vector_product`]).
473    ///
474    /// # Errors
475    /// Returns `Err` when `params` is empty or `data` has no rows/columns.
476    fn compute_task_loss(&self, params: &[T], data: &Array2<T>, task: &TaskBatch<T>) -> Result<T> {
477        let (n_rows, _n_cols) = Self::validate_task_data(params, data)?;
478        let predictions = Self::linear_forward(params, data);
479        let n = T::from(n_rows).unwrap_or_else(|| T::one());
480
481        let mut data_loss = T::zero();
482        for (i, &pred) in predictions.iter().enumerate() {
483            let residual = pred - Self::target_of(data, i);
484            data_loss = data_loss + residual * residual;
485        }
486        data_loss = data_loss / n;
487
488        let ridge = self.ridge_weight(params.len(), task);
489        let mut reg = T::zero();
490        for &p in params.iter().skip(1) {
491            reg = reg + p * p;
492        }
493
494        Ok(data_loss + ridge * reg)
495    }
496
497    /// Exact analytic gradient of [`Self::compute_task_loss`] w.r.t. `params`.
498    ///
499    /// Verified against central finite differences by
500    /// `compute_gradients_matches_finite_differences`.
501    fn compute_gradients(
502        &self,
503        params: &[T],
504        data: &Array2<T>,
505        task: &TaskBatch<T>,
506    ) -> Result<Vec<T>> {
507        let (n_rows, _) = Self::validate_task_data(params, data)?;
508        let predictions = Self::linear_forward(params, data);
509        let residuals: Vec<T> = predictions
510            .iter()
511            .enumerate()
512            .map(|(i, &pred)| pred - Self::target_of(data, i))
513            .collect();
514
515        let mut gradients =
516            Self::accumulate_design_gradient(&residuals, data, params.len(), n_rows);
517
518        // Ridge term: d/dθ[k] of (λ/p)·Σ_{k≥1} θ[k]² = 2·(λ/p)·θ[k], bias excluded.
519        let two = T::one() + T::one();
520        let ridge = self.ridge_weight(params.len(), task);
521        for (k, grad) in gradients.iter_mut().enumerate().skip(1) {
522            *grad = *grad + two * ridge * params[k];
523        }
524
525        Ok(gradients)
526    }
527
528    /// Exact Hessian-vector product `H · v` for [`Self::compute_task_loss`].
529    ///
530    /// The objective is quadratic, so `H` does not depend on `θ` — only on the
531    /// design matrix and the ridge weight. This lets the second-order MAML
532    /// meta-gradient be computed with one matrix-vector product per inner step
533    /// instead of materializing (and powering) a `p × p` Hessian.
534    fn hessian_vector_product(
535        &self,
536        vector: &[T],
537        data: &Array2<T>,
538        task: &TaskBatch<T>,
539    ) -> Result<Vec<T>> {
540        let (n_rows, _) = Self::validate_task_data(vector, data)?;
541        // H·v for the data term is the same accumulation as the gradient, with
542        // the residuals replaced by the *predictions of v* (targets excluded,
543        // since the target contributes only to the constant/linear part).
544        let directional = Self::linear_forward(vector, data);
545        let mut out = Self::accumulate_design_gradient(&directional, data, vector.len(), n_rows);
546
547        let two = T::one() + T::one();
548        let ridge = self.ridge_weight(vector.len(), task);
549        for (k, slot) in out.iter_mut().enumerate().skip(1) {
550            *slot = *slot + two * ridge * vector[k];
551        }
552
553        Ok(out)
554    }
555
556    /// `λ/p` — the per-parameter ridge weight implied by a task descriptor.
557    fn ridge_weight(&self, param_count: usize, task: &TaskBatch<T>) -> T {
558        let lambda: T = scirs2_core::numeric::NumCast::from(task.complexity.max(0.0))
559            .unwrap_or_else(|| T::zero());
560        let p = T::from(param_count.max(1)).unwrap_or_else(|| T::one());
561        lambda / p
562    }
563
564    /// `(n_rows, n_cols)` after checking that a task loss is well defined.
565    fn validate_task_data(params: &[T], data: &Array2<T>) -> Result<(usize, usize)> {
566        if params.is_empty() {
567            return Err(crate::error::OptimError::InvalidConfig(
568                "task loss needs at least one parameter (the bias)".to_string(),
569            ));
570        }
571        let (n_rows, n_cols) = data.dim();
572        if n_rows == 0 || n_cols == 0 {
573            return Err(crate::error::OptimError::InsufficientData(format!(
574                "task data must be non-empty, got shape {n_rows}x{n_cols}"
575            )));
576        }
577        Ok((n_rows, n_cols))
578    }
579
580    /// Number of feature columns actually consumed by a parameter vector.
581    fn effective_features(param_count: usize, n_cols: usize) -> usize {
582        n_cols.saturating_sub(1).min(param_count.saturating_sub(1))
583    }
584
585    /// Regression target of row `i` (the last column).
586    fn target_of(data: &Array2<T>, i: usize) -> T {
587        let n_cols = data.ncols();
588        data[[i, n_cols - 1]]
589    }
590
591    /// `pred_i = w[0] + Σ_{j<f} w[j+1] · x_i[j]` for every row.
592    fn linear_forward(weights: &[T], data: &Array2<T>) -> Vec<T> {
593        let (n_rows, n_cols) = data.dim();
594        let f = Self::effective_features(weights.len(), n_cols);
595        (0..n_rows)
596            .map(|i| {
597                let mut acc = weights[0];
598                for j in 0..f {
599                    acc = acc + weights[j + 1] * data[[i, j]];
600                }
601                acc
602            })
603            .collect()
604    }
605
606    /// `out[0] = (2/n)·Σ r_i`, `out[j+1] = (2/n)·Σ r_i·x_i[j]`, zero elsewhere.
607    fn accumulate_design_gradient(
608        residuals: &[T],
609        data: &Array2<T>,
610        param_count: usize,
611        n_rows: usize,
612    ) -> Vec<T> {
613        let n_cols = data.ncols();
614        let f = Self::effective_features(param_count, n_cols);
615        let two = T::one() + T::one();
616        let scale = two / T::from(n_rows.max(1)).unwrap_or_else(|| T::one());
617
618        let mut out = vec![T::zero(); param_count];
619        for (i, &r) in residuals.iter().enumerate() {
620            out[0] = out[0] + r;
621            for j in 0..f {
622                out[j + 1] = out[j + 1] + r * data[[i, j]];
623            }
624        }
625        for slot in out.iter_mut() {
626            *slot = *slot * scale;
627        }
628        out
629    }
630
631    /// Average the per-task query gradients into the outer-loop meta-gradient.
632    ///
633    /// Each entry was produced by [`Self::maml_step`] as either the first-order
634    /// query gradient `∇L_q(θ')` (FOMAML) or the exact second-order product
635    /// `Π_i (I − α·H_s) ∇L_q(θ')` — see [`Self::maml_step`] for which. This
636    /// function only averages; it no longer invents `θ · L_q` as a "gradient".
637    ///
638    /// # Errors
639    /// Returns `Err` when `adaptations` is empty or the per-task gradients
640    /// disagree on length.
641    fn compute_meta_gradients(&self, adaptations: &[TaskAdaptation<T>]) -> Result<Vec<T>> {
642        let first = adaptations.first().ok_or_else(|| {
643            crate::error::OptimError::InsufficientData(
644                "no task adaptations to derive a meta-gradient from".to_string(),
645            )
646        })?;
647        let param_count = first.query_gradient.len();
648        let mut meta_gradients = vec![T::zero(); param_count];
649
650        for adaptation in adaptations {
651            if adaptation.query_gradient.len() != param_count {
652                return Err(crate::error::OptimError::ComputationError(format!(
653                    "task '{}' reported {} meta-gradient components, expected {}",
654                    adaptation.task_id,
655                    adaptation.query_gradient.len(),
656                    param_count
657                )));
658            }
659            for (slot, &g) in meta_gradients
660                .iter_mut()
661                .zip(adaptation.query_gradient.iter())
662            {
663                *slot = *slot + g;
664            }
665        }
666
667        let num_tasks = T::from(adaptations.len()).unwrap_or_else(|| T::one());
668        for grad in meta_gradients.iter_mut() {
669            *grad = *grad / num_tasks;
670        }
671
672        Ok(meta_gradients)
673    }
674
675    fn estimate_convergence_rate(&self) -> Result<f64> {
676        let loss_history = self.performance_tracker.get_loss_history();
677        if loss_history.len() < 2 {
678            return Ok(0.0);
679        }
680
681        let recent_losses: Vec<_> = loss_history.iter().rev().take(5).cloned().collect();
682        // `loss_history.len() >= 2` was checked above, so `take(5)` yields at
683        // least two entries; reading through the fallible accessors keeps that
684        // reasoning local instead of an `expect` far from its guard.
685        let (Some(oldest), Some(newest)) = (recent_losses.last(), recent_losses.first()) else {
686            return Ok(0.0);
687        };
688        Ok((oldest - newest).clamp(0.0, 1.0))
689    }
690
691    fn apply_meta_scaling(&self, update: &Array1<T>) -> Result<Array1<T>> {
692        // Apply learned scaling factors
693        let scale_factor = self.meta_state.get_scale_factor();
694        Ok(update * scale_factor)
695    }
696
697    fn initialize_from_memory(&self, experiences: &[MemoryExperience<T>]) -> Result<Vec<T>> {
698        if experiences.is_empty() {
699            return Ok(self.meta_state.get_parameters().clone());
700        }
701
702        // Average parameters from similar experiences
703        let param_count = experiences[0].parameters.len();
704        let mut averaged_params = vec![T::zero(); param_count];
705
706        for experience in experiences {
707            for (i, &param) in experience.parameters.iter().enumerate() {
708                averaged_params[i] = averaged_params[i] + param;
709            }
710        }
711
712        let num_experiences: T = cast_positive(experiences.len(), "experience count")?;
713        for param in averaged_params.iter_mut() {
714            *param = *param / num_experiences;
715        }
716
717        Ok(averaged_params)
718    }
719}
720
721/// Meta-optimizer for outer loop updates
722pub struct MetaOptimizer<
723    T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static,
724> {
725    /// Learning rate
726    learning_rate: T,
727
728    /// Momentum for SGD-style updates
729    momentum: Option<T>,
730
731    /// Velocity for momentum
732    velocity: Option<Vec<T>>,
733}
734
735impl<T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static>
736    MetaOptimizer<T>
737{
738    pub fn new(config: &MetaLearningConfig<T>) -> Result<Self> {
739        Ok(Self {
740            learning_rate: config.meta_learning_rate,
741            momentum: config.meta_momentum,
742            velocity: None,
743        })
744    }
745
746    /// Apply one outer-loop update.
747    ///
748    /// The momentum branch is reachable whenever
749    /// `MetaLearningConfig::meta_momentum` is set (or
750    /// [`Self::set_momentum`] was called); with no momentum this is plain SGD.
751    ///
752    /// # Errors
753    /// Returns `Err` when `gradients` is shorter than the parameter vector —
754    /// the momentum branch used to index `gradients[i]` unchecked and panicked.
755    pub fn update(&mut self, state: &mut MetaState<T>, gradients: &[T]) -> Result<()> {
756        let params = state.get_parameters_mut();
757        if gradients.len() < params.len() {
758            return Err(crate::error::OptimError::ComputationError(format!(
759                "meta-gradient has {} components but the meta-parameters have {}",
760                gradients.len(),
761                params.len()
762            )));
763        }
764
765        if let Some(momentum) = self.momentum {
766            // Momentum update
767            let velocity = self
768                .velocity
769                .get_or_insert_with(|| vec![T::zero(); params.len()]);
770            if velocity.len() != params.len() {
771                velocity.resize(params.len(), T::zero());
772            }
773
774            for i in 0..params.len() {
775                velocity[i] = momentum * velocity[i] + self.learning_rate * gradients[i];
776                params[i] = params[i] - velocity[i];
777            }
778        } else {
779            // Simple SGD update
780            for (param, &grad) in params.iter_mut().zip(gradients.iter()) {
781                *param = *param - self.learning_rate * grad;
782            }
783        }
784
785        Ok(())
786    }
787
788    /// Enable (or clear) heavy-ball momentum for the outer loop.
789    ///
790    /// Clearing it also drops the accumulated velocity so a later re-enable
791    /// does not resume from stale state.
792    pub fn set_momentum(&mut self, momentum: Option<T>) {
793        self.momentum = momentum;
794        if momentum.is_none() {
795            self.velocity = None;
796        }
797    }
798
799    /// Current momentum coefficient, if any.
800    pub fn momentum(&self) -> Option<T> {
801        self.momentum
802    }
803}
804
805/// Task adaptation network
806pub struct AdaptationNetwork<
807    T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static,
808> {
809    /// Context encoder
810    context_encoder: FeedForwardNetwork<T>,
811
812    /// Parameter predictor
813    parameter_predictor: FeedForwardNetwork<T>,
814
815    /// Update generator
816    update_generator: FeedForwardNetwork<T>,
817
818    /// Model dimension
819    model_dimension: usize,
820}
821
822impl<T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static>
823    AdaptationNetwork<T>
824{
825    pub fn new(model_dimension: usize, hidden_dimension: usize) -> Result<Self> {
826        let context_encoder =
827            FeedForwardNetwork::new(model_dimension, hidden_dimension, ActivationFunction::ReLU)?;
828
829        let parameter_predictor =
830            FeedForwardNetwork::new(hidden_dimension, model_dimension, ActivationFunction::Tanh)?;
831
832        let update_generator = FeedForwardNetwork::new(
833            model_dimension * 2, // concatenated transformer output and current params
834            model_dimension,
835            ActivationFunction::ReLU,
836        )?;
837
838        Ok(Self {
839            context_encoder,
840            parameter_predictor,
841            update_generator,
842            model_dimension,
843        })
844    }
845
846    /// Encode a task descriptor into a context embedding.
847    ///
848    /// The raw descriptor is three scalars (`difficulty`, `complexity`,
849    /// `data_characteristics`). They are lifted to `model_dimension` features by
850    /// a fixed random-Fourier-style basis: feature `k` is
851    /// `cos(2π·(k+1)·d_{k mod 3} / 3 + φ_k)` with a deterministic per-index
852    /// phase, so distinct descriptors map to distinct, bounded, non-collinear
853    /// embeddings. Previously this ignored the task entirely and fed
854    /// `Array2::ones`, which made every task produce the same context.
855    /// Total learnable parameters across the three sub-networks.
856    pub fn parameter_count(&self) -> usize {
857        self.context_encoder.parameter_count()
858            + self.parameter_predictor.parameter_count()
859            + self.update_generator.parameter_count()
860    }
861
862    pub fn encode_task_context(&mut self, task: &TaskBatch<T>) -> Result<Array2<T>> {
863        let descriptor = [task.difficulty, task.complexity, task.data_characteristics];
864        let mut task_features = Array2::zeros((1, self.model_dimension));
865        for k in 0..self.model_dimension {
866            let d = descriptor[k % descriptor.len()];
867            let harmonic = (k / descriptor.len()) as f64 + 1.0;
868            let phase = (k as f64) * std::f64::consts::FRAC_PI_4;
869            let value = (std::f64::consts::TAU * harmonic * d + phase).cos();
870            task_features[[0, k]] =
871                scirs2_core::numeric::NumCast::from(value).unwrap_or_else(|| T::zero());
872        }
873        self.context_encoder.forward(&task_features)
874    }
875
876    pub fn predict_parameters(&mut self, context: &Array2<T>) -> Result<Vec<T>> {
877        let predicted = self.parameter_predictor.forward(context)?;
878        Ok(predicted.row(0).to_vec())
879    }
880
881    pub fn generate_parameter_update(
882        &mut self,
883        transformer_output: &Array2<T>,
884        current_parameters: &Array1<T>,
885    ) -> Result<Array1<T>> {
886        // Concatenate transformer output and current parameters
887        let batch_size = transformer_output.shape()[0];
888        let mut input = Array2::zeros((batch_size, self.model_dimension * 2));
889
890        for i in 0..batch_size {
891            for j in 0..self.model_dimension {
892                input[[i, j]] = transformer_output[[i, j]];
893                if j < current_parameters.len() {
894                    input[[i, j + self.model_dimension]] = current_parameters[j];
895                }
896            }
897        }
898
899        let update = self.update_generator.forward(&input)?;
900        Ok(update.row(0).to_owned())
901    }
902}
903
904/// Memory bank for storing task experiences
905pub struct MemoryBank<
906    T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static,
907> {
908    /// Stored experiences
909    experiences: VecDeque<MemoryExperience<T>>,
910
911    /// Maximum memory size
912    max_size: usize,
913
914    /// Dimension of stored parameters
915    parameter_dimension: usize,
916}
917
918impl<T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static> MemoryBank<T> {
919    pub fn new(max_size: usize, parameter_dimension: usize) -> Result<Self> {
920        Ok(Self {
921            experiences: VecDeque::new(),
922            max_size,
923            parameter_dimension,
924        })
925    }
926
927    /// Record one task experience.
928    ///
929    /// # Errors
930    /// Returns `Err` when `parameters` is not `parameter_dimension` long.
931    /// [`Self::retrieve_similar_experiences`] compares stored parameter vectors
932    /// against each other, so a bank holding mixed widths silently produces
933    /// meaningless neighbours; the declared dimension used to be stored at
934    /// construction and checked against nothing.
935    pub fn store_experience(
936        &mut self,
937        task: &TaskBatch<T>,
938        parameters: &[T],
939        performance: T,
940    ) -> Result<()> {
941        if parameters.len() != self.parameter_dimension {
942            return Err(crate::error::OptimError::InvalidConfig(format!(
943                "MemoryBank stores {}-dimensional parameter vectors but was given {}",
944                self.parameter_dimension,
945                parameters.len()
946            )));
947        }
948        let experience = MemoryExperience {
949            task_signature: self.compute_task_signature(task),
950            parameters: parameters.to_vec(),
951            performance: performance.to_f64().unwrap_or(0.0),
952            timestamp: Instant::now(),
953        };
954
955        self.experiences.push_back(experience);
956
957        if self.experiences.len() > self.max_size {
958            self.experiences.pop_front();
959        }
960
961        Ok(())
962    }
963
964    pub fn retrieve_similar_experiences(
965        &self,
966        task: &TaskBatch<T>,
967        k: usize,
968    ) -> Result<Vec<MemoryExperience<T>>> {
969        let target_signature = self.compute_task_signature(task);
970
971        let mut scored_experiences: Vec<_> = self
972            .experiences
973            .iter()
974            .map(|exp| {
975                let similarity = self.compute_similarity(&target_signature, &exp.task_signature);
976                (similarity, exp.clone())
977            })
978            .collect();
979
980        // `total_cmp` is a total order, so a NaN similarity sorts
981        // deterministically rather than panicking inside `sort_by`.
982        scored_experiences.sort_by(|a, b| b.0.total_cmp(&a.0));
983
984        Ok(scored_experiences
985            .into_iter()
986            .take(k)
987            .map(|(_, exp)| exp)
988            .collect())
989    }
990
991    fn compute_task_signature(&self, task: &TaskBatch<T>) -> Vec<f64> {
992        // Simplified task signature
993        vec![task.difficulty, task.complexity, task.data_characteristics]
994    }
995
996    fn compute_similarity(&self, sig1: &[f64], sig2: &[f64]) -> f64 {
997        if sig1.len() != sig2.len() {
998            return 0.0;
999        }
1000
1001        let dot_product: f64 = sig1.iter().zip(sig2.iter()).map(|(a, b)| a * b).sum();
1002        let norm1: f64 = sig1.iter().map(|x| x * x).sum::<f64>().sqrt();
1003        let norm2: f64 = sig2.iter().map(|x| x * x).sum::<f64>().sqrt();
1004
1005        if norm1 == 0.0 || norm2 == 0.0 {
1006            0.0
1007        } else {
1008            dot_product / (norm1 * norm2)
1009        }
1010    }
1011}
1012
1013/// Supporting data structures
1014#[derive(Debug, Clone)]
1015pub struct TaskBatch<T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static>
1016{
1017    pub id: String,
1018    pub difficulty: f64,
1019    pub complexity: f64,
1020    pub data_characteristics: f64,
1021    pub _phantom: std::marker::PhantomData<T>,
1022}
1023
1024#[derive(Debug, Clone)]
1025pub struct TaskAdaptation<
1026    T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static,
1027> {
1028    pub task_id: String,
1029    pub adapted_parameters: Vec<T>,
1030    pub support_loss: T,
1031    pub query_loss: T,
1032    /// Outer-loop gradient contributed by this task — the query-loss gradient
1033    /// at the adapted parameters (FOMAML) or its exact second-order
1034    /// back-propagation through the inner steps. Averaged by
1035    /// `TransformerMetaLearning::compute_meta_gradients`.
1036    pub query_gradient: Vec<T>,
1037    pub adaptation_steps: usize,
1038}
1039
1040#[derive(Debug, Clone)]
1041pub struct MetaLearningResult<
1042    T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static,
1043> {
1044    pub meta_loss: f64,
1045    pub task_adaptations: Vec<TaskAdaptation<T>>,
1046    pub computation_time: std::time::Duration,
1047    pub convergence_rate: f64,
1048}
1049
1050#[derive(Debug, Clone)]
1051pub struct MemoryExperience<
1052    T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static,
1053> {
1054    pub task_signature: Vec<f64>,
1055    pub parameters: Vec<T>,
1056    pub performance: f64,
1057    pub timestamp: Instant,
1058}
1059
1060pub struct PerformanceTracker<
1061    T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static,
1062> {
1063    loss_history: VecDeque<f64>,
1064    meta_results: VecDeque<MetaLearningResult<T>>,
1065}
1066
1067impl<T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static> Default
1068    for PerformanceTracker<T>
1069{
1070    fn default() -> Self {
1071        Self::new()
1072    }
1073}
1074
1075impl<T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static>
1076    PerformanceTracker<T>
1077{
1078    pub fn new() -> Self {
1079        Self {
1080            loss_history: VecDeque::new(),
1081            meta_results: VecDeque::new(),
1082        }
1083    }
1084
1085    pub fn record_loss(&mut self, loss: f64) {
1086        self.loss_history.push_back(loss);
1087        if self.loss_history.len() > 1000 {
1088            self.loss_history.pop_front();
1089        }
1090    }
1091
1092    pub fn record_meta_step(&mut self, result: MetaLearningResult<T>) {
1093        self.meta_results.push_back(result);
1094        if self.meta_results.len() > 100 {
1095            self.meta_results.pop_front();
1096        }
1097    }
1098
1099    pub fn get_loss_history(&self) -> &VecDeque<f64> {
1100        &self.loss_history
1101    }
1102}
1103
1104#[derive(Debug, Clone)]
1105pub struct MetaState<T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static>
1106{
1107    parameters: Vec<T>,
1108    loss_history: VecDeque<T>,
1109    scale_factor: T,
1110}
1111
1112impl<T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static> MetaState<T> {
1113    pub fn new(parameter_count: usize) -> Result<Self> {
1114        Ok(Self {
1115            parameters: vec![T::zero(); parameter_count],
1116            loss_history: VecDeque::new(),
1117            scale_factor: T::one(),
1118        })
1119    }
1120
1121    pub fn get_parameters(&self) -> &Vec<T> {
1122        &self.parameters
1123    }
1124
1125    pub fn get_parameters_mut(&mut self) -> &mut Vec<T> {
1126        &mut self.parameters
1127    }
1128
1129    pub fn update_parameters(&mut self, updates: &[T], learning_rate: T) -> Result<()> {
1130        for (param, &update) in self.parameters.iter_mut().zip(updates.iter()) {
1131            *param = *param + learning_rate * update;
1132        }
1133        Ok(())
1134    }
1135
1136    pub fn update_loss_history(&mut self, loss: T) {
1137        self.loss_history.push_back(loss);
1138        if self.loss_history.len() > 100 {
1139            self.loss_history.pop_front();
1140        }
1141    }
1142
1143    pub fn get_scale_factor(&self) -> T {
1144        self.scale_factor
1145    }
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150    use super::*;
1151
1152    /// Small configuration so the meta-learning tests stay fast.
1153    fn small_config(
1154        param_dim: usize,
1155    ) -> super::super::config::TransformerBasedOptimizerConfig<f64> {
1156        let mut config = super::super::config::TransformerBasedOptimizerConfig::<f64> {
1157            model_dimension: param_dim,
1158            feedforward_dimension: 2 * param_dim,
1159            num_attention_heads: 2,
1160            attention_head_dimension: param_dim / 2,
1161            num_transformer_layers: 1,
1162            sequence_length: 8,
1163            ..Default::default()
1164        };
1165        config.meta_learning_config.inner_steps = 3;
1166        config.meta_learning_config.inner_learning_rate = 0.05;
1167        config.meta_learning_config.meta_learning_rate = 0.1;
1168        config
1169    }
1170
1171    fn task(id: &str, difficulty: f64, complexity: f64, characteristics: f64) -> TaskBatch<f64> {
1172        TaskBatch {
1173            id: id.to_string(),
1174            difficulty,
1175            complexity,
1176            data_characteristics: characteristics,
1177            _phantom: std::marker::PhantomData,
1178        }
1179    }
1180
1181    /// `(features..., target)` rows for `y = 2·x0 - x1 + 0.5`.
1182    fn linear_batch(rows: usize, offset: f64) -> Array2<f64> {
1183        Array2::from_shape_fn((rows, 3), |(i, j)| {
1184            let x0 = (i as f64 + offset) * 0.25;
1185            let x1 = (i as f64 - offset) * 0.5;
1186            match j {
1187                0 => x0,
1188                1 => x1,
1189                _ => 2.0 * x0 - x1 + 0.5,
1190            }
1191        })
1192    }
1193
1194    #[test]
1195    fn test_meta_learning_creation() {
1196        let config = super::super::config::TransformerBasedOptimizerConfig::<f32>::default();
1197        let meta_learning = TransformerMetaLearning::new(&config);
1198        assert!(meta_learning.is_ok());
1199    }
1200
1201    /// F27: `compute_prediction_error` ignored `params` entirely, so every
1202    /// parameter vector produced the same loss.
1203    #[test]
1204    fn task_loss_actually_depends_on_the_parameters() {
1205        let ml = TransformerMetaLearning::new(&small_config(4)).expect("construction");
1206        let data = linear_batch(6, 1.0);
1207        let t = task("t", 0.5, 0.0, 0.3);
1208
1209        // The exact solution of y = 2·x0 - x1 + 0.5 is [0.5, 2.0, -1.0].
1210        let exact = vec![0.5, 2.0, -1.0, 0.0];
1211        let wrong = vec![0.0, 0.0, 0.0, 0.0];
1212
1213        let loss_exact = ml.compute_task_loss(&exact, &data, &t).expect("loss");
1214        let loss_wrong = ml.compute_task_loss(&wrong, &data, &t).expect("loss");
1215
1216        assert!(
1217            loss_exact < 1e-18,
1218            "the exact solution should have ~zero loss, got {loss_exact}"
1219        );
1220        assert!(
1221            loss_wrong > 1.0,
1222            "the zero vector should have a large loss, got {loss_wrong}"
1223        );
1224    }
1225
1226    /// F27: the task descriptor must reach the objective — two tasks over the
1227    /// same data used to be indistinguishable.
1228    #[test]
1229    fn task_descriptor_changes_the_objective() {
1230        let ml = TransformerMetaLearning::new(&small_config(4)).expect("construction");
1231        let data = linear_batch(6, 1.0);
1232        let params = vec![0.4, 1.8, -0.9, 0.2];
1233
1234        let plain = ml
1235            .compute_task_loss(&params, &data, &task("a", 0.5, 0.0, 0.3))
1236            .expect("loss");
1237        let regularized = ml
1238            .compute_task_loss(&params, &data, &task("b", 0.5, 4.0, 0.3))
1239            .expect("loss");
1240
1241        assert!(
1242            regularized > plain,
1243            "a higher-complexity task must be regularized more: {plain} vs {regularized}"
1244        );
1245    }
1246
1247    /// F27: `compute_gradients` returned `loss/n` for every component. The real
1248    /// gradient must match central finite differences.
1249    #[test]
1250    fn compute_gradients_matches_finite_differences() {
1251        let ml = TransformerMetaLearning::new(&small_config(4)).expect("construction");
1252        let data = linear_batch(7, 0.5);
1253        let t = task("fd", 0.4, 1.5, 0.2);
1254        let params = vec![0.3, -0.7, 1.1, 0.45];
1255
1256        let analytic = ml.compute_gradients(&params, &data, &t).expect("gradient");
1257        let h = 1e-6;
1258        for k in 0..params.len() {
1259            let mut plus = params.clone();
1260            let mut minus = params.clone();
1261            plus[k] += h;
1262            minus[k] -= h;
1263            let numeric = (ml.compute_task_loss(&plus, &data, &t).expect("loss")
1264                - ml.compute_task_loss(&minus, &data, &t).expect("loss"))
1265                / (2.0 * h);
1266            let scale = numeric.abs().max(1.0);
1267            assert!(
1268                (analytic[k] - numeric).abs() / scale < 1e-6,
1269                "component {k}: analytic {} vs numeric {numeric}",
1270                analytic[k]
1271            );
1272        }
1273    }
1274
1275    /// F27: the Hessian-vector product backing exact second-order MAML must
1276    /// match finite differences of the gradient.
1277    #[test]
1278    fn hessian_vector_product_matches_finite_differences() {
1279        let ml = TransformerMetaLearning::new(&small_config(4)).expect("construction");
1280        let data = linear_batch(7, 0.5);
1281        let t = task("hvp", 0.4, 1.5, 0.2);
1282        let params = vec![0.3, -0.7, 1.1, 0.45];
1283        let direction = vec![0.9, -0.2, 0.4, 1.3];
1284
1285        let hv = ml
1286            .hessian_vector_product(&direction, &data, &t)
1287            .expect("hvp");
1288
1289        let h = 1e-6;
1290        let mut plus = params.clone();
1291        let mut minus = params.clone();
1292        for k in 0..params.len() {
1293            plus[k] += h * direction[k];
1294            minus[k] -= h * direction[k];
1295        }
1296        let gp = ml.compute_gradients(&plus, &data, &t).expect("grad+");
1297        let gm = ml.compute_gradients(&minus, &data, &t).expect("grad-");
1298
1299        for k in 0..params.len() {
1300            let numeric = (gp[k] - gm[k]) / (2.0 * h);
1301            let scale = numeric.abs().max(1.0);
1302            assert!(
1303                (hv[k] - numeric).abs() / scale < 1e-5,
1304                "component {k}: analytic {} vs numeric {numeric}",
1305                hv[k]
1306            );
1307        }
1308    }
1309
1310    /// F27: a real MAML step must reduce the query loss over successive outer
1311    /// iterations. With fabricated gradients it could not.
1312    #[test]
1313    fn maml_step_reduces_the_query_loss() {
1314        let mut ml = TransformerMetaLearning::new(&small_config(4)).expect("construction");
1315        let tasks = vec![task("t0", 0.4, 0.0, 0.2), task("t1", 0.6, 0.0, 0.7)];
1316        let support = vec![linear_batch(8, 0.0), linear_batch(8, 1.0)];
1317        let query = vec![linear_batch(6, 0.5), linear_batch(6, 1.5)];
1318
1319        let first = ml
1320            .maml_step(&tasks, &support, &query)
1321            .expect("first meta step")
1322            .meta_loss;
1323        let mut last = first;
1324        for _ in 0..40 {
1325            last = ml
1326                .maml_step(&tasks, &support, &query)
1327                .expect("meta step")
1328                .meta_loss;
1329        }
1330
1331        assert!(
1332            last < first,
1333            "meta-training did not reduce the query loss: {first} -> {last}"
1334        );
1335    }
1336
1337    /// F27: the exact second-order meta-gradient must differ from the
1338    /// first-order (FOMAML) one — if `first_order` were ignored they would be
1339    /// identical.
1340    #[test]
1341    fn second_order_meta_gradient_differs_from_first_order() {
1342        let tasks = vec![task("t0", 0.4, 0.0, 0.2)];
1343        let support = vec![linear_batch(8, 0.0)];
1344        let query = vec![linear_batch(6, 0.5)];
1345
1346        let mut cfg_first = small_config(4);
1347        cfg_first.meta_learning_config.first_order = true;
1348        let mut fo = TransformerMetaLearning::new(&cfg_first).expect("construction");
1349
1350        let mut cfg_second = small_config(4);
1351        cfg_second.meta_learning_config.first_order = false;
1352        let mut so = TransformerMetaLearning::new(&cfg_second).expect("construction");
1353
1354        let fo_result = fo.maml_step(&tasks, &support, &query).expect("fomaml");
1355        let so_result = so.maml_step(&tasks, &support, &query).expect("maml");
1356
1357        let fo_grad = &fo_result.task_adaptations[0].query_gradient;
1358        let so_grad = &so_result.task_adaptations[0].query_gradient;
1359        let delta = fo_grad
1360            .iter()
1361            .zip(so_grad.iter())
1362            .map(|(a, b)| (a - b).abs())
1363            .fold(0.0, f64::max);
1364        assert!(
1365            delta > 1e-9,
1366            "second-order correction had no effect (max delta {delta})"
1367        );
1368    }
1369
1370    /// F27: two different task descriptors must produce different context
1371    /// embeddings; `encode_task_context` used to feed `Array2::ones` regardless.
1372    #[test]
1373    fn task_context_encoding_depends_on_the_task() {
1374        let mut net = AdaptationNetwork::<f64>::new(8, 16).expect("network");
1375        let a = net
1376            .encode_task_context(&task("a", 0.1, 0.2, 0.3))
1377            .expect("encode a");
1378        let b = net
1379            .encode_task_context(&task("b", 0.8, 0.4, 0.9))
1380            .expect("encode b");
1381        let delta = (&a - &b).iter().map(|d| d.abs()).fold(0.0, f64::max);
1382        assert!(
1383            delta > 1e-9,
1384            "distinct tasks produced identical context embeddings (delta {delta})"
1385        );
1386    }
1387
1388    /// F86: the momentum branch of `MetaOptimizer::update` was unreachable
1389    /// because `momentum` was hardcoded to `None`. It must now be configurable
1390    /// and behave differently from plain SGD.
1391    #[test]
1392    fn meta_optimizer_momentum_branch_is_reachable() {
1393        let mut cfg = MetaLearningConfig::<f64> {
1394            meta_learning_rate: 0.1,
1395            ..Default::default()
1396        };
1397        let mut sgd = MetaOptimizer::new(&cfg).expect("sgd");
1398        assert!(sgd.momentum().is_none(), "default must be plain SGD");
1399
1400        cfg.meta_momentum = Some(0.9);
1401        let mut momentum = MetaOptimizer::new(&cfg).expect("momentum");
1402        assert_eq!(momentum.momentum(), Some(0.9));
1403
1404        let mut state_sgd = MetaState::<f64>::new(3).expect("state");
1405        let mut state_mom = MetaState::<f64>::new(3).expect("state");
1406        let grad = vec![1.0, -2.0, 0.5];
1407        for _ in 0..3 {
1408            sgd.update(&mut state_sgd, &grad).expect("sgd update");
1409            momentum
1410                .update(&mut state_mom, &grad)
1411                .expect("momentum update");
1412        }
1413
1414        let delta = state_sgd
1415            .get_parameters()
1416            .iter()
1417            .zip(state_mom.get_parameters().iter())
1418            .map(|(a, b)| (a - b).abs())
1419            .fold(0.0, f64::max);
1420        assert!(
1421            delta > 1e-9,
1422            "momentum produced the same trajectory as SGD (delta {delta})"
1423        );
1424    }
1425
1426    /// F86: the momentum branch indexed `gradients[i]` unchecked and panicked
1427    /// on a short gradient; it must return an error instead.
1428    #[test]
1429    fn meta_optimizer_rejects_short_gradients() {
1430        let cfg = MetaLearningConfig::<f64> {
1431            meta_momentum: Some(0.9),
1432            ..Default::default()
1433        };
1434        let mut opt = MetaOptimizer::new(&cfg).expect("optimizer");
1435        let mut state = MetaState::<f64>::new(4).expect("state");
1436        assert!(opt.update(&mut state, &[1.0, 2.0]).is_err());
1437    }
1438
1439    /// A short support/query slice used to panic on `support_data[i]`.
1440    #[test]
1441    fn meta_step_rejects_mismatched_batches() {
1442        let mut ml = TransformerMetaLearning::new(&small_config(4)).expect("construction");
1443        let tasks = vec![task("t0", 0.4, 0.0, 0.2), task("t1", 0.6, 0.0, 0.7)];
1444        let support = vec![linear_batch(8, 0.0)];
1445        let query = vec![linear_batch(6, 0.5)];
1446        assert!(ml.meta_step(&tasks, &support, &query).is_err());
1447        assert!(ml.meta_step(&[], &support, &query).is_err());
1448    }
1449
1450    #[test]
1451    fn test_memory_bank() {
1452        let memory = MemoryBank::<f32>::new(100, 64);
1453        assert!(memory.is_ok());
1454
1455        let mut bank = memory.expect("MemoryBank::new should succeed");
1456        let task = TaskBatch {
1457            id: "test".to_string(),
1458            difficulty: 0.5,
1459            complexity: 0.7,
1460            data_characteristics: 0.3,
1461            _phantom: std::marker::PhantomData,
1462        };
1463
1464        let params = vec![0.1f32; 64];
1465        assert!(bank.store_experience(&task, &params, 0.8).is_ok());
1466    }
1467
1468    #[test]
1469    fn test_adaptation_network() {
1470        let network = AdaptationNetwork::<f32>::new(128, 256);
1471        assert!(network.is_ok());
1472
1473        let mut net = network.expect("AdaptationNetwork::new should succeed");
1474        let task = TaskBatch {
1475            id: "test".to_string(),
1476            difficulty: 0.5,
1477            complexity: 0.7,
1478            data_characteristics: 0.3,
1479            _phantom: std::marker::PhantomData,
1480        };
1481
1482        let context = net.encode_task_context(&task);
1483        assert!(context.is_ok());
1484    }
1485}