Skip to main content

ipfrs_tensorlogic/
meta_learner.rs

1//! MetaLearner — MAML-inspired meta-learning system.
2//!
3//! Maintains task-specific adaptations and a shared meta-representation.
4//! The outer loop (meta-update) aggregates task adaptations to improve the
5//! shared initialisation; the inner loop (adapt_to_task) fine-tunes the shared
6//! weights for a single task in a small number of gradient-descent steps.
7
8use std::collections::HashMap;
9use std::fmt;
10
11// ─── Error ────────────────────────────────────────────────────────────────────
12
13/// Errors produced by [`MetaLearner`] operations.
14#[derive(Debug, Clone, PartialEq)]
15pub enum MetaError {
16    /// The support set of a task is empty; cannot compute gradients.
17    EmptySupportSet,
18    /// The query set of a task is empty; cannot evaluate query loss.
19    EmptyQuerySet,
20    /// Feature vector has the wrong number of dimensions.
21    DimensionMismatch {
22        /// Expected number of dimensions.
23        expected: usize,
24        /// Actual number of dimensions encountered.
25        got: usize,
26    },
27    /// `meta_update` was called with an empty slice of adaptations.
28    NoAdaptations,
29}
30
31impl fmt::Display for MetaError {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            MetaError::EmptySupportSet => write!(f, "support set must not be empty"),
35            MetaError::EmptyQuerySet => write!(f, "query set must not be empty"),
36            MetaError::DimensionMismatch { expected, got } => {
37                write!(f, "dimension mismatch: expected {expected}, got {got}")
38            }
39            MetaError::NoAdaptations => write!(f, "no task adaptations provided for meta-update"),
40        }
41    }
42}
43
44impl std::error::Error for MetaError {}
45
46// ─── Core domain types ────────────────────────────────────────────────────────
47
48/// Newtype wrapper around a task identifier string.
49#[derive(Debug, Clone, PartialEq, Eq, Hash)]
50pub struct TaskId(pub String);
51
52impl TaskId {
53    /// Create a new `TaskId` from any `Into<String>` value.
54    pub fn new(id: impl Into<String>) -> Self {
55        TaskId(id.into())
56    }
57}
58
59impl fmt::Display for TaskId {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        write!(f, "{}", self.0)
62    }
63}
64
65/// A single labeled training example.
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}
73
74impl TaskExample {
75    /// Construct a new [`TaskExample`].
76    pub fn new(features: Vec<f64>, label: f64) -> Self {
77        TaskExample { features, label }
78    }
79}
80
81/// Discriminates the learning objective of a meta-task.
82#[derive(Debug, Clone, PartialEq)]
83pub enum TaskType {
84    /// Multi-class classification with a fixed number of categories.
85    Classification {
86        /// Number of classes.
87        n_classes: usize,
88    },
89    /// Continuous-valued regression.
90    Regression,
91    /// Pairwise ranking (query/document relevance).
92    Ranking,
93}
94
95/// A meta-learning task consisting of a support set (used for inner-loop
96/// adaptation) and a query set (used to evaluate the adapted model).
97#[derive(Debug, Clone)]
98pub struct MetaTask {
99    /// Unique task identifier.
100    pub id: TaskId,
101    /// Examples used during the inner-loop gradient-descent adaptation.
102    pub support_set: Vec<TaskExample>,
103    /// Examples used to evaluate the quality of the adaptation.
104    pub query_set: Vec<TaskExample>,
105    /// Discriminator for the learning objective.
106    pub task_type: TaskType,
107}
108
109impl MetaTask {
110    /// Construct a new [`MetaTask`].
111    pub fn new(
112        id: TaskId,
113        support_set: Vec<TaskExample>,
114        query_set: Vec<TaskExample>,
115        task_type: TaskType,
116    ) -> Self {
117        MetaTask {
118            id,
119            support_set,
120            query_set,
121            task_type,
122        }
123    }
124}
125
126// ─── Model parameters ─────────────────────────────────────────────────────────
127
128/// Shared meta-parameters: a linear model with `dims` weights and one bias.
129#[derive(Debug, Clone)]
130pub struct MetaParameters {
131    /// Weight vector of length `dims`.
132    pub weights: Vec<f64>,
133    /// Scalar bias term.
134    pub bias: f64,
135    /// Dimensionality of the input feature space.
136    pub dims: usize,
137}
138
139impl MetaParameters {
140    /// Create zero-initialised meta-parameters with the given dimensionality.
141    pub fn zeros(dims: usize) -> Self {
142        MetaParameters {
143            weights: vec![0.0; dims],
144            bias: 0.0,
145            dims,
146        }
147    }
148}
149
150/// The result of adapting the meta-parameters to a specific task.
151#[derive(Debug, Clone)]
152pub struct TaskAdaptation {
153    /// Which task these weights were adapted for.
154    pub task_id: TaskId,
155    /// Weights after inner-loop gradient descent.
156    pub adapted_weights: Vec<f64>,
157    /// Bias after inner-loop gradient descent.
158    pub adapted_bias: f64,
159    /// Mean loss on the support set after the final inner-loop step.
160    pub support_loss: f64,
161    /// Mean loss on the query set evaluated with the adapted weights.
162    pub query_loss: f64,
163    /// Number of inner-loop gradient-descent steps taken.
164    pub steps: u32,
165}
166
167// ─── Configuration ────────────────────────────────────────────────────────────
168
169/// Configuration for [`MetaLearner`].
170#[derive(Debug, Clone)]
171pub struct MetaLearnerConfig {
172    /// Learning rate used in the inner-loop adaptation.
173    pub inner_lr: f64,
174    /// Learning rate used for the outer meta-update.
175    pub meta_lr: f64,
176    /// Number of inner-loop gradient-descent steps per task.
177    pub inner_steps: u32,
178    /// Dimensionality of the input feature space.
179    pub dims: usize,
180    /// Random seed used to initialise meta-parameters via xorshift64.
181    pub seed: u64,
182}
183
184impl Default for MetaLearnerConfig {
185    fn default() -> Self {
186        MetaLearnerConfig {
187            inner_lr: 0.01,
188            meta_lr: 0.001,
189            inner_steps: 5,
190            dims: 10,
191            seed: 42,
192        }
193    }
194}
195
196// ─── Aggregate statistics ─────────────────────────────────────────────────────
197
198/// Aggregate statistics for a [`MetaLearner`] instance.
199#[derive(Debug, Clone)]
200pub struct MetaLearnerStats {
201    /// Total number of tasks stored in the history.
202    pub total_tasks: usize,
203    /// Number of outer meta-update steps that have been performed.
204    pub meta_steps: u64,
205    /// Mean support-set loss across all stored task adaptations.
206    pub avg_support_loss: f64,
207    /// Mean query-set loss across all stored task adaptations.
208    pub avg_query_loss: f64,
209    /// Lowest query-set loss found in the task history.
210    pub best_query_loss: f64,
211}
212
213// ─── xorshift64 ───────────────────────────────────────────────────────────────
214
215/// Inline xorshift64 PRNG step.  The state must be non-zero.
216#[inline]
217fn xorshift64(state: &mut u64) -> u64 {
218    *state ^= *state << 13;
219    *state ^= *state >> 7;
220    *state ^= *state << 17;
221    *state
222}
223
224// ─── MetaLearner ─────────────────────────────────────────────────────────────
225
226/// MAML-inspired meta-learner.
227///
228/// # Overview
229///
230/// * [`MetaLearner::adapt_to_task`] runs the **inner loop**: starting from the
231///   shared `meta_params` it takes `config.inner_steps` gradient-descent steps
232///   on the support set of the given task and evaluates the adapted model on
233///   the query set.
234/// * [`MetaLearner::meta_update`] runs the **outer loop**: given a batch of
235///   `TaskAdaptation`s it averages the adapted weights, computes a meta-gradient
236///   (direction from current meta-weights to the average), and updates
237///   `meta_params` with `config.meta_lr`.
238/// * [`MetaLearner::predict`] performs linear inference.
239pub struct MetaLearner {
240    /// Hyper-parameters that control the learning algorithm.
241    pub config: MetaLearnerConfig,
242    /// Shared meta-parameters (the "good initialisation" learned by MAML).
243    pub meta_params: MetaParameters,
244    /// Per-task adaptation results keyed by [`TaskId`].
245    pub task_history: HashMap<TaskId, TaskAdaptation>,
246    /// Count of completed outer meta-update steps.
247    pub meta_step: u64,
248}
249
250impl MetaLearner {
251    /// Create a new [`MetaLearner`] with weights initialised using xorshift64.
252    ///
253    /// Each weight is set to `xorshift64(state) as f64 / u64::MAX as f64 * 0.01`
254    /// so that the initial values are small random numbers in `[0, 0.01)`.
255    pub fn new(config: MetaLearnerConfig) -> Self {
256        let mut state = config.seed.max(1); // xorshift64 state must be non-zero
257        let dims = config.dims;
258        let weights: Vec<f64> = (0..dims)
259            .map(|_| {
260                let raw = xorshift64(&mut state);
261                (raw as f64 / u64::MAX as f64) * 0.01
262            })
263            .collect();
264
265        let meta_params = MetaParameters {
266            weights,
267            bias: 0.0,
268            dims,
269        };
270
271        MetaLearner {
272            config,
273            meta_params,
274            task_history: HashMap::new(),
275            meta_step: 0,
276        }
277    }
278
279    // ── Loss helpers ────────────────────────────────────────────────────────
280
281    /// Compute the scalar loss for a single prediction/label pair.
282    ///
283    /// | `task_type`       | loss formula                                           |
284    /// |-------------------|--------------------------------------------------------|
285    /// | Classification    | `max(0, 1 - label * tanh(prediction))`                 |
286    /// | Regression        | `(prediction - label)²`                                |
287    /// | Ranking           | `max(0, 1 - prediction * label)`                       |
288    pub fn loss(prediction: f64, label: f64, task_type: &TaskType) -> f64 {
289        match task_type {
290            TaskType::Classification { .. } => (1.0 - label * prediction.tanh()).max(0.0),
291            TaskType::Regression => (prediction - label).powi(2),
292            TaskType::Ranking => (1.0 - prediction * label).max(0.0),
293        }
294    }
295
296    // ── Gradient helpers ────────────────────────────────────────────────────
297
298    /// Compute the gradient of the **MSE** loss with respect to the linear
299    /// model parameters (weights and bias).
300    ///
301    /// Returns `(dL/dw, dL/db)` where `dL/dw[i] = 2*(prediction-label)*features[i]`
302    /// and `dL/db = 2*(prediction-label)`.
303    pub fn gradient(features: &[f64], prediction: f64, label: f64) -> (Vec<f64>, f64) {
304        let residual = 2.0 * (prediction - label);
305        let dw: Vec<f64> = features.iter().map(|&x| residual * x).collect();
306        let db = residual;
307        (dw, db)
308    }
309
310    /// Compute the average gradient over a batch of examples.
311    fn batch_gradient(
312        weights: &[f64],
313        bias: f64,
314        examples: &[TaskExample],
315        dims: usize,
316    ) -> Result<(Vec<f64>, f64), MetaError> {
317        if examples.is_empty() {
318            return Err(MetaError::EmptySupportSet);
319        }
320        let n = examples.len() as f64;
321        let mut dw_sum = vec![0.0f64; dims];
322        let mut db_sum = 0.0f64;
323
324        for ex in examples {
325            if ex.features.len() != dims {
326                return Err(MetaError::DimensionMismatch {
327                    expected: dims,
328                    got: ex.features.len(),
329                });
330            }
331            let pred = Self::linear_predict(weights, bias, &ex.features);
332            let (dw, db) = Self::gradient(&ex.features, pred, ex.label);
333            for (acc, g) in dw_sum.iter_mut().zip(dw.iter()) {
334                *acc += g;
335            }
336            db_sum += db;
337        }
338
339        let dw_avg: Vec<f64> = dw_sum.iter().map(|&v| v / n).collect();
340        Ok((dw_avg, db_sum / n))
341    }
342
343    // ── Linear prediction ───────────────────────────────────────────────────
344
345    /// Dot-product linear prediction: `weights · features + bias`.
346    fn linear_predict(weights: &[f64], bias: f64, features: &[f64]) -> f64 {
347        weights
348            .iter()
349            .zip(features.iter())
350            .map(|(&w, &x)| w * x)
351            .sum::<f64>()
352            + bias
353    }
354
355    // ── Public API ──────────────────────────────────────────────────────────
356
357    /// Perform a linear prediction using either the meta-parameters or, if
358    /// `adaptation` is `Some`, the task-adapted parameters.
359    ///
360    /// # Errors
361    ///
362    /// Returns [`MetaError::DimensionMismatch`] if `features.len() != config.dims`.
363    pub fn predict(
364        &self,
365        features: &[f64],
366        adaptation: Option<&TaskAdaptation>,
367    ) -> Result<f64, MetaError> {
368        if features.len() != self.config.dims {
369            return Err(MetaError::DimensionMismatch {
370                expected: self.config.dims,
371                got: features.len(),
372            });
373        }
374        let (weights, bias) = match adaptation {
375            Some(a) => (a.adapted_weights.as_slice(), a.adapted_bias),
376            None => (self.meta_params.weights.as_slice(), self.meta_params.bias),
377        };
378        Ok(Self::linear_predict(weights, bias, features))
379    }
380
381    /// Run the **inner loop** for `task`: adapt from `meta_params` using the
382    /// support set, then evaluate on the query set.
383    ///
384    /// The resulting [`TaskAdaptation`] is stored in `task_history` and
385    /// returned to the caller.
386    ///
387    /// # Errors
388    ///
389    /// * [`MetaError::EmptySupportSet`] — if the task support set is empty.
390    /// * [`MetaError::EmptyQuerySet`] — if the task query set is empty.
391    /// * [`MetaError::DimensionMismatch`] — if any example has the wrong
392    ///   number of features.
393    pub fn adapt_to_task(&mut self, task: &MetaTask) -> Result<TaskAdaptation, MetaError> {
394        if task.support_set.is_empty() {
395            return Err(MetaError::EmptySupportSet);
396        }
397        if task.query_set.is_empty() {
398            return Err(MetaError::EmptyQuerySet);
399        }
400
401        let dims = self.config.dims;
402        let inner_lr = self.config.inner_lr;
403        let inner_steps = self.config.inner_steps;
404
405        // Start from the meta-parameters (clone so we don't touch meta_params)
406        let mut w = self.meta_params.weights.clone();
407        let mut b = self.meta_params.bias;
408
409        for _ in 0..inner_steps {
410            // Compute batch gradient on support set
411            let (dw, db) = Self::batch_gradient(&w, b, &task.support_set, dims)?;
412
413            // Gradient descent step
414            for (wi, &gi) in w.iter_mut().zip(dw.iter()) {
415                *wi -= inner_lr * gi;
416            }
417            b -= inner_lr * db;
418        }
419
420        // Compute final support loss with the adapted weights
421        let support_loss = self.mean_loss_raw(&w, b, &task.support_set, &task.task_type)?;
422
423        // Evaluate on query set
424        let query_loss = self.mean_loss_raw(&w, b, &task.query_set, &task.task_type)?;
425
426        let adaptation = TaskAdaptation {
427            task_id: task.id.clone(),
428            adapted_weights: w,
429            adapted_bias: b,
430            support_loss,
431            query_loss,
432            steps: inner_steps,
433        };
434
435        self.task_history
436            .insert(task.id.clone(), adaptation.clone());
437        Ok(adaptation)
438    }
439
440    /// Helper: compute mean loss without the extra `self` borrow on `dims`.
441    fn mean_loss_raw(
442        &self,
443        weights: &[f64],
444        bias: f64,
445        examples: &[TaskExample],
446        task_type: &TaskType,
447    ) -> Result<f64, MetaError> {
448        if examples.is_empty() {
449            return Err(MetaError::EmptySupportSet);
450        }
451        let mut total = 0.0;
452        for ex in examples {
453            if ex.features.len() != self.config.dims {
454                return Err(MetaError::DimensionMismatch {
455                    expected: self.config.dims,
456                    got: ex.features.len(),
457                });
458            }
459            let pred = Self::linear_predict(weights, bias, &ex.features);
460            total += Self::loss(pred, ex.label, task_type);
461        }
462        Ok(total / examples.len() as f64)
463    }
464
465    /// Run the **outer loop** (meta-update).
466    ///
467    /// Averages `adapted_weights` across all provided adaptations, then
468    /// computes a meta-gradient as the direction from current meta-weights to
469    /// that average and applies a single gradient-descent step with `meta_lr`.
470    ///
471    /// # Errors
472    ///
473    /// * [`MetaError::NoAdaptations`] — if `adaptations` is empty.
474    /// * [`MetaError::DimensionMismatch`] — if any adaptation has the wrong
475    ///   number of weight dimensions.
476    pub fn meta_update(&mut self, adaptations: &[TaskAdaptation]) -> Result<(), MetaError> {
477        if adaptations.is_empty() {
478            return Err(MetaError::NoAdaptations);
479        }
480
481        let dims = self.config.dims;
482        let meta_lr = self.config.meta_lr;
483        let n = adaptations.len() as f64;
484
485        // Average adapted weights and biases
486        let mut avg_w = vec![0.0f64; dims];
487        let mut avg_b = 0.0f64;
488
489        for a in adaptations {
490            if a.adapted_weights.len() != dims {
491                return Err(MetaError::DimensionMismatch {
492                    expected: dims,
493                    got: a.adapted_weights.len(),
494                });
495            }
496            for (acc, &v) in avg_w.iter_mut().zip(a.adapted_weights.iter()) {
497                *acc += v;
498            }
499            avg_b += a.adapted_bias;
500        }
501        for v in avg_w.iter_mut() {
502            *v /= n;
503        }
504        avg_b /= n;
505
506        // Meta-gradient = (meta_weights - adapted_avg)
507        // Update: meta_weights += meta_lr * (adapted_avg - meta_weights)
508        for (mw, &aw) in self.meta_params.weights.iter_mut().zip(avg_w.iter()) {
509            let meta_grad = *mw - aw; // gradient pointing back toward meta
510            *mw -= meta_lr * meta_grad;
511        }
512        self.meta_params.bias -= meta_lr * (self.meta_params.bias - avg_b);
513
514        self.meta_step += 1;
515        Ok(())
516    }
517
518    /// Compute the cosine similarity between the `adapted_weights` of two
519    /// task adaptations.  Returns `0.0` if either weight vector is all-zero.
520    pub fn task_similarity(a: &TaskAdaptation, b: &TaskAdaptation) -> f64 {
521        let dot: f64 = a
522            .adapted_weights
523            .iter()
524            .zip(b.adapted_weights.iter())
525            .map(|(&x, &y)| x * y)
526            .sum();
527        let norm_a: f64 = a.adapted_weights.iter().map(|&x| x * x).sum::<f64>().sqrt();
528        let norm_b: f64 = b.adapted_weights.iter().map(|&x| x * x).sum::<f64>().sqrt();
529        let denom = norm_a * norm_b;
530        if denom == 0.0 {
531            0.0
532        } else {
533            (dot / denom).clamp(-1.0, 1.0)
534        }
535    }
536
537    /// Return the task with the lowest query loss from the history, or `None`
538    /// if the history is empty.
539    pub fn best_task(&self) -> Option<(&TaskId, &TaskAdaptation)> {
540        self.task_history.iter().min_by(|(_, a), (_, b)| {
541            a.query_loss
542                .partial_cmp(&b.query_loss)
543                .unwrap_or(std::cmp::Ordering::Equal)
544        })
545    }
546
547    /// Remove the stored adaptation for `task_id` from the history.
548    pub fn reset_task(&mut self, task_id: &TaskId) {
549        self.task_history.remove(task_id);
550    }
551
552    /// Compute aggregate statistics over the current task history.
553    pub fn stats(&self) -> MetaLearnerStats {
554        let total_tasks = self.task_history.len();
555        if total_tasks == 0 {
556            return MetaLearnerStats {
557                total_tasks: 0,
558                meta_steps: self.meta_step,
559                avg_support_loss: 0.0,
560                avg_query_loss: 0.0,
561                best_query_loss: f64::INFINITY,
562            };
563        }
564
565        let mut sum_support = 0.0;
566        let mut sum_query = 0.0;
567        let mut best = f64::INFINITY;
568
569        for a in self.task_history.values() {
570            sum_support += a.support_loss;
571            sum_query += a.query_loss;
572            if a.query_loss < best {
573                best = a.query_loss;
574            }
575        }
576
577        MetaLearnerStats {
578            total_tasks,
579            meta_steps: self.meta_step,
580            avg_support_loss: sum_support / total_tasks as f64,
581            avg_query_loss: sum_query / total_tasks as f64,
582            best_query_loss: best,
583        }
584    }
585}
586
587// ─── Tests ────────────────────────────────────────────────────────────────────
588
589#[cfg(test)]
590mod tests {
591    use crate::meta_learner::{
592        xorshift64, MetaError, MetaLearner, MetaLearnerConfig, MetaParameters, MetaTask,
593        TaskAdaptation, TaskExample, TaskId, TaskType,
594    };
595
596    // ── Helpers ──────────────────────────────────────────────────────────────
597
598    fn simple_config(dims: usize) -> MetaLearnerConfig {
599        MetaLearnerConfig {
600            inner_lr: 0.1,
601            meta_lr: 0.01,
602            inner_steps: 3,
603            dims,
604            seed: 7,
605        }
606    }
607
608    fn make_regression_task(id: &str, dims: usize, n_support: usize, n_query: usize) -> MetaTask {
609        let support_set: Vec<TaskExample> = (0..n_support)
610            .map(|i| {
611                let features: Vec<f64> = (0..dims).map(|j| (i + j) as f64 * 0.1).collect();
612                let label = features.iter().sum::<f64>(); // sum of features as target
613                TaskExample::new(features, label)
614            })
615            .collect();
616        let query_set: Vec<TaskExample> = (n_support..n_support + n_query)
617            .map(|i| {
618                let features: Vec<f64> = (0..dims).map(|j| (i + j) as f64 * 0.1).collect();
619                let label = features.iter().sum::<f64>();
620                TaskExample::new(features, label)
621            })
622            .collect();
623        MetaTask::new(
624            TaskId::new(id),
625            support_set,
626            query_set,
627            TaskType::Regression,
628        )
629    }
630
631    fn make_classification_task(id: &str, dims: usize) -> MetaTask {
632        let make_ex = |v: f64| TaskExample::new(vec![v; dims], if v > 0.0 { 1.0 } else { -1.0 });
633        MetaTask::new(
634            TaskId::new(id),
635            vec![make_ex(0.5), make_ex(-0.5)],
636            vec![make_ex(0.3), make_ex(-0.3)],
637            TaskType::Classification { n_classes: 2 },
638        )
639    }
640
641    fn make_ranking_task(id: &str, dims: usize) -> MetaTask {
642        let make_ex = |v: f64| TaskExample::new(vec![v; dims], if v > 0.5 { 1.0 } else { -1.0 });
643        MetaTask::new(
644            TaskId::new(id),
645            vec![make_ex(0.9), make_ex(0.1)],
646            vec![make_ex(0.8), make_ex(0.2)],
647            TaskType::Ranking,
648        )
649    }
650
651    // ── TaskId tests ─────────────────────────────────────────────────────────
652
653    #[test]
654    fn test_task_id_new() {
655        let id = TaskId::new("task_1");
656        assert_eq!(id.0, "task_1");
657    }
658
659    #[test]
660    fn test_task_id_display() {
661        let id = TaskId::new("hello");
662        assert_eq!(format!("{id}"), "hello");
663    }
664
665    #[test]
666    fn test_task_id_equality() {
667        assert_eq!(TaskId::new("a"), TaskId::new("a"));
668        assert_ne!(TaskId::new("a"), TaskId::new("b"));
669    }
670
671    #[test]
672    fn test_task_id_hash_in_map() {
673        let mut map = std::collections::HashMap::new();
674        map.insert(TaskId::new("k"), 42u32);
675        assert_eq!(map[&TaskId::new("k")], 42);
676    }
677
678    // ── TaskExample tests ────────────────────────────────────────────────────
679
680    #[test]
681    fn test_task_example_new() {
682        let ex = TaskExample::new(vec![1.0, 2.0], 3.0);
683        assert_eq!(ex.features.len(), 2);
684        assert!((ex.label - 3.0).abs() < 1e-12);
685    }
686
687    // ── MetaParameters tests ─────────────────────────────────────────────────
688
689    #[test]
690    fn test_meta_parameters_zeros() {
691        let p = MetaParameters::zeros(5);
692        assert_eq!(p.dims, 5);
693        assert_eq!(p.weights.len(), 5);
694        assert!(p.weights.iter().all(|&w| w == 0.0));
695        assert_eq!(p.bias, 0.0);
696    }
697
698    // ── MetaLearnerConfig defaults ───────────────────────────────────────────
699
700    #[test]
701    fn test_config_defaults() {
702        let cfg = MetaLearnerConfig::default();
703        assert!((cfg.inner_lr - 0.01).abs() < 1e-12);
704        assert!((cfg.meta_lr - 0.001).abs() < 1e-12);
705        assert_eq!(cfg.inner_steps, 5);
706        assert_eq!(cfg.dims, 10);
707        assert_eq!(cfg.seed, 42);
708    }
709
710    // ── xorshift64 ───────────────────────────────────────────────────────────
711
712    #[test]
713    fn test_xorshift64_non_zero() {
714        let mut state = 1u64;
715        for _ in 0..100 {
716            let v = xorshift64(&mut state);
717            assert_ne!(v, 0, "xorshift64 must never produce 0");
718        }
719    }
720
721    #[test]
722    fn test_xorshift64_deterministic() {
723        let mut s1 = 42u64;
724        let mut s2 = 42u64;
725        for _ in 0..50 {
726            assert_eq!(xorshift64(&mut s1), xorshift64(&mut s2));
727        }
728    }
729
730    // ── MetaLearner::new ─────────────────────────────────────────────────────
731
732    #[test]
733    fn test_new_initialises_weights() {
734        let cfg = simple_config(4);
735        let ml = MetaLearner::new(cfg);
736        assert_eq!(ml.meta_params.weights.len(), 4);
737        // All weights should be small positive values in [0, 0.01)
738        for &w in &ml.meta_params.weights {
739            assert!((0.0..0.01).contains(&w), "weight out of range: {w}");
740        }
741    }
742
743    #[test]
744    fn test_new_bias_is_zero() {
745        let ml = MetaLearner::new(simple_config(3));
746        assert_eq!(ml.meta_params.bias, 0.0);
747    }
748
749    #[test]
750    fn test_new_history_empty() {
751        let ml = MetaLearner::new(simple_config(3));
752        assert!(ml.task_history.is_empty());
753    }
754
755    #[test]
756    fn test_new_meta_step_zero() {
757        let ml = MetaLearner::new(simple_config(3));
758        assert_eq!(ml.meta_step, 0);
759    }
760
761    #[test]
762    fn test_new_seed_determines_weights() {
763        let cfg1 = MetaLearnerConfig {
764            seed: 99,
765            ..simple_config(5)
766        };
767        let cfg2 = MetaLearnerConfig {
768            seed: 99,
769            ..simple_config(5)
770        };
771        let ml1 = MetaLearner::new(cfg1);
772        let ml2 = MetaLearner::new(cfg2);
773        assert_eq!(ml1.meta_params.weights, ml2.meta_params.weights);
774    }
775
776    // ── Loss function ────────────────────────────────────────────────────────
777
778    #[test]
779    fn test_loss_regression_zero_residual() {
780        let l = MetaLearner::loss(2.0, 2.0, &TaskType::Regression);
781        assert!(l.abs() < 1e-12);
782    }
783
784    #[test]
785    fn test_loss_regression_positive() {
786        let l = MetaLearner::loss(3.0, 1.0, &TaskType::Regression);
787        assert!((l - 4.0).abs() < 1e-10);
788    }
789
790    #[test]
791    fn test_loss_classification_correct_sign() {
792        // prediction strongly positive, label positive → low loss
793        let l = MetaLearner::loss(5.0, 1.0, &TaskType::Classification { n_classes: 2 });
794        assert!(l < 0.01);
795    }
796
797    #[test]
798    fn test_loss_classification_wrong_sign() {
799        // prediction strongly positive, label negative → high loss
800        let l = MetaLearner::loss(5.0, -1.0, &TaskType::Classification { n_classes: 2 });
801        assert!(l > 0.5);
802    }
803
804    #[test]
805    fn test_loss_classification_non_negative() {
806        for pred in [-2.0, 0.0, 2.0] {
807            for label in [-1.0, 1.0] {
808                let l = MetaLearner::loss(pred, label, &TaskType::Classification { n_classes: 3 });
809                assert!(l >= 0.0, "loss was {l}");
810            }
811        }
812    }
813
814    #[test]
815    fn test_loss_ranking_margin_satisfied() {
816        // prediction and label same sign → margin satisfied → loss = 0
817        let l = MetaLearner::loss(2.0, 1.0, &TaskType::Ranking);
818        assert_eq!(l, 0.0);
819    }
820
821    #[test]
822    fn test_loss_ranking_margin_violated() {
823        // prediction and label opposite sign → loss > 0
824        let l = MetaLearner::loss(-1.0, 1.0, &TaskType::Ranking);
825        assert!(l > 0.0);
826    }
827
828    // ── gradient ────────────────────────────────────────────────────────────
829
830    #[test]
831    fn test_gradient_zero_residual() {
832        let (dw, db) = MetaLearner::gradient(&[1.0, 2.0], 3.0, 3.0);
833        assert!(dw.iter().all(|&g| g.abs() < 1e-12));
834        assert!(db.abs() < 1e-12);
835    }
836
837    #[test]
838    fn test_gradient_direction() {
839        // prediction > label → gradient positive → weight should decrease
840        let (dw, db) = MetaLearner::gradient(&[1.0], 2.0, 1.0);
841        assert!(dw[0] > 0.0);
842        assert!(db > 0.0);
843    }
844
845    #[test]
846    fn test_gradient_length_matches_features() {
847        let features = vec![0.1, 0.2, 0.3, 0.4];
848        let (dw, _) = MetaLearner::gradient(&features, 1.0, 0.0);
849        assert_eq!(dw.len(), features.len());
850    }
851
852    // ── predict ─────────────────────────────────────────────────────────────
853
854    #[test]
855    fn test_predict_zero_weights() {
856        let ml = MetaLearner::new(MetaLearnerConfig {
857            seed: 1,
858            dims: 3,
859            ..MetaLearnerConfig::default()
860        });
861        // override weights to zero for a predictable result
862        let features = vec![1.0, 2.0, 3.0];
863        // We cannot easily zero out weights without a setter, but we can
864        // test through adaptation with known weights.
865        let adaptation = TaskAdaptation {
866            task_id: TaskId::new("t"),
867            adapted_weights: vec![0.0, 0.0, 0.0],
868            adapted_bias: 5.0,
869            support_loss: 0.0,
870            query_loss: 0.0,
871            steps: 0,
872        };
873        let result = ml
874            .predict(&features, Some(&adaptation))
875            .expect("predict should succeed");
876        assert!((result - 5.0).abs() < 1e-12);
877    }
878
879    #[test]
880    fn test_predict_uses_adaptation_weights() {
881        let ml = MetaLearner::new(simple_config(2));
882        let adaptation = TaskAdaptation {
883            task_id: TaskId::new("t"),
884            adapted_weights: vec![1.0, 2.0],
885            adapted_bias: 0.5,
886            support_loss: 0.0,
887            query_loss: 0.0,
888            steps: 0,
889        };
890        // dot([1, 2], [3, 4]) + 0.5 = 3 + 8 + 0.5 = 11.5
891        let result = ml.predict(&[3.0, 4.0], Some(&adaptation)).expect("ok");
892        assert!((result - 11.5).abs() < 1e-10);
893    }
894
895    #[test]
896    fn test_predict_dimension_mismatch() {
897        let ml = MetaLearner::new(simple_config(3));
898        let err = ml.predict(&[1.0, 2.0], None).unwrap_err();
899        assert_eq!(
900            err,
901            MetaError::DimensionMismatch {
902                expected: 3,
903                got: 2
904            }
905        );
906    }
907
908    // ── adapt_to_task ────────────────────────────────────────────────────────
909
910    #[test]
911    fn test_adapt_regression_task_stores_history() {
912        let mut ml = MetaLearner::new(simple_config(3));
913        let task = make_regression_task("t1", 3, 4, 2);
914        ml.adapt_to_task(&task).expect("adapt should succeed");
915        assert!(ml.task_history.contains_key(&TaskId::new("t1")));
916    }
917
918    #[test]
919    fn test_adapt_returns_correct_task_id() {
920        let mut ml = MetaLearner::new(simple_config(3));
921        let task = make_regression_task("my_task", 3, 3, 2);
922        let adaptation = ml.adapt_to_task(&task).expect("ok");
923        assert_eq!(adaptation.task_id, TaskId::new("my_task"));
924    }
925
926    #[test]
927    fn test_adapt_steps_count() {
928        let cfg = MetaLearnerConfig {
929            inner_steps: 7,
930            ..simple_config(3)
931        };
932        let mut ml = MetaLearner::new(cfg);
933        let task = make_regression_task("t", 3, 3, 2);
934        let a = ml.adapt_to_task(&task).expect("ok");
935        assert_eq!(a.steps, 7);
936    }
937
938    #[test]
939    fn test_adapt_empty_support_set_error() {
940        let mut ml = MetaLearner::new(simple_config(3));
941        let task = MetaTask::new(
942            TaskId::new("empty"),
943            vec![],
944            vec![TaskExample::new(vec![0.0, 0.0, 0.0], 0.0)],
945            TaskType::Regression,
946        );
947        assert_eq!(
948            ml.adapt_to_task(&task).unwrap_err(),
949            MetaError::EmptySupportSet
950        );
951    }
952
953    #[test]
954    fn test_adapt_empty_query_set_error() {
955        let mut ml = MetaLearner::new(simple_config(3));
956        let task = MetaTask::new(
957            TaskId::new("empty_q"),
958            vec![TaskExample::new(vec![0.0, 0.0, 0.0], 0.0)],
959            vec![],
960            TaskType::Regression,
961        );
962        assert_eq!(
963            ml.adapt_to_task(&task).unwrap_err(),
964            MetaError::EmptyQuerySet
965        );
966    }
967
968    #[test]
969    fn test_adapt_dimension_mismatch_error() {
970        let mut ml = MetaLearner::new(simple_config(3));
971        let task = MetaTask::new(
972            TaskId::new("bad_dim"),
973            vec![TaskExample::new(vec![1.0, 2.0], 0.0)], // dims=2, expected 3
974            vec![TaskExample::new(vec![1.0, 2.0, 3.0], 0.0)],
975            TaskType::Regression,
976        );
977        assert!(matches!(
978            ml.adapt_to_task(&task).unwrap_err(),
979            MetaError::DimensionMismatch {
980                expected: 3,
981                got: 2
982            }
983        ));
984    }
985
986    #[test]
987    fn test_adapt_classification_task() {
988        let mut ml = MetaLearner::new(simple_config(2));
989        let task = make_classification_task("cls", 2);
990        let a = ml.adapt_to_task(&task).expect("ok");
991        assert!(a.support_loss >= 0.0);
992        assert!(a.query_loss >= 0.0);
993    }
994
995    #[test]
996    fn test_adapt_ranking_task() {
997        let mut ml = MetaLearner::new(simple_config(2));
998        let task = make_ranking_task("rnk", 2);
999        let a = ml.adapt_to_task(&task).expect("ok");
1000        assert!(a.support_loss >= 0.0);
1001        assert!(a.query_loss >= 0.0);
1002    }
1003
1004    #[test]
1005    fn test_adapt_does_not_change_meta_params() {
1006        let mut ml = MetaLearner::new(simple_config(3));
1007        let before = ml.meta_params.weights.clone();
1008        let task = make_regression_task("t", 3, 3, 2);
1009        ml.adapt_to_task(&task).expect("ok");
1010        assert_eq!(ml.meta_params.weights, before);
1011    }
1012
1013    // ── meta_update ──────────────────────────────────────────────────────────
1014
1015    #[test]
1016    fn test_meta_update_increments_step() {
1017        let mut ml = MetaLearner::new(simple_config(3));
1018        let task = make_regression_task("t", 3, 3, 2);
1019        let a = ml.adapt_to_task(&task).expect("ok");
1020        ml.meta_update(&[a]).expect("ok");
1021        assert_eq!(ml.meta_step, 1);
1022    }
1023
1024    #[test]
1025    fn test_meta_update_empty_error() {
1026        let mut ml = MetaLearner::new(simple_config(3));
1027        assert_eq!(ml.meta_update(&[]).unwrap_err(), MetaError::NoAdaptations);
1028    }
1029
1030    #[test]
1031    fn test_meta_update_moves_weights_toward_adapted() {
1032        let cfg = MetaLearnerConfig {
1033            inner_lr: 0.1,
1034            meta_lr: 1.0, // large lr so movement is obvious
1035            dims: 2,
1036            ..MetaLearnerConfig::default()
1037        };
1038        let mut ml = MetaLearner::new(cfg);
1039        // Force meta weights to zero for predictability
1040        ml.meta_params.weights = vec![0.0; 2];
1041        ml.meta_params.bias = 0.0;
1042
1043        let adaptation = TaskAdaptation {
1044            task_id: TaskId::new("t"),
1045            adapted_weights: vec![1.0, 1.0],
1046            adapted_bias: 1.0,
1047            support_loss: 0.0,
1048            query_loss: 0.0,
1049            steps: 1,
1050        };
1051        ml.meta_update(&[adaptation]).expect("ok");
1052        // With meta_lr=1 the update is full: meta_w += 1*(avg - meta_w) = avg
1053        assert!((ml.meta_params.weights[0] - 1.0).abs() < 1e-10);
1054        assert!((ml.meta_params.bias - 1.0).abs() < 1e-10);
1055    }
1056
1057    #[test]
1058    fn test_meta_update_dimension_mismatch() {
1059        let mut ml = MetaLearner::new(simple_config(3));
1060        let bad = TaskAdaptation {
1061            task_id: TaskId::new("bad"),
1062            adapted_weights: vec![1.0, 2.0], // wrong dims
1063            adapted_bias: 0.0,
1064            support_loss: 0.0,
1065            query_loss: 0.0,
1066            steps: 1,
1067        };
1068        assert!(matches!(
1069            ml.meta_update(&[bad]).unwrap_err(),
1070            MetaError::DimensionMismatch {
1071                expected: 3,
1072                got: 2
1073            }
1074        ));
1075    }
1076
1077    // ── task_similarity ──────────────────────────────────────────────────────
1078
1079    #[test]
1080    fn test_task_similarity_identical() {
1081        let a = TaskAdaptation {
1082            task_id: TaskId::new("a"),
1083            adapted_weights: vec![1.0, 0.0, 1.0],
1084            adapted_bias: 0.0,
1085            support_loss: 0.0,
1086            query_loss: 0.0,
1087            steps: 1,
1088        };
1089        let sim = MetaLearner::task_similarity(&a, &a);
1090        assert!((sim - 1.0).abs() < 1e-10);
1091    }
1092
1093    #[test]
1094    fn test_task_similarity_orthogonal() {
1095        let a = TaskAdaptation {
1096            task_id: TaskId::new("a"),
1097            adapted_weights: vec![1.0, 0.0],
1098            adapted_bias: 0.0,
1099            support_loss: 0.0,
1100            query_loss: 0.0,
1101            steps: 1,
1102        };
1103        let b = TaskAdaptation {
1104            task_id: TaskId::new("b"),
1105            adapted_weights: vec![0.0, 1.0],
1106            adapted_bias: 0.0,
1107            support_loss: 0.0,
1108            query_loss: 0.0,
1109            steps: 1,
1110        };
1111        let sim = MetaLearner::task_similarity(&a, &b);
1112        assert!(sim.abs() < 1e-10);
1113    }
1114
1115    #[test]
1116    fn test_task_similarity_opposite() {
1117        let a = TaskAdaptation {
1118            task_id: TaskId::new("a"),
1119            adapted_weights: vec![1.0, 0.0],
1120            adapted_bias: 0.0,
1121            support_loss: 0.0,
1122            query_loss: 0.0,
1123            steps: 1,
1124        };
1125        let b = TaskAdaptation {
1126            task_id: TaskId::new("b"),
1127            adapted_weights: vec![-1.0, 0.0],
1128            adapted_bias: 0.0,
1129            support_loss: 0.0,
1130            query_loss: 0.0,
1131            steps: 1,
1132        };
1133        let sim = MetaLearner::task_similarity(&a, &b);
1134        assert!((sim + 1.0).abs() < 1e-10);
1135    }
1136
1137    #[test]
1138    fn test_task_similarity_zero_vector() {
1139        let a = TaskAdaptation {
1140            task_id: TaskId::new("a"),
1141            adapted_weights: vec![0.0, 0.0],
1142            adapted_bias: 0.0,
1143            support_loss: 0.0,
1144            query_loss: 0.0,
1145            steps: 1,
1146        };
1147        let sim = MetaLearner::task_similarity(&a, &a);
1148        assert_eq!(sim, 0.0);
1149    }
1150
1151    // ── best_task ────────────────────────────────────────────────────────────
1152
1153    #[test]
1154    fn test_best_task_empty_history() {
1155        let ml = MetaLearner::new(simple_config(3));
1156        assert!(ml.best_task().is_none());
1157    }
1158
1159    #[test]
1160    fn test_best_task_returns_lowest_query_loss() {
1161        let mut ml = MetaLearner::new(simple_config(3));
1162        for (id, ql) in [("t1", 0.5), ("t2", 0.1), ("t3", 0.8)] {
1163            ml.task_history.insert(
1164                TaskId::new(id),
1165                TaskAdaptation {
1166                    task_id: TaskId::new(id),
1167                    adapted_weights: vec![0.0; 3],
1168                    adapted_bias: 0.0,
1169                    support_loss: 0.0,
1170                    query_loss: ql,
1171                    steps: 1,
1172                },
1173            );
1174        }
1175        let (best_id, best_a) = ml.best_task().expect("should have a best task");
1176        assert_eq!(best_id, &TaskId::new("t2"));
1177        assert!((best_a.query_loss - 0.1).abs() < 1e-10);
1178    }
1179
1180    // ── reset_task ───────────────────────────────────────────────────────────
1181
1182    #[test]
1183    fn test_reset_task_removes_entry() {
1184        let mut ml = MetaLearner::new(simple_config(3));
1185        let task = make_regression_task("to_remove", 3, 3, 2);
1186        ml.adapt_to_task(&task).expect("ok");
1187        assert!(ml.task_history.contains_key(&TaskId::new("to_remove")));
1188        ml.reset_task(&TaskId::new("to_remove"));
1189        assert!(!ml.task_history.contains_key(&TaskId::new("to_remove")));
1190    }
1191
1192    #[test]
1193    fn test_reset_task_nonexistent_is_noop() {
1194        let mut ml = MetaLearner::new(simple_config(3));
1195        ml.reset_task(&TaskId::new("ghost")); // should not panic
1196    }
1197
1198    // ── stats ────────────────────────────────────────────────────────────────
1199
1200    #[test]
1201    fn test_stats_empty() {
1202        let ml = MetaLearner::new(simple_config(3));
1203        let s = ml.stats();
1204        assert_eq!(s.total_tasks, 0);
1205        assert_eq!(s.meta_steps, 0);
1206        assert_eq!(s.avg_support_loss, 0.0);
1207        assert_eq!(s.avg_query_loss, 0.0);
1208        assert!(s.best_query_loss.is_infinite());
1209    }
1210
1211    #[test]
1212    fn test_stats_after_adapt() {
1213        let mut ml = MetaLearner::new(simple_config(3));
1214        let t1 = make_regression_task("t1", 3, 4, 2);
1215        let t2 = make_regression_task("t2", 3, 4, 2);
1216        ml.adapt_to_task(&t1).expect("ok");
1217        ml.adapt_to_task(&t2).expect("ok");
1218        let s = ml.stats();
1219        assert_eq!(s.total_tasks, 2);
1220    }
1221
1222    #[test]
1223    fn test_stats_best_query_loss_decreases() {
1224        let mut ml = MetaLearner::new(simple_config(3));
1225        ml.task_history.insert(
1226            TaskId::new("t1"),
1227            TaskAdaptation {
1228                task_id: TaskId::new("t1"),
1229                adapted_weights: vec![0.0; 3],
1230                adapted_bias: 0.0,
1231                support_loss: 1.0,
1232                query_loss: 0.3,
1233                steps: 1,
1234            },
1235        );
1236        let s = ml.stats();
1237        assert!((s.best_query_loss - 0.3).abs() < 1e-10);
1238    }
1239
1240    // ── MetaError display ────────────────────────────────────────────────────
1241
1242    #[test]
1243    fn test_meta_error_display() {
1244        assert!(!MetaError::EmptySupportSet.to_string().is_empty());
1245        assert!(!MetaError::EmptyQuerySet.to_string().is_empty());
1246        assert!(!MetaError::NoAdaptations.to_string().is_empty());
1247        assert!(!MetaError::DimensionMismatch {
1248            expected: 3,
1249            got: 2
1250        }
1251        .to_string()
1252        .is_empty());
1253    }
1254
1255    // ── End-to-end: full adapt + meta_update cycle ──────────────────────────
1256
1257    #[test]
1258    fn test_full_maml_cycle() {
1259        let mut ml = MetaLearner::new(simple_config(4));
1260        let tasks: Vec<MetaTask> = (0..3)
1261            .map(|i| make_regression_task(&format!("task_{i}"), 4, 5, 3))
1262            .collect();
1263
1264        let adaptations: Vec<_> = tasks
1265            .iter()
1266            .map(|t| ml.adapt_to_task(t).expect("adapt ok"))
1267            .collect();
1268
1269        ml.meta_update(&adaptations).expect("meta_update ok");
1270
1271        assert_eq!(ml.meta_step, 1);
1272        assert_eq!(ml.task_history.len(), 3);
1273
1274        let s = ml.stats();
1275        assert_eq!(s.total_tasks, 3);
1276        assert_eq!(s.meta_steps, 1);
1277        assert!(s.best_query_loss < f64::INFINITY);
1278    }
1279}