Skip to main content

optirs_learned/
continual_learning.rs

1// Continual Learning for Optimizers
2//
3// This module implements continual learning techniques that allow neural networks
4// and optimizers to learn new tasks sequentially without catastrophically forgetting
5// previous ones. It includes:
6//
7// - Elastic Weight Consolidation (EWC): Regularization-based approach using Fisher
8//   information to protect important parameters for previous tasks.
9// - Progressive Networks: Architecture-based approach that adds new columns for new
10//   tasks while preserving previously learned knowledge via frozen columns and lateral
11//   connections.
12//
13// References:
14// - Kirkpatrick et al., "Overcoming catastrophic forgetting in neural networks" (2017)
15// - Rusu et al., "Progressive Neural Networks" (2016)
16
17use scirs2_core::ndarray::{Array1, Array2};
18use scirs2_core::numeric::Float;
19use std::collections::HashMap;
20use std::fmt::Debug;
21
22use crate::error::{OptimError, Result};
23
24// ---------------------------------------------------------------------------
25// Elastic Weight Consolidation (EWC)
26// ---------------------------------------------------------------------------
27
28/// Elastic Weight Consolidation (EWC) regularizer for continual learning.
29///
30/// EWC prevents catastrophic forgetting by adding a quadratic penalty that
31/// discourages changes to parameters that were important for previous tasks.
32/// The importance of each parameter is estimated via the diagonal of the
33/// Fisher information matrix.
34///
35/// Supports both standard (multi-task) EWC and online EWC with exponential
36/// decay of older Fisher information.
37pub struct ElasticWeightConsolidation<T: Float + Debug + Send + Sync + 'static> {
38    /// Regularization strength (lambda)
39    lambda: T,
40    /// Diagonal Fisher information matrix per parameter group
41    fisher_diagonal: HashMap<String, Array1<T>>,
42    /// Anchor parameters (theta*) stored after training on a task
43    anchor_parameters: HashMap<String, Array1<T>>,
44    /// Per-task Fisher diagonals for standard (non-online) EWC
45    task_fisher_diagonals: Vec<HashMap<String, Array1<T>>>,
46    /// Per-task anchor parameters (for standard EWC penalty over all tasks)
47    task_anchor_parameters: Vec<HashMap<String, Array1<T>>>,
48    /// Number of gradient samples used to estimate the Fisher
49    num_samples_fisher: usize,
50    /// Whether to use online EWC (running average of Fisher)
51    online: bool,
52    /// Decay factor for online EWC
53    gamma: T,
54}
55
56impl<T: Float + Debug + Send + Sync + 'static> ElasticWeightConsolidation<T> {
57    /// Create a new EWC regularizer with the given lambda (regularization strength).
58    ///
59    /// Defaults: num_samples_fisher = 100, online = false, gamma = 0.95
60    pub fn new(lambda: T) -> Self {
61        Self {
62            lambda,
63            fisher_diagonal: HashMap::new(),
64            anchor_parameters: HashMap::new(),
65            task_fisher_diagonals: Vec::new(),
66            task_anchor_parameters: Vec::new(),
67            num_samples_fisher: 100,
68            online: false,
69            gamma: T::from(0.95).unwrap_or_else(|| T::one()),
70        }
71    }
72
73    /// Set the number of gradient samples used to estimate the Fisher information.
74    pub fn with_num_samples(mut self, n: usize) -> Self {
75        self.num_samples_fisher = n;
76        self
77    }
78
79    /// Enable or disable online EWC mode.
80    pub fn with_online(mut self, online: bool) -> Self {
81        self.online = online;
82        self
83    }
84
85    /// Set the decay factor gamma for online EWC.
86    pub fn with_gamma(mut self, gamma: T) -> Self {
87        self.gamma = gamma;
88        self
89    }
90
91    /// Compute the diagonal Fisher information matrix by sampling gradients.
92    ///
93    /// `parameters` - current model parameters keyed by name.
94    /// `gradients_fn` - callable that returns stochastic gradients for the
95    /// sample identified by its second argument. The sample index runs over
96    /// `0..num_samples_fisher`, so each of the `N` Fisher samples is drawn from
97    /// a *different* datum; calling the closure `N` times with identical
98    /// arguments would make the "expectation" a single squared gradient.
99    ///
100    /// The Fisher diagonal is approximated as E[g_i^2] where g_i is the gradient
101    /// of the log-likelihood with respect to parameter i.
102    ///
103    /// Returns `Err` if the closure reports a gradient for a parameter that was
104    /// not supplied, or one whose length differs from that parameter's.
105    pub fn compute_fisher_diagonal(
106        &mut self,
107        parameters: &HashMap<String, Array1<T>>,
108        gradients_fn: impl Fn(&HashMap<String, Array1<T>>, usize) -> Result<HashMap<String, Array1<T>>>,
109    ) -> Result<()> {
110        if parameters.is_empty() {
111            return Err(OptimError::InsufficientData(
112                "parameters map is empty".to_string(),
113            ));
114        }
115
116        if self.num_samples_fisher == 0 {
117            return Err(OptimError::InvalidConfig(
118                "num_samples_fisher must be > 0".to_string(),
119            ));
120        }
121
122        // Accumulator for squared gradients
123        let mut fisher_accum: HashMap<String, Array1<T>> = HashMap::new();
124        for (name, param) in parameters {
125            fisher_accum.insert(name.clone(), Array1::from_elem(param.len(), T::zero()));
126        }
127
128        let n_samples = T::from(self.num_samples_fisher).unwrap_or_else(|| T::one());
129
130        for sample_idx in 0..self.num_samples_fisher {
131            let grads = gradients_fn(parameters, sample_idx)?;
132            for (name, grad) in &grads {
133                let accum = fisher_accum.get_mut(name).ok_or_else(|| {
134                    OptimError::InvalidConfig(format!(
135                        "gradient reported for unknown parameter '{}'; \
136                         known parameters: {:?}",
137                        name,
138                        {
139                            let mut keys: Vec<&str> =
140                                parameters.keys().map(|s| s.as_str()).collect();
141                            keys.sort_unstable();
142                            keys
143                        }
144                    ))
145                })?;
146                if accum.len() != grad.len() {
147                    return Err(OptimError::ComputationError(format!(
148                        "gradient dimension mismatch for '{}': expected {}, got {}",
149                        name,
150                        accum.len(),
151                        grad.len()
152                    )));
153                }
154                // Accumulate g_i^2
155                for (a, g) in accum.iter_mut().zip(grad.iter()) {
156                    *a = *a + (*g) * (*g);
157                }
158            }
159        }
160
161        // Average: F_i = (1/N) * sum(g_i^2)
162        let mut fisher = HashMap::new();
163        for (name, mut accum) in fisher_accum {
164            for a in accum.iter_mut() {
165                *a = *a / n_samples;
166            }
167            fisher.insert(name, accum);
168        }
169
170        self.fisher_diagonal = fisher;
171        Ok(())
172    }
173
174    /// Consolidate the current task by storing anchor parameters and Fisher information.
175    ///
176    /// This should be called after training on a task completes.
177    /// For online EWC, the existing Fisher is decayed by gamma and the new Fisher is added.
178    /// For standard EWC, the new Fisher is appended to the list of per-task Fishers.
179    pub fn consolidate(&mut self, parameters: &HashMap<String, Array1<T>>) -> Result<()> {
180        if self.fisher_diagonal.is_empty() {
181            return Err(OptimError::InvalidState(
182                "Fisher diagonal not computed; call compute_fisher_diagonal first".to_string(),
183            ));
184        }
185
186        // Store anchor parameters
187        self.anchor_parameters = parameters.clone();
188        self.task_anchor_parameters.push(parameters.clone());
189
190        if self.online {
191            // Online EWC: running Fisher = gamma * old_Fisher + new_Fisher
192            let mut merged = HashMap::new();
193            for (name, new_fisher) in &self.fisher_diagonal {
194                let updated = if let Some(old_fisher) = self
195                    .task_fisher_diagonals
196                    .last()
197                    .and_then(|map| map.get(name))
198                {
199                    // gamma * old + new. The two Fisher diagonals for the same
200                    // parameter name can genuinely disagree in length (a layer
201                    // resized between tasks), and indexing `old_fisher[i]` over
202                    // `new_fisher`'s range used to panic on that (finding F71).
203                    Self::require_same_len(
204                        name,
205                        "previous Fisher diagonal",
206                        new_fisher.len(),
207                        old_fisher.len(),
208                    )?;
209                    let mut result = Array1::from_elem(new_fisher.len(), T::zero());
210                    for i in 0..result.len() {
211                        result[i] = self.gamma * old_fisher[i] + new_fisher[i];
212                    }
213                    result
214                } else {
215                    new_fisher.clone()
216                };
217                merged.insert(name.clone(), updated);
218            }
219            self.task_fisher_diagonals.push(merged);
220        } else {
221            // Standard EWC: store per-task Fisher
222            self.task_fisher_diagonals
223                .push(self.fisher_diagonal.clone());
224        }
225
226        Ok(())
227    }
228
229    /// Compute the EWC penalty for the current parameters.
230    ///
231    /// penalty = (lambda / 2) * sum_i F_i * (theta_i - theta*_i)^2
232    ///
233    /// For standard EWC, the penalty sums over all stored tasks.
234    /// For online EWC, only the most recent consolidated Fisher is used.
235    pub fn ewc_penalty(&self, parameters: &HashMap<String, Array1<T>>) -> Result<T> {
236        if self.anchor_parameters.is_empty() {
237            return Err(OptimError::InvalidState(
238                "no anchor parameters stored; call consolidate first".to_string(),
239            ));
240        }
241
242        let half = T::from(0.5).unwrap_or_else(|| T::one());
243        let mut total_penalty = T::zero();
244
245        if self.online {
246            // Online EWC: use the latest consolidated Fisher
247            let fisher_map = self.task_fisher_diagonals.last().ok_or_else(|| {
248                OptimError::InvalidState("no consolidated Fisher available".to_string())
249            })?;
250
251            for (name, anchor) in &self.anchor_parameters {
252                let current = parameters.get(name).ok_or_else(|| {
253                    OptimError::InvalidState(format!("parameter '{}' not found in input", name))
254                })?;
255                let fisher = fisher_map.get(name).ok_or_else(|| {
256                    OptimError::InvalidState(format!("Fisher information for '{}' not found", name))
257                })?;
258
259                Self::require_same_len(name, "current parameter", anchor.len(), current.len())?;
260                Self::require_same_len(name, "Fisher diagonal", anchor.len(), fisher.len())?;
261
262                for i in 0..anchor.len() {
263                    let diff = current[i] - anchor[i];
264                    total_penalty = total_penalty + fisher[i] * diff * diff;
265                }
266            }
267        } else {
268            // Standard EWC: sum penalty over all tasks, each with its own anchor
269            for (task_idx, task_fisher) in self.task_fisher_diagonals.iter().enumerate() {
270                // `task_anchor_parameters` and `task_fisher_diagonals` are pushed
271                // together by `consolidate`, but a caller that mutated one
272                // through a future API (or a partially-applied deserialization)
273                // could desynchronise them; indexing used to panic.
274                let task_anchor = self.task_anchor_parameters.get(task_idx).ok_or_else(|| {
275                    OptimError::InvalidState(format!(
276                        "no anchor parameters stored for task {task_idx}: {} anchors for {} Fisher \
277                         diagonals",
278                        self.task_anchor_parameters.len(),
279                        self.task_fisher_diagonals.len()
280                    ))
281                })?;
282                for (name, anchor) in task_anchor {
283                    let current = parameters.get(name).ok_or_else(|| {
284                        OptimError::InvalidState(format!("parameter '{}' not found in input", name))
285                    })?;
286                    if let Some(fisher) = task_fisher.get(name) {
287                        Self::require_same_len(
288                            name,
289                            "current parameter",
290                            anchor.len(),
291                            current.len(),
292                        )?;
293                        Self::require_same_len(
294                            name,
295                            "Fisher diagonal",
296                            anchor.len(),
297                            fisher.len(),
298                        )?;
299                        for i in 0..anchor.len() {
300                            let diff = current[i] - anchor[i];
301                            total_penalty = total_penalty + fisher[i] * diff * diff;
302                        }
303                    }
304                }
305            }
306        }
307
308        Ok(self.lambda * half * total_penalty)
309    }
310
311    /// Compute the gradient of the EWC penalty with respect to current parameters.
312    ///
313    /// grad_i = lambda * F_i * (theta_i - theta*_i)
314    pub fn ewc_gradient(
315        &self,
316        parameters: &HashMap<String, Array1<T>>,
317    ) -> Result<HashMap<String, Array1<T>>> {
318        if self.anchor_parameters.is_empty() {
319            return Err(OptimError::InvalidState(
320                "no anchor parameters stored; call consolidate first".to_string(),
321            ));
322        }
323
324        let mut gradients: HashMap<String, Array1<T>> = HashMap::new();
325
326        // Initialize gradient arrays to zero
327        for (name, param) in parameters {
328            gradients.insert(name.clone(), Array1::from_elem(param.len(), T::zero()));
329        }
330
331        if self.online {
332            let fisher_map = self.task_fisher_diagonals.last().ok_or_else(|| {
333                OptimError::InvalidState("no consolidated Fisher available".to_string())
334            })?;
335
336            for (name, anchor) in &self.anchor_parameters {
337                let current = parameters.get(name).ok_or_else(|| {
338                    OptimError::InvalidState(format!("parameter '{}' not found in input", name))
339                })?;
340                let fisher = fisher_map.get(name).ok_or_else(|| {
341                    OptimError::InvalidState(format!("Fisher information for '{}' not found", name))
342                })?;
343                let grad = gradients.get_mut(name).ok_or_else(|| {
344                    OptimError::InvalidState(format!("gradient entry for '{}' not found", name))
345                })?;
346
347                Self::require_same_len(name, "current parameter", anchor.len(), current.len())?;
348                Self::require_same_len(name, "Fisher diagonal", anchor.len(), fisher.len())?;
349                Self::require_same_len(name, "gradient buffer", anchor.len(), grad.len())?;
350
351                for i in 0..anchor.len() {
352                    grad[i] = grad[i] + self.lambda * fisher[i] * (current[i] - anchor[i]);
353                }
354            }
355        } else {
356            for (task_idx, task_fisher) in self.task_fisher_diagonals.iter().enumerate() {
357                // `task_anchor_parameters` and `task_fisher_diagonals` are pushed
358                // together by `consolidate`, but a caller that mutated one
359                // through a future API (or a partially-applied deserialization)
360                // could desynchronise them; indexing used to panic.
361                let task_anchor = self.task_anchor_parameters.get(task_idx).ok_or_else(|| {
362                    OptimError::InvalidState(format!(
363                        "no anchor parameters stored for task {task_idx}: {} anchors for {} Fisher \
364                         diagonals",
365                        self.task_anchor_parameters.len(),
366                        self.task_fisher_diagonals.len()
367                    ))
368                })?;
369                for (name, anchor) in task_anchor {
370                    let current = parameters.get(name).ok_or_else(|| {
371                        OptimError::InvalidState(format!("parameter '{}' not found in input", name))
372                    })?;
373                    if let Some(fisher) = task_fisher.get(name) {
374                        let grad = gradients.get_mut(name).ok_or_else(|| {
375                            OptimError::InvalidState(format!(
376                                "gradient entry for '{}' not found",
377                                name
378                            ))
379                        })?;
380
381                        Self::require_same_len(
382                            name,
383                            "current parameter",
384                            anchor.len(),
385                            current.len(),
386                        )?;
387                        Self::require_same_len(
388                            name,
389                            "Fisher diagonal",
390                            anchor.len(),
391                            fisher.len(),
392                        )?;
393                        Self::require_same_len(name, "gradient buffer", anchor.len(), grad.len())?;
394                        for i in 0..anchor.len() {
395                            grad[i] = grad[i] + self.lambda * fisher[i] * (current[i] - anchor[i]);
396                        }
397                    }
398                }
399            }
400        }
401
402        Ok(gradients)
403    }
404
405    /// Reject a per-parameter array whose length does not match its anchor.
406    ///
407    /// F71: `ewc_penalty` / `ewc_gradient` / `consolidate` all looped
408    /// `for i in 0..anchor.len()` and indexed the *current* parameters, the Fisher
409    /// diagonal and the gradient buffer with `i`. They checked that each name was
410    /// **present** but never that the arrays were the same **length**, so a model
411    /// whose layer was resized between tasks — the exact situation continual
412    /// learning exists for — produced an index-out-of-bounds panic instead of a
413    /// diagnosable error.
414    fn require_same_len(name: &str, what: &str, expected: usize, got: usize) -> Result<()> {
415        if expected != got {
416            return Err(OptimError::InvalidState(format!(
417                "{what} for '{name}' has length {got} but its anchor has length {expected}; \
418                 EWC needs matching shapes"
419            )));
420        }
421        Ok(())
422    }
423
424    /// Return the number of tasks that have been consolidated so far.
425    pub fn num_tasks(&self) -> usize {
426        self.task_fisher_diagonals.len()
427    }
428}
429
430// ---------------------------------------------------------------------------
431// Progressive Networks
432// ---------------------------------------------------------------------------
433
434/// A single column in a progressive network, representing one task's parameters.
435#[derive(Debug, Clone)]
436pub struct NetworkColumn<T: Float + Debug + Send + Sync + 'static> {
437    /// Weight matrices per layer (layer l has shape [output_l, input_l])
438    pub weights: Vec<Array2<T>>,
439    /// Bias vectors per layer
440    pub biases: Vec<Array1<T>>,
441    /// Whether the column is frozen (previous tasks are frozen)
442    pub frozen: bool,
443}
444
445impl<T: Float + Debug + Send + Sync + 'static> NetworkColumn<T> {
446    /// Number of layers in this column.
447    pub fn num_layers(&self) -> usize {
448        self.weights.len()
449    }
450
451    /// Forward pass through a single layer with ReLU activation.
452    /// Returns pre-activation and post-activation values.
453    fn forward_layer(&self, input: &Array1<T>, layer_idx: usize) -> Result<(Array1<T>, Array1<T>)> {
454        if layer_idx >= self.weights.len() {
455            return Err(OptimError::NetworkError(format!(
456                "layer index {} out of range (column has {} layers)",
457                layer_idx,
458                self.weights.len()
459            )));
460        }
461        let w = &self.weights[layer_idx];
462        let b = &self.biases[layer_idx];
463
464        // z = W * x + b  (matrix-vector product)
465        let out_dim = w.nrows();
466        let in_dim = w.ncols();
467        if input.len() != in_dim {
468            return Err(OptimError::NetworkError(format!(
469                "input dimension mismatch at layer {}: weight expects {}, got {}",
470                layer_idx,
471                in_dim,
472                input.len()
473            )));
474        }
475
476        let mut z = b.clone();
477        for i in 0..out_dim {
478            let mut sum = T::zero();
479            for j in 0..in_dim {
480                sum = sum + w[[i, j]] * input[j];
481            }
482            z[i] = z[i] + sum;
483        }
484
485        let a = self.apply_activation(&z, layer_idx);
486        Ok((z, a))
487    }
488
489    /// Apply layer `layer_idx`'s activation to a pre-activation vector.
490    ///
491    /// ReLU everywhere except the final layer, which is linear. Split out of
492    /// [`Self::forward_layer`] so a caller that needs to inject a lateral
493    /// contribution into the pre-activation (progressive networks) still applies
494    /// exactly the same nonlinearity.
495    fn apply_activation(&self, z: &Array1<T>, layer_idx: usize) -> Array1<T> {
496        let is_last_layer = layer_idx + 1 == self.weights.len();
497        if is_last_layer {
498            z.clone()
499        } else {
500            z.mapv(|v| if v > T::zero() { v } else { T::zero() })
501        }
502    }
503}
504
505/// Progressive Networks for continual learning.
506///
507/// Each new task gets its own column (set of layers). Previous columns are frozen
508/// and lateral connections from frozen columns feed into the new column, enabling
509/// knowledge transfer without forgetting.
510pub struct ProgressiveNetworks<T: Float + Debug + Send + Sync + 'static> {
511    /// One column per task
512    columns: Vec<NetworkColumn<T>>,
513    /// Lateral connection weights: lateral_connections[col][layer] is a matrix
514    /// of shape [hidden_size, prev_columns_total_hidden] that maps previous
515    /// columns' activations to a contribution for the current column.
516    lateral_connections: Vec<Vec<Array2<T>>>,
517    /// Index of the currently active (trainable) column
518    active_column: usize,
519    /// Hidden layer sizes (shared architecture template)
520    hidden_sizes: Vec<usize>,
521}
522
523impl<T: Float + Debug + Send + Sync + 'static> ProgressiveNetworks<T> {
524    /// Create a new progressive network with the given hidden layer sizes.
525    ///
526    /// No columns are created initially; call `add_task_column` for each task.
527    pub fn new(hidden_sizes: Vec<usize>) -> Self {
528        Self {
529            columns: Vec::new(),
530            lateral_connections: Vec::new(),
531            active_column: 0,
532            hidden_sizes,
533        }
534    }
535
536    /// Add a new column for a new task.
537    ///
538    /// `input_size`  - dimensionality of the input features.
539    /// `output_size` - dimensionality of the output.
540    ///
541    /// Returns the column index (task id).
542    /// All previously existing columns are frozen.
543    pub fn add_task_column(&mut self, input_size: usize, output_size: usize) -> Result<usize> {
544        if input_size == 0 || output_size == 0 {
545            return Err(OptimError::InvalidConfig(
546                "input_size and output_size must be > 0".to_string(),
547            ));
548        }
549
550        let col_id = self.columns.len();
551
552        // Freeze all existing columns
553        for col in &mut self.columns {
554            col.frozen = true;
555        }
556
557        // Build layer sizes: input -> hidden_1 -> ... -> hidden_n -> output
558        let mut layer_sizes = Vec::with_capacity(self.hidden_sizes.len() + 2);
559        layer_sizes.push(input_size);
560        layer_sizes.extend_from_slice(&self.hidden_sizes);
561        layer_sizes.push(output_size);
562
563        // Initialize weights with small values (Xavier-like initialization)
564        let mut weights = Vec::new();
565        let mut biases = Vec::new();
566
567        for l in 0..layer_sizes.len() - 1 {
568            let fan_in = layer_sizes[l];
569            let fan_out = layer_sizes[l + 1];
570
571            // Xavier init scale: sqrt(2 / (fan_in + fan_out))
572            let scale_val = T::from(2.0 / ((fan_in + fan_out) as f64)).unwrap_or_else(|| T::one());
573            let scale = Float::sqrt(scale_val);
574
575            // Deterministic initialization: alternate small positive/negative values
576            let mut w = Array2::from_elem((fan_out, fan_in), T::zero());
577            for i in 0..fan_out {
578                for j in 0..fan_in {
579                    // Simple deterministic pattern
580                    let idx = (i * fan_in + j) as f64;
581                    let sign = if (i + j) % 2 == 0 {
582                        T::one()
583                    } else {
584                        -T::one()
585                    };
586                    let magnitude =
587                        scale * T::from((idx + 1.0).recip()).unwrap_or_else(|| T::one());
588                    w[[i, j]] = sign * magnitude;
589                }
590            }
591
592            let b = Array1::from_elem(fan_out, T::zero());
593
594            weights.push(w);
595            biases.push(b);
596        }
597
598        let column = NetworkColumn {
599            weights,
600            biases,
601            frozen: false,
602        };
603
604        self.columns.push(column);
605
606        // Create lateral connections from all previous columns to this new column
607        let mut laterals_for_col = Vec::new();
608        if col_id > 0 {
609            for l in 0..layer_sizes.len() - 1 {
610                // At layer l, each previous column produces an activation of size layer_sizes[l+1]
611                // (except for the input layer which doesn't have lateral connections).
612                // Lateral input dimension = sum of previous columns' hidden sizes at layer l
613                // For simplicity, each previous column contributes layer_sizes[l+1] activations
614                // at layer l.
615                let lateral_in = if l == 0 {
616                    // No lateral connections at the first layer (input layer)
617                    0
618                } else {
619                    // Each previous column contributes its hidden size at layer l
620                    col_id * layer_sizes[l]
621                };
622                let lateral_out = layer_sizes[l + 1];
623
624                if lateral_in == 0 {
625                    // Placeholder (unused) for consistency
626                    laterals_for_col.push(Array2::from_elem((lateral_out, 1), T::zero()));
627                } else {
628                    // Small initialization for lateral weights
629                    let scale_val = T::from(0.1 / (lateral_in as f64)).unwrap_or_else(|| T::one());
630                    let scale = Float::sqrt(scale_val);
631                    let mut lat_w = Array2::from_elem((lateral_out, lateral_in), T::zero());
632                    for i in 0..lateral_out {
633                        for j in 0..lateral_in {
634                            let sign = if (i + j) % 3 == 0 {
635                                T::one()
636                            } else if (i + j) % 3 == 1 {
637                                -T::one()
638                            } else {
639                                T::zero()
640                            };
641                            lat_w[[i, j]] = sign * scale;
642                        }
643                    }
644                    laterals_for_col.push(lat_w);
645                }
646            }
647        }
648
649        self.lateral_connections.push(laterals_for_col);
650        self.active_column = col_id;
651
652        Ok(col_id)
653    }
654
655    /// Forward pass through the network for a specific task.
656    ///
657    /// For columns beyond the first, lateral connections from all previous columns
658    /// are added at each hidden layer.
659    pub fn forward(&self, input: &Array1<T>, task_id: usize) -> Result<Array1<T>> {
660        let (output, _) = self.forward_with_laterals(input, task_id)?;
661        Ok(output)
662    }
663
664    /// Forward pass that also returns intermediate activations from all columns.
665    ///
666    /// Returns (final_output, all_intermediate_activations) where
667    /// `all_intermediate_activations[col][layer]` is the activation at that layer
668    /// of that column.
669    pub fn forward_with_laterals(
670        &self,
671        input: &Array1<T>,
672        task_id: usize,
673    ) -> Result<(Array1<T>, Vec<Array1<T>>)> {
674        if task_id >= self.columns.len() {
675            return Err(OptimError::InvalidState(format!(
676                "task_id {} out of range; only {} columns exist",
677                task_id,
678                self.columns.len()
679            )));
680        }
681
682        // Compute activations for all columns up to and including task_id
683        // activations[col][layer] holds post-activation output of layer l in column col
684        let mut all_activations: Vec<Vec<Array1<T>>> = Vec::new();
685
686        for col in 0..=task_id {
687            let column = &self.columns[col];
688            let num_layers = column.num_layers();
689            let mut col_activations: Vec<Array1<T>> = Vec::new();
690            let mut h = input.clone();
691
692            for l in 0..num_layers {
693                // Lateral contribution from previous columns (only for col > 0 and l > 0).
694                //
695                // Progressive Neural Networks (Rusu et al. 2016, eq. 1) add the
696                // lateral term to the **pre-activation** of layer `l`:
697                //
698                //     h_l^(k) = f( W_l^(k) h_{l-1}^(k) + Σ_{j<k} U_l^(k:j) h_{l-1}^(j) )
699                //
700                // The previous implementation added it to the layer *input*
701                // instead. `U_l` has `layer_sizes[l+1]` rows while the input has
702                // `layer_sizes[l]` entries, so an `if h.len() ==
703                // lateral_contribution.len()` guard silently dropped the whole
704                // lateral term for every network whose layer widths differ — i.e.
705                // no transfer at all, with no diagnostic.
706                let lateral_contribution = if col > 0 && l > 0 {
707                    let laterals = &self.lateral_connections[col];
708                    if !laterals.is_empty() && l < laterals.len() {
709                        let lat_w = &laterals[l];
710                        let mut lateral_input_parts: Vec<T> = Vec::new();
711                        for prev_col_acts in all_activations.iter().take(col) {
712                            if l - 1 < prev_col_acts.len() {
713                                let prev_act = &prev_col_acts[l - 1];
714                                lateral_input_parts.extend(prev_act.iter().copied());
715                            }
716                        }
717
718                        if lateral_input_parts.is_empty() {
719                            None
720                        } else {
721                            let lateral_input = Array1::from_vec(lateral_input_parts);
722                            if lat_w.ncols() != lateral_input.len() {
723                                return Err(OptimError::NetworkError(format!(
724                                    "lateral weight at column {col} layer {l} expects \
725                                     {} inputs but the previous columns supplied {}",
726                                    lat_w.ncols(),
727                                    lateral_input.len()
728                                )));
729                            }
730                            let lat_out_dim = lat_w.nrows();
731                            let mut contribution = Array1::from_elem(lat_out_dim, T::zero());
732                            for i in 0..lat_out_dim {
733                                let mut sum = T::zero();
734                                for j in 0..lat_w.ncols() {
735                                    sum = sum + lat_w[[i, j]] * lateral_input[j];
736                                }
737                                contribution[i] = sum;
738                            }
739                            Some(contribution)
740                        }
741                    } else {
742                        None
743                    }
744                } else {
745                    None
746                };
747
748                let (mut pre, post_without_lateral) = column.forward_layer(&h, l)?;
749                let post = match lateral_contribution {
750                    Some(contribution) => {
751                        if contribution.len() != pre.len() {
752                            return Err(OptimError::NetworkError(format!(
753                                "lateral contribution at column {col} layer {l} has \
754                                 {} entries but the pre-activation has {}",
755                                contribution.len(),
756                                pre.len()
757                            )));
758                        }
759                        for i in 0..pre.len() {
760                            pre[i] = pre[i] + contribution[i];
761                        }
762                        column.apply_activation(&pre, l)
763                    }
764                    None => post_without_lateral,
765                };
766                col_activations.push(post.clone());
767                h = post;
768            }
769
770            all_activations.push(col_activations);
771        }
772
773        // Final output is the last activation of the target column
774        let target_activations = &all_activations[task_id];
775        let output = target_activations
776            .last()
777            .ok_or_else(|| OptimError::NetworkError("column has no layers".to_string()))?
778            .clone();
779
780        // Collect intermediate activations (flatten from all columns)
781        let mut intermediates: Vec<Array1<T>> = Vec::new();
782        for col_acts in &all_activations {
783            for act in col_acts {
784                intermediates.push(act.clone());
785            }
786        }
787
788        Ok((output, intermediates))
789    }
790
791    /// Freeze a specific column (mark as non-trainable).
792    pub fn freeze_column(&mut self, column_id: usize) -> Result<()> {
793        if column_id >= self.columns.len() {
794            return Err(OptimError::InvalidState(format!(
795                "column_id {} out of range; only {} columns exist",
796                column_id,
797                self.columns.len()
798            )));
799        }
800        self.columns[column_id].frozen = true;
801        Ok(())
802    }
803
804    /// Return the number of columns (one per task).
805    pub fn num_columns(&self) -> usize {
806        self.columns.len()
807    }
808
809    /// Get a reference to a specific column's parameters.
810    pub fn get_column_parameters(&self, column_id: usize) -> Result<&NetworkColumn<T>> {
811        self.columns.get(column_id).ok_or_else(|| {
812            OptimError::InvalidState(format!(
813                "column_id {} out of range; only {} columns exist",
814                column_id,
815                self.columns.len()
816            ))
817        })
818    }
819}
820
821// ---------------------------------------------------------------------------
822// Tests
823// ---------------------------------------------------------------------------
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828    use scirs2_core::ndarray::Array1;
829
830    type F = f64;
831
832    // Helper: create simple parameters map
833    fn make_params(names: &[&str], size: usize, value: f64) -> HashMap<String, Array1<F>> {
834        let mut map = HashMap::new();
835        for name in names {
836            map.insert(name.to_string(), Array1::from_elem(size, value));
837        }
838        map
839    }
840
841    // -----------------------------------------------------------------------
842    // EWC Tests
843    // -----------------------------------------------------------------------
844
845    /// F71: every EWC loop was `for i in 0..anchor.len()` indexing the *current*
846    /// parameters, the Fisher diagonal and the gradient buffer with `i`. Presence
847    /// of each name was checked; length never was. A model whose layer was resized
848    /// between tasks — the whole point of continual learning — panicked with an
849    /// index-out-of-bounds instead of returning a diagnosable error.
850    #[test]
851    fn ewc_reports_length_mismatches_instead_of_panicking() {
852        // Consolidate on width-4 parameters, then evaluate against width-2 ones.
853        let build = |online: bool| -> ElasticWeightConsolidation<F> {
854            let mut ewc: ElasticWeightConsolidation<F> = ElasticWeightConsolidation::new(1.0)
855                .with_num_samples(2)
856                .with_online(online);
857            let wide = make_params(&["w"], 4, 1.0);
858            ewc.compute_fisher_diagonal(&wide, |params, _| {
859                Ok(params
860                    .iter()
861                    .map(|(name, value)| (name.clone(), value.mapv(|v| v * 0.5)))
862                    .collect())
863            })
864            .expect("fisher");
865            ewc.consolidate(&wide).expect("consolidate");
866            ewc
867        };
868
869        for online in [false, true] {
870            let ewc = build(online);
871            let narrow = make_params(&["w"], 2, 1.0);
872
873            let penalty = ewc.ewc_penalty(&narrow);
874            assert!(
875                penalty.is_err(),
876                "online={online}: a width-2 parameter against a width-4 anchor must be an error"
877            );
878            let text = penalty.expect_err("checked above").to_string();
879            assert!(
880                text.contains("length") && text.contains('w'),
881                "online={online}: unhelpful error {text}"
882            );
883
884            let gradient = ewc.ewc_gradient(&narrow);
885            assert!(
886                gradient.is_err(),
887                "online={online}: ewc_gradient must reject the mismatch too"
888            );
889
890            // The matching case must still work, so the guard is not just a
891            // blanket rejection.
892            let wide = make_params(&["w"], 4, 2.0);
893            let ok_penalty = ewc.ewc_penalty(&wide).expect("matching widths");
894            assert!(
895                ok_penalty > 0.0,
896                "online={online}: a displaced parameter must incur a positive penalty"
897            );
898            let grads = ewc.ewc_gradient(&wide).expect("matching widths");
899            assert_eq!(grads["w"].len(), 4);
900            assert!(
901                grads["w"].iter().all(|g| *g > 0.0),
902                "online={online}: gradient must push back toward the anchor"
903            );
904        }
905    }
906
907    /// The online branch of `consolidate` merges the previous Fisher diagonal into
908    /// the new one elementwise; a shorter previous diagonal used to panic.
909    #[test]
910    fn online_consolidation_reports_a_fisher_length_change() {
911        let mut ewc: ElasticWeightConsolidation<F> = ElasticWeightConsolidation::new(1.0)
912            .with_num_samples(2)
913            .with_online(true);
914        let grads = |params: &HashMap<String, Array1<F>>, _: usize| {
915            Ok(params
916                .iter()
917                .map(|(name, value)| (name.clone(), value.mapv(|v| v * 0.5)))
918                .collect())
919        };
920
921        let narrow = make_params(&["w"], 2, 1.0);
922        ewc.compute_fisher_diagonal(&narrow, grads).expect("fisher");
923        ewc.consolidate(&narrow).expect("first consolidate");
924
925        // Second task: same name, wider parameter.
926        let wide = make_params(&["w"], 5, 1.0);
927        ewc.compute_fisher_diagonal(&wide, grads)
928            .expect("fisher for the wider task");
929        let err = ewc
930            .consolidate(&wide)
931            .expect_err("a Fisher length change must be reported, not panic");
932        assert!(err.to_string().contains("length"), "{}", err);
933    }
934
935    #[test]
936    fn test_ewc_creation_and_configuration() {
937        let ewc: ElasticWeightConsolidation<F> = ElasticWeightConsolidation::new(1000.0)
938            .with_num_samples(50)
939            .with_online(true)
940            .with_gamma(0.9);
941
942        assert_eq!(ewc.num_tasks(), 0);
943        assert!(ewc.online);
944        assert!((ewc.gamma - 0.9).abs() < 1e-12);
945        assert!((ewc.lambda - 1000.0).abs() < 1e-12);
946        assert_eq!(ewc.num_samples_fisher, 50);
947    }
948
949    #[test]
950    fn test_fisher_diagonal_computation() {
951        let mut ewc: ElasticWeightConsolidation<F> =
952            ElasticWeightConsolidation::new(1.0).with_num_samples(10);
953
954        let params = make_params(&["w1", "w2"], 3, 1.0);
955
956        // Gradients function: returns constant gradients (simulating quadratic loss)
957        // For a quadratic loss f(x) = 0.5 * x^2, grad = x = [1, 1, 1]
958        let grad_fn = |_p: &HashMap<String, Array1<F>>,
959                       _sample: usize|
960         -> Result<HashMap<String, Array1<F>>> {
961            let mut g = HashMap::new();
962            g.insert("w1".to_string(), Array1::from_vec(vec![1.0, 2.0, 3.0]));
963            g.insert("w2".to_string(), Array1::from_vec(vec![0.5, 0.5, 0.5]));
964            Ok(g)
965        };
966
967        ewc.compute_fisher_diagonal(&params, grad_fn)
968            .expect("compute_fisher_diagonal should succeed");
969
970        // Fisher = E[g^2] = g^2 since gradients are constant
971        let f1 = ewc
972            .fisher_diagonal
973            .get("w1")
974            .expect("w1 Fisher should exist");
975        assert!((f1[0] - 1.0).abs() < 1e-12);
976        assert!((f1[1] - 4.0).abs() < 1e-12);
977        assert!((f1[2] - 9.0).abs() < 1e-12);
978
979        let f2 = ewc
980            .fisher_diagonal
981            .get("w2")
982            .expect("w2 Fisher should exist");
983        assert!((f2[0] - 0.25).abs() < 1e-12);
984    }
985
986    #[test]
987    fn test_ewc_penalty_computation() {
988        let mut ewc: ElasticWeightConsolidation<F> =
989            ElasticWeightConsolidation::new(2.0).with_num_samples(5);
990
991        let anchor = make_params(&["w"], 2, 1.0);
992
993        // Constant Fisher = [1.0, 1.0]
994        let grad_fn = |_p: &HashMap<String, Array1<F>>,
995                       _sample: usize|
996         -> Result<HashMap<String, Array1<F>>> {
997            let mut g = HashMap::new();
998            g.insert("w".to_string(), Array1::from_vec(vec![1.0, 1.0]));
999            Ok(g)
1000        };
1001
1002        ewc.compute_fisher_diagonal(&anchor, grad_fn)
1003            .expect("compute Fisher should succeed");
1004        ewc.consolidate(&anchor)
1005            .expect("consolidate should succeed");
1006
1007        // Current parameters differ from anchor
1008        let mut current = HashMap::new();
1009        current.insert("w".to_string(), Array1::from_vec(vec![2.0, 3.0]));
1010
1011        // Penalty = (lambda/2) * sum F_i * (theta_i - theta*_i)^2
1012        // = (2/2) * (1*(2-1)^2 + 1*(3-1)^2) = 1 * (1 + 4) = 5.0
1013        let penalty = ewc
1014            .ewc_penalty(&current)
1015            .expect("ewc_penalty should succeed");
1016        assert!(
1017            (penalty - 5.0).abs() < 1e-12,
1018            "expected penalty 5.0, got {}",
1019            penalty
1020        );
1021    }
1022
1023    #[test]
1024    fn test_ewc_gradient_computation() {
1025        let mut ewc: ElasticWeightConsolidation<F> =
1026            ElasticWeightConsolidation::new(2.0).with_num_samples(5);
1027
1028        let anchor = make_params(&["w"], 2, 1.0);
1029
1030        let grad_fn = |_p: &HashMap<String, Array1<F>>,
1031                       _sample: usize|
1032         -> Result<HashMap<String, Array1<F>>> {
1033            let mut g = HashMap::new();
1034            g.insert("w".to_string(), Array1::from_vec(vec![1.0, 1.0]));
1035            Ok(g)
1036        };
1037
1038        ewc.compute_fisher_diagonal(&anchor, grad_fn)
1039            .expect("compute Fisher should succeed");
1040        ewc.consolidate(&anchor)
1041            .expect("consolidate should succeed");
1042
1043        let mut current = HashMap::new();
1044        current.insert("w".to_string(), Array1::from_vec(vec![2.0, 3.0]));
1045
1046        // Gradient = lambda * F_i * (theta_i - theta*_i)
1047        // = 2 * 1 * (2-1, 3-1) = (2, 4)
1048        let grads = ewc
1049            .ewc_gradient(&current)
1050            .expect("ewc_gradient should succeed");
1051        let gw = grads.get("w").expect("gradient for w should exist");
1052        assert!((gw[0] - 2.0).abs() < 1e-12, "expected 2.0, got {}", gw[0]);
1053        assert!((gw[1] - 4.0).abs() < 1e-12, "expected 4.0, got {}", gw[1]);
1054    }
1055
1056    #[test]
1057    fn test_consolidation_workflow() {
1058        let mut ewc: ElasticWeightConsolidation<F> =
1059            ElasticWeightConsolidation::new(1.0).with_num_samples(5);
1060
1061        let params_task1 = make_params(&["w"], 2, 1.0);
1062
1063        let grad_fn = |_p: &HashMap<String, Array1<F>>,
1064                       _sample: usize|
1065         -> Result<HashMap<String, Array1<F>>> {
1066            let mut g = HashMap::new();
1067            g.insert("w".to_string(), Array1::from_vec(vec![1.0, 2.0]));
1068            Ok(g)
1069        };
1070
1071        ewc.compute_fisher_diagonal(&params_task1, grad_fn)
1072            .expect("Fisher computation should succeed");
1073        ewc.consolidate(&params_task1)
1074            .expect("consolidation should succeed");
1075        assert_eq!(ewc.num_tasks(), 1);
1076
1077        // Second task
1078        let params_task2 = make_params(&["w"], 2, 2.0);
1079        ewc.compute_fisher_diagonal(&params_task2, grad_fn)
1080            .expect("Fisher computation for task 2 should succeed");
1081        ewc.consolidate(&params_task2)
1082            .expect("consolidation for task 2 should succeed");
1083        assert_eq!(ewc.num_tasks(), 2);
1084
1085        // Penalty at anchor should be zero (for the latest anchor)
1086        let penalty = ewc
1087            .ewc_penalty(&params_task2)
1088            .expect("penalty should succeed");
1089        // The latest anchor is params_task2, so the second task's Fisher penalty is 0.
1090        // But the first task's Fisher penalty is non-zero because params_task2 != params_task1.
1091        // penalty = 0.5 * sum_task1 F_i*(2-1)^2 = 0.5 * (1*1 + 4*1) = 2.5
1092        assert!(
1093            penalty > 0.0,
1094            "penalty should be positive due to task1 Fisher, got {}",
1095            penalty
1096        );
1097    }
1098
1099    #[test]
1100    fn test_online_ewc_multiple_tasks() {
1101        let mut ewc: ElasticWeightConsolidation<F> = ElasticWeightConsolidation::new(1.0)
1102            .with_num_samples(5)
1103            .with_online(true)
1104            .with_gamma(0.5);
1105
1106        // Task 1
1107        let params1 = make_params(&["w"], 2, 0.0);
1108        let grad_fn1 = |_p: &HashMap<String, Array1<F>>,
1109                        _sample: usize|
1110         -> Result<HashMap<String, Array1<F>>> {
1111            let mut g = HashMap::new();
1112            g.insert("w".to_string(), Array1::from_vec(vec![1.0, 1.0]));
1113            Ok(g)
1114        };
1115        ewc.compute_fisher_diagonal(&params1, grad_fn1)
1116            .expect("Fisher 1 should succeed");
1117        ewc.consolidate(&params1)
1118            .expect("consolidate 1 should succeed");
1119        assert_eq!(ewc.num_tasks(), 1);
1120
1121        // Task 1 Fisher = [1.0, 1.0] (since gradients are constant = 1.0)
1122        let fisher1 = ewc.task_fisher_diagonals[0]
1123            .get("w")
1124            .expect("Fisher should exist");
1125        assert!((fisher1[0] - 1.0).abs() < 1e-12);
1126
1127        // Task 2
1128        let params2 = make_params(&["w"], 2, 1.0);
1129        let grad_fn2 = |_p: &HashMap<String, Array1<F>>,
1130                        _sample: usize|
1131         -> Result<HashMap<String, Array1<F>>> {
1132            let mut g = HashMap::new();
1133            g.insert("w".to_string(), Array1::from_vec(vec![2.0, 2.0]));
1134            Ok(g)
1135        };
1136        ewc.compute_fisher_diagonal(&params2, grad_fn2)
1137            .expect("Fisher 2 should succeed");
1138        ewc.consolidate(&params2)
1139            .expect("consolidate 2 should succeed");
1140        assert_eq!(ewc.num_tasks(), 2);
1141
1142        // Online Fisher for task 2 = gamma * old + new = 0.5 * [1,1] + [4,4] = [4.5, 4.5]
1143        let fisher2 = ewc.task_fisher_diagonals[1]
1144            .get("w")
1145            .expect("Fisher should exist");
1146        assert!(
1147            (fisher2[0] - 4.5).abs() < 1e-12,
1148            "expected 4.5, got {}",
1149            fisher2[0]
1150        );
1151
1152        // Penalty at params different from anchor (params2)
1153        let mut test_params = HashMap::new();
1154        test_params.insert("w".to_string(), Array1::from_vec(vec![2.0, 2.0]));
1155
1156        let penalty = ewc
1157            .ewc_penalty(&test_params)
1158            .expect("penalty should succeed");
1159        // penalty = 0.5 * sum 4.5 * (2-1)^2 = 0.5 * (4.5 + 4.5) = 4.5
1160        assert!(
1161            (penalty - 4.5).abs() < 1e-12,
1162            "expected 4.5, got {}",
1163            penalty
1164        );
1165    }
1166
1167    // -----------------------------------------------------------------------
1168    // Progressive Networks Tests
1169    // -----------------------------------------------------------------------
1170
1171    #[test]
1172    fn test_progressive_network_creation_and_columns() {
1173        let mut pn: ProgressiveNetworks<F> = ProgressiveNetworks::new(vec![16, 8]);
1174
1175        assert_eq!(pn.num_columns(), 0);
1176
1177        let col0 = pn
1178            .add_task_column(4, 2)
1179            .expect("add column 0 should succeed");
1180        assert_eq!(col0, 0);
1181        assert_eq!(pn.num_columns(), 1);
1182
1183        // Check column structure: 4 -> 16 -> 8 -> 2 = 3 layers
1184        let params0 = pn
1185            .get_column_parameters(0)
1186            .expect("get column 0 should succeed");
1187        assert_eq!(params0.num_layers(), 3);
1188        assert!(!params0.frozen);
1189
1190        // Add second column - first should now be frozen
1191        let col1 = pn
1192            .add_task_column(4, 2)
1193            .expect("add column 1 should succeed");
1194        assert_eq!(col1, 1);
1195        assert_eq!(pn.num_columns(), 2);
1196
1197        let params0_after = pn
1198            .get_column_parameters(0)
1199            .expect("get column 0 should succeed");
1200        assert!(params0_after.frozen, "column 0 should be frozen");
1201
1202        let params1 = pn
1203            .get_column_parameters(1)
1204            .expect("get column 1 should succeed");
1205        assert!(!params1.frozen, "column 1 should not be frozen");
1206    }
1207
1208    #[test]
1209    fn test_progressive_network_forward_single_column() {
1210        let mut pn: ProgressiveNetworks<F> = ProgressiveNetworks::new(vec![8]);
1211
1212        pn.add_task_column(4, 2).expect("add column should succeed");
1213
1214        let input = Array1::from_vec(vec![1.0, 0.5, -0.3, 0.8]);
1215        let output = pn.forward(&input, 0).expect("forward should succeed");
1216
1217        // Output should have 2 elements (output_size = 2)
1218        assert_eq!(output.len(), 2, "output should have 2 elements");
1219
1220        // Output should be finite
1221        for val in output.iter() {
1222            assert!(val.is_finite(), "output should be finite, got {}", val);
1223        }
1224    }
1225
1226    #[test]
1227    fn test_progressive_network_forward_with_laterals() {
1228        let mut pn: ProgressiveNetworks<F> = ProgressiveNetworks::new(vec![8]);
1229
1230        pn.add_task_column(4, 2)
1231            .expect("add column 0 should succeed");
1232        pn.add_task_column(4, 2)
1233            .expect("add column 1 should succeed");
1234
1235        let input = Array1::from_vec(vec![1.0, 0.5, -0.3, 0.8]);
1236
1237        // Forward through task 0 (no laterals)
1238        let output0 = pn
1239            .forward(&input, 0)
1240            .expect("forward task 0 should succeed");
1241        assert_eq!(output0.len(), 2);
1242
1243        // Forward through task 1 (with lateral connections from task 0)
1244        let (output1, intermediates) = pn
1245            .forward_with_laterals(&input, 1)
1246            .expect("forward_with_laterals task 1 should succeed");
1247        assert_eq!(output1.len(), 2);
1248
1249        // Should have intermediate activations from both columns
1250        assert!(
1251            !intermediates.is_empty(),
1252            "should have intermediate activations"
1253        );
1254
1255        // The outputs for task 0 and task 1 should differ due to
1256        // different weights and lateral connections
1257        let diff: f64 = output0
1258            .iter()
1259            .zip(output1.iter())
1260            .map(|(a, b)| (a - b).abs())
1261            .sum();
1262        // They are almost certainly different given different random inits
1263        // but we don't require it -- just check both are finite
1264        for val in output1.iter() {
1265            assert!(val.is_finite(), "output should be finite, got {}", val);
1266        }
1267        let _ = diff; // suppress unused warning
1268    }
1269
1270    #[test]
1271    fn test_progressive_network_freeze_column() {
1272        let mut pn: ProgressiveNetworks<F> = ProgressiveNetworks::new(vec![8]);
1273
1274        pn.add_task_column(4, 2).expect("add column should succeed");
1275
1276        assert!(
1277            !pn.get_column_parameters(0).expect("get params").frozen,
1278            "column should not be frozen initially"
1279        );
1280
1281        pn.freeze_column(0).expect("freeze should succeed");
1282
1283        assert!(
1284            pn.get_column_parameters(0).expect("get params").frozen,
1285            "column should be frozen after freeze_column"
1286        );
1287
1288        // Freeze out-of-range column should error
1289        let err = pn.freeze_column(99);
1290        assert!(err.is_err(), "freezing out-of-range column should fail");
1291    }
1292
1293    /// F39: the lateral contribution was added to the layer *input* while being
1294    /// sized for the layer *output*, so an `if h.len() == contribution.len()`
1295    /// guard silently dropped every lateral term whenever consecutive layer
1296    /// widths differed — i.e. no transfer at all, with no diagnostic.
1297    ///
1298    /// With `hidden_sizes = [8, 4]` and input 4 / output 2, *every* consecutive
1299    /// pair of widths differs (4→8→4→2), so under the old code the second column
1300    /// was mathematically identical to a standalone column. Zeroing the lateral
1301    /// weights must therefore change the output.
1302    #[test]
1303    fn lateral_connections_actually_contribute_with_varying_layer_widths() {
1304        let mut pn: ProgressiveNetworks<F> = ProgressiveNetworks::new(vec![8, 4]);
1305        pn.add_task_column(4, 2).expect("column 0");
1306        pn.add_task_column(4, 2).expect("column 1");
1307
1308        let input = Array1::from_vec(vec![0.6, -0.4, 0.9, -1.1]);
1309        let with_laterals = pn.forward(&input, 1).expect("forward with laterals");
1310
1311        // Confirm the lateral weights are non-trivial to begin with.
1312        let lateral_magnitude = pn.lateral_connections[1]
1313            .iter()
1314            .flat_map(|m| m.iter())
1315            .fold(0.0_f64, |acc, v| acc.max(v.abs()));
1316        assert!(
1317            lateral_magnitude > 0.0,
1318            "column 1 has no lateral weights to contribute"
1319        );
1320
1321        // Zero them out: if the laterals were being applied, the output changes.
1322        for matrix in pn.lateral_connections[1].iter_mut() {
1323            matrix.fill(0.0);
1324        }
1325        let without_laterals = pn.forward(&input, 1).expect("forward without laterals");
1326
1327        let delta = with_laterals
1328            .iter()
1329            .zip(without_laterals.iter())
1330            .map(|(a, b)| (a - b).abs())
1331            .fold(0.0_f64, f64::max);
1332        assert!(
1333            delta > 1e-9,
1334            "zeroing the lateral weights changed nothing (max delta {delta}); \
1335             the lateral contribution is still being silently dropped"
1336        );
1337
1338        // Column 0 has no laterals, so it must be unaffected either way.
1339        let col0 = pn.forward(&input, 0).expect("forward column 0");
1340        assert_eq!(col0.len(), 2);
1341    }
1342
1343    /// F39: an inconsistent lateral weight shape must now be an error rather
1344    /// than a silent skip.
1345    #[test]
1346    fn a_mis_shaped_lateral_weight_is_an_error() {
1347        let mut pn: ProgressiveNetworks<F> = ProgressiveNetworks::new(vec![8, 4]);
1348        pn.add_task_column(4, 2).expect("column 0");
1349        pn.add_task_column(4, 2).expect("column 1");
1350
1351        // Corrupt one lateral matrix's input width.
1352        pn.lateral_connections[1][1] = Array2::from_elem((4, 3), 0.1);
1353        let input = Array1::from_vec(vec![0.6, -0.4, 0.9, -1.1]);
1354        assert!(
1355            pn.forward(&input, 1).is_err(),
1356            "a lateral weight whose width does not match the concatenated \
1357             previous activations must be reported, not ignored"
1358        );
1359    }
1360
1361    #[test]
1362    fn test_progressive_network_multiple_tasks_forward() {
1363        let mut pn: ProgressiveNetworks<F> = ProgressiveNetworks::new(vec![8, 4]);
1364
1365        // Add 3 task columns
1366        for _ in 0..3 {
1367            pn.add_task_column(4, 2).expect("add column should succeed");
1368        }
1369        assert_eq!(pn.num_columns(), 3);
1370
1371        let input = Array1::from_vec(vec![0.5, -0.5, 1.0, -1.0]);
1372
1373        // Forward through each task should work
1374        for task_id in 0..3 {
1375            let output = pn
1376                .forward(&input, task_id)
1377                .unwrap_or_else(|_| panic!("forward task {} should succeed", task_id));
1378            assert_eq!(
1379                output.len(),
1380                2,
1381                "output for task {} should have 2 elements",
1382                task_id
1383            );
1384            for val in output.iter() {
1385                assert!(
1386                    val.is_finite(),
1387                    "output for task {} should be finite",
1388                    task_id
1389                );
1390            }
1391        }
1392
1393        // Forward with invalid task_id should fail
1394        let err = pn.forward(&input, 10);
1395        assert!(err.is_err(), "forward with invalid task_id should fail");
1396    }
1397}