Skip to main content

ipfrs_tensorlogic/
meta_learning_optimizer.rs

1//! MetaLearningOptimizer — production-quality meta-learning (learning to learn) optimization.
2//!
3//! Implements MAML, Reptile, FOMAML, and ProtoNet for few-shot adaptation
4//! of a linear regression model (`y = w·x + b`) over many tasks.
5//!
6//! # Collision aliases (types already exported at crate root from other modules)
7//! - `TaskId`      → `MloTaskId`
8//! - `TaskExample` → `MloTaskExample`
9//! - `MetaTask`    → `MloMetaTask`
10//! - `MetaError`   → `MloMetaError`
11
12use std::collections::HashMap;
13use std::fmt;
14
15// ─── PRNG ─────────────────────────────────────────────────────────────────────
16
17/// XorShift-64 PRNG step — deterministic, no external deps.
18#[inline]
19fn xorshift64(state: &mut u64) -> u64 {
20    let mut x = *state;
21    x ^= x << 13;
22    x ^= x >> 7;
23    x ^= x << 17;
24    *state = x;
25    x
26}
27
28/// Generate a `f64` in `[0, 1)` from the XorShift-64 state.
29#[inline]
30fn xorshift_f64(state: &mut u64) -> f64 {
31    (xorshift64(state) >> 11) as f64 / (1u64 << 53) as f64
32}
33
34// ─── TaskId ───────────────────────────────────────────────────────────────────
35
36/// A unique identifier for a meta-learning task.
37///
38/// Aliased as `MloTaskId` at crate root to avoid collision with the
39/// `TaskId` exported from `meta_learner`.
40#[derive(Debug, Clone, PartialEq, Eq, Hash)]
41pub struct TaskId(pub String);
42
43impl TaskId {
44    /// Create a `TaskId` from any `Into<String>`.
45    pub fn new(id: impl Into<String>) -> Self {
46        TaskId(id.into())
47    }
48
49    /// Return the inner string slice.
50    pub fn as_str(&self) -> &str {
51        &self.0
52    }
53}
54
55impl fmt::Display for TaskId {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        write!(f, "{}", self.0)
58    }
59}
60
61// ─── TaskExample ──────────────────────────────────────────────────────────────
62
63/// A single labeled example belonging to a specific task.
64///
65/// Aliased as `MloTaskExample` at crate root.
66#[derive(Debug, Clone)]
67pub struct TaskExample {
68    /// Input feature vector.
69    pub features: Vec<f64>,
70    /// Ground-truth label.
71    pub label: f64,
72    /// The task this example belongs to.
73    pub task_id: TaskId,
74}
75
76impl TaskExample {
77    /// Construct a new `TaskExample`.
78    pub fn new(features: Vec<f64>, label: f64, task_id: TaskId) -> Self {
79        TaskExample {
80            features,
81            label,
82            task_id,
83        }
84    }
85}
86
87// ─── ModelParams ──────────────────────────────────────────────────────────────
88
89/// Linear model parameters: `y = w·x + b`.
90#[derive(Debug, Clone)]
91pub struct ModelParams {
92    /// Weight vector, length == `dim`.
93    pub weights: Vec<f64>,
94    /// Scalar bias.
95    pub bias: f64,
96    /// Input dimensionality.
97    pub dim: usize,
98}
99
100impl ModelParams {
101    /// Create zero-initialised params of dimensionality `dim`.
102    pub fn zeros(dim: usize) -> Self {
103        ModelParams {
104            weights: vec![0.0; dim],
105            bias: 0.0,
106            dim,
107        }
108    }
109
110    /// Linear prediction: `w·x + b`.
111    fn predict(&self, x: &[f64]) -> f64 {
112        let dot: f64 = self
113            .weights
114            .iter()
115            .zip(x.iter())
116            .map(|(w, xi)| w * xi)
117            .sum();
118        dot + self.bias
119    }
120
121    /// MSE loss and gradients for a batch of examples.
122    fn mse_and_grads(&self, examples: &[TaskExample]) -> (f64, Vec<f64>, f64) {
123        let n = examples.len() as f64;
124        let mut grad_w = vec![0.0; self.dim];
125        let mut grad_b = 0.0_f64;
126        let mut loss = 0.0_f64;
127
128        for ex in examples {
129            let pred = self.predict(&ex.features);
130            let residual = pred - ex.label;
131            loss += residual * residual;
132            let coeff = 2.0 * residual / n;
133            for (gw, xi) in grad_w.iter_mut().zip(ex.features.iter()) {
134                *gw += coeff * xi;
135            }
136            grad_b += coeff;
137        }
138        loss /= n;
139        (loss, grad_w, grad_b)
140    }
141}
142
143// ─── AdaptationStep ───────────────────────────────────────────────────────────
144
145/// Snapshot of the model state after one inner-loop gradient step.
146#[derive(Debug, Clone)]
147pub struct AdaptationStep {
148    /// Model parameters after this step.
149    pub params: ModelParams,
150    /// MSE loss on the support set at this step.
151    pub loss: f64,
152    /// Gradient vector (weights component) used in this step.
153    pub gradient: Vec<f64>,
154    /// Zero-based step index.
155    pub step_num: usize,
156}
157
158// ─── MetaTask ─────────────────────────────────────────────────────────────────
159
160/// A meta-learning task holding support and query sets.
161///
162/// Aliased as `MloMetaTask` at crate root.
163#[derive(Debug, Clone)]
164pub struct MetaTask {
165    /// Unique task identifier.
166    pub id: TaskId,
167    /// Support set — used in inner-loop adaptation (K examples).
168    pub support_set: Vec<TaskExample>,
169    /// Query set — used to evaluate the adapted model.
170    pub query_set: Vec<TaskExample>,
171    /// Adapted parameters after the last call to `adapt_to_task`, if any.
172    pub adapted_params: Option<ModelParams>,
173}
174
175impl MetaTask {
176    /// Construct a `MetaTask` with explicit support and query sets.
177    pub fn new(id: TaskId, support_set: Vec<TaskExample>, query_set: Vec<TaskExample>) -> Self {
178        MetaTask {
179            id,
180            support_set,
181            query_set,
182            adapted_params: None,
183        }
184    }
185
186    /// Return the feature dimensionality inferred from the support set,
187    /// or `None` if the support set is empty.
188    pub fn feature_dim(&self) -> Option<usize> {
189        self.support_set.first().map(|ex| ex.features.len())
190    }
191}
192
193// ─── MetaAlgorithm ────────────────────────────────────────────────────────────
194
195/// The meta-learning algorithm variant.
196#[derive(Debug, Clone)]
197pub enum MetaAlgorithm {
198    /// Model-Agnostic Meta-Learning (MAML).
199    MAML {
200        /// Inner-loop learning rate.
201        inner_lr: f64,
202        /// Number of inner-loop gradient steps.
203        inner_steps: u8,
204    },
205    /// Prototypical Networks (ProtoNet).
206    ProtoNet,
207    /// Reptile (first-order meta-learner via parameter interpolation).
208    Reptile {
209        /// Interpolation step size toward each task's adapted params.
210        step_size: f64,
211    },
212    /// First-Order MAML (omit second-order terms).
213    FOMAML {
214        /// Inner-loop learning rate.
215        inner_lr: f64,
216    },
217}
218
219// ─── OptimizerConfig ──────────────────────────────────────────────────────────
220
221/// Configuration for [`MetaLearningOptimizer`].
222#[derive(Debug, Clone)]
223pub struct OptimizerConfig {
224    /// The meta-learning algorithm to use.
225    pub algorithm: MetaAlgorithm,
226    /// Outer (meta) learning rate.
227    pub meta_lr: f64,
228    /// Number of tasks sampled per meta-update batch.
229    pub n_tasks_per_batch: usize,
230    /// Maximum allowed parameter dimensionality.
231    pub max_params_dim: usize,
232}
233
234impl OptimizerConfig {
235    /// Create a default MAML configuration.
236    pub fn default_maml(dim: usize) -> Self {
237        OptimizerConfig {
238            algorithm: MetaAlgorithm::MAML {
239                inner_lr: 0.01,
240                inner_steps: 5,
241            },
242            meta_lr: 0.001,
243            n_tasks_per_batch: 4,
244            max_params_dim: dim,
245        }
246    }
247
248    /// Create a default Reptile configuration.
249    pub fn default_reptile(dim: usize) -> Self {
250        OptimizerConfig {
251            algorithm: MetaAlgorithm::Reptile { step_size: 0.1 },
252            meta_lr: 0.001,
253            n_tasks_per_batch: 4,
254            max_params_dim: dim,
255        }
256    }
257}
258
259// ─── MetaStats ────────────────────────────────────────────────────────────────
260
261/// Accumulated statistics for the meta-learning optimizer.
262#[derive(Debug, Clone, Default)]
263pub struct MetaStats {
264    /// Total number of tasks trained.
265    pub tasks_trained: u64,
266    /// Total number of outer-loop meta-update steps performed.
267    pub meta_updates: u64,
268    /// Running average of inner-loop (support-set) loss.
269    pub avg_adaptation_loss: f64,
270    /// Running average of outer-loop (query-set) loss.
271    pub avg_query_loss: f64,
272    /// Change in outer loss between the last two meta-updates (for convergence).
273    pub convergence_delta: f64,
274}
275
276// ─── MetaError ────────────────────────────────────────────────────────────────
277
278/// Errors produced by [`MetaLearningOptimizer`] operations.
279///
280/// Aliased as `MloMetaError` at crate root.
281#[derive(Debug, Clone, PartialEq)]
282pub enum MetaError {
283    /// Fewer tasks were provided than required by the algorithm.
284    InsufficientTasks(usize),
285    /// Feature dimensionality does not match the expected value.
286    DimensionMismatch {
287        /// Expected dimensionality.
288        expected: usize,
289        /// Actual dimensionality encountered.
290        got: usize,
291    },
292    /// The inner-loop adaptation procedure failed.
293    AdaptationFailed(String),
294    /// The optimizer configuration is invalid.
295    InvalidConfig(String),
296}
297
298impl fmt::Display for MetaError {
299    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
300        match self {
301            MetaError::InsufficientTasks(n) => {
302                write!(f, "insufficient tasks: need at least {n}")
303            }
304            MetaError::DimensionMismatch { expected, got } => {
305                write!(f, "dimension mismatch: expected {expected}, got {got}")
306            }
307            MetaError::AdaptationFailed(msg) => write!(f, "adaptation failed: {msg}"),
308            MetaError::InvalidConfig(msg) => write!(f, "invalid config: {msg}"),
309        }
310    }
311}
312
313impl std::error::Error for MetaError {}
314
315// ─── MetaLearningOptimizer ────────────────────────────────────────────────────
316
317/// A production-quality meta-learning optimizer.
318///
319/// Supports MAML, Reptile, FOMAML, and ProtoNet meta-algorithms over a
320/// linear regression model (`y = w·x + b`, MSE loss).
321///
322/// # Example
323///
324/// ```
325/// use ipfrs_tensorlogic::{
326///     MetaLearningOptimizer, MloTaskId, MloTaskExample, MloMetaTask,
327///     OptimizerConfig, MetaAlgorithm,
328/// };
329///
330/// let config = OptimizerConfig::default_maml(2);
331/// let mut opt = MetaLearningOptimizer::new(config);
332///
333/// // Build a simple task
334/// let tid = MloTaskId::new("t1");
335/// let ex = MloTaskExample::new(vec![1.0, 0.0], 1.0, tid.clone());
336/// let qex = MloTaskExample::new(vec![0.0, 1.0], 0.5, tid.clone());
337/// let task = MloMetaTask::new(tid, vec![ex], vec![qex]);
338///
339/// opt.add_task(task).expect("example: should succeed in docs");
340/// let init = MetaLearningOptimizer::initialize_params(2, 42);
341/// let steps = opt.adapt_to_task(&MloTaskId::new("t1"), &init, 3, 0.01).expect("example: should succeed in docs");
342/// assert!(!steps.is_empty());
343/// ```
344pub struct MetaLearningOptimizer {
345    config: OptimizerConfig,
346    tasks: HashMap<TaskId, MetaTask>,
347    /// Expected feature dimensionality (inferred from first registered task).
348    feature_dim: Option<usize>,
349    stats: MetaStats,
350    /// Running sum for the average adaptation loss (numerator).
351    adaptation_loss_sum: f64,
352    /// Number of adaptation loss samples.
353    adaptation_loss_count: u64,
354    /// Running sum for the average query loss (numerator).
355    query_loss_sum: f64,
356    /// Number of query loss samples.
357    query_loss_count: u64,
358    /// Last recorded query loss (for convergence_delta).
359    prev_query_loss: Option<f64>,
360}
361
362impl MetaLearningOptimizer {
363    /// Create a new `MetaLearningOptimizer` with the given configuration.
364    pub fn new(config: OptimizerConfig) -> Self {
365        MetaLearningOptimizer {
366            config,
367            tasks: HashMap::new(),
368            feature_dim: None,
369            stats: MetaStats::default(),
370            adaptation_loss_sum: 0.0,
371            adaptation_loss_count: 0,
372            query_loss_sum: 0.0,
373            query_loss_count: 0,
374            prev_query_loss: None,
375        }
376    }
377
378    // ── Task registration ─────────────────────────────────────────────────────
379
380    /// Register a task with the optimizer.
381    ///
382    /// Validates that the feature dimensionality is consistent with previously
383    /// registered tasks.
384    pub fn add_task(&mut self, task: MetaTask) -> Result<(), MetaError> {
385        // Determine feature dim from support set
386        if let Some(dim) = task.feature_dim() {
387            match self.feature_dim {
388                None => {
389                    if dim > self.config.max_params_dim {
390                        return Err(MetaError::InvalidConfig(format!(
391                            "feature dim {dim} exceeds max_params_dim {}",
392                            self.config.max_params_dim
393                        )));
394                    }
395                    self.feature_dim = Some(dim);
396                }
397                Some(expected) => {
398                    if dim != expected {
399                        return Err(MetaError::DimensionMismatch { expected, got: dim });
400                    }
401                }
402            }
403        }
404        // Validate query set dims
405        for qex in &task.query_set {
406            let got = qex.features.len();
407            if let Some(expected) = self.feature_dim {
408                if got != expected {
409                    return Err(MetaError::DimensionMismatch { expected, got });
410                }
411            }
412        }
413        self.tasks.insert(task.id.clone(), task);
414        self.stats.tasks_trained += 1;
415        Ok(())
416    }
417
418    // ── Inner-loop adaptation ─────────────────────────────────────────────────
419
420    /// Perform gradient descent on the support set of a task for `steps` steps.
421    ///
422    /// Returns the full adaptation history (one [`AdaptationStep`] per step).
423    pub fn adapt_to_task(
424        &self,
425        task_id: &TaskId,
426        init_params: &ModelParams,
427        steps: u8,
428        lr: f64,
429    ) -> Result<Vec<AdaptationStep>, MetaError> {
430        let task = self
431            .tasks
432            .get(task_id)
433            .ok_or_else(|| MetaError::AdaptationFailed(format!("unknown task: {task_id}")))?;
434
435        if task.support_set.is_empty() {
436            return Err(MetaError::AdaptationFailed(
437                "support set is empty".to_string(),
438            ));
439        }
440
441        // Validate dimensionality
442        let expected_dim = init_params.dim;
443        for ex in &task.support_set {
444            let got = ex.features.len();
445            if got != expected_dim {
446                return Err(MetaError::DimensionMismatch {
447                    expected: expected_dim,
448                    got,
449                });
450            }
451        }
452
453        let mut params = init_params.clone();
454        let mut history = Vec::with_capacity(steps as usize);
455
456        for step in 0..steps {
457            let (loss, grad_w, grad_b) = params.mse_and_grads(&task.support_set);
458
459            // Gradient descent step
460            for (w, gw) in params.weights.iter_mut().zip(grad_w.iter()) {
461                *w -= lr * gw;
462            }
463            params.bias -= lr * grad_b;
464
465            history.push(AdaptationStep {
466                params: params.clone(),
467                loss,
468                gradient: grad_w,
469                step_num: step as usize,
470            });
471        }
472
473        Ok(history)
474    }
475
476    // ── Outer-loop meta-update ────────────────────────────────────────────────
477
478    /// Perform one outer-loop meta-update over the specified task IDs.
479    ///
480    /// Algorithm dispatch:
481    /// - **MAML / FOMAML**: for each task, run inner-loop adaptation; compute
482    ///   meta-gradient as the mean of `(adapted − init)`; update
483    ///   `new = current + meta_lr * meta_grad`.
484    /// - **Reptile**: move `current` toward each task's adapted params by
485    ///   `step_size`.
486    /// - **ProtoNet**: compute per-task prototypes, return params whose weights
487    ///   encode mean prototype and whose bias encodes the grand mean.
488    pub fn meta_update(
489        &mut self,
490        task_ids: &[TaskId],
491        current_params: &ModelParams,
492    ) -> Result<ModelParams, MetaError> {
493        if task_ids.is_empty() {
494            return Err(MetaError::InsufficientTasks(1));
495        }
496        let dim = current_params.dim;
497
498        let result = match &self.config.algorithm.clone() {
499            MetaAlgorithm::MAML {
500                inner_lr,
501                inner_steps,
502            } => self.meta_update_maml(task_ids, current_params, *inner_lr, *inner_steps, dim)?,
503            MetaAlgorithm::FOMAML { inner_lr } => {
504                self.meta_update_fomaml(task_ids, current_params, *inner_lr, dim)?
505            }
506            MetaAlgorithm::Reptile { step_size } => {
507                self.meta_update_reptile(task_ids, current_params, *step_size, dim)?
508            }
509            MetaAlgorithm::ProtoNet => self.meta_update_protonet(task_ids, current_params, dim)?,
510        };
511
512        // Update query-loss stats
513        let avg_q = self.compute_avg_query_loss(task_ids, &result);
514        self.query_loss_sum += avg_q;
515        self.query_loss_count += 1;
516        let new_avg = self.query_loss_sum / self.query_loss_count as f64;
517        let delta = match self.prev_query_loss {
518            Some(prev) => (new_avg - prev).abs(),
519            None => 0.0,
520        };
521        self.prev_query_loss = Some(new_avg);
522        self.stats.avg_query_loss = new_avg;
523        self.stats.convergence_delta = delta;
524        self.stats.meta_updates += 1;
525
526        Ok(result)
527    }
528
529    // ── Evaluate task ─────────────────────────────────────────────────────────
530
531    /// Compute MSE of `params` on the query set of `task_id`.
532    pub fn evaluate_task(&self, task_id: &TaskId, params: &ModelParams) -> Result<f64, MetaError> {
533        let task = self
534            .tasks
535            .get(task_id)
536            .ok_or_else(|| MetaError::AdaptationFailed(format!("unknown task: {task_id}")))?;
537
538        if task.query_set.is_empty() {
539            return Err(MetaError::AdaptationFailed(
540                "query set is empty".to_string(),
541            ));
542        }
543
544        let (loss, _, _) = params.mse_and_grads(&task.query_set);
545        Ok(loss)
546    }
547
548    // ── Parameter initialisation ──────────────────────────────────────────────
549
550    /// Create a small-random `ModelParams` of dimensionality `dim` using an
551    /// XorShift-64 PRNG seeded with `seed`.
552    pub fn initialize_params(dim: usize, seed: u64) -> ModelParams {
553        let mut state = if seed == 0 { 0xdeadbeef_cafebabe } else { seed };
554        let weights: Vec<f64> = (0..dim)
555            .map(|_| (xorshift_f64(&mut state) - 0.5) * 0.01)
556            .collect();
557        let bias = (xorshift_f64(&mut state) - 0.5) * 0.01;
558        ModelParams { weights, bias, dim }
559    }
560
561    // ── Few-shot prediction ───────────────────────────────────────────────────
562
563    /// Adapt to the task's support set and predict the label for `x`.
564    pub fn few_shot_predict(
565        &self,
566        task: &MetaTask,
567        x: &[f64],
568        init_params: &ModelParams,
569    ) -> Result<f64, MetaError> {
570        // Determine adaptation hyper-params from config
571        let (steps, lr) = match &self.config.algorithm {
572            MetaAlgorithm::MAML {
573                inner_lr,
574                inner_steps,
575            } => (*inner_steps, *inner_lr),
576            MetaAlgorithm::FOMAML { inner_lr } => (5u8, *inner_lr),
577            MetaAlgorithm::Reptile { step_size } => (5u8, *step_size),
578            MetaAlgorithm::ProtoNet => (1u8, 0.01),
579        };
580
581        if task.support_set.is_empty() {
582            return Err(MetaError::AdaptationFailed(
583                "support set is empty for few_shot_predict".to_string(),
584            ));
585        }
586
587        let dim = init_params.dim;
588        if x.len() != dim {
589            return Err(MetaError::DimensionMismatch {
590                expected: dim,
591                got: x.len(),
592            });
593        }
594
595        // Validate support set dims
596        for ex in &task.support_set {
597            let got = ex.features.len();
598            if got != dim {
599                return Err(MetaError::DimensionMismatch { expected: dim, got });
600            }
601        }
602
603        // Perform inner-loop adaptation on a temporary task
604        let mut params = init_params.clone();
605        for _ in 0..steps {
606            let (_, grad_w, grad_b) = params.mse_and_grads(&task.support_set);
607            for (w, gw) in params.weights.iter_mut().zip(grad_w.iter()) {
608                *w -= lr * gw;
609            }
610            params.bias -= lr * grad_b;
611        }
612
613        Ok(params.predict(x))
614    }
615
616    // ── Stats ─────────────────────────────────────────────────────────────────
617
618    /// Return a snapshot of the current optimizer statistics.
619    pub fn stats(&self) -> MetaStats {
620        self.stats.clone()
621    }
622
623    // ─── Private helpers ───────────────────────────────────────────────────────
624
625    /// MAML outer update: for each task, run inner adaptation; accumulate
626    /// the mean parameter delta and add `meta_lr * mean_delta` to `current`.
627    fn meta_update_maml(
628        &mut self,
629        task_ids: &[TaskId],
630        current_params: &ModelParams,
631        inner_lr: f64,
632        inner_steps: u8,
633        dim: usize,
634    ) -> Result<ModelParams, MetaError> {
635        let mut meta_grad_w = vec![0.0_f64; dim];
636        let mut meta_grad_b = 0.0_f64;
637        let mut valid_count = 0usize;
638
639        for tid in task_ids {
640            let history = self.adapt_to_task(tid, current_params, inner_steps, inner_lr)?;
641            if let Some(last) = history.last() {
642                // meta-gradient = adapted_params - init_params
643                for (mg, (aw, iw)) in meta_grad_w.iter_mut().zip(
644                    last.params
645                        .weights
646                        .iter()
647                        .zip(current_params.weights.iter()),
648                ) {
649                    *mg += aw - iw;
650                }
651                meta_grad_b += last.params.bias - current_params.bias;
652
653                // Track adaptation loss
654                let adapt_loss = last.loss;
655                self.adaptation_loss_sum += adapt_loss;
656                self.adaptation_loss_count += 1;
657                self.stats.avg_adaptation_loss =
658                    self.adaptation_loss_sum / self.adaptation_loss_count as f64;
659
660                valid_count += 1;
661            }
662        }
663
664        if valid_count == 0 {
665            return Err(MetaError::InsufficientTasks(1));
666        }
667
668        let inv = 1.0 / valid_count as f64;
669        let meta_lr = self.config.meta_lr;
670        let mut new_w = current_params.weights.clone();
671        for (w, mg) in new_w.iter_mut().zip(meta_grad_w.iter()) {
672            *w += meta_lr * mg * inv;
673        }
674        let new_b = current_params.bias + meta_lr * meta_grad_b * inv;
675
676        Ok(ModelParams {
677            weights: new_w,
678            bias: new_b,
679            dim,
680        })
681    }
682
683    /// FOMAML — identical structure to MAML; uses only first-order gradients.
684    fn meta_update_fomaml(
685        &mut self,
686        task_ids: &[TaskId],
687        current_params: &ModelParams,
688        inner_lr: f64,
689        dim: usize,
690    ) -> Result<ModelParams, MetaError> {
691        // FOMAML uses a single inner step and first-order gradient approximation
692        let inner_steps: u8 = 1;
693        let mut meta_grad_w = vec![0.0_f64; dim];
694        let mut meta_grad_b = 0.0_f64;
695        let mut valid_count = 0usize;
696
697        for tid in task_ids {
698            let task = self
699                .tasks
700                .get(tid)
701                .ok_or_else(|| MetaError::AdaptationFailed(format!("unknown task: {tid}")))?;
702            if task.support_set.is_empty() {
703                continue;
704            }
705            // Validate dims
706            for ex in &task.support_set {
707                let got = ex.features.len();
708                if got != dim {
709                    return Err(MetaError::DimensionMismatch { expected: dim, got });
710                }
711            }
712            // First-order gradient at query set after one inner step
713            let history = self.adapt_to_task(tid, current_params, inner_steps, inner_lr)?;
714            if let Some(last) = history.last() {
715                // Compute query-set gradient at adapted params
716                let task2 = self
717                    .tasks
718                    .get(tid)
719                    .ok_or_else(|| MetaError::AdaptationFailed(format!("unknown task: {tid}")))?;
720                let (qloss, qgrad_w, qgrad_b) = last.params.mse_and_grads(&task2.query_set);
721
722                // FOMAML meta-gradient = query-set gradient (no second-order terms)
723                for (mg, qg) in meta_grad_w.iter_mut().zip(qgrad_w.iter()) {
724                    *mg += qg;
725                }
726                meta_grad_b += qgrad_b;
727
728                self.adaptation_loss_sum += qloss;
729                self.adaptation_loss_count += 1;
730                self.stats.avg_adaptation_loss =
731                    self.adaptation_loss_sum / self.adaptation_loss_count as f64;
732
733                valid_count += 1;
734            }
735        }
736
737        if valid_count == 0 {
738            return Err(MetaError::InsufficientTasks(1));
739        }
740
741        let inv = 1.0 / valid_count as f64;
742        let meta_lr = self.config.meta_lr;
743        let mut new_w = current_params.weights.clone();
744        for (w, mg) in new_w.iter_mut().zip(meta_grad_w.iter()) {
745            *w -= meta_lr * mg * inv;
746        }
747        let new_b = current_params.bias - meta_lr * meta_grad_b * inv;
748
749        Ok(ModelParams {
750            weights: new_w,
751            bias: new_b,
752            dim,
753        })
754    }
755
756    /// Reptile meta-update: move `current` toward each task's adapted params.
757    fn meta_update_reptile(
758        &mut self,
759        task_ids: &[TaskId],
760        current_params: &ModelParams,
761        step_size: f64,
762        dim: usize,
763    ) -> Result<ModelParams, MetaError> {
764        let inner_steps = 5u8;
765        let inner_lr = 0.01;
766        let mut result = current_params.clone();
767        let mut valid_count = 0usize;
768
769        for tid in task_ids {
770            let history = self.adapt_to_task(tid, current_params, inner_steps, inner_lr)?;
771            if let Some(last) = history.last() {
772                // Move toward adapted params: result_w += step_size * (adapted_w - init_w)
773                for (idx, rw) in result.weights.iter_mut().enumerate() {
774                    let init_w = current_params.weights[idx];
775                    let adapted_w = last.params.weights[idx];
776                    *rw += step_size * (adapted_w - init_w);
777                }
778                result.bias += step_size * (last.params.bias - current_params.bias);
779
780                self.adaptation_loss_sum += last.loss;
781                self.adaptation_loss_count += 1;
782                self.stats.avg_adaptation_loss =
783                    self.adaptation_loss_sum / self.adaptation_loss_count as f64;
784
785                valid_count += 1;
786            }
787        }
788
789        if valid_count == 0 {
790            return Err(MetaError::InsufficientTasks(1));
791        }
792
793        let _ = dim; // dim captured from current_params
794        Ok(result)
795    }
796
797    /// ProtoNet meta-update: compute per-class prototypes, encode them in params.
798    ///
799    /// For regression tasks the "prototype" is the mean feature vector weighted
800    /// by label; for the bias, we use the grand mean label.
801    fn meta_update_protonet(
802        &mut self,
803        task_ids: &[TaskId],
804        current_params: &ModelParams,
805        dim: usize,
806    ) -> Result<ModelParams, MetaError> {
807        let mut proto_w = vec![0.0_f64; dim];
808        let mut proto_b = 0.0_f64;
809        let mut valid_count = 0usize;
810
811        for tid in task_ids {
812            let task = self
813                .tasks
814                .get(tid)
815                .ok_or_else(|| MetaError::AdaptationFailed(format!("unknown task: {tid}")))?;
816            if task.support_set.is_empty() {
817                continue;
818            }
819            let n = task.support_set.len() as f64;
820            let mean_label: f64 = task.support_set.iter().map(|e| e.label).sum::<f64>() / n;
821            let mut mean_feat = vec![0.0_f64; dim];
822            for ex in &task.support_set {
823                if ex.features.len() != dim {
824                    return Err(MetaError::DimensionMismatch {
825                        expected: dim,
826                        got: ex.features.len(),
827                    });
828                }
829                for (mf, xi) in mean_feat.iter_mut().zip(ex.features.iter()) {
830                    *mf += xi / n;
831                }
832            }
833            // Prototype direction ≈ mean_feat * mean_label
834            for (pw, mf) in proto_w.iter_mut().zip(mean_feat.iter()) {
835                *pw += mf * mean_label;
836            }
837            proto_b += mean_label;
838            valid_count += 1;
839        }
840
841        if valid_count == 0 {
842            return Err(MetaError::InsufficientTasks(1));
843        }
844
845        let inv = 1.0 / valid_count as f64;
846        let meta_lr = self.config.meta_lr;
847        let mut new_w = current_params.weights.clone();
848        for (w, pw) in new_w.iter_mut().zip(proto_w.iter()) {
849            *w += meta_lr * pw * inv;
850        }
851        let new_b = current_params.bias + meta_lr * proto_b * inv;
852
853        Ok(ModelParams {
854            weights: new_w,
855            bias: new_b,
856            dim,
857        })
858    }
859
860    /// Compute the average query-set MSE over the given tasks.
861    fn compute_avg_query_loss(&self, task_ids: &[TaskId], params: &ModelParams) -> f64 {
862        let mut sum = 0.0;
863        let mut count = 0usize;
864        for tid in task_ids {
865            if let Ok(loss) = self.evaluate_task(tid, params) {
866                sum += loss;
867                count += 1;
868            }
869        }
870        if count == 0 {
871            0.0
872        } else {
873            sum / count as f64
874        }
875    }
876}
877
878// ─── Tests ────────────────────────────────────────────────────────────────────
879
880#[cfg(test)]
881mod tests {
882    use super::*;
883
884    // ── Helpers ──────────────────────────────────────────────────────────────
885
886    /// Build a simple 1-D regression task:  label = slope * feature + intercept + noise
887    fn make_regression_task(
888        id: &str,
889        slope: f64,
890        intercept: f64,
891        n_support: usize,
892        n_query: usize,
893        seed: u64,
894    ) -> MetaTask {
895        let tid = TaskId::new(id);
896        let mut state = seed;
897        let mut support = Vec::with_capacity(n_support);
898        for _ in 0..n_support {
899            let x = xorshift_f64(&mut state) * 4.0 - 2.0;
900            let y = slope * x + intercept;
901            support.push(TaskExample::new(vec![x], y, tid.clone()));
902        }
903        let mut query = Vec::with_capacity(n_query);
904        for _ in 0..n_query {
905            let x = xorshift_f64(&mut state) * 4.0 - 2.0;
906            let y = slope * x + intercept;
907            query.push(TaskExample::new(vec![x], y, tid.clone()));
908        }
909        MetaTask::new(tid, support, query)
910    }
911
912    /// Build a 2-D regression task.
913    fn make_2d_task(
914        id: &str,
915        w0: f64,
916        w1: f64,
917        bias: f64,
918        n_support: usize,
919        n_query: usize,
920        seed: u64,
921    ) -> MetaTask {
922        let tid = TaskId::new(id);
923        let mut state = seed;
924        let mut support = Vec::with_capacity(n_support);
925        for _ in 0..n_support {
926            let x0 = xorshift_f64(&mut state) * 2.0 - 1.0;
927            let x1 = xorshift_f64(&mut state) * 2.0 - 1.0;
928            let y = w0 * x0 + w1 * x1 + bias;
929            support.push(TaskExample::new(vec![x0, x1], y, tid.clone()));
930        }
931        let mut query = Vec::with_capacity(n_query);
932        for _ in 0..n_query {
933            let x0 = xorshift_f64(&mut state) * 2.0 - 1.0;
934            let x1 = xorshift_f64(&mut state) * 2.0 - 1.0;
935            let y = w0 * x0 + w1 * x1 + bias;
936            query.push(TaskExample::new(vec![x0, x1], y, tid.clone()));
937        }
938        MetaTask::new(tid, support, query)
939    }
940
941    // ── add_task ──────────────────────────────────────────────────────────────
942
943    #[test]
944    fn test_add_task_basic() {
945        let config = OptimizerConfig::default_maml(1);
946        let mut opt = MetaLearningOptimizer::new(config);
947        let task = make_regression_task("t1", 2.0, 1.0, 5, 5, 1);
948        assert!(opt.add_task(task).is_ok());
949        assert_eq!(opt.stats().tasks_trained, 1);
950    }
951
952    #[test]
953    fn test_add_multiple_tasks() {
954        let config = OptimizerConfig::default_maml(1);
955        let mut opt = MetaLearningOptimizer::new(config);
956        for i in 0..5 {
957            let task = make_regression_task(&format!("t{i}"), i as f64, 0.0, 4, 4, i as u64 + 1);
958            assert!(opt.add_task(task).is_ok());
959        }
960        assert_eq!(opt.stats().tasks_trained, 5);
961    }
962
963    #[test]
964    fn test_add_task_dimension_consistency() {
965        let config = OptimizerConfig::default_maml(2);
966        let mut opt = MetaLearningOptimizer::new(config);
967        let t1 = make_2d_task("t1", 1.0, 2.0, 0.5, 4, 4, 10);
968        assert!(opt.add_task(t1).is_ok());
969        // t2 with wrong dim (1D)
970        let t2 = make_regression_task("t2", 1.0, 0.0, 4, 4, 20);
971        let err = opt.add_task(t2).unwrap_err();
972        assert!(matches!(
973            err,
974            MetaError::DimensionMismatch {
975                expected: 2,
976                got: 1
977            }
978        ));
979    }
980
981    #[test]
982    fn test_add_task_dim_exceeds_max() {
983        let config = OptimizerConfig {
984            algorithm: MetaAlgorithm::MAML {
985                inner_lr: 0.01,
986                inner_steps: 3,
987            },
988            meta_lr: 0.001,
989            n_tasks_per_batch: 2,
990            max_params_dim: 2,
991        };
992        let mut opt = MetaLearningOptimizer::new(config);
993        // Build a 3-D task
994        let tid = TaskId::new("too-big");
995        let ex = TaskExample::new(vec![1.0, 2.0, 3.0], 0.5, tid.clone());
996        let task = MetaTask::new(tid, vec![ex.clone()], vec![ex]);
997        let err = opt.add_task(task).unwrap_err();
998        assert!(matches!(err, MetaError::InvalidConfig(_)));
999    }
1000
1001    #[test]
1002    fn test_add_task_empty_support_allowed() {
1003        // Tasks with empty support sets are allowed at registration time
1004        let config = OptimizerConfig::default_maml(1);
1005        let mut opt = MetaLearningOptimizer::new(config);
1006        let tid = TaskId::new("empty");
1007        let qex = TaskExample::new(vec![1.0], 1.0, tid.clone());
1008        let task = MetaTask::new(tid, vec![], vec![qex]);
1009        // Should succeed — dim not inferred from empty support
1010        assert!(opt.add_task(task).is_ok());
1011    }
1012
1013    // ── adapt_to_task ─────────────────────────────────────────────────────────
1014
1015    #[test]
1016    fn test_adapt_returns_correct_step_count() {
1017        let config = OptimizerConfig::default_maml(1);
1018        let mut opt = MetaLearningOptimizer::new(config);
1019        let task = make_regression_task("t1", 3.0, 0.5, 10, 5, 42);
1020        opt.add_task(task).expect("test: should succeed");
1021        let init = MetaLearningOptimizer::initialize_params(1, 1);
1022        let steps = opt
1023            .adapt_to_task(&TaskId::new("t1"), &init, 7, 0.01)
1024            .expect("test: should succeed");
1025        assert_eq!(steps.len(), 7);
1026    }
1027
1028    #[test]
1029    fn test_adapt_step_numbers_sequential() {
1030        let config = OptimizerConfig::default_maml(1);
1031        let mut opt = MetaLearningOptimizer::new(config);
1032        let task = make_regression_task("t1", 1.0, 0.0, 8, 4, 5);
1033        opt.add_task(task).expect("test: should succeed");
1034        let init = MetaLearningOptimizer::initialize_params(1, 7);
1035        let steps = opt
1036            .adapt_to_task(&TaskId::new("t1"), &init, 5, 0.05)
1037            .expect("test: should succeed");
1038        for (i, step) in steps.iter().enumerate() {
1039            assert_eq!(step.step_num, i);
1040        }
1041    }
1042
1043    #[test]
1044    fn test_adapt_loss_non_negative() {
1045        let config = OptimizerConfig::default_maml(1);
1046        let mut opt = MetaLearningOptimizer::new(config);
1047        let task = make_regression_task("t1", 2.0, -1.0, 10, 5, 11);
1048        opt.add_task(task).expect("test: should succeed");
1049        let init = MetaLearningOptimizer::initialize_params(1, 99);
1050        let steps = opt
1051            .adapt_to_task(&TaskId::new("t1"), &init, 10, 0.01)
1052            .expect("test: should succeed");
1053        for step in &steps {
1054            assert!(step.loss >= 0.0, "loss must be non-negative");
1055        }
1056    }
1057
1058    #[test]
1059    fn test_adapt_loss_decreases_over_steps() {
1060        // With sufficient steps and a well-posed problem, loss should decrease
1061        let config = OptimizerConfig::default_maml(1);
1062        let mut opt = MetaLearningOptimizer::new(config);
1063        let task = make_regression_task("t1", 1.5, 0.3, 20, 5, 7);
1064        opt.add_task(task).expect("test: should succeed");
1065        let init = MetaLearningOptimizer::initialize_params(1, 3);
1066        let steps = opt
1067            .adapt_to_task(&TaskId::new("t1"), &init, 20, 0.05)
1068            .expect("test: should succeed");
1069        let first_loss = steps.first().map(|s| s.loss).unwrap_or(f64::MAX);
1070        let last_loss = steps.last().map(|s| s.loss).unwrap_or(f64::MAX);
1071        assert!(
1072            last_loss <= first_loss + 1e-10,
1073            "loss should decrease: {first_loss} -> {last_loss}"
1074        );
1075    }
1076
1077    #[test]
1078    fn test_adapt_2d_loss_decreases() {
1079        let config = OptimizerConfig::default_maml(2);
1080        let mut opt = MetaLearningOptimizer::new(config);
1081        let task = make_2d_task("t1", 1.0, -1.0, 0.5, 15, 5, 42);
1082        opt.add_task(task).expect("test: should succeed");
1083        let init = MetaLearningOptimizer::initialize_params(2, 9);
1084        let steps = opt
1085            .adapt_to_task(&TaskId::new("t1"), &init, 30, 0.02)
1086            .expect("test: should succeed");
1087        let first = steps.first().map(|s| s.loss).expect("test: should succeed");
1088        let last = steps.last().map(|s| s.loss).expect("test: should succeed");
1089        assert!(last <= first + 1e-9);
1090    }
1091
1092    #[test]
1093    fn test_adapt_unknown_task_error() {
1094        let config = OptimizerConfig::default_maml(1);
1095        let opt = MetaLearningOptimizer::new(config);
1096        let init = MetaLearningOptimizer::initialize_params(1, 1);
1097        let err = opt
1098            .adapt_to_task(&TaskId::new("no-such"), &init, 5, 0.01)
1099            .unwrap_err();
1100        assert!(matches!(err, MetaError::AdaptationFailed(_)));
1101    }
1102
1103    #[test]
1104    fn test_adapt_empty_support_error() {
1105        let config = OptimizerConfig::default_maml(1);
1106        let mut opt = MetaLearningOptimizer::new(config);
1107        let tid = TaskId::new("empty");
1108        let qex = TaskExample::new(vec![1.0], 1.0, tid.clone());
1109        let task = MetaTask::new(tid.clone(), vec![], vec![qex]);
1110        opt.add_task(task).expect("test: should succeed");
1111        let init = MetaLearningOptimizer::initialize_params(1, 1);
1112        let err = opt.adapt_to_task(&tid, &init, 3, 0.01).unwrap_err();
1113        assert!(matches!(err, MetaError::AdaptationFailed(_)));
1114    }
1115
1116    #[test]
1117    fn test_adapt_dim_mismatch_error() {
1118        let config = OptimizerConfig::default_maml(2);
1119        let mut opt = MetaLearningOptimizer::new(config);
1120        let task = make_2d_task("t1", 1.0, 1.0, 0.0, 5, 5, 1);
1121        opt.add_task(task).expect("test: should succeed");
1122        // init with wrong dim
1123        let bad_init = MetaLearningOptimizer::initialize_params(3, 1);
1124        let err = opt
1125            .adapt_to_task(&TaskId::new("t1"), &bad_init, 3, 0.01)
1126            .unwrap_err();
1127        assert!(matches!(err, MetaError::DimensionMismatch { .. }));
1128    }
1129
1130    // ── meta_update (MAML) ────────────────────────────────────────────────────
1131
1132    #[test]
1133    fn test_meta_update_maml_returns_new_params() {
1134        let config = OptimizerConfig::default_maml(1);
1135        let mut opt = MetaLearningOptimizer::new(config);
1136        for i in 0..4 {
1137            let task = make_regression_task(
1138                &format!("t{i}"),
1139                (i + 1) as f64,
1140                0.1,
1141                8,
1142                4,
1143                (i * 7 + 1) as u64,
1144            );
1145            opt.add_task(task).expect("test: should succeed");
1146        }
1147        let init = MetaLearningOptimizer::initialize_params(1, 42);
1148        let ids: Vec<TaskId> = (0..4).map(|i| TaskId::new(format!("t{i}"))).collect();
1149        let new_params = opt.meta_update(&ids, &init).expect("test: should succeed");
1150        assert_eq!(new_params.dim, 1);
1151        assert_eq!(opt.stats().meta_updates, 1);
1152    }
1153
1154    #[test]
1155    fn test_meta_update_maml_params_changed() {
1156        let config = OptimizerConfig::default_maml(1);
1157        let mut opt = MetaLearningOptimizer::new(config);
1158        for i in 0..4 {
1159            let task = make_regression_task(
1160                &format!("t{i}"),
1161                (i as f64 + 1.0) * 0.7,
1162                0.3,
1163                10,
1164                5,
1165                i as u64 + 11,
1166            );
1167            opt.add_task(task).expect("test: should succeed");
1168        }
1169        let init = MetaLearningOptimizer::initialize_params(1, 17);
1170        let ids: Vec<TaskId> = (0..4).map(|i| TaskId::new(format!("t{i}"))).collect();
1171        let new_params = opt.meta_update(&ids, &init).expect("test: should succeed");
1172        // At least one parameter should have changed
1173        let changed = new_params.weights[0] != init.weights[0] || new_params.bias != init.bias;
1174        assert!(changed, "meta_update should change parameters");
1175    }
1176
1177    #[test]
1178    fn test_meta_update_maml_multiple_rounds_converge() {
1179        let config = OptimizerConfig {
1180            algorithm: MetaAlgorithm::MAML {
1181                inner_lr: 0.05,
1182                inner_steps: 5,
1183            },
1184            meta_lr: 0.1,
1185            n_tasks_per_batch: 4,
1186            max_params_dim: 1,
1187        };
1188        let mut opt = MetaLearningOptimizer::new(config);
1189        for i in 0..4 {
1190            let task = make_regression_task(&format!("t{i}"), 1.0, 0.0, 10, 5, i as u64 + 1);
1191            opt.add_task(task).expect("test: should succeed");
1192        }
1193        let mut params = MetaLearningOptimizer::initialize_params(1, 5);
1194        let ids: Vec<TaskId> = (0..4).map(|i| TaskId::new(format!("t{i}"))).collect();
1195        for _ in 0..20 {
1196            params = opt
1197                .meta_update(&ids, &params)
1198                .expect("test: should succeed");
1199        }
1200        assert_eq!(opt.stats().meta_updates, 20);
1201    }
1202
1203    #[test]
1204    fn test_meta_update_maml_empty_task_list_error() {
1205        let config = OptimizerConfig::default_maml(1);
1206        let mut opt = MetaLearningOptimizer::new(config);
1207        let init = MetaLearningOptimizer::initialize_params(1, 1);
1208        let err = opt.meta_update(&[], &init).unwrap_err();
1209        assert!(matches!(err, MetaError::InsufficientTasks(_)));
1210    }
1211
1212    // ── meta_update (Reptile) ─────────────────────────────────────────────────
1213
1214    #[test]
1215    fn test_meta_update_reptile_basic() {
1216        let config = OptimizerConfig::default_reptile(1);
1217        let mut opt = MetaLearningOptimizer::new(config);
1218        for i in 0..3 {
1219            let task =
1220                make_regression_task(&format!("r{i}"), (i + 1) as f64, 0.0, 8, 4, i as u64 + 5);
1221            opt.add_task(task).expect("test: should succeed");
1222        }
1223        let init = MetaLearningOptimizer::initialize_params(1, 42);
1224        let ids: Vec<TaskId> = (0..3).map(|i| TaskId::new(format!("r{i}"))).collect();
1225        let new_p = opt.meta_update(&ids, &init).expect("test: should succeed");
1226        assert_eq!(new_p.dim, 1);
1227    }
1228
1229    #[test]
1230    fn test_meta_update_reptile_params_change() {
1231        let config = OptimizerConfig::default_reptile(1);
1232        let mut opt = MetaLearningOptimizer::new(config);
1233        for i in 0..3 {
1234            let task = make_regression_task(&format!("r{i}"), 2.0, 1.0, 10, 5, i as u64 + 100);
1235            opt.add_task(task).expect("test: should succeed");
1236        }
1237        let init = MetaLearningOptimizer::initialize_params(1, 77);
1238        let ids: Vec<TaskId> = (0..3).map(|i| TaskId::new(format!("r{i}"))).collect();
1239        let new_p = opt.meta_update(&ids, &init).expect("test: should succeed");
1240        let changed = new_p.weights[0] != init.weights[0] || new_p.bias != init.bias;
1241        assert!(changed);
1242    }
1243
1244    #[test]
1245    fn test_meta_update_reptile_multiple_rounds() {
1246        let config = OptimizerConfig::default_reptile(1);
1247        let mut opt = MetaLearningOptimizer::new(config);
1248        for i in 0..3 {
1249            let task = make_regression_task(&format!("r{i}"), 1.0, 0.5, 8, 4, i as u64 + 3);
1250            opt.add_task(task).expect("test: should succeed");
1251        }
1252        let mut params = MetaLearningOptimizer::initialize_params(1, 17);
1253        let ids: Vec<TaskId> = (0..3).map(|i| TaskId::new(format!("r{i}"))).collect();
1254        for _ in 0..10 {
1255            params = opt
1256                .meta_update(&ids, &params)
1257                .expect("test: should succeed");
1258        }
1259        assert_eq!(opt.stats().meta_updates, 10);
1260    }
1261
1262    // ── meta_update (FOMAML) ──────────────────────────────────────────────────
1263
1264    #[test]
1265    fn test_meta_update_fomaml_basic() {
1266        let config = OptimizerConfig {
1267            algorithm: MetaAlgorithm::FOMAML { inner_lr: 0.02 },
1268            meta_lr: 0.01,
1269            n_tasks_per_batch: 3,
1270            max_params_dim: 1,
1271        };
1272        let mut opt = MetaLearningOptimizer::new(config);
1273        for i in 0..3 {
1274            let task = make_regression_task(
1275                &format!("f{i}"),
1276                i as f64 + 0.5,
1277                0.0,
1278                8,
1279                4,
1280                (i * 3 + 2) as u64,
1281            );
1282            opt.add_task(task).expect("test: should succeed");
1283        }
1284        let init = MetaLearningOptimizer::initialize_params(1, 55);
1285        let ids: Vec<TaskId> = (0..3).map(|i| TaskId::new(format!("f{i}"))).collect();
1286        let new_p = opt.meta_update(&ids, &init).expect("test: should succeed");
1287        assert_eq!(new_p.dim, 1);
1288    }
1289
1290    #[test]
1291    fn test_meta_update_fomaml_params_change() {
1292        let config = OptimizerConfig {
1293            algorithm: MetaAlgorithm::FOMAML { inner_lr: 0.05 },
1294            meta_lr: 0.1,
1295            n_tasks_per_batch: 3,
1296            max_params_dim: 1,
1297        };
1298        let mut opt = MetaLearningOptimizer::new(config);
1299        for i in 0..3 {
1300            let task = make_regression_task(&format!("f{i}"), 2.0, 0.5, 10, 5, i as u64 + 20);
1301            opt.add_task(task).expect("test: should succeed");
1302        }
1303        let init = MetaLearningOptimizer::initialize_params(1, 3);
1304        let ids: Vec<TaskId> = (0..3).map(|i| TaskId::new(format!("f{i}"))).collect();
1305        let new_p = opt.meta_update(&ids, &init).expect("test: should succeed");
1306        let changed = new_p.weights[0] != init.weights[0] || new_p.bias != init.bias;
1307        assert!(changed);
1308    }
1309
1310    // ── meta_update (ProtoNet) ────────────────────────────────────────────────
1311
1312    #[test]
1313    fn test_meta_update_protonet_basic() {
1314        let config = OptimizerConfig {
1315            algorithm: MetaAlgorithm::ProtoNet,
1316            meta_lr: 0.01,
1317            n_tasks_per_batch: 3,
1318            max_params_dim: 2,
1319        };
1320        let mut opt = MetaLearningOptimizer::new(config);
1321        for i in 0..3 {
1322            let task = make_2d_task(&format!("p{i}"), 1.0, 1.0, 0.0, 6, 4, (i * 5 + 1) as u64);
1323            opt.add_task(task).expect("test: should succeed");
1324        }
1325        let init = MetaLearningOptimizer::initialize_params(2, 11);
1326        let ids: Vec<TaskId> = (0..3).map(|i| TaskId::new(format!("p{i}"))).collect();
1327        let new_p = opt.meta_update(&ids, &init).expect("test: should succeed");
1328        assert_eq!(new_p.dim, 2);
1329        assert_eq!(opt.stats().meta_updates, 1);
1330    }
1331
1332    // ── evaluate_task ─────────────────────────────────────────────────────────
1333
1334    #[test]
1335    fn test_evaluate_task_perfect_params() {
1336        let config = OptimizerConfig::default_maml(1);
1337        let mut opt = MetaLearningOptimizer::new(config);
1338        // Task: y = 2x + 1
1339        let task = make_regression_task("t1", 2.0, 1.0, 5, 10, 13);
1340        opt.add_task(task).expect("test: should succeed");
1341        // Perfect params
1342        let params = ModelParams {
1343            weights: vec![2.0],
1344            bias: 1.0,
1345            dim: 1,
1346        };
1347        let loss = opt
1348            .evaluate_task(&TaskId::new("t1"), &params)
1349            .expect("test: should succeed");
1350        assert!(
1351            loss < 1e-20,
1352            "perfect params should give ~0 MSE, got {loss}"
1353        );
1354    }
1355
1356    #[test]
1357    fn test_evaluate_task_non_negative() {
1358        let config = OptimizerConfig::default_maml(1);
1359        let mut opt = MetaLearningOptimizer::new(config);
1360        let task = make_regression_task("t1", 3.0, -1.0, 5, 10, 17);
1361        opt.add_task(task).expect("test: should succeed");
1362        let params = MetaLearningOptimizer::initialize_params(1, 7);
1363        let loss = opt
1364            .evaluate_task(&TaskId::new("t1"), &params)
1365            .expect("test: should succeed");
1366        assert!(loss >= 0.0);
1367    }
1368
1369    #[test]
1370    fn test_evaluate_task_unknown_error() {
1371        let config = OptimizerConfig::default_maml(1);
1372        let opt = MetaLearningOptimizer::new(config);
1373        let params = MetaLearningOptimizer::initialize_params(1, 1);
1374        let err = opt
1375            .evaluate_task(&TaskId::new("no-such"), &params)
1376            .unwrap_err();
1377        assert!(matches!(err, MetaError::AdaptationFailed(_)));
1378    }
1379
1380    #[test]
1381    fn test_evaluate_task_empty_query_error() {
1382        let config = OptimizerConfig::default_maml(1);
1383        let mut opt = MetaLearningOptimizer::new(config);
1384        let tid = TaskId::new("empty-q");
1385        let sex = TaskExample::new(vec![1.0], 1.0, tid.clone());
1386        let task = MetaTask::new(tid.clone(), vec![sex], vec![]);
1387        opt.add_task(task).expect("test: should succeed");
1388        let params = MetaLearningOptimizer::initialize_params(1, 1);
1389        let err = opt.evaluate_task(&tid, &params).unwrap_err();
1390        assert!(matches!(err, MetaError::AdaptationFailed(_)));
1391    }
1392
1393    // ── initialize_params ─────────────────────────────────────────────────────
1394
1395    #[test]
1396    fn test_initialize_params_dim() {
1397        let params = MetaLearningOptimizer::initialize_params(4, 42);
1398        assert_eq!(params.dim, 4);
1399        assert_eq!(params.weights.len(), 4);
1400    }
1401
1402    #[test]
1403    fn test_initialize_params_small_values() {
1404        // Values should be in roughly [-0.01, 0.01]
1405        let params = MetaLearningOptimizer::initialize_params(100, 123);
1406        for w in &params.weights {
1407            assert!(w.abs() <= 0.01, "weight {w} exceeds 0.01");
1408        }
1409        assert!(
1410            params.bias.abs() <= 0.01,
1411            "bias {} exceeds 0.01",
1412            params.bias
1413        );
1414    }
1415
1416    #[test]
1417    fn test_initialize_params_deterministic() {
1418        let p1 = MetaLearningOptimizer::initialize_params(5, 77);
1419        let p2 = MetaLearningOptimizer::initialize_params(5, 77);
1420        assert_eq!(p1.weights, p2.weights);
1421        assert_eq!(p1.bias, p2.bias);
1422    }
1423
1424    #[test]
1425    fn test_initialize_params_different_seeds() {
1426        let p1 = MetaLearningOptimizer::initialize_params(5, 1);
1427        let p2 = MetaLearningOptimizer::initialize_params(5, 2);
1428        assert_ne!(
1429            p1.weights, p2.weights,
1430            "different seeds should give different weights"
1431        );
1432    }
1433
1434    #[test]
1435    fn test_initialize_params_zero_seed_fallback() {
1436        // seed=0 should use internal fallback and not panic
1437        let params = MetaLearningOptimizer::initialize_params(3, 0);
1438        assert_eq!(params.dim, 3);
1439    }
1440
1441    // ── few_shot_predict ──────────────────────────────────────────────────────
1442
1443    #[test]
1444    fn test_few_shot_predict_basic() {
1445        let config = OptimizerConfig::default_maml(1);
1446        let opt = MetaLearningOptimizer::new(config);
1447        // Build task manually so we know the answer
1448        let tid = TaskId::new("fs1");
1449        let support: Vec<TaskExample> = (0..8)
1450            .map(|i| {
1451                let x = i as f64;
1452                TaskExample::new(vec![x], 2.0 * x + 1.0, tid.clone())
1453            })
1454            .collect();
1455        let task = MetaTask::new(tid, support, vec![]);
1456        let init = MetaLearningOptimizer::initialize_params(1, 42);
1457        let pred = opt
1458            .few_shot_predict(&task, &[3.0], &init)
1459            .expect("test: should succeed");
1460        // After adaptation we expect a value closer to 7.0 than the raw init
1461        assert!(pred.is_finite(), "prediction should be finite");
1462    }
1463
1464    #[test]
1465    fn test_few_shot_predict_dim_mismatch() {
1466        let config = OptimizerConfig::default_maml(2);
1467        let opt = MetaLearningOptimizer::new(config);
1468        let tid = TaskId::new("fs2");
1469        let support = vec![TaskExample::new(vec![1.0, 2.0], 3.0, tid.clone())];
1470        let task = MetaTask::new(tid, support, vec![]);
1471        let init = MetaLearningOptimizer::initialize_params(2, 9);
1472        // x has wrong dim
1473        let err = opt.few_shot_predict(&task, &[1.0], &init).unwrap_err();
1474        assert!(matches!(
1475            err,
1476            MetaError::DimensionMismatch {
1477                expected: 2,
1478                got: 1
1479            }
1480        ));
1481    }
1482
1483    #[test]
1484    fn test_few_shot_predict_empty_support_error() {
1485        let config = OptimizerConfig::default_maml(1);
1486        let opt = MetaLearningOptimizer::new(config);
1487        let tid = TaskId::new("fse");
1488        let task = MetaTask::new(tid, vec![], vec![]);
1489        let init = MetaLearningOptimizer::initialize_params(1, 1);
1490        let err = opt.few_shot_predict(&task, &[1.0], &init).unwrap_err();
1491        assert!(matches!(err, MetaError::AdaptationFailed(_)));
1492    }
1493
1494    #[test]
1495    fn test_few_shot_predict_reptile() {
1496        let config = OptimizerConfig::default_reptile(1);
1497        let opt = MetaLearningOptimizer::new(config);
1498        let tid = TaskId::new("rfs");
1499        let support: Vec<TaskExample> = (0..5)
1500            .map(|i| {
1501                let x = i as f64 * 0.5;
1502                TaskExample::new(vec![x], 3.0 * x, tid.clone())
1503            })
1504            .collect();
1505        let task = MetaTask::new(tid, support, vec![]);
1506        let init = MetaLearningOptimizer::initialize_params(1, 7);
1507        let pred = opt
1508            .few_shot_predict(&task, &[2.0], &init)
1509            .expect("test: should succeed");
1510        assert!(pred.is_finite());
1511    }
1512
1513    #[test]
1514    fn test_few_shot_predict_adapts_correctly_2d() {
1515        let config = OptimizerConfig {
1516            algorithm: MetaAlgorithm::MAML {
1517                inner_lr: 0.05,
1518                inner_steps: 20,
1519            },
1520            meta_lr: 0.01,
1521            n_tasks_per_batch: 2,
1522            max_params_dim: 2,
1523        };
1524        let opt = MetaLearningOptimizer::new(config);
1525        let tid = TaskId::new("2dfs");
1526        // Ground truth: y = 1.5*x0 + 0.5*x1
1527        let support: Vec<TaskExample> = (0..20)
1528            .map(|i| {
1529                let x0 = i as f64 * 0.1;
1530                let x1 = (i as f64) * 0.2 - 1.0;
1531                TaskExample::new(vec![x0, x1], 1.5 * x0 + 0.5 * x1, tid.clone())
1532            })
1533            .collect();
1534        let task = MetaTask::new(tid, support, vec![]);
1535        let init = MetaLearningOptimizer::initialize_params(2, 42);
1536        let pred = opt
1537            .few_shot_predict(&task, &[1.0, 0.0], &init)
1538            .expect("test: should succeed");
1539        // After adaptation, prediction for [1,0] should be closer to 1.5
1540        assert!((pred - 1.5).abs() < 1.0, "pred {pred} should be near 1.5");
1541    }
1542
1543    // ── stats ─────────────────────────────────────────────────────────────────
1544
1545    #[test]
1546    fn test_stats_initial_state() {
1547        let config = OptimizerConfig::default_maml(1);
1548        let opt = MetaLearningOptimizer::new(config);
1549        let stats = opt.stats();
1550        assert_eq!(stats.tasks_trained, 0);
1551        assert_eq!(stats.meta_updates, 0);
1552        assert_eq!(stats.avg_adaptation_loss, 0.0);
1553        assert_eq!(stats.avg_query_loss, 0.0);
1554    }
1555
1556    #[test]
1557    fn test_stats_tasks_trained_increments() {
1558        let config = OptimizerConfig::default_maml(1);
1559        let mut opt = MetaLearningOptimizer::new(config);
1560        for i in 0..5 {
1561            let task = make_regression_task(&format!("t{i}"), 1.0, 0.0, 5, 5, i as u64 + 1);
1562            opt.add_task(task).expect("test: should succeed");
1563        }
1564        assert_eq!(opt.stats().tasks_trained, 5);
1565    }
1566
1567    #[test]
1568    fn test_stats_meta_updates_increments() {
1569        let config = OptimizerConfig::default_maml(1);
1570        let mut opt = MetaLearningOptimizer::new(config);
1571        for i in 0..3 {
1572            let task = make_regression_task(&format!("t{i}"), 1.0, 0.0, 5, 5, i as u64 + 1);
1573            opt.add_task(task).expect("test: should succeed");
1574        }
1575        let mut params = MetaLearningOptimizer::initialize_params(1, 42);
1576        let ids: Vec<TaskId> = (0..3).map(|i| TaskId::new(format!("t{i}"))).collect();
1577        for n in 1..=5 {
1578            params = opt
1579                .meta_update(&ids, &params)
1580                .expect("test: should succeed");
1581            assert_eq!(opt.stats().meta_updates, n);
1582        }
1583    }
1584
1585    #[test]
1586    fn test_stats_avg_adaptation_loss_non_negative() {
1587        let config = OptimizerConfig::default_maml(1);
1588        let mut opt = MetaLearningOptimizer::new(config);
1589        for i in 0..4 {
1590            let task =
1591                make_regression_task(&format!("t{i}"), (i + 1) as f64, 0.5, 8, 4, i as u64 + 2);
1592            opt.add_task(task).expect("test: should succeed");
1593        }
1594        let init = MetaLearningOptimizer::initialize_params(1, 9);
1595        let ids: Vec<TaskId> = (0..4).map(|i| TaskId::new(format!("t{i}"))).collect();
1596        opt.meta_update(&ids, &init).expect("test: should succeed");
1597        assert!(opt.stats().avg_adaptation_loss >= 0.0);
1598    }
1599
1600    #[test]
1601    fn test_stats_query_loss_non_negative() {
1602        let config = OptimizerConfig::default_maml(1);
1603        let mut opt = MetaLearningOptimizer::new(config);
1604        for i in 0..4 {
1605            let task = make_regression_task(&format!("t{i}"), 1.0, 0.5, 8, 4, i as u64 + 3);
1606            opt.add_task(task).expect("test: should succeed");
1607        }
1608        let init = MetaLearningOptimizer::initialize_params(1, 11);
1609        let ids: Vec<TaskId> = (0..4).map(|i| TaskId::new(format!("t{i}"))).collect();
1610        opt.meta_update(&ids, &init).expect("test: should succeed");
1611        assert!(opt.stats().avg_query_loss >= 0.0);
1612    }
1613
1614    // ── error cases ───────────────────────────────────────────────────────────
1615
1616    #[test]
1617    fn test_error_display_insufficient_tasks() {
1618        let err = MetaError::InsufficientTasks(2);
1619        assert!(err.to_string().contains("insufficient"));
1620    }
1621
1622    #[test]
1623    fn test_error_display_dimension_mismatch() {
1624        let err = MetaError::DimensionMismatch {
1625            expected: 4,
1626            got: 3,
1627        };
1628        let s = err.to_string();
1629        assert!(s.contains("4") && s.contains("3"));
1630    }
1631
1632    #[test]
1633    fn test_error_display_adaptation_failed() {
1634        let err = MetaError::AdaptationFailed("oops".to_string());
1635        assert!(err.to_string().contains("oops"));
1636    }
1637
1638    #[test]
1639    fn test_error_display_invalid_config() {
1640        let err = MetaError::InvalidConfig("bad lr".to_string());
1641        assert!(err.to_string().contains("bad lr"));
1642    }
1643
1644    #[test]
1645    fn test_error_is_clone() {
1646        let err = MetaError::InsufficientTasks(3);
1647        let err2 = err.clone();
1648        assert_eq!(err, err2);
1649    }
1650
1651    // ── xorshift PRNG internal tests ──────────────────────────────────────────
1652
1653    #[test]
1654    fn test_xorshift64_deterministic() {
1655        let mut s1 = 12345u64;
1656        let mut s2 = 12345u64;
1657        assert_eq!(xorshift64(&mut s1), xorshift64(&mut s2));
1658    }
1659
1660    #[test]
1661    fn test_xorshift_f64_range() {
1662        let mut state = 99999u64;
1663        for _ in 0..1000 {
1664            let v = xorshift_f64(&mut state);
1665            assert!((0.0..1.0).contains(&v), "out of range: {v}");
1666        }
1667    }
1668
1669    // ── ModelParams helpers ───────────────────────────────────────────────────
1670
1671    #[test]
1672    fn test_model_params_predict() {
1673        let p = ModelParams {
1674            weights: vec![2.0, -1.0],
1675            bias: 0.5,
1676            dim: 2,
1677        };
1678        let pred = p.predict(&[1.0, 1.0]);
1679        assert!((pred - 1.5).abs() < 1e-12);
1680    }
1681
1682    #[test]
1683    fn test_model_params_zeros() {
1684        let p = ModelParams::zeros(3);
1685        assert_eq!(p.weights, vec![0.0, 0.0, 0.0]);
1686        assert_eq!(p.bias, 0.0);
1687        assert_eq!(p.dim, 3);
1688    }
1689
1690    #[test]
1691    fn test_mse_zero_on_perfect_fit() {
1692        // y = 3x; params = w=[3], b=0
1693        let p = ModelParams {
1694            weights: vec![3.0],
1695            bias: 0.0,
1696            dim: 1,
1697        };
1698        let tid = TaskId::new("t");
1699        let examples: Vec<TaskExample> = (0..5)
1700            .map(|i| {
1701                let x = i as f64;
1702                TaskExample::new(vec![x], 3.0 * x, tid.clone())
1703            })
1704            .collect();
1705        let (loss, _, _) = p.mse_and_grads(&examples);
1706        assert!(loss < 1e-20, "MSE should be ~0 for perfect fit, got {loss}");
1707    }
1708
1709    // ── Integration tests ─────────────────────────────────────────────────────
1710
1711    #[test]
1712    fn test_end_to_end_maml_regression() {
1713        // After enough meta-updates, adapted params should perform well
1714        let config = OptimizerConfig {
1715            algorithm: MetaAlgorithm::MAML {
1716                inner_lr: 0.05,
1717                inner_steps: 5,
1718            },
1719            meta_lr: 0.1,
1720            n_tasks_per_batch: 4,
1721            max_params_dim: 1,
1722        };
1723        let mut opt = MetaLearningOptimizer::new(config);
1724        // All tasks share slope=2 but different intercepts
1725        for i in 0..4 {
1726            let task = make_regression_task(
1727                &format!("t{i}"),
1728                2.0,
1729                i as f64 * 0.5,
1730                15,
1731                5,
1732                (i * 13 + 7) as u64,
1733            );
1734            opt.add_task(task).expect("test: should succeed");
1735        }
1736        let mut meta_params = MetaLearningOptimizer::initialize_params(1, 42);
1737        let ids: Vec<TaskId> = (0..4).map(|i| TaskId::new(format!("t{i}"))).collect();
1738        for _ in 0..30 {
1739            meta_params = opt
1740                .meta_update(&ids, &meta_params)
1741                .expect("test: should succeed");
1742        }
1743        // Adapt on a new unseen task
1744        let tid = TaskId::new("new");
1745        let new_support: Vec<TaskExample> = (0..5)
1746            .map(|i| {
1747                let x = i as f64 * 0.5;
1748                TaskExample::new(vec![x], 2.0 * x + 0.3, tid.clone())
1749            })
1750            .collect();
1751        let new_query: Vec<TaskExample> = (0..5)
1752            .map(|i| {
1753                let x = i as f64 * 0.5 + 0.1;
1754                TaskExample::new(vec![x], 2.0 * x + 0.3, tid.clone())
1755            })
1756            .collect();
1757        let new_task = MetaTask::new(tid.clone(), new_support, new_query);
1758        opt.add_task(new_task).expect("test: should succeed");
1759        let adapted = opt
1760            .adapt_to_task(&tid, &meta_params, 10, 0.05)
1761            .expect("test: should succeed");
1762        let init_loss = adapted.first().map(|s| s.loss).unwrap_or(f64::MAX);
1763        let final_loss = adapted.last().map(|s| s.loss).unwrap_or(f64::MAX);
1764        assert!(
1765            final_loss <= init_loss + 1e-6,
1766            "adaptation should reduce loss: {init_loss} -> {final_loss}"
1767        );
1768    }
1769
1770    #[test]
1771    fn test_end_to_end_reptile() {
1772        let config = OptimizerConfig::default_reptile(1);
1773        let mut opt = MetaLearningOptimizer::new(config);
1774        for i in 0..4 {
1775            let task = make_regression_task(
1776                &format!("t{i}"),
1777                1.5,
1778                i as f64 * 0.2,
1779                10,
1780                5,
1781                (i + 1) as u64 * 7,
1782            );
1783            opt.add_task(task).expect("test: should succeed");
1784        }
1785        let mut params = MetaLearningOptimizer::initialize_params(1, 33);
1786        let ids: Vec<TaskId> = (0..4).map(|i| TaskId::new(format!("t{i}"))).collect();
1787        for _ in 0..15 {
1788            params = opt
1789                .meta_update(&ids, &params)
1790                .expect("test: should succeed");
1791        }
1792        assert_eq!(opt.stats().meta_updates, 15);
1793        assert!(opt.stats().avg_query_loss >= 0.0);
1794    }
1795
1796    #[test]
1797    fn test_task_id_display() {
1798        let tid = TaskId::new("hello");
1799        assert_eq!(tid.to_string(), "hello");
1800        assert_eq!(tid.as_str(), "hello");
1801    }
1802
1803    #[test]
1804    fn test_meta_task_feature_dim() {
1805        let tid = TaskId::new("t");
1806        let ex = TaskExample::new(vec![1.0, 2.0, 3.0], 0.0, tid.clone());
1807        let task = MetaTask::new(tid, vec![ex], vec![]);
1808        assert_eq!(task.feature_dim(), Some(3));
1809    }
1810
1811    #[test]
1812    fn test_meta_task_feature_dim_empty() {
1813        let tid = TaskId::new("t");
1814        let task = MetaTask::new(tid, vec![], vec![]);
1815        assert_eq!(task.feature_dim(), None);
1816    }
1817}