Skip to main content

optirs_core/online_learning/
mod.rs

1// Online learning and lifelong optimization
2//
3// This module provides optimization strategies for continuous learning scenarios,
4// including online learning, continual learning, and lifelong optimization systems.
5
6use crate::error::{OptimError, Result};
7use crate::utils::{scalar_or, total_order, try_f64, try_scalar};
8use scirs2_core::ndarray::{Array, Dimension, ScalarOperand};
9use scirs2_core::numeric::Float;
10use scirs2_core::random::thread_rng;
11use std::collections::{HashMap, VecDeque};
12use std::fmt::Debug;
13
14mod transfer;
15
16pub use transfer::{
17    cosine_similarity, TaskStatistics, TransferOutcome, DEFAULT_TASK_EMBEDDING_DIM,
18    DEFAULT_TRANSFER_THRESHOLD, MAX_TASK_EMBEDDING_DIM,
19};
20
21/// Online learning strategy
22#[derive(Debug, Clone)]
23pub enum OnlineLearningStrategy {
24    /// Stochastic Gradient Descent with adaptive learning rate
25    AdaptiveSGD {
26        /// Initial learning rate
27        initial_lr: f64,
28        /// Learning rate adaptation method
29        adaptation_method: LearningRateAdaptation,
30    },
31    /// Online Newton's method (second-order)
32    OnlineNewton {
33        /// Damping parameter for stability
34        damping: f64,
35        /// Window size for Hessian estimation
36        hessian_window: usize,
37    },
38    /// Follow The Regularized Leader (FTRL)
39    FTRL {
40        /// L1 regularization strength
41        l1_regularization: f64,
42        /// L2 regularization strength
43        l2_regularization: f64,
44        /// Learning rate power
45        learning_rate_power: f64,
46    },
47    /// Online Mirror Descent
48    MirrorDescent {
49        /// Mirror function type
50        mirror_function: MirrorFunction,
51        /// Regularization strength
52        regularization: f64,
53    },
54    /// Adaptive Multi-Task Learning
55    AdaptiveMultiTask {
56        /// Task similarity threshold
57        similarity_threshold: f64,
58        /// Task-specific learning rates
59        task_lr_adaptation: bool,
60    },
61}
62
63/// Learning rate adaptation methods for online learning
64#[derive(Debug, Clone)]
65pub enum LearningRateAdaptation {
66    /// AdaGrad-style adaptation
67    AdaGrad {
68        /// Small constant for numerical stability
69        epsilon: f64,
70    },
71    /// RMSprop-style adaptation
72    RMSprop {
73        /// Decay rate
74        decay: f64,
75        /// Small constant for numerical stability
76        epsilon: f64,
77    },
78    /// Adam-style adaptation
79    Adam {
80        /// Exponential decay rate for first moment
81        beta1: f64,
82        /// Exponential decay rate for second moment
83        beta2: f64,
84        /// Small constant for numerical stability
85        epsilon: f64,
86    },
87    /// Exponential decay
88    ExponentialDecay {
89        /// Decay rate
90        decay_rate: f64,
91    },
92    /// Inverse scaling
93    InverseScaling {
94        /// Scaling power
95        power: f64,
96    },
97}
98
99/// Mirror functions for mirror descent
100#[derive(Debug, Clone)]
101pub enum MirrorFunction {
102    /// Euclidean (L2) regularization
103    Euclidean,
104    /// Entropy regularization (for probability simplex)
105    Entropy,
106    /// L1 regularization
107    L1,
108    /// Nuclear norm (for matrices)
109    Nuclear,
110}
111
112/// Lifelong learning strategy
113#[derive(Debug, Clone)]
114pub enum LifelongStrategy {
115    /// Elastic Weight Consolidation (EWC)
116    ElasticWeightConsolidation {
117        /// Importance weight for previous tasks
118        importance_weight: f64,
119        /// Fisher information estimation samples
120        fisher_samples: usize,
121    },
122    /// Progressive Neural Networks
123    ProgressiveNetworks {
124        /// Lateral connection strength
125        lateral_strength: f64,
126        /// Column growth strategy
127        growth_strategy: ColumnGrowthStrategy,
128    },
129    /// Memory-Augmented Networks
130    MemoryAugmented {
131        /// Memory size
132        memory_size: usize,
133        /// Memory update strategy
134        update_strategy: MemoryUpdateStrategy,
135    },
136    /// Meta-Learning Based Continual Learning
137    MetaLearning {
138        /// Meta-learning rate
139        meta_lr: f64,
140        /// Inner loop steps
141        inner_steps: usize,
142        /// Task embedding size
143        task_embedding_size: usize,
144    },
145    /// Gradient Episodic Memory (GEM)
146    GradientEpisodicMemory {
147        /// Memory buffer size per task
148        memory_per_task: usize,
149        /// Constraint violation tolerance
150        violation_tolerance: f64,
151    },
152}
153
154/// Column growth strategies for progressive networks
155#[derive(Debug, Clone)]
156pub enum ColumnGrowthStrategy {
157    /// Add new column for each task
158    PerTask,
159    /// Add new column when performance drops
160    PerformanceBased {
161        /// Performance threshold
162        threshold: f64,
163    },
164    /// Add new column after fixed intervals
165    FixedInterval {
166        /// Fixed interval
167        interval: usize,
168    },
169}
170
171/// Memory update strategies
172#[derive(Debug, Clone)]
173pub enum MemoryUpdateStrategy {
174    /// First In First Out
175    FIFO,
176    /// Random replacement
177    Random,
178    /// Importance-based replacement
179    ImportanceBased,
180    /// Gradient diversity
181    GradientDiversity,
182}
183
184/// Online optimizer that adapts to streaming data
185#[derive(Debug)]
186pub struct OnlineOptimizer<A: Float, D: Dimension> {
187    /// Online learning strategy
188    strategy: OnlineLearningStrategy,
189    /// Current parameters
190    parameters: Array<A, D>,
191    /// Accumulated gradients for adaptation
192    gradient_accumulator: Array<A, D>,
193    /// Second moment accumulator (for Adam-like methods)
194    second_moment_accumulator: Option<Array<A, D>>,
195    /// Current learning rate
196    current_lr: A,
197    /// Step counter
198    step_count: usize,
199    /// Performance history
200    performance_history: VecDeque<A>,
201    /// Regret bounds tracking
202    regret_bound: A,
203    /// Bounded history of the (possibly adapted) learning rate, used to
204    /// compute a real `lr_stability` metric (F81).
205    lr_history: VecDeque<A>,
206}
207
208/// Lifelong optimizer that learns continuously across tasks
209#[derive(Debug)]
210pub struct LifelongOptimizer<A: Float, D: Dimension> {
211    /// Lifelong learning strategy
212    strategy: LifelongStrategy,
213    /// Task-specific optimizers
214    task_optimizers: HashMap<String, OnlineOptimizer<A, D>>,
215    /// Shared knowledge across tasks
216    shared_knowledge: SharedKnowledge<A, D>,
217    /// Task sequence and relationships
218    task_graph: TaskGraph,
219    /// Memory buffer for important examples
220    memory_buffer: MemoryBuffer<A, D>,
221    /// Current active task
222    current_task: Option<String>,
223    /// Performance tracking across tasks
224    task_performance: HashMap<String, Vec<A>>,
225    /// Last loss each task recorded while it was still the active task: the
226    /// reference a later re-evaluation is compared against to measure
227    /// catastrophic forgetting.
228    task_reference_loss: HashMap<String, A>,
229    /// Cosine similarity a new task must reach before it is warm-started from
230    /// an existing one, and the linkage at which tasks are clustered.
231    transfer_threshold: f64,
232}
233
234/// Shared knowledge representation for lifelong learning
235#[derive(Debug)]
236pub struct SharedKnowledge<A: Float, D: Dimension> {
237    /// Diagonal empirical Fisher information `F_i = E[g_i^2]`, accumulated
238    /// across the gradients observed for the current task (EWC).
239    fisher_information: Option<Array<A, D>>,
240    /// Number of gradients folded into `fisher_information` so far, capped at
241    /// the strategy's `fisher_samples` so the estimate keeps tracking as an
242    /// exponential moving average afterwards.
243    fisher_sample_count: usize,
244    /// Consolidated parameters `θ*` of the previously-learned tasks: the anchor
245    /// the EWC penalty pulls the active task back toward.
246    important_parameters: Option<Array<A, D>>,
247    /// Meta-parameters shared across tasks, maintained by the first-order
248    /// Reptile update in [`LifelongOptimizer::apply_meta_learning`].
249    meta_parameters: Option<Array<A, D>>,
250    /// Running gradient statistics per task, the raw material every task
251    /// embedding is derived from.
252    task_statistics: HashMap<String, TaskStatistics>,
253    /// Fixed-width task embeddings derived from `task_statistics`.
254    task_embeddings: HashMap<String, Vec<f64>>,
255    /// Warm-start strength actually applied, keyed by `(source, target)`.
256    transfer_weights: HashMap<(String, String), f64>,
257}
258
259/// Task relationship graph
260#[derive(Debug)]
261pub struct TaskGraph {
262    /// Task relationships (similarity scores)
263    task_similarities: HashMap<(String, String), f64>,
264    /// Tasks each task was warm-started from.
265    task_dependencies: HashMap<String, Vec<String>>,
266    /// Agglomerative clustering of `task_similarities`.
267    task_clusters: Vec<Vec<String>>,
268}
269
270/// Memory buffer for important examples
271#[derive(Debug)]
272pub struct MemoryBuffer<A: Float, D: Dimension> {
273    /// Stored examples
274    examples: VecDeque<MemoryExample<A, D>>,
275    /// Maximum buffer size
276    max_size: usize,
277    /// Update strategy
278    update_strategy: MemoryUpdateStrategy,
279    /// Importance scores
280    importance_scores: VecDeque<A>,
281}
282
283/// Single memory example
284#[derive(Debug, Clone)]
285pub struct MemoryExample<A: Float, D: Dimension> {
286    /// Input data
287    pub input: Array<A, D>,
288    /// Target output
289    pub target: Array<A, D>,
290    /// Task identifier
291    pub task_id: String,
292    /// Importance score
293    pub importance: A,
294    /// Gradient information
295    pub gradient: Option<Array<A, D>>,
296}
297
298/// Online learning performance metrics
299#[derive(Debug, Clone)]
300pub struct OnlinePerformanceMetrics<A: Float> {
301    /// Cumulative regret
302    pub cumulative_regret: A,
303    /// Average loss over window
304    pub average_loss: A,
305    /// Learning rate stability
306    pub lr_stability: A,
307    /// Adaptation speed
308    pub adaptation_speed: A,
309    /// Memory efficiency
310    pub memory_efficiency: A,
311}
312
313impl<A: Float + ScalarOperand + Debug + std::iter::Sum, D: Dimension + Send + Sync>
314    OnlineOptimizer<A, D>
315{
316    /// Create a new online optimizer
317    pub fn new(strategy: OnlineLearningStrategy, initial_parameters: Array<A, D>) -> Self {
318        let paramshape = initial_parameters.raw_dim();
319        let gradient_accumulator = Array::zeros(paramshape.clone());
320        let second_moment_accumulator = match &strategy {
321            OnlineLearningStrategy::AdaptiveSGD {
322                adaptation_method: LearningRateAdaptation::Adam { .. },
323                ..
324            } => Some(Array::zeros(paramshape)),
325            _ => None,
326        };
327
328        let current_lr = match &strategy {
329            OnlineLearningStrategy::AdaptiveSGD { initial_lr, .. } => {
330                scalar_or(*initial_lr, A::zero())
331            }
332            OnlineLearningStrategy::OnlineNewton { .. } => scalar_or(0.01, A::zero()),
333            OnlineLearningStrategy::FTRL { .. } => scalar_or(0.1, A::zero()),
334            OnlineLearningStrategy::MirrorDescent { .. } => scalar_or(0.01, A::zero()),
335            OnlineLearningStrategy::AdaptiveMultiTask { .. } => scalar_or(0.001, A::zero()),
336        };
337
338        Self {
339            strategy,
340            parameters: initial_parameters,
341            gradient_accumulator,
342            second_moment_accumulator,
343            current_lr,
344            step_count: 0,
345            performance_history: VecDeque::new(),
346            regret_bound: A::zero(),
347            lr_history: VecDeque::new(),
348        }
349    }
350
351    /// Perform online update with new gradient
352    pub fn online_update(&mut self, gradient: &Array<A, D>, loss: A) -> Result<()> {
353        self.step_count += 1;
354        self.performance_history.push_back(loss);
355
356        // Keep performance history bounded
357        if self.performance_history.len() > 1000 {
358            self.performance_history.pop_front();
359        }
360
361        match self.strategy.clone() {
362            OnlineLearningStrategy::AdaptiveSGD {
363                adaptation_method, ..
364            } => {
365                self.adaptive_sgd_update(gradient, &adaptation_method)?;
366            }
367            OnlineLearningStrategy::OnlineNewton { damping, .. } => {
368                self.online_newton_update(gradient, damping)?;
369            }
370            OnlineLearningStrategy::FTRL {
371                l1_regularization,
372                l2_regularization,
373                learning_rate_power,
374            } => {
375                self.ftrl_update(
376                    gradient,
377                    l1_regularization,
378                    l2_regularization,
379                    learning_rate_power,
380                )?;
381            }
382            OnlineLearningStrategy::MirrorDescent {
383                mirror_function,
384                regularization,
385            } => {
386                self.mirror_descent_update(gradient, &mirror_function, regularization)?;
387            }
388            OnlineLearningStrategy::AdaptiveMultiTask { .. } => {
389                self.adaptive_multitask_update(gradient)?;
390            }
391        }
392
393        // Record the (possibly adapted) learning rate for the stability
394        // metric (F81).
395        self.lr_history.push_back(self.current_lr);
396        if self.lr_history.len() > 1000 {
397            self.lr_history.pop_front();
398        }
399
400        // Update regret bound
401        self.update_regret_bound(loss);
402
403        Ok(())
404    }
405
406    /// Adaptive SGD update
407    fn adaptive_sgd_update(
408        &mut self,
409        gradient: &Array<A, D>,
410        adaptation: &LearningRateAdaptation,
411    ) -> Result<()> {
412        match adaptation {
413            LearningRateAdaptation::AdaGrad { epsilon } => {
414                // Accumulate squared gradients
415                self.gradient_accumulator = &self.gradient_accumulator + &gradient.mapv(|g| g * g);
416
417                // Compute adaptive learning rate
418                // Hoisted out of the closure: the conversion is loop-invariant and
419                // `?` cannot cross a closure boundary.
420                let eps = try_scalar::<A, _>(*epsilon)?;
421                let adaptive_lr = self.gradient_accumulator.mapv(|acc| eps + A::sqrt(acc));
422
423                // Update parameters
424                self.parameters = &self.parameters - &(gradient / &adaptive_lr * self.current_lr);
425            }
426            LearningRateAdaptation::RMSprop { decay, epsilon } => {
427                let decay_factor = try_scalar::<A, _>(*decay)?;
428                let one_minus_decay = A::one() - decay_factor;
429
430                // Update moving average of squared gradients
431                self.gradient_accumulator = &self.gradient_accumulator * decay_factor
432                    + &gradient.mapv(|g| g * g * one_minus_decay);
433
434                // Compute adaptive learning rate
435                let eps = try_scalar::<A, _>(*epsilon)?;
436                let adaptive_lr = self.gradient_accumulator.mapv(|acc| A::sqrt(acc + eps));
437
438                // Update parameters
439                self.parameters = &self.parameters - &(gradient / &adaptive_lr * self.current_lr);
440            }
441            LearningRateAdaptation::Adam {
442                beta1,
443                beta2,
444                epsilon,
445            } => {
446                let beta1_val = try_scalar::<A, _>(*beta1)?;
447                let beta2_val = try_scalar::<A, _>(*beta2)?;
448                let one_minus_beta1 = A::one() - beta1_val;
449                let one_minus_beta2 = A::one() - beta2_val;
450
451                // Update first moment (gradient accumulator)
452                self.gradient_accumulator =
453                    &self.gradient_accumulator * beta1_val + gradient * one_minus_beta1;
454
455                // Update second moment
456                if let Some(ref mut second_moment) = self.second_moment_accumulator {
457                    *second_moment =
458                        &*second_moment * beta2_val + &gradient.mapv(|g| g * g * one_minus_beta2);
459
460                    // Bias correction
461                    let step_count_float = try_scalar::<A, _>(self.step_count)?;
462                    let bias_correction1 = A::one() - A::powf(beta1_val, step_count_float);
463                    let bias_correction2 = A::one() - A::powf(beta2_val, step_count_float);
464
465                    let corrected_first = &self.gradient_accumulator / bias_correction1;
466                    let corrected_second = &*second_moment / bias_correction2;
467
468                    // Update parameters
469                    let eps = try_scalar::<A, _>(*epsilon)?;
470                    let adaptive_lr = corrected_second.mapv(|v| A::sqrt(v) + eps);
471                    self.parameters =
472                        &self.parameters - &(corrected_first / adaptive_lr * self.current_lr);
473                }
474            }
475            LearningRateAdaptation::ExponentialDecay { decay_rate } => {
476                // Simple exponential decay
477                self.current_lr = self.current_lr * try_scalar::<A, _>(*decay_rate)?;
478                self.parameters = &self.parameters - gradient * self.current_lr;
479            }
480            LearningRateAdaptation::InverseScaling { power } => {
481                // Inverse scaling: lr = initial_lr / (step^power)
482                let step_power = A::powf(
483                    try_scalar::<A, _>(self.step_count)?,
484                    try_scalar::<A, _>(*power)?,
485                );
486                let decayed_lr = self.current_lr / step_power;
487                self.parameters = &self.parameters - gradient * decayed_lr;
488            }
489        }
490
491        Ok(())
492    }
493
494    /// Online Newton's method update
495    fn online_newton_update(&mut self, gradient: &Array<A, D>, damping: f64) -> Result<()> {
496        // Simplified online Newton update with damping
497        let damping_val = try_scalar::<A, _>(damping)?;
498
499        // Approximate Hessian diagonal with gradient squares (simplified)
500        let hessian_approx = gradient.mapv(|g| g * g + damping_val);
501
502        // Newton step
503        let newton_step = gradient / hessian_approx;
504        self.parameters = &self.parameters - &newton_step * self.current_lr;
505
506        Ok(())
507    }
508
509    /// FTRL update
510    fn ftrl_update(
511        &mut self,
512        gradient: &Array<A, D>,
513        l1_reg: f64,
514        l2_reg: f64,
515        lr_power: f64,
516    ) -> Result<()> {
517        // Accumulate gradients
518        self.gradient_accumulator = &self.gradient_accumulator + gradient;
519
520        // FTRL update rule (simplified)
521        let step_factor = A::powf(
522            try_scalar::<A, _>(self.step_count)?,
523            try_scalar::<A, _>(lr_power)?,
524        );
525        let learning_rate = self.current_lr / step_factor;
526
527        // Apply L1 and L2 regularization
528        let l1_weight = try_scalar::<A, _>(l1_reg)?;
529        let l2_weight = try_scalar::<A, _>(l2_reg)?;
530
531        self.parameters = self.gradient_accumulator.mapv(|g| {
532            let abs_g = A::abs(g);
533            if abs_g <= l1_weight {
534                A::zero()
535            } else {
536                let sign = if g > A::zero() { A::one() } else { -A::one() };
537                -sign * (abs_g - l1_weight) / (l2_weight + A::sqrt(abs_g))
538            }
539        }) * learning_rate;
540
541        Ok(())
542    }
543
544    /// Mirror descent update
545    fn mirror_descent_update(
546        &mut self,
547        gradient: &Array<A, D>,
548        mirror_fn: &MirrorFunction,
549        regularization: f64,
550    ) -> Result<()> {
551        match mirror_fn {
552            MirrorFunction::Euclidean => {
553                // Standard gradient descent
554                self.parameters = &self.parameters - gradient * self.current_lr;
555            }
556            MirrorFunction::Entropy => {
557                // Entropy regularized update (for probability simplex)
558                let reg_val = try_scalar::<A, _>(regularization)?;
559                let updated = self
560                    .parameters
561                    .mapv(|p| A::exp(A::ln(p) - self.current_lr * reg_val));
562                let sum = updated.sum();
563                self.parameters = updated / sum; // Normalize to probability simplex
564            }
565            MirrorFunction::L1 => {
566                // L1 regularized update with soft thresholding
567                let threshold = self.current_lr * try_scalar::<A, _>(regularization)?;
568                self.parameters = (&self.parameters - gradient * self.current_lr).mapv(|p| {
569                    if A::abs(p) <= threshold {
570                        A::zero()
571                    } else {
572                        p - A::signum(p) * threshold
573                    }
574                });
575            }
576            MirrorFunction::Nuclear => {
577                // Simplified nuclear norm update (requires matrix structure)
578                self.parameters = &self.parameters - gradient * self.current_lr;
579            }
580        }
581
582        Ok(())
583    }
584
585    /// Adaptive multi-task update
586    fn adaptive_multitask_update(&mut self, gradient: &Array<A, D>) -> Result<()> {
587        // Simplified multi-task update
588        self.parameters = &self.parameters - gradient * self.current_lr;
589        Ok(())
590    }
591
592    /// Update regret bound estimation
593    fn update_regret_bound(&mut self, loss: A) {
594        if let Some(&best_loss) = self
595            .performance_history
596            .iter()
597            .min_by(|a, b| total_order(*a, *b))
598        {
599            let regret = loss - best_loss;
600            self.regret_bound = self.regret_bound + regret.max(A::zero());
601        }
602    }
603
604    /// Get current parameters
605    pub fn parameters(&self) -> &Array<A, D> {
606        &self.parameters
607    }
608
609    /// Get performance metrics
610    pub fn get_performance_metrics(&self) -> OnlinePerformanceMetrics<A> {
611        let average_loss = if self.performance_history.is_empty() {
612            A::zero()
613        } else {
614            self.performance_history.iter().copied().sum::<A>()
615                / A::from(self.performance_history.len()).unwrap_or_else(A::one)
616        };
617
618        // Learning-rate stability (F81): high when the recent learning rate
619        // has low variance, mapped into `(0, 1]` via `1 / (1 + std(lr))`.
620        // Previously a hardcoded `1.0`.
621        let lr_stability = {
622            let n = self.lr_history.len();
623            if n < 2 {
624                A::one()
625            } else {
626                let count = A::from(n).unwrap_or_else(A::one);
627                let mean = self.lr_history.iter().copied().sum::<A>() / count;
628                let variance = self
629                    .lr_history
630                    .iter()
631                    .map(|&lr| (lr - mean) * (lr - mean))
632                    .sum::<A>()
633                    / count;
634                A::one() / (A::one() + variance.sqrt())
635            }
636        };
637
638        // Adaptation speed (F81): mean per-step loss improvement over a
639        // recent window (positive = loss decreasing), clamped at 0.
640        // Previously the raw `step_count`, which is a count, not a speed.
641        let adaptation_speed = {
642            let history_len = self.performance_history.len();
643            let window = 20usize.min(history_len);
644            if window < 2 {
645                A::zero()
646            } else {
647                let first = self.performance_history[history_len - window];
648                let last = self.performance_history[history_len - 1];
649                let steps = A::from(window - 1).unwrap_or_else(A::one);
650                ((first - last) / steps).max(A::zero())
651            }
652        };
653
654        // Memory efficiency (F81): parameter storage as a fraction of the
655        // optimizer's total array storage (parameters + accumulators).
656        // Higher means less auxiliary-state overhead. Previously a hardcoded
657        // `0.8`.
658        let memory_efficiency = {
659            let param_len = self.parameters.len();
660            let mut total = param_len + self.gradient_accumulator.len();
661            if let Some(second_moment) = &self.second_moment_accumulator {
662                total += second_moment.len();
663            }
664            if total == 0 {
665                A::zero()
666            } else {
667                A::from(param_len).unwrap_or_else(A::one) / A::from(total).unwrap_or_else(A::one)
668            }
669        };
670
671        OnlinePerformanceMetrics {
672            cumulative_regret: self.regret_bound,
673            average_loss,
674            lr_stability,
675            adaptation_speed,
676            memory_efficiency,
677        }
678    }
679}
680
681/// Euclidean dot product of two equally-shaped arrays. Used by the
682/// Gradient Episodic Memory projection (F81).
683fn array_dot<A: Float + std::iter::Sum, D: Dimension>(a: &Array<A, D>, b: &Array<A, D>) -> A {
684    a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum()
685}
686
687impl<A: Float + ScalarOperand + Debug + std::iter::Sum, D: Dimension + Send + Sync>
688    LifelongOptimizer<A, D>
689{
690    /// Create a new lifelong optimizer
691    pub fn new(strategy: LifelongStrategy) -> Self {
692        Self {
693            strategy,
694            task_optimizers: HashMap::new(),
695            shared_knowledge: SharedKnowledge {
696                fisher_information: None,
697                fisher_sample_count: 0,
698                important_parameters: None,
699                meta_parameters: None,
700                task_statistics: HashMap::new(),
701                task_embeddings: HashMap::new(),
702                transfer_weights: HashMap::new(),
703            },
704            task_graph: TaskGraph {
705                task_similarities: HashMap::new(),
706                task_dependencies: HashMap::new(),
707                task_clusters: Vec::new(),
708            },
709            memory_buffer: MemoryBuffer {
710                examples: VecDeque::new(),
711                max_size: 1000,
712                update_strategy: MemoryUpdateStrategy::FIFO,
713                importance_scores: VecDeque::new(),
714            },
715            current_task: None,
716            task_performance: HashMap::new(),
717            task_reference_loss: HashMap::new(),
718            transfer_threshold: DEFAULT_TRANSFER_THRESHOLD,
719        }
720    }
721
722    /// Start learning a new task
723    ///
724    /// Before switching, the outgoing task is *consolidated*: its final
725    /// parameters become the shared anchor `θ*` that the Elastic Weight
726    /// Consolidation penalty pulls subsequent tasks back toward, and the
727    /// Fisher-information sample counter is reset so the next task's estimate
728    /// starts from its own gradients rather than inheriting the old average.
729    /// The outgoing task's last loss is also pinned as the reference a later
730    /// re-evaluation is compared against ([`Self::record_task_performance`]).
731    pub fn start_task(&mut self, task_id: String, initial_parameters: Array<A, D>) -> Result<()> {
732        if let Some(previous) = self.current_task.clone() {
733            if let Some(optimizer) = self.task_optimizers.get(&previous) {
734                self.shared_knowledge.important_parameters = Some(optimizer.parameters().clone());
735                self.shared_knowledge.fisher_sample_count = 0;
736            }
737            if let Some(&last_loss) = self
738                .task_performance
739                .get(&previous)
740                .and_then(|history| history.last())
741            {
742                self.task_reference_loss.insert(previous, last_loss);
743            }
744        }
745
746        self.current_task = Some(task_id.clone());
747
748        // Create task-specific optimizer
749        let online_strategy = OnlineLearningStrategy::AdaptiveSGD {
750            initial_lr: 0.001,
751            adaptation_method: LearningRateAdaptation::Adam {
752                beta1: 0.9,
753                beta2: 0.999,
754                epsilon: 1e-8,
755            },
756        };
757
758        let task_optimizer = OnlineOptimizer::new(online_strategy, initial_parameters);
759        self.task_optimizers.insert(task_id.clone(), task_optimizer);
760
761        // Initialize task performance tracking
762        self.task_performance.insert(task_id, Vec::new());
763
764        Ok(())
765    }
766
767    /// Update current task with new data
768    pub fn update_current_task(&mut self, gradient: &Array<A, D>, loss: A) -> Result<()> {
769        let task_id = self
770            .current_task
771            .as_ref()
772            .ok_or_else(|| OptimError::InvalidConfig("No current task set".to_string()))?
773            .clone();
774
775        // For Gradient Episodic Memory, project the incoming gradient onto
776        // the halfspace that does not increase loss on stored past-task
777        // gradients BEFORE it is applied (F81). Other strategies use the raw
778        // gradient. Projecting here (rather than in a no-op after the step)
779        // is what makes the stored replay gradients actually shape learning.
780        let is_gem = matches!(
781            self.strategy,
782            LifelongStrategy::GradientEpisodicMemory { .. }
783        );
784        let ewc_weight = match self.strategy {
785            LifelongStrategy::ElasticWeightConsolidation {
786                importance_weight, ..
787            } => Some(importance_weight),
788            _ => None,
789        };
790        let effective_gradient = if is_gem {
791            self.project_gradient_gem(gradient)
792        } else if let Some(importance_weight) = ewc_weight {
793            // EWC: the total gradient is the task gradient plus the quadratic
794            // penalty gradient `λ * F ⊙ (θ - θ*)`. Adding it *before* the step
795            // (rather than in a no-op afterwards, as this used to) is what
796            // makes `importance_weight`, `fisher_information` and
797            // `important_parameters` actually shape learning.
798            match self.ewc_penalty_gradient(importance_weight) {
799                Some(penalty) => gradient + &penalty,
800                None => gradient.clone(),
801            }
802        } else {
803            gradient.clone()
804        };
805
806        // Update task-specific optimizer with the effective gradient
807        if let Some(optimizer) = self.task_optimizers.get_mut(&task_id) {
808            optimizer.online_update(&effective_gradient, loss)?;
809        }
810
811        // Track performance
812        if let Some(performance) = self.task_performance.get_mut(&task_id) {
813            performance.push(loss);
814        }
815
816        // Fold the task's *own* gradient (not the EWC/GEM-adjusted one, which
817        // carries the regularizer rather than the task) into the running
818        // statistics that drive cross-task transfer.
819        self.record_task_observation(gradient)?;
820
821        // Apply lifelong learning strategy
822        match &self.strategy {
823            LifelongStrategy::ElasticWeightConsolidation {
824                importance_weight, ..
825            } => {
826                self.apply_ewc_regularization(gradient, *importance_weight)?;
827            }
828            LifelongStrategy::ProgressiveNetworks { .. } => {
829                self.apply_progressive_networks(gradient)?;
830            }
831            LifelongStrategy::MemoryAugmented { .. } => {
832                self.update_memory_buffer(gradient, loss)?;
833            }
834            LifelongStrategy::MetaLearning { .. } => {
835                self.apply_meta_learning(gradient)?;
836            }
837            LifelongStrategy::GradientEpisodicMemory { .. } => {
838                // The projection was already applied above; record this
839                // step's *raw* gradient as an episodic-memory constraint for
840                // future updates to be projected against.
841                self.update_memory_buffer(gradient, loss)?;
842            }
843        }
844
845        Ok(())
846    }
847
848    /// Fisher-information penalty gradient for Elastic Weight Consolidation.
849    ///
850    /// Returns `λ * F ⊙ (θ - θ*)`, the gradient of the EWC quadratic penalty
851    /// `λ/2 * Σ F_i (θ_i - θ*_i)^2` (Kirkpatrick et al., "Overcoming
852    /// catastrophic forgetting in neural networks", PNAS 2017), where `F` is
853    /// the diagonal empirical Fisher accumulated for the current task and `θ*`
854    /// is the consolidated parameter vector of the previously-learned tasks.
855    ///
856    /// Returns `None` — meaning "no penalty yet", not "failed" — while any of
857    /// the three inputs is missing (before the first task has been
858    /// consolidated there is nothing to forget) or when their shapes disagree,
859    /// which can only happen if tasks of different parameter dimensionality
860    /// are interleaved.
861    fn ewc_penalty_gradient(&self, importance_weight: f64) -> Option<Array<A, D>> {
862        let fisher = self.shared_knowledge.fisher_information.as_ref()?;
863        let anchor = self.shared_knowledge.important_parameters.as_ref()?;
864        let task_id = self.current_task.as_ref()?;
865        let parameters = self.task_optimizers.get(task_id)?.parameters();
866
867        if fisher.raw_dim() != anchor.raw_dim() || parameters.raw_dim() != anchor.raw_dim() {
868            return None;
869        }
870
871        let lambda = scalar_or(importance_weight, A::zero());
872        let mut penalty = Array::zeros(parameters.raw_dim());
873        for (((out, &f), &p), &a) in penalty
874            .iter_mut()
875            .zip(fisher.iter())
876            .zip(parameters.iter())
877            .zip(anchor.iter())
878        {
879            *out = lambda * f * (p - a);
880        }
881        Some(penalty)
882    }
883
884    /// Fold the observed gradient into the diagonal empirical Fisher estimate
885    /// used by Elastic Weight Consolidation.
886    ///
887    /// The diagonal Fisher of a log-likelihood model is `E[g_i^2]`, so it is
888    /// estimated here as a running mean of the squared gradients seen for the
889    /// current task. The averaging weight is `1/k` with `k` capped at the
890    /// strategy's `fisher_samples`: up to that many observations this is an
891    /// exact running mean, after which it becomes an exponential moving
892    /// average with the same weight, so the estimate keeps tracking a
893    /// non-stationary task instead of freezing.
894    ///
895    /// This replaces a no-op that ignored both its arguments and left
896    /// `fisher_information` permanently `None`.
897    fn apply_ewc_regularization(
898        &mut self,
899        gradient: &Array<A, D>,
900        _importance_weight: f64,
901    ) -> Result<()> {
902        let fisher_samples = match self.strategy {
903            LifelongStrategy::ElasticWeightConsolidation { fisher_samples, .. } => {
904                fisher_samples.max(1)
905            }
906            _ => return Ok(()),
907        };
908
909        let shape_matches = self
910            .shared_knowledge
911            .fisher_information
912            .as_ref()
913            .is_some_and(|fisher| fisher.raw_dim() == gradient.raw_dim());
914
915        if shape_matches {
916            let count = (self.shared_knowledge.fisher_sample_count + 1).min(fisher_samples);
917            // `count >= 1`, so this weight is always in (0, 1].
918            let weight = A::one() / scalar_or(count as f64, A::one());
919            if let Some(fisher) = self.shared_knowledge.fisher_information.as_mut() {
920                for (f, &g) in fisher.iter_mut().zip(gradient.iter()) {
921                    *f = *f + (g * g - *f) * weight;
922                }
923            }
924            self.shared_knowledge.fisher_sample_count = count;
925        } else {
926            // First gradient for this parameter shape: seed the estimate with
927            // `g^2` rather than averaging against a zero/stale accumulator.
928            let mut fisher = Array::zeros(gradient.raw_dim());
929            for (f, &g) in fisher.iter_mut().zip(gradient.iter()) {
930                *f = g * g;
931            }
932            self.shared_knowledge.fisher_information = Some(fisher);
933            self.shared_knowledge.fisher_sample_count = 1;
934        }
935
936        Ok(())
937    }
938
939    /// Apply the Progressive Neural Networks strategy.
940    ///
941    /// Not implemented, and deliberately reported as such rather than silently
942    /// doing nothing: Progressive Networks (Rusu et al., arXiv:1606.04671)
943    /// works by *growing a new network column per task and adding lateral
944    /// connections from the frozen previous columns*. Both operations act on a
945    /// model's layer graph, which an optimizer-only crate does not represent —
946    /// there is no per-column parameter partition here to freeze, and
947    /// `lateral_strength` has nothing to scale. Previously this returned
948    /// `Ok(())` and ignored its argument, so selecting the strategy quietly
949    /// produced plain online SGD while reporting success.
950    fn apply_progressive_networks(&mut self, _gradient: &Array<A, D>) -> Result<()> {
951        Err(OptimError::UnsupportedOperation(
952            "LifelongStrategy::ProgressiveNetworks requires per-column model \
953             parameter partitioning and lateral connections, which optirs-core \
954             does not model; use ElasticWeightConsolidation, MemoryAugmented, \
955             MetaLearning or GradientEpisodicMemory instead"
956                .to_string(),
957        ))
958    }
959
960    /// Update memory buffer with important examples
961    fn update_memory_buffer(&mut self, gradient: &Array<A, D>, loss: A) -> Result<()> {
962        if let Some(task_id) = &self.current_task {
963            // `input`/`target` are not available at this call site (only the
964            // gradient and loss are passed in), so they are recorded as
965            // empty/zero and intentionally left unused. The `gradient` field
966            // below is the real replay signal consumed by
967            // `project_gradient_gem` (F81).
968            let example = MemoryExample {
969                input: Array::zeros(gradient.raw_dim()),
970                target: Array::zeros(gradient.raw_dim()),
971                task_id: task_id.clone(),
972                importance: loss,
973                gradient: Some(gradient.clone()),
974            };
975
976            // Add to buffer
977            if self.memory_buffer.examples.len() >= self.memory_buffer.max_size {
978                match self.memory_buffer.update_strategy {
979                    MemoryUpdateStrategy::FIFO => {
980                        self.memory_buffer.examples.pop_front();
981                        self.memory_buffer.importance_scores.pop_front();
982                    }
983                    MemoryUpdateStrategy::Random => {
984                        let idx = thread_rng().gen_range(0..self.memory_buffer.examples.len());
985                        self.memory_buffer.examples.remove(idx);
986                        self.memory_buffer.importance_scores.remove(idx);
987                    }
988                    MemoryUpdateStrategy::ImportanceBased => {
989                        // Remove least important example
990                        if let Some(min_idx) = self
991                            .memory_buffer
992                            .importance_scores
993                            .iter()
994                            .enumerate()
995                            .min_by(|a, b| total_order(a.1, b.1))
996                            .map(|(idx, _)| idx)
997                        {
998                            self.memory_buffer.examples.remove(min_idx);
999                            self.memory_buffer.importance_scores.remove(min_idx);
1000                        }
1001                    }
1002                    MemoryUpdateStrategy::GradientDiversity => {
1003                        // Remove most similar gradient (simplified)
1004                        self.memory_buffer.examples.pop_front();
1005                        self.memory_buffer.importance_scores.pop_front();
1006                    }
1007                }
1008            }
1009
1010            self.memory_buffer.examples.push_back(example);
1011            self.memory_buffer.importance_scores.push_back(loss);
1012        }
1013
1014        Ok(())
1015    }
1016
1017    /// Apply the meta-learning strategy: a first-order Reptile update of the
1018    /// shared meta-parameters.
1019    ///
1020    /// Reptile (Nichol, Achiam & Schulman, "On First-Order Meta-Learning
1021    /// Algorithms", arXiv:1803.02999) needs no second derivatives: after the
1022    /// inner-loop task update it simply moves the shared initialisation toward
1023    /// the adapted task parameters,
1024    /// `φ ← φ + meta_lr * (θ_task − φ)`,
1025    /// which is exactly what is available at this call site. The meta-parameters
1026    /// are seeded from the first task's parameters, so `φ` starts on the
1027    /// parameter manifold instead of at an arbitrary origin.
1028    ///
1029    /// `gradient` is used only for its shape (the parameter update itself has
1030    /// already been applied by the task optimizer, which is what makes this the
1031    /// *first-order* variant). This replaces a no-op that left
1032    /// `meta_parameters` permanently `None`.
1033    fn apply_meta_learning(&mut self, gradient: &Array<A, D>) -> Result<()> {
1034        let meta_lr = match self.strategy {
1035            LifelongStrategy::MetaLearning { meta_lr, .. } => meta_lr,
1036            _ => return Ok(()),
1037        };
1038
1039        let Some(task_id) = self.current_task.clone() else {
1040            return Ok(());
1041        };
1042        let Some(optimizer) = self.task_optimizers.get(&task_id) else {
1043            return Ok(());
1044        };
1045        let task_parameters = optimizer.parameters();
1046        if task_parameters.raw_dim() != gradient.raw_dim() {
1047            return Err(OptimError::DimensionMismatch(format!(
1048                "meta-learning update: task parameters have shape {:?} but the \
1049                 gradient has shape {:?}",
1050                task_parameters.raw_dim().slice(),
1051                gradient.raw_dim().slice()
1052            )));
1053        }
1054
1055        let shape_matches = self
1056            .shared_knowledge
1057            .meta_parameters
1058            .as_ref()
1059            .is_some_and(|meta| meta.raw_dim() == task_parameters.raw_dim());
1060
1061        if shape_matches {
1062            let step = scalar_or(meta_lr, A::zero());
1063            let adapted = task_parameters.clone();
1064            if let Some(meta) = self.shared_knowledge.meta_parameters.as_mut() {
1065                for (phi, &theta) in meta.iter_mut().zip(adapted.iter()) {
1066                    *phi = *phi + (theta - *phi) * step;
1067                }
1068            }
1069        } else {
1070            self.shared_knowledge.meta_parameters = Some(task_parameters.clone());
1071        }
1072
1073        Ok(())
1074    }
1075
1076    /// Shared meta-parameters maintained by the Reptile update, if the
1077    /// `MetaLearning` strategy is active and at least one task has been seen.
1078    pub fn meta_parameters(&self) -> Option<&Array<A, D>> {
1079        self.shared_knowledge.meta_parameters.as_ref()
1080    }
1081
1082    /// Diagonal empirical Fisher information accumulated for the current task,
1083    /// if the `ElasticWeightConsolidation` strategy is active and at least one
1084    /// gradient has been observed.
1085    pub fn fisher_information(&self) -> Option<&Array<A, D>> {
1086        self.shared_knowledge.fisher_information.as_ref()
1087    }
1088
1089    /// Consolidated parameters `θ*` of the previously-learned tasks, set when
1090    /// [`Self::start_task`] switches away from a task.
1091    pub fn consolidated_parameters(&self) -> Option<&Array<A, D>> {
1092        self.shared_knowledge.important_parameters.as_ref()
1093    }
1094
1095    /// Project `gradient` onto the halfspace that does not increase loss on
1096    /// any gradient stored in the episodic memory buffer (Gradient Episodic
1097    /// Memory, F81).
1098    ///
1099    /// For every stored memory gradient `g_mem` that conflicts with the
1100    /// incoming gradient (`dot(gradient, g_mem) < 0`), the violating
1101    /// component is removed:
1102    /// `g <- g - (dot(g, g_mem) / dot(g_mem, g_mem)) * g_mem`.
1103    /// Non-conflicting memories and shape-mismatched entries are skipped.
1104    ///
1105    /// This replaces the previous no-op and makes the replay gradients
1106    /// stored in the memory buffer actually influence learning instead of
1107    /// being dead data.
1108    pub fn project_gradient_gem(&self, gradient: &Array<A, D>) -> Array<A, D> {
1109        let mut projected = gradient.clone();
1110        for example in &self.memory_buffer.examples {
1111            if let Some(memory_gradient) = example.gradient.as_ref() {
1112                if memory_gradient.raw_dim() != projected.raw_dim() {
1113                    continue;
1114                }
1115                let dot_gm = array_dot(&projected, memory_gradient);
1116                if dot_gm < A::zero() {
1117                    let denom = array_dot(memory_gradient, memory_gradient);
1118                    if denom > A::zero() {
1119                        let scale = dot_gm / denom;
1120                        projected = &projected - &memory_gradient.mapv(|g| g * scale);
1121                    }
1122                }
1123            }
1124        }
1125        projected
1126    }
1127
1128    /// Compute task similarity
1129    pub fn compute_task_similarity(&self, task1: &str, task2: &str) -> f64 {
1130        self.task_graph
1131            .task_similarities
1132            .get(&(task1.to_string(), task2.to_string()))
1133            .or_else(|| {
1134                self.task_graph
1135                    .task_similarities
1136                    .get(&(task2.to_string(), task1.to_string()))
1137            })
1138            .copied()
1139            .unwrap_or(0.0)
1140    }
1141
1142    /// Get lifelong learning statistics
1143    pub fn get_lifelong_stats(&self) -> LifelongStats<A> {
1144        let num_tasks = self.task_optimizers.len();
1145        let avg_performance = if self.task_performance.is_empty() {
1146            A::zero()
1147        } else {
1148            let total_performance: A = self.task_performance.values().flatten().copied().sum();
1149            let total_samples = self
1150                .task_performance
1151                .values()
1152                .map(|v| v.len())
1153                .sum::<usize>();
1154            if total_samples > 0 {
1155                total_performance / scalar_or(total_samples, A::one())
1156            } else {
1157                A::zero()
1158            }
1159        };
1160
1161        LifelongStats {
1162            num_tasks,
1163            average_performance: avg_performance,
1164            memory_usage: self.memory_buffer.examples.len(),
1165            // Mean strength of the warm starts actually performed; zero when no
1166            // task has been transferred into.
1167            transfer_efficiency: scalar_or(self.mean_transfer_weight(), A::zero()),
1168            catastrophic_forgetting: scalar_or(self.measured_forgetting(), A::zero()),
1169        }
1170    }
1171
1172    /// Backward transfer measured from the losses recorded for tasks that are no
1173    /// longer current.
1174    ///
1175    /// A task's *reference* loss is the last one recorded while it was the
1176    /// active task. Anything recorded for it afterwards — a re-evaluation the
1177    /// caller performs with [`Self::record_task_performance`] — is compared
1178    /// against that reference, and the mean positive degradation across tasks is
1179    /// the forgetting measure (Lopez-Paz & Ranzato, "Gradient Episodic Memory
1180    /// for Continual Learning", NeurIPS 2017, report the same quantity with the
1181    /// opposite sign).
1182    ///
1183    /// It is `0` until an old task is actually re-evaluated: forgetting is not
1184    /// observable without measuring the old task again, and reporting a
1185    /// non-zero constant instead — which this used to do — would be a
1186    /// fabrication.
1187    fn measured_forgetting(&self) -> f64 {
1188        let mut total = 0.0;
1189        let mut counted = 0usize;
1190
1191        for (task_id, reference) in &self.task_reference_loss {
1192            let Some(history) = self.task_performance.get(task_id) else {
1193                continue;
1194            };
1195            let Some(&latest) = history.last() else {
1196                continue;
1197            };
1198            let (Ok(latest), Ok(reference)) = (try_f64(latest), try_f64(*reference)) else {
1199                continue;
1200            };
1201            if !latest.is_finite() || !reference.is_finite() {
1202                continue;
1203            }
1204            total += (latest - reference).max(0.0);
1205            counted += 1;
1206        }
1207
1208        if counted == 0 {
1209            0.0
1210        } else {
1211            total / counted as f64
1212        }
1213    }
1214
1215    /// Record a loss measured for a task that is not the active one.
1216    ///
1217    /// This is how a caller feeds re-evaluation of previously-learned tasks back
1218    /// in, which is what makes [`LifelongStats::catastrophic_forgetting`]
1219    /// measurable. Recording against the active task is rejected: its losses
1220    /// arrive through [`Self::update_current_task`], and mixing the two would
1221    /// move the reference the measurement is taken against.
1222    pub fn record_task_performance(&mut self, task_id: &str, loss: A) -> Result<()> {
1223        if self.current_task.as_deref() == Some(task_id) {
1224            return Err(OptimError::InvalidConfig(format!(
1225                "task '{task_id}' is the active task; its losses are recorded by \
1226                 update_current_task"
1227            )));
1228        }
1229        match self.task_performance.get_mut(task_id) {
1230            Some(history) => {
1231                history.push(loss);
1232                Ok(())
1233            }
1234            None => Err(OptimError::InvalidConfig(format!(
1235                "unknown task '{task_id}'"
1236            ))),
1237        }
1238    }
1239}
1240
1241/// Lifelong learning statistics
1242#[derive(Debug, Clone)]
1243pub struct LifelongStats<A: Float> {
1244    /// Number of tasks learned
1245    pub num_tasks: usize,
1246    /// Average performance across all tasks
1247    pub average_performance: A,
1248    /// Current memory usage
1249    pub memory_usage: usize,
1250    /// Transfer learning efficiency
1251    pub transfer_efficiency: A,
1252    /// Catastrophic forgetting measure
1253    pub catastrophic_forgetting: A,
1254}
1255
1256#[cfg(test)]
1257mod tests {
1258    use super::*;
1259    use approx::assert_relative_eq;
1260    use scirs2_core::ndarray::{Array1, Ix1};
1261
1262    #[test]
1263    fn test_online_optimizer_creation() {
1264        let strategy = OnlineLearningStrategy::AdaptiveSGD {
1265            initial_lr: 0.01,
1266            adaptation_method: LearningRateAdaptation::AdaGrad { epsilon: 1e-8 },
1267        };
1268
1269        let initial_params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
1270        let optimizer = OnlineOptimizer::new(strategy, initial_params);
1271
1272        assert_eq!(optimizer.step_count, 0);
1273        assert_relative_eq!(optimizer.current_lr, 0.01, epsilon = 1e-6);
1274    }
1275
1276    #[test]
1277    fn test_online_update() {
1278        let strategy = OnlineLearningStrategy::AdaptiveSGD {
1279            initial_lr: 0.1,
1280            adaptation_method: LearningRateAdaptation::ExponentialDecay { decay_rate: 0.99 },
1281        };
1282
1283        let initial_params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
1284        let mut optimizer = OnlineOptimizer::new(strategy, initial_params);
1285
1286        let gradient = Array1::from_vec(vec![0.1, 0.2, 0.3]);
1287        let loss = 0.5;
1288
1289        optimizer
1290            .online_update(&gradient, loss)
1291            .expect("unwrap failed");
1292
1293        assert_eq!(optimizer.step_count, 1);
1294        assert_eq!(optimizer.performance_history.len(), 1);
1295        assert_relative_eq!(optimizer.performance_history[0], 0.5, epsilon = 1e-6);
1296    }
1297
1298    #[test]
1299    fn test_lifelong_optimizer_creation() {
1300        let strategy = LifelongStrategy::ElasticWeightConsolidation {
1301            importance_weight: 1000.0,
1302            fisher_samples: 100,
1303        };
1304
1305        let optimizer = LifelongOptimizer::<f64, scirs2_core::ndarray::Ix1>::new(strategy);
1306
1307        assert_eq!(optimizer.task_optimizers.len(), 0);
1308        assert!(optimizer.current_task.is_none());
1309    }
1310
1311    #[test]
1312    fn test_task_management() {
1313        let strategy = LifelongStrategy::MemoryAugmented {
1314            memory_size: 100,
1315            update_strategy: MemoryUpdateStrategy::FIFO,
1316        };
1317
1318        let mut optimizer = LifelongOptimizer::<f64, scirs2_core::ndarray::Ix1>::new(strategy);
1319        let initial_params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
1320
1321        optimizer
1322            .start_task("task1".to_string(), initial_params)
1323            .expect("unwrap failed");
1324
1325        assert_eq!(optimizer.current_task, Some("task1".to_string()));
1326        assert!(optimizer.task_optimizers.contains_key("task1"));
1327        assert!(optimizer.task_performance.contains_key("task1"));
1328    }
1329
1330    #[test]
1331    fn test_memory_buffer_update() {
1332        let strategy = LifelongStrategy::MemoryAugmented {
1333            memory_size: 2,
1334            update_strategy: MemoryUpdateStrategy::FIFO,
1335        };
1336
1337        let mut optimizer = LifelongOptimizer::<f64, scirs2_core::ndarray::Ix1>::new(strategy);
1338        optimizer.memory_buffer.max_size = 2;
1339
1340        let initial_params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
1341        optimizer
1342            .start_task("task1".to_string(), initial_params)
1343            .expect("unwrap failed");
1344
1345        let gradient = Array1::from_vec(vec![0.1, 0.2, 0.3]);
1346
1347        // Add first example
1348        optimizer
1349            .update_current_task(&gradient, 0.5)
1350            .expect("unwrap failed");
1351        assert_eq!(optimizer.memory_buffer.examples.len(), 1);
1352
1353        // Add second example
1354        optimizer
1355            .update_current_task(&gradient, 0.6)
1356            .expect("unwrap failed");
1357        assert_eq!(optimizer.memory_buffer.examples.len(), 2);
1358
1359        // Add third example (should remove first due to FIFO)
1360        optimizer
1361            .update_current_task(&gradient, 0.7)
1362            .expect("unwrap failed");
1363        assert_eq!(optimizer.memory_buffer.examples.len(), 2);
1364    }
1365
1366    #[test]
1367    fn test_performance_metrics() {
1368        let strategy = OnlineLearningStrategy::AdaptiveSGD {
1369            initial_lr: 0.01,
1370            adaptation_method: LearningRateAdaptation::Adam {
1371                beta1: 0.9,
1372                beta2: 0.999,
1373                epsilon: 1e-8,
1374            },
1375        };
1376
1377        let initial_params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
1378        let mut optimizer = OnlineOptimizer::new(strategy, initial_params);
1379
1380        // Add some performance data
1381        optimizer.performance_history.push_back(0.8);
1382        optimizer.performance_history.push_back(0.6);
1383        optimizer.performance_history.push_back(0.4);
1384        optimizer.regret_bound = 0.5;
1385
1386        let metrics = optimizer.get_performance_metrics();
1387
1388        assert_relative_eq!(metrics.cumulative_regret, 0.5, epsilon = 1e-6);
1389        assert_relative_eq!(metrics.average_loss, 0.6, epsilon = 1e-6);
1390    }
1391
1392    #[test]
1393    fn test_lifelong_stats() {
1394        let strategy = LifelongStrategy::MetaLearning {
1395            meta_lr: 0.001,
1396            inner_steps: 5,
1397            task_embedding_size: 64,
1398        };
1399
1400        let mut optimizer = LifelongOptimizer::<f64, scirs2_core::ndarray::Ix1>::new(strategy);
1401
1402        // Add some tasks
1403        let initial_params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
1404        optimizer
1405            .start_task("task1".to_string(), initial_params.clone())
1406            .expect("unwrap failed");
1407        optimizer
1408            .start_task("task2".to_string(), initial_params)
1409            .expect("unwrap failed");
1410
1411        // Add some performance data
1412        optimizer
1413            .task_performance
1414            .get_mut("task1")
1415            .expect("unwrap failed")
1416            .extend(vec![0.8, 0.7]);
1417        optimizer
1418            .task_performance
1419            .get_mut("task2")
1420            .expect("unwrap failed")
1421            .extend(vec![0.9, 0.8]);
1422
1423        let stats = optimizer.get_lifelong_stats();
1424
1425        assert_eq!(stats.num_tasks, 2);
1426        assert_relative_eq!(stats.average_performance, 0.8, epsilon = 1e-6);
1427    }
1428
1429    #[test]
1430    fn test_learning_rate_adaptations() {
1431        let strategies = vec![
1432            LearningRateAdaptation::AdaGrad { epsilon: 1e-8 },
1433            LearningRateAdaptation::RMSprop {
1434                decay: 0.9,
1435                epsilon: 1e-8,
1436            },
1437            LearningRateAdaptation::Adam {
1438                beta1: 0.9,
1439                beta2: 0.999,
1440                epsilon: 1e-8,
1441            },
1442            LearningRateAdaptation::ExponentialDecay { decay_rate: 0.99 },
1443            LearningRateAdaptation::InverseScaling { power: 0.5 },
1444        ];
1445
1446        for adaptation in strategies {
1447            let strategy = OnlineLearningStrategy::AdaptiveSGD {
1448                initial_lr: 0.01,
1449                adaptation_method: adaptation,
1450            };
1451
1452            let initial_params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
1453            let mut optimizer = OnlineOptimizer::new(strategy, initial_params);
1454
1455            let gradient = Array1::from_vec(vec![0.1, 0.2, 0.3]);
1456            let result = optimizer.online_update(&gradient, 0.5);
1457
1458            assert!(result.is_ok());
1459            assert_eq!(optimizer.step_count, 1);
1460        }
1461    }
1462
1463    /// F81: the GEM projection must remove the component of a new gradient
1464    /// that conflicts with a stored past-task gradient, so the previously
1465    /// dead replay data actually shapes the update.
1466    #[test]
1467    fn gem_projection_removes_conflicting_component() {
1468        let strategy = LifelongStrategy::GradientEpisodicMemory {
1469            memory_per_task: 10,
1470            violation_tolerance: 0.0,
1471        };
1472        let mut opt = LifelongOptimizer::<f64, scirs2_core::ndarray::Ix1>::new(strategy);
1473        let params = Array1::from_vec(vec![0.0, 0.0, 0.0]);
1474        opt.start_task("t".to_string(), params).expect("start_task");
1475
1476        // Store a past-task gradient g1 = [1, 0, 0] in episodic memory.
1477        let g1 = Array1::from_vec(vec![1.0, 0.0, 0.0]);
1478        opt.update_current_task(&g1, 0.5).expect("update");
1479
1480        // A conflicting new gradient g2 with dot(g2, g1) < 0.
1481        let g2 = Array1::from_vec(vec![-1.0, 1.0, 0.0]);
1482        let projected = opt.project_gradient_gem(&g2);
1483
1484        let dot: f64 = projected.iter().zip(g1.iter()).map(|(&x, &y)| x * y).sum();
1485        assert!(
1486            dot >= -1e-9,
1487            "GEM projection did not remove the conflicting component (F81): dot={dot}"
1488        );
1489    }
1490
1491    /// F81: `get_performance_metrics` must derive its fields from real state
1492    /// instead of returning hardcoded `1.0`/`step_count`/`0.8`.
1493    #[test]
1494    fn performance_metrics_are_derived_from_state() {
1495        let strategy = OnlineLearningStrategy::AdaptiveSGD {
1496            initial_lr: 0.01,
1497            adaptation_method: LearningRateAdaptation::AdaGrad { epsilon: 1e-8 },
1498        };
1499        let params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
1500        let mut opt = OnlineOptimizer::new(strategy, params);
1501
1502        let gradient = Array1::from_vec(vec![0.05, 0.05, 0.05]);
1503        let mut loss = 1.0_f64;
1504        for _ in 0..30 {
1505            opt.online_update(&gradient, loss).expect("update");
1506            loss *= 0.9;
1507        }
1508
1509        let metrics = opt.get_performance_metrics();
1510        assert!(
1511            metrics.adaptation_speed > 0.0,
1512            "adaptation_speed not derived from the loss trend (F81): {}",
1513            metrics.adaptation_speed
1514        );
1515        assert!(
1516            metrics.lr_stability > 0.0 && metrics.lr_stability <= 1.0,
1517            "lr_stability out of range (F81): {}",
1518            metrics.lr_stability
1519        );
1520        // AdaGrad keeps no second-moment array, so memory efficiency is
1521        // params / (params + grad_accumulator) = 0.5, not the old 0.8.
1522        assert!(
1523            (metrics.memory_efficiency - 0.5).abs() < 1e-9,
1524            "memory_efficiency not derived from real optimizer state (F81): {}",
1525            metrics.memory_efficiency
1526        );
1527    }
1528
1529    // --- Lifelong-strategy wiring regression tests -----------------------
1530    //
1531    // `apply_ewc_regularization`, `apply_progressive_networks` and
1532    // `apply_meta_learning` were all `Ok(())` no-ops that ignored their
1533    // `gradient` argument, and `SharedKnowledge::{fisher_information,
1534    // important_parameters, meta_parameters}` were `#[allow(dead_code)]`
1535    // fields nothing ever wrote. These tests pin the real behaviour.
1536
1537    fn ewc_optimizer(importance_weight: f64) -> LifelongOptimizer<f64, Ix1> {
1538        LifelongOptimizer::new(LifelongStrategy::ElasticWeightConsolidation {
1539            importance_weight,
1540            fisher_samples: 100,
1541        })
1542    }
1543
1544    /// The diagonal empirical Fisher must actually be accumulated from the
1545    /// observed gradients (it stayed `None` forever before).
1546    #[test]
1547    fn ewc_accumulates_diagonal_fisher_information() {
1548        let mut opt = ewc_optimizer(1.0);
1549        opt.start_task("a".to_string(), Array1::zeros(3))
1550            .expect("start_task");
1551        assert!(
1552            opt.fisher_information().is_none(),
1553            "no gradient observed yet, so there can be no Fisher estimate"
1554        );
1555
1556        let gradient = Array1::from_vec(vec![2.0, 0.0, -4.0]);
1557        for _ in 0..5 {
1558            opt.update_current_task(&gradient, 1.0)
1559                .expect("update_current_task");
1560        }
1561
1562        let fisher = opt
1563            .fisher_information()
1564            .expect("Fisher information must exist after observing gradients");
1565        // A constant gradient g gives a running mean of exactly g^2.
1566        assert!((fisher[0] - 4.0).abs() < 1e-9, "F[0] = {}", fisher[0]);
1567        assert!((fisher[1] - 0.0).abs() < 1e-9, "F[1] = {}", fisher[1]);
1568        assert!((fisher[2] - 16.0).abs() < 1e-9, "F[2] = {}", fisher[2]);
1569    }
1570
1571    /// Switching tasks must consolidate the outgoing task's parameters into
1572    /// the shared EWC anchor `theta*`.
1573    #[test]
1574    fn starting_a_new_task_consolidates_the_previous_parameters() {
1575        let mut opt = ewc_optimizer(1.0);
1576        opt.start_task("a".to_string(), Array1::from_vec(vec![1.0, 2.0]))
1577            .expect("start_task a");
1578        assert!(
1579            opt.consolidated_parameters().is_none(),
1580            "nothing has been consolidated before the first task switch"
1581        );
1582
1583        opt.start_task("b".to_string(), Array1::zeros(2))
1584            .expect("start_task b");
1585        let anchor = opt
1586            .consolidated_parameters()
1587            .expect("task a must have been consolidated on switching to b");
1588        assert_eq!(anchor.len(), 2);
1589    }
1590
1591    /// The EWC penalty must pull the *new* task's parameters back toward the
1592    /// consolidated anchor: with a large importance weight, a task whose own
1593    /// gradient is zero must still move toward `theta*` instead of standing
1594    /// still (which is what the old no-op produced).
1595    #[test]
1596    fn ewc_penalty_pulls_parameters_toward_the_consolidated_anchor() {
1597        let mut opt = ewc_optimizer(1000.0);
1598
1599        // Task A settles at a non-zero parameter vector, and produces a
1600        // gradient so a Fisher estimate exists for those coordinates.
1601        opt.start_task("a".to_string(), Array1::from_vec(vec![5.0, 5.0]))
1602            .expect("start_task a");
1603        let probe = Array1::from_vec(vec![1.0, 1.0]);
1604        opt.update_current_task(&probe, 1.0).expect("task a step");
1605        let anchor = opt
1606            .task_optimizers
1607            .get("a")
1608            .expect("task a optimizer")
1609            .parameters()
1610            .clone();
1611
1612        // Task B starts far away from the anchor with a zero task gradient:
1613        // the only force acting on it is the EWC penalty.
1614        opt.start_task("b".to_string(), Array1::from_vec(vec![50.0, 50.0]))
1615            .expect("start_task b");
1616        let before = opt
1617            .task_optimizers
1618            .get("b")
1619            .expect("task b optimizer")
1620            .parameters()
1621            .clone();
1622        let zero = Array1::zeros(2);
1623        opt.update_current_task(&zero, 1.0).expect("task b step");
1624        let after = opt
1625            .task_optimizers
1626            .get("b")
1627            .expect("task b optimizer")
1628            .parameters()
1629            .clone();
1630
1631        assert_ne!(
1632            before, after,
1633            "EWC regression: a zero task gradient left the parameters \
1634             untouched, so the penalty term is not being applied"
1635        );
1636        let moved_toward_anchor = (after[0] - anchor[0]).abs() < (before[0] - anchor[0]).abs();
1637        assert!(
1638            moved_toward_anchor,
1639            "EWC penalty moved the parameter away from the anchor \
1640             (anchor={}, before={}, after={})",
1641            anchor[0], before[0], after[0]
1642        );
1643    }
1644
1645    /// A zero importance weight must disable the penalty entirely, so the
1646    /// weight is genuinely read rather than ignored.
1647    #[test]
1648    fn zero_importance_weight_disables_the_ewc_penalty() {
1649        let mut opt = ewc_optimizer(0.0);
1650        opt.start_task("a".to_string(), Array1::from_vec(vec![5.0, 5.0]))
1651            .expect("start_task a");
1652        opt.update_current_task(&Array1::from_vec(vec![1.0, 1.0]), 1.0)
1653            .expect("task a step");
1654        opt.start_task("b".to_string(), Array1::from_vec(vec![50.0, 50.0]))
1655            .expect("start_task b");
1656
1657        let before = opt
1658            .task_optimizers
1659            .get("b")
1660            .expect("task b optimizer")
1661            .parameters()
1662            .clone();
1663        opt.update_current_task(&Array1::zeros(2), 1.0)
1664            .expect("task b step");
1665        let after = opt
1666            .task_optimizers
1667            .get("b")
1668            .expect("task b optimizer")
1669            .parameters()
1670            .clone();
1671
1672        assert_eq!(
1673            before, after,
1674            "with importance_weight = 0 the EWC penalty must vanish"
1675        );
1676    }
1677
1678    /// Reptile must move the shared meta-parameters toward the adapted task
1679    /// parameters; they used to stay `None` forever.
1680    #[test]
1681    fn meta_learning_maintains_reptile_meta_parameters() {
1682        let mut opt: LifelongOptimizer<f64, Ix1> =
1683            LifelongOptimizer::new(LifelongStrategy::MetaLearning {
1684                meta_lr: 0.5,
1685                inner_steps: 1,
1686                task_embedding_size: 4,
1687            });
1688        assert!(opt.meta_parameters().is_none());
1689
1690        opt.start_task("a".to_string(), Array1::from_vec(vec![0.0, 0.0]))
1691            .expect("start_task a");
1692        let gradient = Array1::from_vec(vec![1.0, -1.0]);
1693        opt.update_current_task(&gradient, 1.0)
1694            .expect("task a step");
1695        let seeded = opt
1696            .meta_parameters()
1697            .expect("meta-parameters must be seeded from the first task")
1698            .clone();
1699
1700        // A second task that starts far away must drag the meta-parameters
1701        // toward it by exactly meta_lr of the gap.
1702        opt.start_task("b".to_string(), Array1::from_vec(vec![10.0, 10.0]))
1703            .expect("start_task b");
1704        opt.update_current_task(&Array1::zeros(2), 1.0)
1705            .expect("task b step");
1706        let updated = opt.meta_parameters().expect("meta-parameters").clone();
1707        let task_b = opt
1708            .task_optimizers
1709            .get("b")
1710            .expect("task b optimizer")
1711            .parameters()
1712            .clone();
1713
1714        let expected = seeded[0] + (task_b[0] - seeded[0]) * 0.5;
1715        assert!(
1716            (updated[0] - expected).abs() < 1e-9,
1717            "Reptile update wrong: seeded={}, task={}, expected={}, got={}",
1718            seeded[0],
1719            task_b[0],
1720            expected,
1721            updated[0]
1722        );
1723    }
1724
1725    /// Progressive Networks is not implementable without a model-column
1726    /// partition, so it must report that honestly instead of silently running
1727    /// plain online SGD and returning `Ok(())`.
1728    #[test]
1729    fn progressive_networks_reports_that_it_is_unsupported() {
1730        let mut opt: LifelongOptimizer<f64, Ix1> =
1731            LifelongOptimizer::new(LifelongStrategy::ProgressiveNetworks {
1732                lateral_strength: 0.5,
1733                growth_strategy: ColumnGrowthStrategy::PerTask,
1734            });
1735        opt.start_task("a".to_string(), Array1::zeros(2))
1736            .expect("start_task");
1737        let err = opt
1738            .update_current_task(&Array1::from_vec(vec![1.0, 1.0]), 1.0)
1739            .expect_err("ProgressiveNetworks must not fabricate success");
1740        assert!(
1741            matches!(err, OptimError::UnsupportedOperation(_)),
1742            "expected UnsupportedOperation, got {err:?}"
1743        );
1744    }
1745}