Skip to main content

optirs_core/online_learning/
transfer.rs

1// Similarity-driven cross-task transfer for [`LifelongOptimizer`].
2//
3// The lifelong optimizer's EWC and Reptile paths consolidate knowledge *within*
4// a parameter vector, but nothing related one task to another: `task_embeddings`,
5// `transfer_weights`, `task_dependencies` and `task_clusters` had no producer at
6// all, so a new task always started cold no matter how close it was to something
7// already learned. This module supplies the missing half:
8//
9// * every gradient a task sees is folded into running first- and second-moment
10//   statistics for that task,
11// * those statistics are turned into a fixed-width task embedding, so tasks of
12//   different parameter shapes stay comparable,
13// * embeddings are compared by cosine similarity, which fills the task graph's
14//   similarity matrix,
15// * a new task started through [`LifelongOptimizer::start_task_with_probe`] is
16//   warm-started from its nearest neighbour when that similarity clears the
17//   transfer threshold, and
18// * the similarity matrix is clustered agglomeratively into `task_clusters`.
19
20use scirs2_core::ndarray::{Array, Dimension, ScalarOperand};
21use scirs2_core::numeric::Float;
22use std::fmt::Debug;
23
24use super::{LifelongOptimizer, LifelongStrategy};
25use crate::error::{OptimError, Result};
26use crate::utils::{scalar_or, try_f64};
27
28/// Width of a task embedding when the strategy does not specify one.
29///
30/// [`LifelongStrategy::MetaLearning`] carries an explicit `task_embedding_size`
31/// and that value is used instead.
32pub const DEFAULT_TASK_EMBEDDING_DIM: usize = 64;
33
34/// Upper bound on the embedding width, so a mis-configured
35/// `task_embedding_size` cannot ask for an unbounded allocation per task.
36pub const MAX_TASK_EMBEDDING_DIM: usize = 4096;
37
38/// Cosine similarity a new task must reach before it is warm-started from an
39/// existing one, and the linkage at which two tasks are placed in the same
40/// cluster.
41pub const DEFAULT_TRANSFER_THRESHOLD: f64 = 0.5;
42
43/// Floor for the whitening denominator, so a coordinate whose gradient is
44/// always exactly zero contributes 0 rather than a division by zero.
45const WHITENING_EPSILON: f64 = 1e-12;
46
47/// Running gradient statistics for one task.
48///
49/// Both moments are exact running means over every gradient the task has
50/// observed, which is what makes the derived embedding a property of the *task*
51/// rather than of the particular step it was sampled at.
52#[derive(Debug, Clone, Default)]
53pub struct TaskStatistics {
54    /// Number of gradients folded in.
55    observations: usize,
56    /// Running mean of the gradient, `E[g]`.
57    mean_gradient: Vec<f64>,
58    /// Running mean of the squared gradient, `E[g^2]`.
59    mean_squared_gradient: Vec<f64>,
60}
61
62impl TaskStatistics {
63    /// Fold one gradient into the running statistics.
64    ///
65    /// A gradient of a different length than the ones seen so far restarts the
66    /// statistics: it can only come from a task whose parameter vector changed
67    /// shape, and averaging across shapes would produce a meaningless
68    /// descriptor.
69    pub fn observe(&mut self, gradient: &[f64]) {
70        if self.mean_gradient.len() != gradient.len() {
71            self.mean_gradient = vec![0.0; gradient.len()];
72            self.mean_squared_gradient = vec![0.0; gradient.len()];
73            self.observations = 0;
74        }
75
76        self.observations += 1;
77        let weight = 1.0 / self.observations as f64;
78        for ((mean, squared), &value) in self
79            .mean_gradient
80            .iter_mut()
81            .zip(self.mean_squared_gradient.iter_mut())
82            .zip(gradient.iter())
83        {
84            *mean += (value - *mean) * weight;
85            *squared += (value * value - *squared) * weight;
86        }
87    }
88
89    /// Number of gradients folded in.
90    pub fn observations(&self) -> usize {
91        self.observations
92    }
93
94    /// Running mean gradient.
95    pub fn mean_gradient(&self) -> &[f64] {
96        &self.mean_gradient
97    }
98
99    /// Running mean squared gradient.
100    pub fn mean_squared_gradient(&self) -> &[f64] {
101        &self.mean_squared_gradient
102    }
103
104    /// Fixed-width task embedding derived from the statistics.
105    ///
106    /// The descriptor is the *whitened* mean gradient
107    /// `E[g_i] / sqrt(E[g_i^2] + eps)`: dividing the first moment by the square
108    /// root of the second is the diagonal natural-gradient direction, so a
109    /// coordinate that is merely noisy (large `E[g^2]`, small `E[g]`) does not
110    /// dominate a coordinate that consistently pushes the same way. That vector
111    /// is then reduced to `dim` components by signed feature hashing
112    /// (Weinberger et al., "Feature Hashing for Large Scale Multitask
113    /// Learning", ICML 2009), which preserves inner products in expectation
114    /// while making tasks of different parameter dimensionality comparable, and
115    /// finally normalized to unit length so cosine similarity is a plain dot
116    /// product.
117    ///
118    /// A task whose gradients have averaged out to exactly zero has no
119    /// direction; its embedding is all zeros and its similarity to everything is
120    /// zero, which is the honest answer rather than an arbitrary one.
121    pub fn embedding(&self, dim: usize) -> Vec<f64> {
122        let dim = dim.clamp(1, MAX_TASK_EMBEDDING_DIM);
123        let mut projected = vec![0.0f64; dim];
124
125        for (index, (&mean, &squared)) in self
126            .mean_gradient
127            .iter()
128            .zip(self.mean_squared_gradient.iter())
129            .enumerate()
130        {
131            let whitened = mean / (squared + WHITENING_EPSILON).sqrt();
132            if !whitened.is_finite() {
133                continue;
134            }
135            let hash = mix64(index as u64);
136            let bucket = (hash % dim as u64) as usize;
137            let sign = if (hash >> 63) & 1 == 1 { -1.0 } else { 1.0 };
138            projected[bucket] += sign * whitened;
139        }
140
141        let norm = projected.iter().map(|v| v * v).sum::<f64>().sqrt();
142        if !norm.is_finite() || norm <= 0.0 {
143            return vec![0.0; dim];
144        }
145        for value in projected.iter_mut() {
146            *value /= norm;
147        }
148        projected
149    }
150}
151
152/// SplitMix64 finalizer.
153///
154/// Used as the hash for feature hashing. It is written out rather than taken
155/// from [`std::collections::hash_map::DefaultHasher`] because the standard
156/// hasher's output is explicitly not stable across releases, and an embedding
157/// that changes meaning between compiler versions would make transfer
158/// decisions irreproducible.
159fn mix64(mut x: u64) -> u64 {
160    x ^= x >> 30;
161    x = x.wrapping_mul(0xbf58_476d_1ce4_e5b9);
162    x ^= x >> 27;
163    x = x.wrapping_mul(0x94d0_49bb_1331_11eb);
164    x ^ (x >> 31)
165}
166
167/// Cosine similarity of two task embeddings.
168///
169/// Embeddings produced by [`TaskStatistics::embedding`] are unit length, so this
170/// is their dot product; the explicit normalization keeps the function correct
171/// for any caller-supplied vector, and embeddings of different widths (which can
172/// only come from a strategy change mid-run) are reported as unrelated rather
173/// than compared over a truncated prefix.
174pub fn cosine_similarity(left: &[f64], right: &[f64]) -> f64 {
175    if left.len() != right.len() || left.is_empty() {
176        return 0.0;
177    }
178
179    let mut dot = 0.0;
180    let mut left_norm = 0.0;
181    let mut right_norm = 0.0;
182    for (&a, &b) in left.iter().zip(right.iter()) {
183        dot += a * b;
184        left_norm += a * a;
185        right_norm += b * b;
186    }
187
188    let denominator = (left_norm * right_norm).sqrt();
189    if !denominator.is_finite() || denominator <= 0.0 {
190        return 0.0;
191    }
192    (dot / denominator).clamp(-1.0, 1.0)
193}
194
195/// Canonical key for an unordered task pair, so `(a, b)` and `(b, a)` land in
196/// the same slot of the similarity matrix.
197fn similarity_key(left: &str, right: &str) -> (String, String) {
198    if left <= right {
199        (left.to_string(), right.to_string())
200    } else {
201        (right.to_string(), left.to_string())
202    }
203}
204
205/// What [`LifelongOptimizer::start_task_with_probe`] did.
206#[derive(Debug, Clone, PartialEq)]
207pub struct TransferOutcome {
208    /// Task the new task was warm-started from, if any.
209    pub source_task: Option<String>,
210    /// Similarity to the nearest previously-seen task. Reported even when no
211    /// transfer happened, so a caller can see how close the decision was.
212    pub similarity: f64,
213    /// Weight the source parameters were blended in with: `0` means the task
214    /// started cold, `1` means it started exactly at the source's parameters.
215    pub transfer_weight: f64,
216}
217
218impl<A: Float + ScalarOperand + Debug + std::iter::Sum, D: Dimension + Send + Sync>
219    LifelongOptimizer<A, D>
220{
221    /// Similarity a new task must reach before it is warm-started, and the
222    /// linkage at which two tasks are clustered together.
223    pub fn transfer_threshold(&self) -> f64 {
224        self.transfer_threshold
225    }
226
227    /// Set the transfer/clustering threshold.
228    ///
229    /// A cosine similarity lives in `[-1, 1]`; anything outside that range would
230    /// either transfer unconditionally or never, which is a configuration error
231    /// rather than a policy.
232    pub fn set_transfer_threshold(&mut self, threshold: f64) -> Result<()> {
233        if !threshold.is_finite() || !(-1.0..=1.0).contains(&threshold) {
234            return Err(OptimError::InvalidConfig(format!(
235                "transfer threshold {threshold} is not a cosine similarity in [-1, 1]"
236            )));
237        }
238        self.transfer_threshold = threshold;
239        self.rebuild_task_clusters();
240        Ok(())
241    }
242
243    /// Embedding of a task, once it has observed at least one gradient.
244    pub fn task_embedding(&self, task_id: &str) -> Option<&[f64]> {
245        self.shared_knowledge
246            .task_embeddings
247            .get(task_id)
248            .map(|embedding| embedding.as_slice())
249    }
250
251    /// Running gradient statistics of a task.
252    pub fn task_statistics(&self, task_id: &str) -> Option<&TaskStatistics> {
253        self.shared_knowledge.task_statistics.get(task_id)
254    }
255
256    /// Weight `target` was warm-started from `source` with, or `0` if no
257    /// transfer took place between them.
258    pub fn transfer_weight(&self, source: &str, target: &str) -> f64 {
259        self.shared_knowledge
260            .transfer_weights
261            .get(&(source.to_string(), target.to_string()))
262            .copied()
263            .unwrap_or(0.0)
264    }
265
266    /// Tasks `task_id` was warm-started from, most recent last.
267    pub fn task_dependencies(&self, task_id: &str) -> &[String] {
268        self.task_graph
269            .task_dependencies
270            .get(task_id)
271            .map(|dependencies| dependencies.as_slice())
272            .unwrap_or(&[])
273    }
274
275    /// Current clustering of the tasks, each cluster sorted by task id and the
276    /// clusters themselves sorted by their first member.
277    pub fn task_clusters(&self) -> &[Vec<String>] {
278        &self.task_graph.task_clusters
279    }
280
281    /// Current parameters of a task.
282    pub fn task_parameters(&self, task_id: &str) -> Option<&Array<A, D>> {
283        self.task_optimizers
284            .get(task_id)
285            .map(|optimizer| optimizer.parameters())
286    }
287
288    /// Mean strength of the warm starts performed so far, or `0` when nothing
289    /// has been transferred.
290    ///
291    /// This is what [`super::LifelongStats::transfer_efficiency`] reports.
292    pub fn mean_transfer_weight(&self) -> f64 {
293        let weights = &self.shared_knowledge.transfer_weights;
294        if weights.is_empty() {
295            return 0.0;
296        }
297        weights.values().sum::<f64>() / weights.len() as f64
298    }
299
300    /// Start a new task, warm-starting it from the most similar task seen so
301    /// far.
302    ///
303    /// `probe_gradient` is a gradient of the *new* task evaluated at
304    /// `initial_parameters` — one backward pass on its first batch. It is what
305    /// makes the decision possible at all: a task that has taken no steps yet
306    /// has no statistics of its own, so without a probe the nearest neighbour
307    /// could only be guessed at. The probe is also folded into the new task's
308    /// statistics, so its embedding exists from the first moment.
309    ///
310    /// When the nearest neighbour's cosine similarity reaches
311    /// [`Self::transfer_threshold`] and its parameters have the same shape, the
312    /// new task starts at
313    /// `(1 - w) * initial_parameters + w * source_parameters` with
314    /// `w = similarity`, an edge is recorded in the task graph, and the weight
315    /// is recorded in `transfer_weights`. Otherwise the task starts exactly
316    /// where the caller put it.
317    ///
318    /// Everything [`Self::start_task`] does — consolidating the outgoing task
319    /// into the EWC anchor, creating the task optimizer, starting performance
320    /// tracking — still happens.
321    pub fn start_task_with_probe(
322        &mut self,
323        task_id: String,
324        initial_parameters: Array<A, D>,
325        probe_gradient: &Array<A, D>,
326    ) -> Result<TransferOutcome> {
327        if probe_gradient.raw_dim() != initial_parameters.raw_dim() {
328            return Err(OptimError::DimensionMismatch(format!(
329                "transfer probe: initial parameters have shape {:?} but the probe \
330                 gradient has shape {:?}",
331                initial_parameters.raw_dim().slice(),
332                probe_gradient.raw_dim().slice()
333            )));
334        }
335
336        let dim = self.embedding_dim();
337        let probe_values = flatten_to_f64(probe_gradient)?;
338        let mut probe_statistics = TaskStatistics::default();
339        probe_statistics.observe(&probe_values);
340        let probe_embedding = probe_statistics.embedding(dim);
341
342        // Score the probe against every task that already has an embedding, and
343        // record the scores: they are the new task's row of the similarity
344        // matrix, and they are what the clustering below reads.
345        let mut best: Option<(String, f64)> = None;
346        for (candidate, embedding) in &self.shared_knowledge.task_embeddings {
347            if *candidate == task_id {
348                continue;
349            }
350            let similarity = cosine_similarity(&probe_embedding, embedding);
351            self.task_graph
352                .task_similarities
353                .insert(similarity_key(&task_id, candidate), similarity);
354
355            // Ties are broken by task id so the choice does not depend on the
356            // iteration order of a hash map.
357            let better = match &best {
358                None => true,
359                Some((best_id, best_similarity)) => {
360                    similarity > *best_similarity
361                        || (similarity == *best_similarity && candidate < best_id)
362                }
363            };
364            if better {
365                best = Some((candidate.clone(), similarity));
366            }
367        }
368
369        let mut parameters = initial_parameters;
370        let mut outcome = TransferOutcome {
371            source_task: None,
372            similarity: best.as_ref().map(|(_, s)| *s).unwrap_or(0.0),
373            transfer_weight: 0.0,
374        };
375
376        if let Some((source, similarity)) = best {
377            if similarity >= self.transfer_threshold {
378                let source_parameters = self.task_parameters(&source).cloned();
379                if let Some(source_parameters) = source_parameters {
380                    if source_parameters.raw_dim() == parameters.raw_dim() {
381                        let weight = similarity.clamp(0.0, 1.0);
382                        let blend = scalar_or(weight, A::zero());
383                        let keep = A::one() - blend;
384                        for (slot, &learned) in parameters.iter_mut().zip(source_parameters.iter())
385                        {
386                            *slot = *slot * keep + learned * blend;
387                        }
388                        outcome.source_task = Some(source.clone());
389                        outcome.transfer_weight = weight;
390                    }
391                }
392            }
393        }
394
395        self.start_task(task_id.clone(), parameters)?;
396
397        if let Some(source) = outcome.source_task.clone() {
398            self.shared_knowledge
399                .transfer_weights
400                .insert((source.clone(), task_id.clone()), outcome.transfer_weight);
401            let dependencies = self
402                .task_graph
403                .task_dependencies
404                .entry(task_id.clone())
405                .or_default();
406            if !dependencies.contains(&source) {
407                dependencies.push(source);
408            }
409        }
410
411        // Seed the new task's own statistics with the probe so it has an
412        // embedding before its first real update.
413        self.shared_knowledge
414            .task_statistics
415            .insert(task_id.clone(), probe_statistics);
416        self.shared_knowledge
417            .task_embeddings
418            .insert(task_id, probe_embedding);
419        self.rebuild_task_clusters();
420
421        Ok(outcome)
422    }
423
424    /// Fold one observed gradient into the current task's statistics and refresh
425    /// everything derived from them.
426    ///
427    /// Called from [`Self::update_current_task`] on every update, which is what
428    /// keeps a task's embedding tracking the task rather than freezing at
429    /// whatever its first gradient happened to be.
430    pub(super) fn record_task_observation(&mut self, gradient: &Array<A, D>) -> Result<()> {
431        let Some(task_id) = self.current_task.clone() else {
432            return Ok(());
433        };
434
435        let dim = self.embedding_dim();
436        let values = flatten_to_f64(gradient)?;
437
438        let statistics = self
439            .shared_knowledge
440            .task_statistics
441            .entry(task_id.clone())
442            .or_default();
443        statistics.observe(&values);
444        let embedding = statistics.embedding(dim);
445
446        self.shared_knowledge
447            .task_embeddings
448            .insert(task_id.clone(), embedding);
449        self.refresh_similarities(&task_id);
450        self.rebuild_task_clusters();
451
452        Ok(())
453    }
454
455    /// Embedding width: the strategy's `task_embedding_size` when it has one,
456    /// otherwise [`DEFAULT_TASK_EMBEDDING_DIM`].
457    fn embedding_dim(&self) -> usize {
458        match self.strategy {
459            LifelongStrategy::MetaLearning {
460                task_embedding_size,
461                ..
462            } => task_embedding_size.clamp(1, MAX_TASK_EMBEDDING_DIM),
463            _ => DEFAULT_TASK_EMBEDDING_DIM,
464        }
465    }
466
467    /// Recompute one task's row of the similarity matrix.
468    fn refresh_similarities(&mut self, task_id: &str) {
469        let Some(embedding) = self.shared_knowledge.task_embeddings.get(task_id).cloned() else {
470            return;
471        };
472
473        for (other, other_embedding) in &self.shared_knowledge.task_embeddings {
474            if other == task_id {
475                continue;
476            }
477            let similarity = cosine_similarity(&embedding, other_embedding);
478            self.task_graph
479                .task_similarities
480                .insert(similarity_key(task_id, other), similarity);
481        }
482    }
483
484    /// Rebuild `task_clusters` by single-linkage agglomerative clustering of the
485    /// similarity matrix, cut at [`Self::transfer_threshold`].
486    ///
487    /// The cutoff is the same threshold that decides whether transfer happens,
488    /// so "these tasks cluster together" and "these tasks transfer to each
489    /// other" cannot disagree.
490    ///
491    /// Single linkage merged repeatedly until the closest pair falls below a
492    /// cutoff produces exactly the connected components of the graph whose
493    /// edges are the pairs at or above that cutoff, so that is what is computed
494    /// here: a union-find pass over the pairs, which is `O(T^2)` rather than the
495    /// `O(T^4)` of running the merge loop literally. This function runs after
496    /// every observed gradient, so the difference is not academic.
497    ///
498    /// Task ids are sorted before clustering and roots are always the smallest
499    /// member index, so the output does not depend on hash-map iteration order.
500    fn rebuild_task_clusters(&mut self) {
501        let mut tasks: Vec<String> = self
502            .shared_knowledge
503            .task_embeddings
504            .keys()
505            .cloned()
506            .collect();
507        tasks.sort();
508
509        let count = tasks.len();
510        if count == 0 {
511            self.task_graph.task_clusters.clear();
512            return;
513        }
514
515        let threshold = self.transfer_threshold;
516        let mut parent: Vec<usize> = (0..count).collect();
517        for left in 0..count {
518            for right in (left + 1)..count {
519                if self.compute_task_similarity(&tasks[left], &tasks[right]) >= threshold {
520                    let left_root = find_root(&mut parent, left);
521                    let right_root = find_root(&mut parent, right);
522                    if left_root != right_root {
523                        parent[left_root.max(right_root)] = left_root.min(right_root);
524                    }
525                }
526            }
527        }
528
529        let mut buckets: Vec<Vec<String>> = vec![Vec::new(); count];
530        for (index, task) in tasks.iter().enumerate() {
531            let root = find_root(&mut parent, index);
532            buckets[root].push(task.clone());
533        }
534
535        let mut named: Vec<Vec<String>> = buckets
536            .into_iter()
537            .filter(|cluster| !cluster.is_empty())
538            .collect();
539        named.sort();
540        self.task_graph.task_clusters = named;
541    }
542}
543
544/// Union-find root with path halving.
545fn find_root(parent: &mut [usize], node: usize) -> usize {
546    let mut current = node;
547    while parent[current] != current {
548        parent[current] = parent[parent[current]];
549        current = parent[current];
550    }
551    current
552}
553
554/// Flatten an array into `f64`, reporting a value the target type cannot
555/// represent rather than silently substituting one.
556fn flatten_to_f64<A: Float, D: Dimension>(array: &Array<A, D>) -> Result<Vec<f64>> {
557    array.iter().map(|&value| try_f64(value)).collect()
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    use crate::online_learning::MemoryUpdateStrategy;
564    use scirs2_core::ndarray::{Array1, Ix1};
565
566    /// A quadratic task `f(x) = ||x - target||^2`, whose gradient is
567    /// `2 (x - target)`.
568    fn quadratic_gradient(parameters: &Array1<f64>, target: &Array1<f64>) -> Array1<f64> {
569        parameters
570            .iter()
571            .zip(target.iter())
572            .map(|(&x, &t)| 2.0 * (x - t))
573            .collect()
574    }
575
576    fn quadratic_loss(parameters: &Array1<f64>, target: &Array1<f64>) -> f64 {
577        parameters
578            .iter()
579            .zip(target.iter())
580            .map(|(&x, &t)| (x - t) * (x - t))
581            .sum()
582    }
583
584    fn optimizer() -> LifelongOptimizer<f64, Ix1> {
585        LifelongOptimizer::new(LifelongStrategy::MemoryAugmented {
586            memory_size: 128,
587            update_strategy: MemoryUpdateStrategy::FIFO,
588        })
589    }
590
591    /// Train `task_id` on the quadratic with `target` for `steps` updates.
592    fn train(
593        optimizer: &mut LifelongOptimizer<f64, Ix1>,
594        task_id: &str,
595        target: &Array1<f64>,
596        steps: usize,
597    ) {
598        for _ in 0..steps {
599            let parameters = optimizer
600                .task_parameters(task_id)
601                .expect("task must exist")
602                .clone();
603            let gradient = quadratic_gradient(&parameters, target);
604            let loss = quadratic_loss(&parameters, target);
605            optimizer
606                .update_current_task(&gradient, loss)
607                .expect("update must succeed");
608        }
609    }
610
611    /// Two tasks that pull in the same direction must transfer, and the warm
612    /// start must show up as a faster initial loss drop than a cold start.
613    #[test]
614    fn a_similar_task_is_warm_started_and_drops_its_loss_faster() {
615        let target_a = Array1::from_vec(vec![3.0, 3.0, 3.0, 3.0]);
616        let target_b = Array1::from_vec(vec![3.05, 3.05, 3.05, 3.05]);
617        let cold_start = Array1::zeros(4);
618
619        let mut opt = optimizer();
620        opt.start_task("a".to_string(), cold_start.clone())
621            .expect("start a");
622        train(&mut opt, "a", &target_a, 4000);
623
624        let learned_a = opt.task_parameters("a").expect("task a").clone();
625        assert!(
626            quadratic_loss(&learned_a, &target_a) < 1.0,
627            "task a did not learn: {learned_a:?}"
628        );
629
630        // The probe is one backward pass of task b at its cold-start point.
631        let probe = quadratic_gradient(&cold_start, &target_b);
632        let outcome = opt
633            .start_task_with_probe("b".to_string(), cold_start.clone(), &probe)
634            .expect("start b");
635
636        assert_eq!(
637            outcome.source_task.as_deref(),
638            Some("a"),
639            "a task pulling the same way must transfer (similarity {})",
640            outcome.similarity
641        );
642        assert!(
643            outcome.similarity > 0.9,
644            "similarity {} is implausibly low for near-identical tasks",
645            outcome.similarity
646        );
647        assert!(outcome.transfer_weight > 0.9);
648
649        let warm_parameters = opt.task_parameters("b").expect("task b").clone();
650        let warm_initial_loss = quadratic_loss(&warm_parameters, &target_b);
651        let cold_initial_loss = quadratic_loss(&cold_start, &target_b);
652        assert!(
653            warm_initial_loss < cold_initial_loss * 0.1,
654            "warm start did not help: {warm_initial_loss} vs cold {cold_initial_loss}"
655        );
656
657        // And after the same number of updates the warm-started task is still
658        // ahead of an identically configured cold start.
659        train(&mut opt, "b", &target_b, 20);
660        let warm_after = quadratic_loss(opt.task_parameters("b").expect("task b"), &target_b);
661
662        let mut cold = optimizer();
663        cold.start_task("b".to_string(), cold_start.clone())
664            .expect("cold start b");
665        train(&mut cold, "b", &target_b, 20);
666        let cold_after = quadratic_loss(cold.task_parameters("b").expect("cold task b"), &target_b);
667
668        assert!(
669            warm_after < cold_after,
670            "warm start ({warm_after}) must beat cold start ({cold_after}) after 20 steps"
671        );
672    }
673
674    /// A task pulling the opposite way must not be warm-started from the
675    /// existing one — transfer has to be similarity-driven, not automatic.
676    #[test]
677    fn a_dissimilar_task_is_not_warm_started() {
678        let target_a = Array1::from_vec(vec![3.0, 3.0, 3.0, 3.0]);
679        let target_c = Array1::from_vec(vec![-3.0, -3.0, -3.0, -3.0]);
680        let cold_start = Array1::zeros(4);
681
682        let mut opt = optimizer();
683        opt.start_task("a".to_string(), cold_start.clone())
684            .expect("start a");
685        train(&mut opt, "a", &target_a, 500);
686
687        let probe = quadratic_gradient(&cold_start, &target_c);
688        let outcome = opt
689            .start_task_with_probe("c".to_string(), cold_start.clone(), &probe)
690            .expect("start c");
691
692        assert_eq!(
693            outcome.source_task, None,
694            "an opposing task must not transfer (similarity {})",
695            outcome.similarity
696        );
697        assert!(
698            outcome.similarity < 0.0,
699            "opposing gradients must score below zero, got {}",
700            outcome.similarity
701        );
702        assert_eq!(outcome.transfer_weight, 0.0);
703        assert_eq!(
704            opt.task_parameters("c").expect("task c"),
705            &cold_start,
706            "a task that did not transfer must start exactly where the caller put it"
707        );
708        assert!(opt.task_dependencies("c").is_empty());
709    }
710
711    /// The transfer must be recorded in the task graph and in the shared
712    /// knowledge, not just applied and forgotten.
713    #[test]
714    fn transfer_weights_and_dependencies_are_recorded() {
715        let target_a = Array1::from_vec(vec![2.0, -2.0]);
716        let target_b = Array1::from_vec(vec![2.1, -2.1]);
717        let cold_start = Array1::zeros(2);
718
719        let mut opt = optimizer();
720        opt.start_task("a".to_string(), cold_start.clone())
721            .expect("start a");
722        train(&mut opt, "a", &target_a, 500);
723
724        let probe = quadratic_gradient(&cold_start, &target_b);
725        opt.start_task_with_probe("b".to_string(), cold_start, &probe)
726            .expect("start b");
727
728        assert_eq!(opt.task_dependencies("b").to_vec(), vec!["a".to_string()]);
729        assert!(opt.transfer_weight("a", "b") > 0.9);
730        assert_eq!(
731            opt.transfer_weight("b", "a"),
732            0.0,
733            "transfer is directed: b was started from a, not the other way round"
734        );
735        assert!(
736            opt.compute_task_similarity("a", "b") > 0.9,
737            "the similarity matrix must be populated"
738        );
739        assert!(
740            opt.get_lifelong_stats().transfer_efficiency > 0.9,
741            "transfer efficiency must reflect the transfers that happened"
742        );
743        assert!(opt.task_embedding("a").is_some());
744        assert!(opt.task_embedding("b").is_some());
745    }
746
747    /// Similar tasks must land in one cluster and a dissimilar one on its own.
748    #[test]
749    fn similar_tasks_cluster_together() {
750        let cold_start = Array1::zeros(3);
751        let target_a = Array1::from_vec(vec![1.0, 1.0, 1.0]);
752        let target_b = Array1::from_vec(vec![1.2, 1.1, 1.05]);
753        let target_c = Array1::from_vec(vec![-1.0, -1.0, -1.0]);
754
755        let mut opt = optimizer();
756        opt.start_task("a".to_string(), cold_start.clone())
757            .expect("start a");
758        train(&mut opt, "a", &target_a, 200);
759
760        opt.start_task("b".to_string(), cold_start.clone())
761            .expect("start b");
762        train(&mut opt, "b", &target_b, 200);
763
764        opt.start_task("c".to_string(), cold_start)
765            .expect("start c");
766        train(&mut opt, "c", &target_c, 200);
767
768        let clusters = opt.task_clusters().to_vec();
769        assert_eq!(
770            clusters,
771            vec![
772                vec!["a".to_string(), "b".to_string()],
773                vec!["c".to_string()]
774            ],
775            "clusters: {clusters:?}, sim(a,b) = {}, sim(a,c) = {}",
776            opt.compute_task_similarity("a", "b"),
777            opt.compute_task_similarity("a", "c")
778        );
779    }
780
781    /// Raising the threshold above the measured similarity must break the
782    /// cluster apart and stop the transfer — the threshold is genuinely read,
783    /// not decorative.
784    ///
785    /// The two tasks agree on five of six coordinates and disagree on the
786    /// sixth, which puts their similarity between the default threshold and the
787    /// raised one. (Two tasks whose gradient directions are exactly parallel —
788    /// `[1, 1]` against `[1.1, 1.1]`, say — have a cosine of exactly 1 and can
789    /// never be separated by a threshold, which is the honest behaviour of a
790    /// direction-based descriptor, not a defect.)
791    #[test]
792    fn the_threshold_controls_clustering_and_transfer() {
793        let cold_start = Array1::zeros(6);
794        let target_a = Array1::from_vec(vec![1.0, 1.0, 1.0, 1.0, 1.0, 1.0]);
795        let target_b = Array1::from_vec(vec![1.0, 1.0, 1.0, 1.0, 1.0, -1.0]);
796
797        let mut opt = optimizer();
798        opt.start_task("a".to_string(), cold_start.clone())
799            .expect("start a");
800        train(&mut opt, "a", &target_a, 200);
801        opt.start_task("b".to_string(), cold_start.clone())
802            .expect("start b");
803        train(&mut opt, "b", &target_b, 200);
804
805        let similarity = opt.compute_task_similarity("a", "b");
806        assert!(
807            (0.5..0.9).contains(&similarity),
808            "five agreeing coordinates out of six should score in [0.5, 0.9), got {similarity}"
809        );
810        assert_eq!(
811            opt.task_clusters().len(),
812            1,
813            "similar tasks share a cluster"
814        );
815
816        opt.set_transfer_threshold(0.9).expect("valid threshold");
817        assert_eq!(
818            opt.task_clusters().len(),
819            2,
820            "a threshold above the measured similarity must separate the tasks"
821        );
822
823        // And no transfer happens at that threshold either: task d also agrees
824        // with a on five of six coordinates, which clears the default threshold
825        // but not the raised one.
826        let target_d = Array1::from_vec(vec![1.0, 1.0, 1.0, 1.0, -1.0, 1.0]);
827        let probe = quadratic_gradient(&cold_start, &target_d);
828        let outcome = opt
829            .start_task_with_probe("d".to_string(), cold_start, &probe)
830            .expect("start d");
831        assert!(
832            (0.5..0.9).contains(&outcome.similarity),
833            "probe similarity {} is outside the band this test needs",
834            outcome.similarity
835        );
836        assert_eq!(
837            outcome.source_task, None,
838            "similarity {} cleared a threshold of 0.9",
839            outcome.similarity
840        );
841
842        assert!(opt.set_transfer_threshold(2.0).is_err());
843        assert!(opt.set_transfer_threshold(f64::NAN).is_err());
844    }
845
846    /// A probe of the wrong shape is a caller error and must be reported.
847    #[test]
848    fn a_mismatched_probe_is_reported() {
849        let mut opt = optimizer();
850        let error = opt
851            .start_task_with_probe(
852                "a".to_string(),
853                Array1::zeros(3),
854                &Array1::from_vec(vec![1.0, 2.0]),
855            )
856            .expect_err("a shape mismatch must be reported");
857        assert!(
858            matches!(error, OptimError::DimensionMismatch(_)),
859            "{error:?}"
860        );
861    }
862
863    /// The very first task has nothing to transfer from, and must say so.
864    #[test]
865    fn the_first_task_has_nothing_to_transfer_from() {
866        let mut opt = optimizer();
867        let outcome = opt
868            .start_task_with_probe(
869                "a".to_string(),
870                Array1::zeros(2),
871                &Array1::from_vec(vec![1.0, -1.0]),
872            )
873            .expect("start a");
874        assert_eq!(outcome.source_task, None);
875        assert_eq!(outcome.similarity, 0.0);
876        assert_eq!(opt.mean_transfer_weight(), 0.0);
877        assert_eq!(opt.task_clusters().to_vec(), vec![vec!["a".to_string()]]);
878    }
879
880    /// Feature hashing must keep tasks of different parameter dimensionality
881    /// comparable instead of panicking or reporting a fixed zero.
882    #[test]
883    fn embeddings_have_a_fixed_width_regardless_of_parameter_count() {
884        let mut small = TaskStatistics::default();
885        small.observe(&[1.0, -1.0, 1.0]);
886        let mut large = TaskStatistics::default();
887        large.observe(&vec![0.5; 500]);
888
889        assert_eq!(small.embedding(64).len(), 64);
890        assert_eq!(large.embedding(64).len(), 64);
891        let similarity = cosine_similarity(&small.embedding(64), &large.embedding(64));
892        assert!(
893            (-1.0..=1.0).contains(&similarity),
894            "similarity {similarity} is not a cosine"
895        );
896    }
897
898    /// Opposite directions must score -1 and identical ones +1, so the
899    /// similarity really is a cosine of the task direction.
900    #[test]
901    fn the_embedding_captures_gradient_direction() {
902        let mut forward = TaskStatistics::default();
903        let mut backward = TaskStatistics::default();
904        for step in 0..10 {
905            let scale = 1.0 + step as f64;
906            forward.observe(&[scale, 2.0 * scale, -scale]);
907            backward.observe(&[-scale, -2.0 * scale, scale]);
908        }
909
910        let same = cosine_similarity(&forward.embedding(32), &forward.embedding(32));
911        let opposite = cosine_similarity(&forward.embedding(32), &backward.embedding(32));
912        assert!((same - 1.0).abs() < 1e-9, "same direction scored {same}");
913        assert!(
914            (opposite + 1.0).abs() < 1e-9,
915            "opposite direction scored {opposite}"
916        );
917    }
918
919    /// A task whose gradients average to zero has no direction, and must report
920    /// that rather than inventing one.
921    #[test]
922    fn a_directionless_task_has_a_zero_embedding() {
923        let mut statistics = TaskStatistics::default();
924        statistics.observe(&[1.0, 1.0]);
925        statistics.observe(&[-1.0, -1.0]);
926        let embedding = statistics.embedding(8);
927        assert!(embedding.iter().all(|&value| value == 0.0));
928        assert_eq!(cosine_similarity(&embedding, &embedding), 0.0);
929    }
930
931    /// Forgetting must be measured from a re-evaluation of an old task, not
932    /// reported as the constant `0.1` this used to return.
933    #[test]
934    fn forgetting_is_measured_from_re_evaluated_tasks() {
935        let cold_start = Array1::zeros(2);
936        let target_a = Array1::from_vec(vec![1.0, 1.0]);
937
938        let mut opt = optimizer();
939        opt.start_task("a".to_string(), cold_start.clone())
940            .expect("start a");
941        train(&mut opt, "a", &target_a, 50);
942
943        assert_eq!(
944            opt.get_lifelong_stats().catastrophic_forgetting,
945            0.0,
946            "forgetting is not observable while the task is still current"
947        );
948
949        opt.start_task("b".to_string(), cold_start)
950            .expect("start b");
951        assert_eq!(
952            opt.get_lifelong_stats().catastrophic_forgetting,
953            0.0,
954            "switching away does not by itself demonstrate forgetting"
955        );
956
957        // Re-evaluating task a and finding it worse is what forgetting is.
958        let reference = opt
959            .task_performance
960            .get("a")
961            .and_then(|history| history.last())
962            .copied()
963            .expect("task a recorded losses");
964        opt.record_task_performance("a", reference + 0.25)
965            .expect("recording a re-evaluation must succeed");
966        let forgetting = opt.get_lifelong_stats().catastrophic_forgetting;
967        assert!(
968            (forgetting - 0.25).abs() < 1e-9,
969            "forgetting was not measured from the re-evaluation: {forgetting}"
970        );
971
972        // Recording against the active task, or an unknown one, is rejected.
973        assert!(opt.record_task_performance("b", 1.0).is_err());
974        assert!(opt.record_task_performance("nope", 1.0).is_err());
975    }
976
977    /// Statistics must be running means over every gradient, not the latest one.
978    #[test]
979    fn statistics_are_running_means() {
980        let mut statistics = TaskStatistics::default();
981        statistics.observe(&[2.0, 0.0]);
982        statistics.observe(&[4.0, 0.0]);
983        assert_eq!(statistics.observations(), 2);
984        assert!((statistics.mean_gradient()[0] - 3.0).abs() < 1e-12);
985        assert!((statistics.mean_squared_gradient()[0] - 10.0).abs() < 1e-12);
986    }
987}