1use 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#[derive(Debug, Clone)]
23pub enum OnlineLearningStrategy {
24 AdaptiveSGD {
26 initial_lr: f64,
28 adaptation_method: LearningRateAdaptation,
30 },
31 OnlineNewton {
33 damping: f64,
35 hessian_window: usize,
37 },
38 FTRL {
40 l1_regularization: f64,
42 l2_regularization: f64,
44 learning_rate_power: f64,
46 },
47 MirrorDescent {
49 mirror_function: MirrorFunction,
51 regularization: f64,
53 },
54 AdaptiveMultiTask {
56 similarity_threshold: f64,
58 task_lr_adaptation: bool,
60 },
61}
62
63#[derive(Debug, Clone)]
65pub enum LearningRateAdaptation {
66 AdaGrad {
68 epsilon: f64,
70 },
71 RMSprop {
73 decay: f64,
75 epsilon: f64,
77 },
78 Adam {
80 beta1: f64,
82 beta2: f64,
84 epsilon: f64,
86 },
87 ExponentialDecay {
89 decay_rate: f64,
91 },
92 InverseScaling {
94 power: f64,
96 },
97}
98
99#[derive(Debug, Clone)]
101pub enum MirrorFunction {
102 Euclidean,
104 Entropy,
106 L1,
108 Nuclear,
110}
111
112#[derive(Debug, Clone)]
114pub enum LifelongStrategy {
115 ElasticWeightConsolidation {
117 importance_weight: f64,
119 fisher_samples: usize,
121 },
122 ProgressiveNetworks {
124 lateral_strength: f64,
126 growth_strategy: ColumnGrowthStrategy,
128 },
129 MemoryAugmented {
131 memory_size: usize,
133 update_strategy: MemoryUpdateStrategy,
135 },
136 MetaLearning {
138 meta_lr: f64,
140 inner_steps: usize,
142 task_embedding_size: usize,
144 },
145 GradientEpisodicMemory {
147 memory_per_task: usize,
149 violation_tolerance: f64,
151 },
152}
153
154#[derive(Debug, Clone)]
156pub enum ColumnGrowthStrategy {
157 PerTask,
159 PerformanceBased {
161 threshold: f64,
163 },
164 FixedInterval {
166 interval: usize,
168 },
169}
170
171#[derive(Debug, Clone)]
173pub enum MemoryUpdateStrategy {
174 FIFO,
176 Random,
178 ImportanceBased,
180 GradientDiversity,
182}
183
184#[derive(Debug)]
186pub struct OnlineOptimizer<A: Float, D: Dimension> {
187 strategy: OnlineLearningStrategy,
189 parameters: Array<A, D>,
191 gradient_accumulator: Array<A, D>,
193 second_moment_accumulator: Option<Array<A, D>>,
195 current_lr: A,
197 step_count: usize,
199 performance_history: VecDeque<A>,
201 regret_bound: A,
203 lr_history: VecDeque<A>,
206}
207
208#[derive(Debug)]
210pub struct LifelongOptimizer<A: Float, D: Dimension> {
211 strategy: LifelongStrategy,
213 task_optimizers: HashMap<String, OnlineOptimizer<A, D>>,
215 shared_knowledge: SharedKnowledge<A, D>,
217 task_graph: TaskGraph,
219 memory_buffer: MemoryBuffer<A, D>,
221 current_task: Option<String>,
223 task_performance: HashMap<String, Vec<A>>,
225 task_reference_loss: HashMap<String, A>,
229 transfer_threshold: f64,
232}
233
234#[derive(Debug)]
236pub struct SharedKnowledge<A: Float, D: Dimension> {
237 fisher_information: Option<Array<A, D>>,
240 fisher_sample_count: usize,
244 important_parameters: Option<Array<A, D>>,
247 meta_parameters: Option<Array<A, D>>,
250 task_statistics: HashMap<String, TaskStatistics>,
253 task_embeddings: HashMap<String, Vec<f64>>,
255 transfer_weights: HashMap<(String, String), f64>,
257}
258
259#[derive(Debug)]
261pub struct TaskGraph {
262 task_similarities: HashMap<(String, String), f64>,
264 task_dependencies: HashMap<String, Vec<String>>,
266 task_clusters: Vec<Vec<String>>,
268}
269
270#[derive(Debug)]
272pub struct MemoryBuffer<A: Float, D: Dimension> {
273 examples: VecDeque<MemoryExample<A, D>>,
275 max_size: usize,
277 update_strategy: MemoryUpdateStrategy,
279 importance_scores: VecDeque<A>,
281}
282
283#[derive(Debug, Clone)]
285pub struct MemoryExample<A: Float, D: Dimension> {
286 pub input: Array<A, D>,
288 pub target: Array<A, D>,
290 pub task_id: String,
292 pub importance: A,
294 pub gradient: Option<Array<A, D>>,
296}
297
298#[derive(Debug, Clone)]
300pub struct OnlinePerformanceMetrics<A: Float> {
301 pub cumulative_regret: A,
303 pub average_loss: A,
305 pub lr_stability: A,
307 pub adaptation_speed: A,
309 pub memory_efficiency: A,
311}
312
313impl<A: Float + ScalarOperand + Debug + std::iter::Sum, D: Dimension + Send + Sync>
314 OnlineOptimizer<A, D>
315{
316 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 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 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 self.lr_history.push_back(self.current_lr);
396 if self.lr_history.len() > 1000 {
397 self.lr_history.pop_front();
398 }
399
400 self.update_regret_bound(loss);
402
403 Ok(())
404 }
405
406 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 self.gradient_accumulator = &self.gradient_accumulator + &gradient.mapv(|g| g * g);
416
417 let eps = try_scalar::<A, _>(*epsilon)?;
421 let adaptive_lr = self.gradient_accumulator.mapv(|acc| eps + A::sqrt(acc));
422
423 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 self.gradient_accumulator = &self.gradient_accumulator * decay_factor
432 + &gradient.mapv(|g| g * g * one_minus_decay);
433
434 let eps = try_scalar::<A, _>(*epsilon)?;
436 let adaptive_lr = self.gradient_accumulator.mapv(|acc| A::sqrt(acc + eps));
437
438 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 self.gradient_accumulator =
453 &self.gradient_accumulator * beta1_val + gradient * one_minus_beta1;
454
455 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 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 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 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 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 fn online_newton_update(&mut self, gradient: &Array<A, D>, damping: f64) -> Result<()> {
496 let damping_val = try_scalar::<A, _>(damping)?;
498
499 let hessian_approx = gradient.mapv(|g| g * g + damping_val);
501
502 let newton_step = gradient / hessian_approx;
504 self.parameters = &self.parameters - &newton_step * self.current_lr;
505
506 Ok(())
507 }
508
509 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 self.gradient_accumulator = &self.gradient_accumulator + gradient;
519
520 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 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 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 self.parameters = &self.parameters - gradient * self.current_lr;
555 }
556 MirrorFunction::Entropy => {
557 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; }
565 MirrorFunction::L1 => {
566 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 self.parameters = &self.parameters - gradient * self.current_lr;
579 }
580 }
581
582 Ok(())
583 }
584
585 fn adaptive_multitask_update(&mut self, gradient: &Array<A, D>) -> Result<()> {
587 self.parameters = &self.parameters - gradient * self.current_lr;
589 Ok(())
590 }
591
592 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 pub fn parameters(&self) -> &Array<A, D> {
606 &self.parameters
607 }
608
609 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 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 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 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
681fn 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 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 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 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 self.task_performance.insert(task_id, Vec::new());
763
764 Ok(())
765 }
766
767 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 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 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 if let Some(optimizer) = self.task_optimizers.get_mut(&task_id) {
808 optimizer.online_update(&effective_gradient, loss)?;
809 }
810
811 if let Some(performance) = self.task_performance.get_mut(&task_id) {
813 performance.push(loss);
814 }
815
816 self.record_task_observation(gradient)?;
820
821 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 self.update_memory_buffer(gradient, loss)?;
842 }
843 }
844
845 Ok(())
846 }
847
848 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 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 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 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 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 fn update_memory_buffer(&mut self, gradient: &Array<A, D>, loss: A) -> Result<()> {
962 if let Some(task_id) = &self.current_task {
963 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 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 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 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 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 pub fn meta_parameters(&self) -> Option<&Array<A, D>> {
1079 self.shared_knowledge.meta_parameters.as_ref()
1080 }
1081
1082 pub fn fisher_information(&self) -> Option<&Array<A, D>> {
1086 self.shared_knowledge.fisher_information.as_ref()
1087 }
1088
1089 pub fn consolidated_parameters(&self) -> Option<&Array<A, D>> {
1092 self.shared_knowledge.important_parameters.as_ref()
1093 }
1094
1095 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 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 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 transfer_efficiency: scalar_or(self.mean_transfer_weight(), A::zero()),
1168 catastrophic_forgetting: scalar_or(self.measured_forgetting(), A::zero()),
1169 }
1170 }
1171
1172 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 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#[derive(Debug, Clone)]
1243pub struct LifelongStats<A: Float> {
1244 pub num_tasks: usize,
1246 pub average_performance: A,
1248 pub memory_usage: usize,
1250 pub transfer_efficiency: A,
1252 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 optimizer
1349 .update_current_task(&gradient, 0.5)
1350 .expect("unwrap failed");
1351 assert_eq!(optimizer.memory_buffer.examples.len(), 1);
1352
1353 optimizer
1355 .update_current_task(&gradient, 0.6)
1356 .expect("unwrap failed");
1357 assert_eq!(optimizer.memory_buffer.examples.len(), 2);
1358
1359 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 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 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 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 #[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 let g1 = Array1::from_vec(vec![1.0, 0.0, 0.0]);
1478 opt.update_current_task(&g1, 0.5).expect("update");
1479
1480 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 #[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 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 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 #[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 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 #[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 #[test]
1596 fn ewc_penalty_pulls_parameters_toward_the_consolidated_anchor() {
1597 let mut opt = ewc_optimizer(1000.0);
1598
1599 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 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 #[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 #[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 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 #[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}