Skip to main content

pc_rl_core/
pc_actor.rs

1// Author: Julian Bolivar
2// Version: 1.0.0
3// Date: 2026-03-25
4
5//! Predictive Coding Actor Network.
6//!
7//! Implements an actor that uses iterative top-down/bottom-up predictive coding
8//! inference loops instead of standard feedforward passes. The prediction error
9//! (surprise score) drives learning rate modulation in the actor-critic agent.
10
11use rand::Rng;
12use serde::{Deserialize, Serialize};
13
14use crate::activation::Activation;
15use crate::error::PcError;
16use crate::layer::{Layer, LayerDef};
17use crate::linalg::cpu::CpuLinAlg;
18use crate::linalg::LinAlg;
19
20/// Configuration for the predictive coding actor network.
21///
22/// # Examples
23///
24/// ```
25/// use pc_rl_core::activation::Activation;
26/// use pc_rl_core::layer::LayerDef;
27/// use pc_rl_core::pc_actor::PcActorConfig;
28///
29/// let config = PcActorConfig {
30///     input_size: 9,
31///     hidden_layers: vec![LayerDef { size: 18, activation: Activation::Tanh }],
32///     output_size: 9,
33///     output_activation: Activation::Tanh,
34///     alpha: 0.1,
35///     tol: 0.01,
36///     min_steps: 1,
37///     max_steps: 20,
38///     lr_weights: 0.01,
39///     synchronous: true,
40///     temperature: 1.0,
41///     local_lambda: 1.0,
42///     residual: false,
43///     rezero_init: 0.001,
44/// };
45/// ```
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct PcActorConfig {
48    /// Number of input features (e.g. 9 for tic-tac-toe board).
49    pub input_size: usize,
50    /// Hidden layer topology definitions.
51    pub hidden_layers: Vec<LayerDef>,
52    /// Number of output actions.
53    pub output_size: usize,
54    /// Activation function for the output layer.
55    pub output_activation: Activation,
56    /// Inference learning rate for PC loop state updates (`h += alpha * error`).
57    /// Set to 0.0 to disable PC inference (network behaves as standard MLP).
58    /// Active regardless of `residual` setting.
59    pub alpha: f64,
60    /// Convergence threshold for RMS prediction error.
61    /// PC loop exits early when surprise < tol (after at least `min_steps`).
62    /// Active regardless of `residual` setting.
63    pub tol: f64,
64    /// Minimum PC inference steps before convergence check is allowed.
65    /// Active regardless of `residual` setting.
66    pub min_steps: usize,
67    /// Maximum PC inference steps per action.
68    /// Active regardless of `residual` setting.
69    pub max_steps: usize,
70    /// Base learning rate for weight updates.
71    pub lr_weights: f64,
72    /// If true, use synchronous snapshot mode; otherwise in-place.
73    pub synchronous: bool,
74    /// Softmax temperature for action selection.
75    pub temperature: f64,
76    /// Blend factor for hidden layer weight updates, range `[0.0, 1.0]`.
77    ///
78    /// Controls how hidden layers combine two gradient signals:
79    /// `delta = lambda * backprop_grad + (1 - lambda) * pc_prediction_error`
80    ///
81    /// - `1.0` — Pure backprop: reward signal propagated from output (default).
82    /// - `0.0` — Pure local PC: prediction errors from inference loop
83    ///   used as gradients (Millidge et al. 2022). No vanishing gradient
84    ///   but no reward signal reaches hidden layers.
85    /// - `0.0 < lambda < 1.0` — Hybrid: reward-aware backprop regularized
86    ///   by local PC consistency errors.
87    ///
88    /// The output layer always uses standard backprop regardless of this value.
89    #[serde(default = "default_local_lambda")]
90    pub local_lambda: f64,
91    /// Enable residual skip connections between same-dimension hidden layers.
92    /// When false, `rezero_init` is ignored. When true, all hidden layers
93    /// must have the same size, and skip connections with learnable ReZero
94    /// scaling are added between consecutive hidden layers (not the first,
95    /// since input_size typically differs from hidden_size).
96    #[serde(default)]
97    pub residual: bool,
98    /// Initial value for ReZero scaling factors on residual connections.
99    /// Only used when `residual = true`. Controls initial contribution of
100    /// the nonlinear component: `h[i] = rezero_init * tanh(...) + h[i-1]`.
101    ///
102    /// - `0.001` — Near-identity start (ReZero: network learns depth gradually)
103    /// - `1.0` — Standard ResNet residual (full contribution from start)
104    ///
105    /// Ignored when `residual = false`.
106    #[serde(default = "default_rezero_init")]
107    pub rezero_init: f64,
108}
109
110/// Default rezero_init: 0.001 (near-identity at start).
111fn default_rezero_init() -> f64 {
112    0.001
113}
114
115/// Default local_lambda: 1.0 (pure backprop).
116fn default_local_lambda() -> f64 {
117    1.0
118}
119
120/// Result of the predictive coding inference loop.
121///
122/// Contains converged output logits, hidden state representations,
123/// and diagnostic information about the inference process.
124///
125/// Generic over a [`LinAlg`] backend `L`. Defaults to [`CpuLinAlg`].
126#[derive(Debug, Clone)]
127pub struct InferResult<L: LinAlg = CpuLinAlg> {
128    /// Converged output logits.
129    pub y_conv: L::Vector,
130    /// All hidden states concatenated (fed to critic).
131    pub latent_concat: L::Vector,
132    /// Per-layer hidden state activations.
133    pub hidden_states: Vec<L::Vector>,
134    /// Per-layer prediction errors from the last PC inference step.
135    /// Ordered from top hidden layer to bottom (reverse layer order).
136    pub prediction_errors: Vec<L::Vector>,
137    /// RMS prediction error across layers.
138    pub surprise_score: f64,
139    /// Number of inference steps performed.
140    pub steps_used: usize,
141    /// Whether the inference loop converged within tolerance.
142    pub converged: bool,
143    /// Per-layer tanh components for residual layers.
144    /// `None` for non-skip layers, `Some(tanh_out)` for skip-eligible layers.
145    /// Needed for correct backward pass (derivative on tanh_out, not full h\[i\]).
146    pub tanh_components: Vec<Option<L::Vector>>,
147}
148
149/// Action selection mode.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum SelectionMode {
152    /// Stochastic sampling from softmax distribution.
153    Training,
154    /// Deterministic argmax selection.
155    Play,
156}
157
158/// Predictive coding actor network.
159///
160/// Uses iterative top-down/bottom-up inference loops to produce
161/// stable hidden representations and output logits.
162///
163/// Generic over a [`LinAlg`] backend `L`. Defaults to [`CpuLinAlg`].
164///
165/// # Examples
166///
167/// ```
168/// use pc_rl_core::activation::Activation;
169/// use pc_rl_core::layer::LayerDef;
170/// use pc_rl_core::pc_actor::{PcActor, PcActorConfig, SelectionMode};
171/// use rand::SeedableRng;
172/// use rand::rngs::StdRng;
173///
174/// let config = PcActorConfig {
175///     input_size: 9,
176///     hidden_layers: vec![LayerDef { size: 18, activation: Activation::Tanh }],
177///     output_size: 9,
178///     output_activation: Activation::Tanh,
179///     alpha: 0.1, tol: 0.01, min_steps: 1, max_steps: 20,
180///     lr_weights: 0.01, synchronous: true, temperature: 1.0,
181///     local_lambda: 1.0,
182///     residual: false,
183///     rezero_init: 0.001,
184/// };
185/// let mut rng = StdRng::seed_from_u64(42);
186/// let actor: PcActor = PcActor::new(config, &mut rng).unwrap();
187/// let result = actor.infer(&[0.0; 9]);
188/// assert_eq!(result.y_conv.len(), 9);
189/// ```
190#[derive(Debug)]
191pub struct PcActor<L: LinAlg = CpuLinAlg> {
192    /// Network layers: hidden_layers.len() + 1 (output layer).
193    pub(crate) layers: Vec<Layer<L>>,
194    /// Actor configuration.
195    pub config: PcActorConfig,
196    /// ReZero scaling factors for skip connections. One per skip layer (all i >= 1 when residual=true).
197    pub(crate) rezero_alpha: Vec<f64>,
198    /// Projection matrices for skip connections between layers of different sizes.
199    /// One entry per skip layer: `None` for identity (same size), `Some(Matrix)` for projection.
200    pub(crate) skip_projections: Vec<Option<L::Matrix>>,
201}
202
203impl<L: LinAlg> PcActor<L> {
204    /// Creates a new PC actor with Xavier-initialized layers.
205    ///
206    /// # Arguments
207    ///
208    /// * `config` - Actor configuration specifying topology and hyperparameters.
209    /// * `rng` - Random number generator for weight initialization.
210    ///
211    /// # Errors
212    ///
213    /// Returns `PcError::ConfigValidation` if `input_size`, `output_size`,
214    /// or `temperature` are invalid.
215    pub fn new(config: PcActorConfig, rng: &mut impl Rng) -> Result<Self, PcError> {
216        if config.input_size == 0 {
217            return Err(PcError::ConfigValidation("input_size must be > 0".into()));
218        }
219        if config.output_size == 0 {
220            return Err(PcError::ConfigValidation("output_size must be > 0".into()));
221        }
222        if config.temperature <= 0.0 {
223            return Err(PcError::ConfigValidation(format!(
224                "temperature must be positive, got {}",
225                config.temperature
226            )));
227        }
228        if !(0.0..=1.0).contains(&config.local_lambda) {
229            return Err(PcError::ConfigValidation(format!(
230                "local_lambda must be in [0.0, 1.0], got {}",
231                config.local_lambda
232            )));
233        }
234        if config.rezero_init < 0.0 {
235            return Err(PcError::ConfigValidation(format!(
236                "rezero_init must be >= 0, got {}",
237                config.rezero_init
238            )));
239        }
240        let mut layers: Vec<Layer<L>> = Vec::new();
241        let mut prev_size = config.input_size;
242
243        for def in &config.hidden_layers {
244            layers.push(Layer::<L>::new(prev_size, def.size, def.activation, rng));
245            prev_size = def.size;
246        }
247
248        // Output layer
249        layers.push(Layer::<L>::new(
250            prev_size,
251            config.output_size,
252            config.output_activation,
253            rng,
254        ));
255
256        // Compute rezero_alpha and skip_projections: one per skip layer (all i >= 1)
257        let (rezero_alpha, skip_projections) = if config.residual {
258            let mut alphas = Vec::new();
259            let mut projs = Vec::new();
260            for i in 1..config.hidden_layers.len() {
261                alphas.push(config.rezero_init);
262                if config.hidden_layers[i].size != config.hidden_layers[i - 1].size {
263                    projs.push(Some(L::xavier_mat(
264                        config.hidden_layers[i].size,
265                        config.hidden_layers[i - 1].size,
266                        rng,
267                    )));
268                } else {
269                    projs.push(None);
270                }
271            }
272            (alphas, projs)
273        } else {
274            (Vec::new(), Vec::new())
275        };
276
277        Ok(Self {
278            layers,
279            config,
280            rezero_alpha,
281            skip_projections,
282        })
283    }
284
285    /// Creates a child actor by crossing over two parent actors using CCA neuron alignment.
286    ///
287    /// Aligns hidden neurons functionally via CCA before blending weights.
288    /// Input and output layers use positional crossover (no permutation problem).
289    ///
290    /// # Arguments
291    ///
292    /// * `parent_a` - First parent (reference, typically higher fitness).
293    /// * `parent_b` - Second parent (aligned to A via CCA).
294    /// * `caches_a` - Per-layer activation matrices for parent A `[batch × neurons]`.
295    /// * `caches_b` - Per-layer activation matrices for parent B `[batch × neurons]`.
296    /// * `alpha` - Blending weight: 1.0 = all A, 0.0 = all B.
297    /// * `child_config` - Topology configuration for the child network.
298    /// * `rng` - Random number generator for Xavier initialization.
299    ///
300    /// # Errors
301    ///
302    /// Returns `PcError::ConfigValidation` if `child_config` is invalid.
303    pub fn crossover(
304        parent_a: &PcActor<L>,
305        parent_b: &PcActor<L>,
306        caches_a: &[L::Matrix],
307        caches_b: &[L::Matrix],
308        alpha: f64,
309        child_config: PcActorConfig,
310        rng: &mut impl Rng,
311    ) -> Result<Self, PcError> {
312        let num_child_hidden = child_config.hidden_layers.len();
313        if num_child_hidden == 0 {
314            return Err(PcError::ConfigValidation(
315                "crossover requires at least one hidden layer".into(),
316            ));
317        }
318        let num_parent_a_hidden = parent_a.config.hidden_layers.len();
319        let num_parent_b_hidden = parent_b.config.hidden_layers.len();
320
321        let mut layers: Vec<Layer<L>> = Vec::new();
322        // Track the previous layer's CCA permutation for column propagation
323        let mut prev_perm: Option<Vec<usize>> = None;
324
325        // ── Input layer (layer 0): CCA-aligned crossover ─────────
326        let child_h0 = &child_config.hidden_layers[0];
327
328        if parent_a.config.input_size == child_config.input_size
329            && parent_b.config.input_size == child_config.input_size
330        {
331            let cache_a_0 = caches_a.first();
332            let cache_b_0 = caches_b.first();
333            let (layer, perm) = cca_align_and_blend_layer::<L>(
334                &parent_a.layers[0],
335                &parent_b.layers[0],
336                cache_a_0,
337                cache_b_0,
338                None, // No previous perm for first layer
339                child_h0.size,
340                L::mat_cols(&parent_a.layers[0].weights),
341                child_h0.activation,
342                alpha,
343                rng,
344            )?;
345            layers.push(layer);
346            prev_perm = perm;
347        } else {
348            layers.push(Layer::<L>::new(
349                child_config.input_size,
350                child_h0.size,
351                child_h0.activation,
352                rng,
353            ));
354        }
355
356        // ── Hidden layers 1..n: CCA-aligned crossover ────────────
357        for h_idx in 1..num_child_hidden {
358            let child_def = &child_config.hidden_layers[h_idx];
359            let prev_child_size = child_config.hidden_layers[h_idx - 1].size;
360
361            let a_has = h_idx < num_parent_a_hidden;
362            let b_has = h_idx < num_parent_b_hidden;
363
364            if a_has && b_has {
365                let cache_a_h = caches_a.get(h_idx);
366                let cache_b_h = caches_b.get(h_idx);
367                let (layer, perm) = cca_align_and_blend_layer::<L>(
368                    &parent_a.layers[h_idx],
369                    &parent_b.layers[h_idx],
370                    cache_a_h,
371                    cache_b_h,
372                    prev_perm.as_deref(),
373                    child_def.size,
374                    prev_child_size,
375                    child_def.activation,
376                    alpha,
377                    rng,
378                )?;
379                layers.push(layer);
380                prev_perm = perm;
381            } else {
382                layers.push(Layer::<L>::new(
383                    prev_child_size,
384                    child_def.size,
385                    child_def.activation,
386                    rng,
387                ));
388                prev_perm = None;
389            }
390        }
391
392        // ── Output layer: positional crossover or Xavier ─────────
393        let last_child_hidden = child_config.hidden_layers.last().map(|d| d.size).unwrap();
394        let a_out = parent_a.layers.last().unwrap();
395        let b_out = parent_b.layers.last().unwrap();
396        let a_out_in = L::mat_cols(&a_out.weights);
397        let b_out_in = L::mat_cols(&b_out.weights);
398
399        if a_out_in == last_child_hidden && b_out_in == last_child_hidden {
400            // Positional crossover with column permutation from last hidden layer
401            let b_out_permuted = if let Some(ref pp) = prev_perm {
402                permute_cols::<L>(&b_out.weights, pp)
403            } else {
404                b_out.weights.clone()
405            };
406            let out_rows = child_config.output_size;
407            let mut weights = L::zeros_mat(out_rows, last_child_hidden);
408            let mut biases = L::zeros_vec(out_rows);
409            let blend_rows = out_rows
410                .min(L::mat_rows(&a_out.weights))
411                .min(L::mat_rows(&b_out_permuted));
412            for r in 0..blend_rows {
413                for c in 0..last_child_hidden {
414                    let va = L::mat_get(&a_out.weights, r, c);
415                    let vb = L::mat_get(&b_out_permuted, r, c);
416                    L::mat_set(&mut weights, r, c, alpha * va + (1.0 - alpha) * vb);
417                }
418                let ba = L::vec_get(&a_out.bias, r);
419                let bb = L::vec_get(&b_out.bias, r);
420                L::vec_set(&mut biases, r, alpha * ba + (1.0 - alpha) * bb);
421            }
422            layers.push(Layer {
423                weights,
424                bias: biases,
425                activation: child_config.output_activation,
426            });
427        } else {
428            layers.push(Layer::<L>::new(
429                last_child_hidden,
430                child_config.output_size,
431                child_config.output_activation,
432                rng,
433            ));
434        }
435
436        // ── Residual components ──────────────────────────────────
437        let (rezero_alpha, skip_projections) = if child_config.residual {
438            let mut alphas = Vec::new();
439            let mut projs = Vec::new();
440            for i in 1..num_child_hidden {
441                // ReZero alpha: blend if both parents have it
442                let a_has_rz = i - 1 < parent_a.rezero_alpha.len();
443                let b_has_rz = i - 1 < parent_b.rezero_alpha.len();
444                let rz = if a_has_rz && b_has_rz {
445                    alpha * parent_a.rezero_alpha[i - 1]
446                        + (1.0 - alpha) * parent_b.rezero_alpha[i - 1]
447                } else if a_has_rz {
448                    parent_a.rezero_alpha[i - 1]
449                } else if b_has_rz {
450                    parent_b.rezero_alpha[i - 1]
451                } else {
452                    child_config.rezero_init
453                };
454                alphas.push(rz);
455
456                // Skip projections
457                let cur_size = child_config.hidden_layers[i].size;
458                let prev_size = child_config.hidden_layers[i - 1].size;
459                if cur_size != prev_size {
460                    let a_proj = parent_a
461                        .skip_projections
462                        .get(i - 1)
463                        .and_then(|p| p.as_ref());
464                    let b_proj = parent_b
465                        .skip_projections
466                        .get(i - 1)
467                        .and_then(|p| p.as_ref());
468                    if let (Some(ap), Some(bp)) = (a_proj, b_proj) {
469                        if L::mat_rows(ap) == cur_size
470                            && L::mat_cols(ap) == prev_size
471                            && L::mat_rows(bp) == cur_size
472                            && L::mat_cols(bp) == prev_size
473                        {
474                            // Blend projections
475                            let mut proj = L::zeros_mat(cur_size, prev_size);
476                            for r in 0..cur_size {
477                                for c in 0..prev_size {
478                                    let va = L::mat_get(ap, r, c);
479                                    let vb = L::mat_get(bp, r, c);
480                                    L::mat_set(&mut proj, r, c, alpha * va + (1.0 - alpha) * vb);
481                                }
482                            }
483                            projs.push(Some(proj));
484                        } else {
485                            projs.push(Some(L::xavier_mat(cur_size, prev_size, rng)));
486                        }
487                    } else {
488                        projs.push(Some(L::xavier_mat(cur_size, prev_size, rng)));
489                    }
490                } else {
491                    projs.push(None);
492                }
493            }
494            (alphas, projs)
495        } else {
496            (Vec::new(), Vec::new())
497        };
498
499        Ok(Self {
500            layers,
501            config: child_config,
502            rezero_alpha,
503            skip_projections,
504        })
505    }
506
507    /// Returns the total size of the latent concatenation (sum of hidden layer sizes).
508    pub fn latent_size(&self) -> usize {
509        self.config.hidden_layers.iter().map(|def| def.size).sum()
510    }
511
512    /// Runs the predictive coding inference loop on the given input.
513    ///
514    /// This method is `&self` — it never modifies weights.
515    ///
516    /// # Arguments
517    ///
518    /// * `input` - Input vector of length `input_size`.
519    ///
520    /// # Panics
521    ///
522    /// Panics if `input.len() != config.input_size`.
523    /// Returns whether hidden layer `i` has a skip connection (identity or projection).
524    fn is_skip_layer(&self, i: usize) -> bool {
525        self.config.residual && i >= 1
526    }
527
528    /// Returns the rezero_alpha/skip_projections index for hidden layer `i`.
529    fn skip_alpha_index(&self, i: usize) -> Option<usize> {
530        if !self.is_skip_layer(i) {
531            return None;
532        }
533        Some(i - 1)
534    }
535
536    pub fn infer(&self, input: &[f64]) -> InferResult<L> {
537        assert_eq!(
538            input.len(),
539            self.config.input_size,
540            "input size mismatch: got {}, expected {}",
541            input.len(),
542            self.config.input_size
543        );
544
545        let input_vec = L::vec_from_slice(input);
546        let n_hidden = self.config.hidden_layers.len();
547
548        // Forward pass to initialize hidden states and output
549        let mut hidden_states: Vec<L::Vector> = Vec::with_capacity(n_hidden);
550        let mut tanh_components: Vec<Option<L::Vector>> = Vec::with_capacity(n_hidden);
551        let mut prev = input_vec.clone();
552        for (i, layer) in self.layers[..n_hidden].iter().enumerate() {
553            let tanh_out = layer.forward(&prev);
554            if let Some(alpha_idx) = self.skip_alpha_index(i) {
555                let alpha = self.rezero_alpha[alpha_idx];
556                let scaled = L::vec_scale(&tanh_out, alpha);
557                let skip_path = if let Some(ref proj) = self.skip_projections[alpha_idx] {
558                    L::mat_vec_mul(proj, &prev)
559                } else {
560                    prev.clone()
561                };
562                prev = L::vec_add(&skip_path, &scaled);
563                tanh_components.push(Some(tanh_out));
564            } else {
565                prev = tanh_out;
566                tanh_components.push(None);
567            }
568            hidden_states.push(prev.clone());
569        }
570        // Output from last hidden (or input if no hidden)
571        let last_input = if n_hidden > 0 {
572            &hidden_states[n_hidden - 1]
573        } else {
574            &input_vec
575        };
576        let mut y = self.layers[n_hidden].forward(last_input);
577
578        // PC inference loop
579        let mut steps_used = 0;
580        let mut converged = false;
581        let mut surprise_score = 0.0;
582        let mut last_errors: Vec<L::Vector> = Vec::new();
583
584        for step in 0..self.config.max_steps {
585            steps_used = step + 1;
586
587            // Synchronous mode freezes states before updating (snapshot);
588            // in-place mode reads live states that include prior updates.
589            // Both modes need an owned copy of target[i] since we write
590            // hidden_states[i] within the loop body.
591            let snap_h: Vec<L::Vector>;
592            let snap_tc: Vec<Option<L::Vector>>;
593            let use_snapshot = self.config.synchronous;
594            if use_snapshot {
595                snap_h = hidden_states.clone();
596                snap_tc = tanh_components.clone();
597            } else {
598                snap_h = Vec::new();
599                snap_tc = Vec::new();
600            }
601
602            let mut error_vecs: Vec<L::Vector> = Vec::new();
603
604            for i in (0..n_hidden).rev() {
605                // state_above: sync reads frozen snapshot, in-place reads live
606                let state_above = if i == n_hidden - 1 {
607                    &y
608                } else if use_snapshot {
609                    snap_tc[i + 1].as_ref().unwrap_or(&snap_h[i + 1])
610                } else {
611                    tanh_components[i + 1]
612                        .as_ref()
613                        .unwrap_or(&hidden_states[i + 1])
614                };
615
616                // target: always read pre-update value (clone to own it)
617                let target = if use_snapshot {
618                    snap_tc[i].as_ref().unwrap_or(&snap_h[i]).clone()
619                } else {
620                    tanh_components[i]
621                        .as_ref()
622                        .unwrap_or(&hidden_states[i])
623                        .clone()
624                };
625
626                let prediction = self.layers[i + 1]
627                    .transpose_forward(state_above, self.config.hidden_layers[i].activation);
628
629                let error = L::vec_sub(&prediction, &target);
630                error_vecs.push(error.clone());
631
632                let updated_target = L::vec_add(&target, &L::vec_scale(&error, self.config.alpha));
633                if let Some(alpha_idx) = self.skip_alpha_index(i) {
634                    tanh_components[i] = Some(updated_target.clone());
635                    let alpha = self.rezero_alpha[alpha_idx];
636                    let prev_h = if i > 0 {
637                        &hidden_states[i - 1]
638                    } else {
639                        &input_vec
640                    };
641                    let skip_path = if let Some(ref proj) = self.skip_projections[alpha_idx] {
642                        L::mat_vec_mul(proj, prev_h)
643                    } else {
644                        prev_h.clone()
645                    };
646                    hidden_states[i] =
647                        L::vec_add(&skip_path, &L::vec_scale(&updated_target, alpha));
648                } else {
649                    hidden_states[i] = updated_target;
650                }
651            }
652
653            let top_hidden = if n_hidden > 0 {
654                &hidden_states[n_hidden - 1]
655            } else {
656                &input_vec
657            };
658            y = self.layers[n_hidden].forward(top_hidden);
659
660            let refs: Vec<&L::Vector> = error_vecs.iter().collect();
661            surprise_score = L::rms_error(&refs);
662            last_errors = error_vecs;
663
664            // Convergence check (alpha must be > 0 for meaningful convergence)
665            if self.config.alpha > 0.0
666                && step + 1 >= self.config.min_steps
667                && surprise_score < self.config.tol
668            {
669                converged = true;
670                break;
671            }
672        }
673
674        // Build latent_concat (uses vec_to_vec for GPU compatibility)
675        let mut latent_raw: Vec<f64> = Vec::new();
676        for h in &hidden_states {
677            latent_raw.extend_from_slice(&L::vec_to_vec(h));
678        }
679        let latent_concat = L::vec_from_slice(&latent_raw);
680
681        InferResult {
682            y_conv: y,
683            latent_concat,
684            hidden_states,
685            prediction_errors: last_errors,
686            surprise_score,
687            steps_used,
688            converged,
689            tanh_components,
690        }
691    }
692
693    /// Selects an action given converged output logits and valid actions.
694    ///
695    /// # Arguments
696    ///
697    /// * `y_conv` - Output logits from inference.
698    /// * `valid_actions` - Indices of valid actions.
699    /// * `mode` - Training (stochastic) or Play (deterministic).
700    /// * `rng` - Random number generator (used only in Training mode).
701    ///
702    /// # Panics
703    ///
704    /// Panics if `valid_actions` is empty.
705    pub fn select_action(
706        &self,
707        y_conv: &L::Vector,
708        valid_actions: &[usize],
709        mode: SelectionMode,
710        rng: &mut impl Rng,
711    ) -> usize {
712        assert!(!valid_actions.is_empty(), "valid_actions must not be empty");
713
714        // Scale logits by temperature
715        let scaled = L::vec_scale(y_conv, 1.0 / self.config.temperature);
716
717        let probs = L::softmax_masked(&scaled, valid_actions);
718
719        match mode {
720            SelectionMode::Play => L::argmax_masked(&probs, valid_actions),
721            SelectionMode::Training => L::sample_from_probs(&probs, valid_actions, rng),
722        }
723    }
724
725    /// Updates network weights using a blend of backprop and local PC error.
726    ///
727    /// The `local_lambda` config controls the blend: 1.0 = pure backprop,
728    /// 0.0 = pure local PC learning (Millidge et al. 2022), intermediate = hybrid.
729    ///
730    /// # Arguments
731    ///
732    /// * `output_delta` - Error signal at the output layer.
733    /// * `infer_result` - Result from the most recent inference.
734    /// * `input` - Original input that was fed to `infer`.
735    /// * `surprise_scale` - Multiplier on learning rate based on surprise.
736    ///
737    /// # Panics
738    ///
739    /// Panics if `input.len() != config.input_size`.
740    pub fn update_weights(
741        &mut self,
742        output_delta: &[f64],
743        infer_result: &InferResult<L>,
744        input: &[f64],
745        surprise_scale: f64,
746    ) {
747        assert_eq!(
748            input.len(),
749            self.config.input_size,
750            "input size mismatch: got {}, expected {}",
751            input.len(),
752            self.config.input_size
753        );
754
755        self.update_weights_hybrid(
756            output_delta,
757            infer_result,
758            input,
759            surprise_scale,
760            self.config.local_lambda,
761        );
762    }
763
764    /// Hybrid weight update blending backprop and local PC error signals.
765    ///
766    /// For hidden layers, the effective delta is:
767    /// `delta = lambda * backprop_delta + (1 - lambda) * pc_error`
768    ///
769    /// * `lambda = 1.0` → pure backprop (standard mode).
770    /// * `lambda = 0.0` → pure local PC learning (Millidge et al. 2022).
771    /// * `0 < lambda < 1` → hybrid blend.
772    ///
773    /// The output layer always uses standard backprop from `output_delta`.
774    fn update_weights_hybrid(
775        &mut self,
776        output_delta: &[f64],
777        infer_result: &InferResult<L>,
778        input: &[f64],
779        surprise_scale: f64,
780        lambda: f64,
781    ) {
782        let input_vec = L::vec_from_slice(input);
783        let output_delta_vec = L::vec_from_slice(output_delta);
784        let n_hidden = self.config.hidden_layers.len();
785        let n_layers = self.layers.len();
786
787        // Output layer: always standard backward
788        let output_input = if n_hidden > 0 {
789            &infer_result.hidden_states[n_hidden - 1]
790        } else {
791            &input_vec
792        };
793        let output_output = &infer_result.y_conv;
794        let mut bp_delta = self.layers[n_layers - 1].backward(
795            output_input,
796            output_output,
797            &output_delta_vec,
798            self.config.lr_weights,
799            surprise_scale,
800        );
801
802        // Hidden layers (from top to bottom)
803        for i in (0..n_hidden).rev() {
804            let layer_input = if i > 0 {
805                &infer_result.hidden_states[i - 1]
806            } else {
807                &input_vec
808            };
809
810            // Blend backprop delta with local PC error
811            let effective_delta = if (lambda - 1.0).abs() < f64::EPSILON {
812                bp_delta.clone()
813            } else if lambda.abs() < f64::EPSILON {
814                let error_idx = n_hidden - 1 - i;
815                infer_result.prediction_errors[error_idx].clone()
816            } else {
817                let error_idx = n_hidden - 1 - i;
818                let pc_error = &infer_result.prediction_errors[error_idx];
819                let bp_scaled = L::vec_scale(&bp_delta, lambda);
820                let pc_scaled = L::vec_scale(pc_error, 1.0 - lambda);
821                L::vec_add(&bp_scaled, &pc_scaled)
822            };
823
824            if let Some(alpha_idx) = self.skip_alpha_index(i) {
825                // Skip-eligible layer: use tanh_out for derivative, scale by alpha,
826                // add identity path to propagated gradient, update alpha.
827                let tanh_out = infer_result.tanh_components[i].as_ref().unwrap();
828                let alpha = self.rezero_alpha[alpha_idx];
829                let effective_lr = self.config.lr_weights * surprise_scale;
830
831                // Scale delta by rezero_alpha for the nonlinear path
832                let scaled_delta = L::vec_scale(&effective_delta, alpha);
833
834                // Backward through the layer using tanh_out (not hidden_states[i])
835                let propagated = self.layers[i].backward(
836                    layer_input,
837                    tanh_out,
838                    &scaled_delta,
839                    self.config.lr_weights,
840                    surprise_scale,
841                );
842
843                // Update rezero_alpha: dL/d(alpha) = delta · tanh_out
844                let grad_alpha: f64 = L::vec_dot(&effective_delta, tanh_out);
845                self.rezero_alpha[alpha_idx] -= effective_lr * grad_alpha;
846
847                // Propagated delta = nonlinear path + skip path (identity or projection)
848                if let Some(ref mut proj) = self.skip_projections[alpha_idx] {
849                    // Projection path: W_proj^T × delta
850                    let proj_t = L::mat_transpose(proj);
851                    let skip_delta = L::mat_vec_mul(&proj_t, &effective_delta);
852                    // Update projection: W_proj -= lr × outer(delta, layer_input)
853                    let dw_proj = L::outer_product(&effective_delta, layer_input);
854                    L::mat_scale_add(proj, &dw_proj, -effective_lr);
855                    bp_delta = L::vec_add(&propagated, &skip_delta);
856                } else {
857                    // Identity path: + delta
858                    bp_delta = L::vec_add(&propagated, &effective_delta);
859                }
860            } else {
861                // Standard layer: use hidden_states[i] as output
862                let layer_output = &infer_result.hidden_states[i];
863                bp_delta = self.layers[i].backward(
864                    layer_input,
865                    layer_output,
866                    &effective_delta,
867                    self.config.lr_weights,
868                    surprise_scale,
869                );
870            }
871        }
872    }
873
874    /// Extracts a serializable snapshot of current weights.
875    ///
876    /// Converts generic layers and skip projections to CPU-backed types.
877    pub fn to_weights(&self) -> crate::serializer::PcActorWeights {
878        let cpu_layers: Vec<Layer<CpuLinAlg>> = self
879            .layers
880            .iter()
881            .map(|layer| {
882                let rows = L::mat_rows(&layer.weights);
883                let cols = L::mat_cols(&layer.weights);
884                let mut cpu_weights = crate::matrix::Matrix::zeros(rows, cols);
885                for r in 0..rows {
886                    for c in 0..cols {
887                        cpu_weights.set(r, c, L::mat_get(&layer.weights, r, c));
888                    }
889                }
890                let bias_data = L::vec_to_vec(&layer.bias);
891                Layer {
892                    weights: cpu_weights,
893                    bias: bias_data,
894                    activation: layer.activation,
895                }
896            })
897            .collect();
898        let cpu_projs: Vec<Option<crate::matrix::Matrix>> = self
899            .skip_projections
900            .iter()
901            .map(|opt| {
902                opt.as_ref().map(|m| {
903                    let rows = L::mat_rows(m);
904                    let cols = L::mat_cols(m);
905                    let mut cpu_m = crate::matrix::Matrix::zeros(rows, cols);
906                    for r in 0..rows {
907                        for c in 0..cols {
908                            cpu_m.set(r, c, L::mat_get(m, r, c));
909                        }
910                    }
911                    cpu_m
912                })
913            })
914            .collect();
915        crate::serializer::PcActorWeights {
916            layers: cpu_layers,
917            rezero_alpha: self.rezero_alpha.clone(),
918            skip_projections: cpu_projs,
919        }
920    }
921
922    /// Restores an actor from saved weights without requiring an RNG.
923    ///
924    /// Converts CPU-backed weight snapshots to the target backend `L`.
925    /// Validates that all weight matrix dimensions and bias lengths match
926    /// the expected topology from `config`.
927    ///
928    /// # Errors
929    ///
930    /// Returns `PcError::DimensionMismatch` if any weight matrix or bias
931    /// vector has dimensions inconsistent with the config topology.
932    pub fn from_weights(
933        config: PcActorConfig,
934        weights: crate::serializer::PcActorWeights,
935    ) -> Result<Self, PcError> {
936        let n_hidden = config.hidden_layers.len();
937        let expected_layers = n_hidden + 1;
938
939        if weights.layers.len() != expected_layers {
940            return Err(PcError::DimensionMismatch {
941                expected: expected_layers,
942                got: weights.layers.len(),
943                context: "actor layer count",
944            });
945        }
946
947        // Validate each layer's dimensions
948        let mut prev_size = config.input_size;
949        for (i, cpu_layer) in weights.layers.iter().enumerate() {
950            let (expected_rows, expected_cols) = if i < n_hidden {
951                (config.hidden_layers[i].size, prev_size)
952            } else {
953                (config.output_size, prev_size)
954            };
955
956            if cpu_layer.weights.rows != expected_rows {
957                return Err(PcError::DimensionMismatch {
958                    expected: expected_rows,
959                    got: cpu_layer.weights.rows,
960                    context: "actor layer weight rows",
961                });
962            }
963            if cpu_layer.weights.cols != expected_cols {
964                return Err(PcError::DimensionMismatch {
965                    expected: expected_cols,
966                    got: cpu_layer.weights.cols,
967                    context: "actor layer weight cols",
968                });
969            }
970            if cpu_layer.bias.len() != expected_rows {
971                return Err(PcError::DimensionMismatch {
972                    expected: expected_rows,
973                    got: cpu_layer.bias.len(),
974                    context: "actor layer bias length",
975                });
976            }
977
978            if i < n_hidden {
979                prev_size = config.hidden_layers[i].size;
980            }
981        }
982
983        // Validate residual components
984        if config.residual {
985            let expected_residual = n_hidden.saturating_sub(1);
986            if weights.rezero_alpha.len() != expected_residual {
987                return Err(PcError::DimensionMismatch {
988                    expected: expected_residual,
989                    got: weights.rezero_alpha.len(),
990                    context: "actor rezero_alpha count",
991                });
992            }
993            if weights.skip_projections.len() != expected_residual {
994                return Err(PcError::DimensionMismatch {
995                    expected: expected_residual,
996                    got: weights.skip_projections.len(),
997                    context: "actor skip_projections count",
998                });
999            }
1000        }
1001
1002        // Convert layers
1003        let layers: Vec<Layer<L>> = weights
1004            .layers
1005            .into_iter()
1006            .map(|cpu_layer| {
1007                let rows = cpu_layer.weights.rows;
1008                let cols = cpu_layer.weights.cols;
1009                let mut mat = L::zeros_mat(rows, cols);
1010                for r in 0..rows {
1011                    for c in 0..cols {
1012                        L::mat_set(&mut mat, r, c, cpu_layer.weights.get(r, c));
1013                    }
1014                }
1015                let bias = L::vec_from_slice(&cpu_layer.bias);
1016                Layer {
1017                    weights: mat,
1018                    bias,
1019                    activation: cpu_layer.activation,
1020                }
1021            })
1022            .collect();
1023        let skip_projections: Vec<Option<L::Matrix>> = weights
1024            .skip_projections
1025            .into_iter()
1026            .map(|opt| {
1027                opt.map(|cpu_m| {
1028                    let rows = cpu_m.rows;
1029                    let cols = cpu_m.cols;
1030                    let mut mat = L::zeros_mat(rows, cols);
1031                    for r in 0..rows {
1032                        for c in 0..cols {
1033                            L::mat_set(&mut mat, r, c, cpu_m.get(r, c));
1034                        }
1035                    }
1036                    mat
1037                })
1038            })
1039            .collect();
1040        Ok(Self {
1041            layers,
1042            config,
1043            rezero_alpha: weights.rezero_alpha,
1044            skip_projections,
1045        })
1046    }
1047}
1048
1049/// Permute columns of a weight matrix according to a permutation.
1050/// `perm[i]` = source column index for destination column i.
1051pub(crate) fn permute_cols<L: LinAlg>(m: &L::Matrix, perm: &[usize]) -> L::Matrix {
1052    let rows = L::mat_rows(m);
1053    let cols = L::mat_cols(m);
1054    let perm_len = perm.len();
1055    let mut result = L::zeros_mat(rows, cols);
1056    for (dst, &src) in perm.iter().enumerate().take(cols.min(perm_len)) {
1057        if src < cols {
1058            for r in 0..rows {
1059                L::mat_set(&mut result, r, dst, L::mat_get(m, r, src));
1060            }
1061        }
1062    }
1063    // Copy remaining columns (beyond permutation length) in original order
1064    for dst in perm_len..cols {
1065        for r in 0..rows {
1066            L::mat_set(&mut result, r, dst, L::mat_get(m, r, dst));
1067        }
1068    }
1069    result
1070}
1071
1072/// Permute rows of a weight matrix according to a permutation.
1073/// `perm[i]` = source row index for destination row i.
1074pub(crate) fn permute_rows<L: LinAlg>(m: &L::Matrix, perm: &[usize], n: usize) -> L::Matrix {
1075    let cols = L::mat_cols(m);
1076    let perm_len = perm.len();
1077    let mut result = L::zeros_mat(n, cols);
1078    for (dst, &src) in perm.iter().enumerate().take(n.min(perm_len)) {
1079        if src < L::mat_rows(m) {
1080            for c in 0..cols {
1081                L::mat_set(&mut result, dst, c, L::mat_get(m, src, c));
1082            }
1083        }
1084    }
1085    // Copy remaining rows (unmatched) in original order
1086    for dst in perm_len..n {
1087        if dst < L::mat_rows(m) {
1088            for c in 0..cols {
1089                L::mat_set(&mut result, dst, c, L::mat_get(m, dst, c));
1090            }
1091        }
1092    }
1093    result
1094}
1095
1096/// Permute elements of a bias vector according to a permutation.
1097pub(crate) fn permute_vec<L: LinAlg>(v: &L::Vector, perm: &[usize], n: usize) -> L::Vector {
1098    let perm_len = perm.len();
1099    let mut result = L::zeros_vec(n);
1100    for (dst, &src) in perm.iter().enumerate().take(n.min(perm_len)) {
1101        if src < L::vec_len(v) {
1102            L::vec_set(&mut result, dst, L::vec_get(v, src));
1103        }
1104    }
1105    for dst in perm_len..n {
1106        if dst < L::vec_len(v) {
1107            L::vec_set(&mut result, dst, L::vec_get(v, dst));
1108        }
1109    }
1110    result
1111}
1112
1113/// Blend weights from two parent layers into a child layer.
1114/// Handles all 4 dimension cases (equal, child smaller, parents differ, child larger).
1115///
1116/// * `parent_a` - (weights, bias, neuron_count) for parent A.
1117/// * `parent_b` - (weights, bias, neuron_count) for parent B (already CCA-aligned).
1118/// * `child_cols` - Number of columns (input size) for child layer.
1119#[allow(clippy::too_many_arguments)]
1120pub(crate) fn blend_layer_weights<L: LinAlg>(
1121    parent_a: (&L::Matrix, &L::Vector, usize),
1122    parent_b: (&L::Matrix, &L::Vector, usize),
1123    n_child: usize,
1124    child_cols: usize,
1125    alpha: f64,
1126    rng: &mut impl Rng,
1127) -> (L::Matrix, L::Vector) {
1128    let (a_weights, a_biases, n_a) = parent_a;
1129    let (b_weights, b_biases, n_b) = parent_b;
1130    let n_min = n_a.min(n_b);
1131    let n_max = n_a.max(n_b);
1132    let a_cols = L::mat_cols(a_weights);
1133    let b_cols = L::mat_cols(b_weights);
1134    let use_cols = child_cols.min(a_cols).min(b_cols);
1135
1136    let mut weights = L::zeros_mat(n_child, child_cols);
1137    let mut biases = L::zeros_vec(n_child);
1138
1139    // Blending zone [0..min(n_min, n_child))
1140    let blend_end = n_min.min(n_child);
1141    for r in 0..blend_end {
1142        for c in 0..use_cols {
1143            let va = L::mat_get(a_weights, r, c);
1144            let vb = L::mat_get(b_weights, r, c);
1145            L::mat_set(&mut weights, r, c, alpha * va + (1.0 - alpha) * vb);
1146        }
1147        let ba = L::vec_get(a_biases, r);
1148        let bb = L::vec_get(b_biases, r);
1149        L::vec_set(&mut biases, r, alpha * ba + (1.0 - alpha) * bb);
1150    }
1151
1152    // Copy zone [n_min..min(n_max, n_child)) from the larger parent
1153    let copy_end = n_max.min(n_child);
1154    if copy_end > blend_end {
1155        let (larger_w, larger_b) = if n_a >= n_b {
1156            (a_weights, a_biases)
1157        } else {
1158            (b_weights, b_biases)
1159        };
1160        let larger_cols = L::mat_cols(larger_w);
1161        for r in blend_end..copy_end {
1162            for c in 0..child_cols.min(larger_cols) {
1163                L::mat_set(&mut weights, r, c, L::mat_get(larger_w, r, c));
1164            }
1165            L::vec_set(&mut biases, r, L::vec_get(larger_b, r));
1166        }
1167    }
1168
1169    // Xavier zone [n_max..n_child) for new neurons
1170    if n_child > n_max {
1171        let xavier = L::xavier_mat(n_child - n_max, child_cols, rng);
1172        for r in n_max..n_child {
1173            for c in 0..child_cols {
1174                L::mat_set(&mut weights, r, c, L::mat_get(&xavier, r - n_max, c));
1175            }
1176            // biases stay zero for Xavier zone
1177        }
1178    }
1179
1180    (weights, biases)
1181}
1182
1183/// CCA-aligns and blends a single hidden layer from two parents.
1184///
1185/// Handles the common pattern: CCA alignment → column permutation from
1186/// previous layer → row permutation → blend. Returns the blended layer
1187/// and the CCA permutation applied (for column propagation to the next layer).
1188///
1189/// * `prev_perm` — Permutation from the previous layer to apply to columns.
1190///   Pass `None` to skip column propagation.
1191#[allow(clippy::too_many_arguments)]
1192pub(crate) fn cca_align_and_blend_layer<L: LinAlg>(
1193    a_layer: &Layer<L>,
1194    b_layer: &Layer<L>,
1195    cache_a: Option<&L::Matrix>,
1196    cache_b: Option<&L::Matrix>,
1197    prev_perm: Option<&[usize]>,
1198    child_rows: usize,
1199    child_cols: usize,
1200    child_activation: Activation,
1201    alpha: f64,
1202    rng: &mut impl Rng,
1203) -> Result<(Layer<L>, Option<Vec<usize>>), crate::error::PcError> {
1204    let n_a = L::mat_rows(&a_layer.weights);
1205    let n_b = L::mat_rows(&b_layer.weights);
1206
1207    // CCA alignment
1208    let perm = if let (Some(ca), Some(cb)) = (cache_a, cache_b) {
1209        Some(crate::matrix::cca_neuron_alignment::<L>(ca, cb)?)
1210    } else {
1211        None
1212    };
1213
1214    // Apply previous layer's permutation to columns of parent B
1215    let b_weights_col = if let Some(pp) = prev_perm {
1216        permute_cols::<L>(&b_layer.weights, pp)
1217    } else {
1218        b_layer.weights.clone()
1219    };
1220
1221    // Apply CCA row permutation to parent B
1222    let b_weights_aligned = if let Some(ref p) = perm {
1223        permute_rows::<L>(&b_weights_col, p, n_b)
1224    } else {
1225        b_weights_col
1226    };
1227    let b_bias_aligned = if let Some(ref p) = perm {
1228        permute_vec::<L>(&b_layer.bias, p, n_b)
1229    } else {
1230        b_layer.bias.clone()
1231    };
1232
1233    let (weights, biases) = blend_layer_weights::<L>(
1234        (&a_layer.weights, &a_layer.bias, n_a),
1235        (&b_weights_aligned, &b_bias_aligned, n_b),
1236        child_rows,
1237        child_cols,
1238        alpha,
1239        rng,
1240    );
1241
1242    Ok((
1243        Layer {
1244            weights,
1245            bias: biases,
1246            activation: child_activation,
1247        },
1248        perm,
1249    ))
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254    use super::*;
1255    use crate::activation::Activation;
1256    use crate::layer::LayerDef;
1257    use crate::matrix::WEIGHT_CLIP;
1258    use rand::rngs::StdRng;
1259    use rand::SeedableRng;
1260
1261    fn make_rng() -> StdRng {
1262        StdRng::seed_from_u64(42)
1263    }
1264
1265    fn default_config() -> PcActorConfig {
1266        PcActorConfig {
1267            input_size: 9,
1268            hidden_layers: vec![LayerDef {
1269                size: 18,
1270                activation: Activation::Tanh,
1271            }],
1272            output_size: 9,
1273            output_activation: Activation::Tanh,
1274            alpha: 0.1,
1275            tol: 0.01,
1276            min_steps: 1,
1277            max_steps: 20,
1278            lr_weights: 0.01,
1279            synchronous: true,
1280            temperature: 1.0,
1281            local_lambda: 1.0,
1282            residual: false,
1283            rezero_init: 0.001,
1284        }
1285    }
1286
1287    fn two_hidden_config() -> PcActorConfig {
1288        PcActorConfig {
1289            hidden_layers: vec![
1290                LayerDef {
1291                    size: 18,
1292                    activation: Activation::Tanh,
1293                },
1294                LayerDef {
1295                    size: 12,
1296                    activation: Activation::Tanh,
1297                },
1298            ],
1299            ..default_config()
1300        }
1301    }
1302
1303    // ── Inference Tests ──────────────────────────────────────────────
1304
1305    #[test]
1306    fn test_infer_converges_on_zero_board() {
1307        let mut rng = make_rng();
1308        let actor: PcActor = PcActor::new(default_config(), &mut rng).unwrap();
1309        let result = actor.infer(&[0.0; 9]);
1310        // Should complete without panic; all finite
1311        for &v in &result.y_conv {
1312            assert!(v.is_finite());
1313        }
1314    }
1315
1316    #[test]
1317    fn test_infer_steps_used_at_least_min_steps() {
1318        let mut rng = make_rng();
1319        let config = PcActorConfig {
1320            min_steps: 3,
1321            ..default_config()
1322        };
1323        let actor: PcActor = PcActor::new(config, &mut rng).unwrap();
1324        let result = actor.infer(&[0.0; 9]);
1325        assert!(result.steps_used >= 3);
1326    }
1327
1328    #[test]
1329    fn test_infer_alpha_zero_does_not_converge() {
1330        let mut rng = make_rng();
1331        let config = PcActorConfig {
1332            alpha: 0.0,
1333            ..default_config()
1334        };
1335        let actor: PcActor = PcActor::new(config, &mut rng).unwrap();
1336        let result = actor.infer(&[0.0; 9]);
1337        assert!(!result.converged);
1338        assert_eq!(result.steps_used, 20);
1339    }
1340
1341    #[test]
1342    fn test_infer_does_not_modify_weights() {
1343        let mut rng = make_rng();
1344        let actor: PcActor = PcActor::new(default_config(), &mut rng).unwrap();
1345        let weights_before: Vec<Vec<f64>> = actor
1346            .layers
1347            .iter()
1348            .map(|l| l.weights.data.clone())
1349            .collect();
1350        let _ = actor.infer(&[0.0; 9]);
1351        for (i, layer) in actor.layers.iter().enumerate() {
1352            assert_eq!(layer.weights.data, weights_before[i]);
1353        }
1354    }
1355
1356    #[test]
1357    fn test_infer_latent_size_single_hidden() {
1358        let mut rng = make_rng();
1359        let actor: PcActor = PcActor::new(default_config(), &mut rng).unwrap();
1360        let result = actor.infer(&[0.0; 9]);
1361        assert_eq!(result.latent_concat.len(), 18);
1362    }
1363
1364    #[test]
1365    fn test_infer_latent_size_two_hidden() {
1366        let mut rng = make_rng();
1367        let actor: PcActor = PcActor::new(two_hidden_config(), &mut rng).unwrap();
1368        let result = actor.infer(&[0.0; 9]);
1369        assert_eq!(result.latent_concat.len(), 30);
1370    }
1371
1372    #[test]
1373    fn test_infer_latent_size_matches_latent_size_method() {
1374        let mut rng = make_rng();
1375        let actor: PcActor = PcActor::new(two_hidden_config(), &mut rng).unwrap();
1376        let result = actor.infer(&[0.0; 9]);
1377        assert_eq!(result.latent_concat.len(), actor.latent_size());
1378    }
1379
1380    #[test]
1381    fn test_infer_y_conv_length_equals_output_size() {
1382        let mut rng = make_rng();
1383        let actor: PcActor = PcActor::new(default_config(), &mut rng).unwrap();
1384        let result = actor.infer(&[0.0; 9]);
1385        assert_eq!(result.y_conv.len(), 9);
1386    }
1387
1388    #[test]
1389    fn test_infer_hidden_states_count_matches_hidden_layers() {
1390        let mut rng = make_rng();
1391        let actor: PcActor = PcActor::new(two_hidden_config(), &mut rng).unwrap();
1392        let result = actor.infer(&[0.0; 9]);
1393        assert_eq!(result.hidden_states.len(), 2);
1394    }
1395
1396    #[test]
1397    fn test_infer_all_outputs_finite() {
1398        let mut rng = make_rng();
1399        let actor: PcActor = PcActor::new(default_config(), &mut rng).unwrap();
1400        let result = actor.infer(&[1.0, -1.0, 0.5, -0.5, 0.0, 1.0, -1.0, 0.5, -0.5]);
1401        for &v in &result.y_conv {
1402            assert!(v.is_finite());
1403        }
1404        for &v in &result.latent_concat {
1405            assert!(v.is_finite());
1406        }
1407        assert!(result.surprise_score.is_finite());
1408    }
1409
1410    #[test]
1411    fn test_infer_surprise_score_nonnegative() {
1412        let mut rng = make_rng();
1413        let actor: PcActor = PcActor::new(default_config(), &mut rng).unwrap();
1414        let result = actor.infer(&[0.0; 9]);
1415        assert!(result.surprise_score >= 0.0);
1416    }
1417
1418    #[test]
1419    fn test_infer_synchronous_and_inplace_both_converge() {
1420        let mut rng = make_rng();
1421        let sync_actor: PcActor = PcActor::new(default_config(), &mut rng).unwrap();
1422        let mut rng2 = make_rng();
1423        let inplace_config = PcActorConfig {
1424            synchronous: false,
1425            ..default_config()
1426        };
1427        let inplace_actor: PcActor = PcActor::new(inplace_config, &mut rng2).unwrap();
1428        let sync_result = sync_actor.infer(&[0.0; 9]);
1429        let inplace_result = inplace_actor.infer(&[0.0; 9]);
1430        // Both should complete without panic; at least one should converge or use all steps
1431        assert!(sync_result.steps_used > 0);
1432        assert!(inplace_result.steps_used > 0);
1433    }
1434
1435    #[test]
1436    fn test_infer_synchronous_produces_different_result_than_inplace() {
1437        let mut rng = make_rng();
1438        let config = PcActorConfig {
1439            hidden_layers: vec![
1440                LayerDef {
1441                    size: 18,
1442                    activation: Activation::Tanh,
1443                },
1444                LayerDef {
1445                    size: 12,
1446                    activation: Activation::Tanh,
1447                },
1448            ],
1449            alpha: 0.3,
1450            tol: 1e-15,
1451            min_steps: 1,
1452            max_steps: 3,
1453            ..default_config()
1454        };
1455        let sync_actor: PcActor = PcActor::new(config.clone(), &mut rng).unwrap();
1456        let mut rng2 = make_rng();
1457        let inplace_config = PcActorConfig {
1458            synchronous: false,
1459            ..config
1460        };
1461        let inplace_actor: PcActor = PcActor::new(inplace_config, &mut rng2).unwrap();
1462        let input = [1.0, -1.0, 0.5, -0.5, 0.0, 1.0, -1.0, 0.5, -0.5];
1463        let sync_result = sync_actor.infer(&input);
1464        let inplace_result = inplace_actor.infer(&input);
1465        // Different update orders should produce different hidden representations
1466        let differs = sync_result
1467            .latent_concat
1468            .iter()
1469            .zip(inplace_result.latent_concat.iter())
1470            .any(|(a, b)| (a - b).abs() > 1e-12);
1471        assert!(
1472            differs,
1473            "Synchronous and in-place should produce different results"
1474        );
1475    }
1476
1477    #[test]
1478    #[should_panic(expected = "input size")]
1479    fn test_infer_panics_wrong_input_length() {
1480        let mut rng = make_rng();
1481        let actor: PcActor = PcActor::new(default_config(), &mut rng).unwrap();
1482        let _ = actor.infer(&[0.0; 5]);
1483    }
1484
1485    // ── Action Selection Tests ───────────────────────────────────────
1486
1487    #[test]
1488    fn test_select_action_training_always_in_valid() {
1489        let mut rng = make_rng();
1490        let actor: PcActor = PcActor::new(default_config(), &mut rng).unwrap();
1491        let logits = vec![0.1, -0.2, 0.5, -0.1, 0.3, 0.0, -0.3, 0.2, 0.4];
1492        let valid = vec![0, 2, 4, 6, 8];
1493        for _ in 0..20 {
1494            let action = actor.select_action(&logits, &valid, SelectionMode::Training, &mut rng);
1495            assert!(valid.contains(&action));
1496        }
1497    }
1498
1499    #[test]
1500    fn test_select_action_play_mode_deterministic() {
1501        let mut rng1 = StdRng::seed_from_u64(1);
1502        let mut rng2 = StdRng::seed_from_u64(99);
1503        let mut rng_init = make_rng();
1504        let actor: PcActor = PcActor::new(default_config(), &mut rng_init).unwrap();
1505        let logits = vec![0.1, -0.2, 0.5, -0.1, 0.3, 0.0, -0.3, 0.2, 0.4];
1506        let valid = vec![0, 2, 4, 6, 8];
1507        let a1 = actor.select_action(&logits, &valid, SelectionMode::Play, &mut rng1);
1508        let a2 = actor.select_action(&logits, &valid, SelectionMode::Play, &mut rng2);
1509        assert_eq!(a1, a2, "Play mode should be deterministic");
1510    }
1511
1512    #[test]
1513    fn test_select_action_temperature_gt_one_more_uniform() {
1514        let mut rng = make_rng();
1515        let hot_config = PcActorConfig {
1516            temperature: 5.0,
1517            ..default_config()
1518        };
1519        let actor: PcActor = PcActor::new(hot_config, &mut rng).unwrap();
1520        // With high temperature, sampling should visit more actions
1521        let logits = vec![10.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1522        let valid: Vec<usize> = (0..9).collect();
1523        let mut seen = std::collections::HashSet::new();
1524        let mut rng2 = StdRng::seed_from_u64(123);
1525        for _ in 0..100 {
1526            let a = actor.select_action(&logits, &valid, SelectionMode::Training, &mut rng2);
1527            seen.insert(a);
1528        }
1529        assert!(seen.len() > 1, "High temperature should explore more");
1530    }
1531
1532    #[test]
1533    #[should_panic]
1534    fn test_select_action_empty_valid_panics() {
1535        let mut rng = make_rng();
1536        let actor: PcActor = PcActor::new(default_config(), &mut rng).unwrap();
1537        let logits = vec![0.1; 9];
1538        let _ = actor.select_action(&logits, &[], SelectionMode::Training, &mut rng);
1539    }
1540
1541    // ── Weight Update Tests ──────────────────────────────────────────
1542
1543    #[test]
1544    fn test_update_weights_changes_first_layer() {
1545        let mut rng = make_rng();
1546        let mut actor: PcActor = PcActor::new(default_config(), &mut rng).unwrap();
1547        let input = vec![1.0, -1.0, 0.5, -0.5, 0.0, 1.0, -1.0, 0.5, -0.5];
1548        let infer_result = actor.infer(&input);
1549        let weights_before = actor.layers[0].weights.data.clone();
1550        let delta = vec![0.1; 9];
1551        actor.update_weights(&delta, &infer_result, &input, 1.0);
1552        assert_ne!(actor.layers[0].weights.data, weights_before);
1553    }
1554
1555    #[test]
1556    fn test_update_weights_clips_all_layers() {
1557        let mut rng = make_rng();
1558        let mut actor: PcActor = PcActor::new(default_config(), &mut rng).unwrap();
1559        let input = vec![1.0; 9];
1560        let infer_result = actor.infer(&input);
1561        let delta = vec![1e6; 9];
1562        actor.update_weights(&delta, &infer_result, &input, 1.0);
1563        for layer in &actor.layers {
1564            for &w in &layer.weights.data {
1565                assert!(
1566                    w.abs() <= WEIGHT_CLIP + 1e-12,
1567                    "Weight {w} exceeds WEIGHT_CLIP"
1568                );
1569            }
1570        }
1571    }
1572
1573    #[test]
1574    fn test_update_weights_two_hidden_changes_both_layers() {
1575        let mut rng = make_rng();
1576        let mut actor: PcActor = PcActor::new(two_hidden_config(), &mut rng).unwrap();
1577        let input = vec![0.5; 9];
1578        let infer_result = actor.infer(&input);
1579        let w0_before = actor.layers[0].weights.data.clone();
1580        let w1_before = actor.layers[1].weights.data.clone();
1581        let delta = vec![0.1; 9];
1582        actor.update_weights(&delta, &infer_result, &input, 1.0);
1583        assert_ne!(
1584            actor.layers[0].weights.data, w0_before,
1585            "Layer 0 should change"
1586        );
1587        assert_ne!(
1588            actor.layers[1].weights.data, w1_before,
1589            "Layer 1 should change"
1590        );
1591    }
1592
1593    #[test]
1594    #[should_panic(expected = "input size")]
1595    fn test_update_weights_panics_wrong_x_size() {
1596        let mut rng = make_rng();
1597        let mut actor: PcActor = PcActor::new(default_config(), &mut rng).unwrap();
1598        let input = vec![0.0; 9];
1599        let infer_result = actor.infer(&input);
1600        let delta = vec![0.1; 9];
1601        actor.update_weights(&delta, &infer_result, &[0.0; 5], 1.0);
1602    }
1603
1604    // ── Zero Hidden Layers Test ─────────────────────────────────
1605
1606    #[test]
1607    fn test_infer_zero_hidden_layers_produces_finite_output() {
1608        let mut rng = make_rng();
1609        let config = PcActorConfig {
1610            hidden_layers: vec![],
1611            ..default_config()
1612        };
1613        let actor: PcActor = PcActor::new(config, &mut rng).unwrap();
1614        let result = actor.infer(&[0.5; 9]);
1615        assert_eq!(result.y_conv.len(), 9);
1616        assert!(result.y_conv.iter().all(|v| v.is_finite()));
1617        assert!(result.latent_concat.is_empty());
1618        assert!(result.hidden_states.is_empty());
1619    }
1620
1621    // ── Config Validation Tests ─────────────────────────────────
1622
1623    #[test]
1624    fn test_new_zero_input_size_returns_error() {
1625        let mut rng = make_rng();
1626        let config = PcActorConfig {
1627            input_size: 0,
1628            ..default_config()
1629        };
1630        let result: Result<PcActor, _> = PcActor::new(config, &mut rng);
1631        assert!(result.is_err());
1632        let err = result.unwrap_err();
1633        assert!(matches!(err, crate::error::PcError::ConfigValidation(_)));
1634    }
1635
1636    #[test]
1637    fn test_new_zero_output_size_returns_error() {
1638        let mut rng = make_rng();
1639        let config = PcActorConfig {
1640            output_size: 0,
1641            ..default_config()
1642        };
1643        let result: Result<PcActor, _> = PcActor::new(config, &mut rng);
1644        assert!(result.is_err());
1645    }
1646
1647    #[test]
1648    fn test_new_zero_temperature_returns_error() {
1649        let mut rng = make_rng();
1650        let config = PcActorConfig {
1651            temperature: 0.0,
1652            ..default_config()
1653        };
1654        let result: Result<PcActor, _> = PcActor::new(config, &mut rng);
1655        assert!(result.is_err());
1656    }
1657
1658    #[test]
1659    fn test_new_negative_temperature_returns_error() {
1660        let mut rng = make_rng();
1661        let config = PcActorConfig {
1662            temperature: -1.0,
1663            ..default_config()
1664        };
1665        let result: Result<PcActor, _> = PcActor::new(config, &mut rng);
1666        assert!(result.is_err());
1667    }
1668
1669    // ── Residual / ReZero Config Tests ────────────────────────
1670
1671    #[test]
1672    fn test_default_config_residual_false() {
1673        let config = default_config();
1674        assert!(!config.residual);
1675    }
1676
1677    #[test]
1678    fn test_default_config_rezero_init() {
1679        let config = default_config();
1680        assert!((config.rezero_init - 0.001).abs() < 1e-12);
1681    }
1682
1683    #[test]
1684    fn test_new_negative_rezero_init_returns_error() {
1685        let mut rng = make_rng();
1686        let config = PcActorConfig {
1687            residual: true,
1688            rezero_init: -0.1,
1689            ..default_config()
1690        };
1691        let result: Result<PcActor, _> = PcActor::new(config, &mut rng);
1692        assert!(result.is_err());
1693    }
1694
1695    #[test]
1696    fn test_residual_mixed_sizes_accepted() {
1697        let mut rng = make_rng();
1698        let config = PcActorConfig {
1699            residual: true,
1700            hidden_layers: vec![
1701                LayerDef {
1702                    size: 27,
1703                    activation: Activation::Tanh,
1704                },
1705                LayerDef {
1706                    size: 18,
1707                    activation: Activation::Tanh,
1708                },
1709            ],
1710            ..default_config()
1711        };
1712        let result: Result<PcActor, _> = PcActor::new(config, &mut rng);
1713        assert!(result.is_ok());
1714    }
1715
1716    #[test]
1717    fn test_residual_mixed_sizes_all_skip() {
1718        // [27, 27, 18]: ALL layers i>=1 get skip — identity for 27→27, projection for 27→18
1719        let mut rng = make_rng();
1720        let config = PcActorConfig {
1721            residual: true,
1722            hidden_layers: vec![
1723                LayerDef {
1724                    size: 27,
1725                    activation: Activation::Tanh,
1726                },
1727                LayerDef {
1728                    size: 27,
1729                    activation: Activation::Tanh,
1730                },
1731                LayerDef {
1732                    size: 18,
1733                    activation: Activation::Tanh,
1734                },
1735            ],
1736            ..default_config()
1737        };
1738        let actor: PcActor = PcActor::new(config, &mut rng).unwrap();
1739        // 2 skips: layer 1 (identity) + layer 2 (projection)
1740        assert_eq!(actor.rezero_alpha.len(), 2);
1741    }
1742
1743    #[test]
1744    fn test_residual_heterogeneous_has_projection() {
1745        // [27, 18]: different sizes → projection matrix created
1746        let mut rng = make_rng();
1747        let config = PcActorConfig {
1748            residual: true,
1749            hidden_layers: vec![
1750                LayerDef {
1751                    size: 27,
1752                    activation: Activation::Tanh,
1753                },
1754                LayerDef {
1755                    size: 18,
1756                    activation: Activation::Tanh,
1757                },
1758            ],
1759            ..default_config()
1760        };
1761        let actor: PcActor = PcActor::new(config, &mut rng).unwrap();
1762        assert_eq!(actor.rezero_alpha.len(), 1);
1763        assert_eq!(actor.skip_projections.len(), 1);
1764        assert!(actor.skip_projections[0].is_some());
1765        let proj = actor.skip_projections[0].as_ref().unwrap();
1766        assert_eq!(proj.rows, 18); // output dim
1767        assert_eq!(proj.cols, 27); // input dim
1768    }
1769
1770    #[test]
1771    fn test_residual_homogeneous_no_projection() {
1772        // [27, 27]: same sizes → no projection needed
1773        let mut rng = make_rng();
1774        let actor: PcActor = PcActor::new(residual_two_hidden_config(), &mut rng).unwrap();
1775        assert_eq!(actor.skip_projections.len(), 1);
1776        assert!(actor.skip_projections[0].is_none());
1777    }
1778
1779    #[test]
1780    fn test_residual_mixed_sizes_infer_finite() {
1781        let mut rng = make_rng();
1782        let config = PcActorConfig {
1783            residual: true,
1784            hidden_layers: vec![
1785                LayerDef {
1786                    size: 27,
1787                    activation: Activation::Tanh,
1788                },
1789                LayerDef {
1790                    size: 27,
1791                    activation: Activation::Tanh,
1792                },
1793                LayerDef {
1794                    size: 18,
1795                    activation: Activation::Tanh,
1796                },
1797            ],
1798            ..default_config()
1799        };
1800        let actor: PcActor = PcActor::new(config, &mut rng).unwrap();
1801        let result = actor.infer(&[0.5; 9]);
1802        for &v in &result.y_conv {
1803            assert!(v.is_finite());
1804        }
1805        assert_eq!(result.hidden_states.len(), 3);
1806        assert_eq!(result.latent_concat.len(), 27 + 27 + 18);
1807    }
1808
1809    #[test]
1810    fn test_residual_same_size_hidden_layers_accepted() {
1811        let mut rng = make_rng();
1812        let config = PcActorConfig {
1813            residual: true,
1814            hidden_layers: vec![
1815                LayerDef {
1816                    size: 27,
1817                    activation: Activation::Tanh,
1818                },
1819                LayerDef {
1820                    size: 27,
1821                    activation: Activation::Tanh,
1822                },
1823            ],
1824            ..default_config()
1825        };
1826        let result: Result<PcActor, _> = PcActor::new(config, &mut rng);
1827        assert!(result.is_ok());
1828    }
1829
1830    fn residual_two_hidden_config() -> PcActorConfig {
1831        PcActorConfig {
1832            residual: true,
1833            hidden_layers: vec![
1834                LayerDef {
1835                    size: 27,
1836                    activation: Activation::Tanh,
1837                },
1838                LayerDef {
1839                    size: 27,
1840                    activation: Activation::Tanh,
1841                },
1842            ],
1843            ..default_config()
1844        }
1845    }
1846
1847    #[test]
1848    fn test_non_residual_actor_empty_rezero_alpha() {
1849        let mut rng = make_rng();
1850        let actor: PcActor = PcActor::new(default_config(), &mut rng).unwrap();
1851        assert!(actor.rezero_alpha.is_empty());
1852    }
1853
1854    #[test]
1855    fn test_residual_two_hidden_one_rezero_alpha() {
1856        let mut rng = make_rng();
1857        let actor: PcActor = PcActor::new(residual_two_hidden_config(), &mut rng).unwrap();
1858        assert_eq!(actor.rezero_alpha.len(), 1);
1859    }
1860
1861    #[test]
1862    fn test_residual_three_hidden_two_rezero_alpha() {
1863        let mut rng = make_rng();
1864        let config = PcActorConfig {
1865            residual: true,
1866            hidden_layers: vec![
1867                LayerDef {
1868                    size: 27,
1869                    activation: Activation::Tanh,
1870                },
1871                LayerDef {
1872                    size: 27,
1873                    activation: Activation::Tanh,
1874                },
1875                LayerDef {
1876                    size: 27,
1877                    activation: Activation::Tanh,
1878                },
1879            ],
1880            ..default_config()
1881        };
1882        let actor: PcActor = PcActor::new(config, &mut rng).unwrap();
1883        assert_eq!(actor.rezero_alpha.len(), 2);
1884    }
1885
1886    #[test]
1887    fn test_rezero_alpha_initialized_to_rezero_init() {
1888        let mut rng = make_rng();
1889        let config = PcActorConfig {
1890            rezero_init: 0.005,
1891            ..residual_two_hidden_config()
1892        };
1893        let actor: PcActor = PcActor::new(config, &mut rng).unwrap();
1894        assert!((actor.rezero_alpha[0] - 0.005).abs() < 1e-12);
1895    }
1896
1897    #[test]
1898    fn test_residual_single_hidden_zero_rezero_alpha() {
1899        let mut rng = make_rng();
1900        let config = PcActorConfig {
1901            residual: true,
1902            ..default_config()
1903        };
1904        let actor: PcActor = PcActor::new(config, &mut rng).unwrap();
1905        assert!(actor.rezero_alpha.is_empty());
1906    }
1907
1908    #[test]
1909    fn test_residual_single_hidden_accepted() {
1910        let mut rng = make_rng();
1911        let config = PcActorConfig {
1912            residual: true,
1913            ..default_config()
1914        };
1915        let result: Result<PcActor, _> = PcActor::new(config, &mut rng);
1916        assert!(result.is_ok());
1917    }
1918
1919    // ── Local Learning (PC-based weight updates) Tests ──────────
1920
1921    // ── Residual Inference Tests ──────────────────────────────
1922
1923    #[test]
1924    fn test_residual_false_identical_to_non_residual() {
1925        let input = vec![1.0, -1.0, 0.5, -0.5, 0.0, 1.0, -1.0, 0.5, -0.5];
1926        let mut rng1 = make_rng();
1927        let actor1: PcActor = PcActor::new(two_hidden_config(), &mut rng1).unwrap();
1928        let result1 = actor1.infer(&input);
1929
1930        let mut rng2 = make_rng();
1931        let config2 = PcActorConfig {
1932            residual: false,
1933            ..two_hidden_config()
1934        };
1935        let actor2: PcActor = PcActor::new(config2, &mut rng2).unwrap();
1936        let result2 = actor2.infer(&input);
1937
1938        for (a, b) in result1.y_conv.iter().zip(result2.y_conv.iter()) {
1939            assert!((a - b).abs() < 1e-12);
1940        }
1941    }
1942
1943    #[test]
1944    fn test_residual_rezero_zero_second_hidden_near_identity() {
1945        let mut rng = make_rng();
1946        let config = PcActorConfig {
1947            rezero_init: 0.0,
1948            alpha: 0.0,
1949            ..residual_two_hidden_config()
1950        };
1951        let actor: PcActor = PcActor::new(config, &mut rng).unwrap();
1952        let result = actor.infer(&[0.5; 9]);
1953        let h0 = &result.hidden_states[0];
1954        let h1 = &result.hidden_states[1];
1955        for (a, b) in h0.iter().zip(h1.iter()) {
1956            assert!(
1957                (a - b).abs() < 1e-12,
1958                "With rezero_init=0, h[1] should equal h[0]"
1959            );
1960        }
1961    }
1962
1963    #[test]
1964    fn test_residual_infer_all_outputs_finite() {
1965        let mut rng = make_rng();
1966        let actor: PcActor = PcActor::new(residual_two_hidden_config(), &mut rng).unwrap();
1967        let result = actor.infer(&[0.5; 9]);
1968        for &v in &result.y_conv {
1969            assert!(v.is_finite());
1970        }
1971        for &v in &result.latent_concat {
1972            assert!(v.is_finite());
1973        }
1974        assert!(result.surprise_score.is_finite());
1975    }
1976
1977    #[test]
1978    fn test_residual_latent_concat_size() {
1979        let mut rng = make_rng();
1980        let actor: PcActor = PcActor::new(residual_two_hidden_config(), &mut rng).unwrap();
1981        let result = actor.infer(&[0.5; 9]);
1982        assert_eq!(result.latent_concat.len(), 54); // 27 + 27
1983    }
1984
1985    #[test]
1986    fn test_residual_pc_loop_completes() {
1987        let mut rng = make_rng();
1988        let config = PcActorConfig {
1989            alpha: 0.03,
1990            max_steps: 5,
1991            ..residual_two_hidden_config()
1992        };
1993        let actor: PcActor = PcActor::new(config, &mut rng).unwrap();
1994        let result = actor.infer(&[0.5; 9]);
1995        assert!(result.steps_used > 0);
1996        assert!(result.steps_used <= 5);
1997    }
1998
1999    #[test]
2000    fn test_residual_hidden_states_count() {
2001        let mut rng = make_rng();
2002        let actor: PcActor = PcActor::new(residual_two_hidden_config(), &mut rng).unwrap();
2003        let result = actor.infer(&[0.5; 9]);
2004        assert_eq!(result.hidden_states.len(), 2);
2005    }
2006
2007    #[test]
2008    fn test_residual_infer_does_not_modify_weights() {
2009        let mut rng = make_rng();
2010        let actor: PcActor = PcActor::new(residual_two_hidden_config(), &mut rng).unwrap();
2011        let weights_before: Vec<Vec<f64>> = actor
2012            .layers
2013            .iter()
2014            .map(|l| l.weights.data.clone())
2015            .collect();
2016        let alpha_before = actor.rezero_alpha.clone();
2017        let _ = actor.infer(&[0.5; 9]);
2018        for (i, layer) in actor.layers.iter().enumerate() {
2019            assert_eq!(layer.weights.data, weights_before[i]);
2020        }
2021        assert_eq!(actor.rezero_alpha, alpha_before);
2022    }
2023
2024    #[test]
2025    fn test_residual_three_hidden_infer_finite() {
2026        let mut rng = make_rng();
2027        let config = PcActorConfig {
2028            residual: true,
2029            hidden_layers: vec![
2030                LayerDef {
2031                    size: 27,
2032                    activation: Activation::Tanh,
2033                },
2034                LayerDef {
2035                    size: 27,
2036                    activation: Activation::Tanh,
2037                },
2038                LayerDef {
2039                    size: 27,
2040                    activation: Activation::Tanh,
2041                },
2042            ],
2043            ..default_config()
2044        };
2045        let actor: PcActor = PcActor::new(config, &mut rng).unwrap();
2046        let result = actor.infer(&[0.5; 9]);
2047        for &v in &result.y_conv {
2048            assert!(v.is_finite());
2049        }
2050    }
2051
2052    #[test]
2053    fn test_residual_tanh_components_populated() {
2054        let mut rng = make_rng();
2055        let actor: PcActor = PcActor::new(residual_two_hidden_config(), &mut rng).unwrap();
2056        let result = actor.infer(&[0.5; 9]);
2057        assert_eq!(result.tanh_components.len(), 2);
2058        assert!(result.tanh_components[0].is_none()); // layer 0: no skip
2059        assert!(result.tanh_components[1].is_some()); // layer 1: has skip
2060        assert_eq!(result.tanh_components[1].as_ref().unwrap().len(), 27);
2061    }
2062
2063    #[test]
2064    fn test_residual_pc_prediction_uses_tanh_component_not_full_state() {
2065        // With rezero_init=1.0, h[1] = tanh_out + h[0] (significantly different
2066        // from tanh_out alone). If PC prediction uses h[1] instead of tanh_out,
2067        // the surprise score and convergence will differ.
2068        // Two runs with same weights: one with alpha=0 (no PC), one with alpha>0.
2069        // The PC loop should converge meaningfully (surprise decreases).
2070        let mut rng = make_rng();
2071        let config = PcActorConfig {
2072            rezero_init: 1.0,
2073            alpha: 0.1,
2074            max_steps: 20,
2075            tol: 0.001,
2076            min_steps: 1,
2077            ..residual_two_hidden_config()
2078        };
2079        let actor: PcActor = PcActor::new(config, &mut rng).unwrap();
2080        let result = actor.infer(&[1.0, -1.0, 0.5, -0.5, 0.0, 1.0, -1.0, 0.5, -0.5]);
2081        // With proper PC predictions, surprise should be finite and non-negative
2082        assert!(result.surprise_score.is_finite());
2083        assert!(result.surprise_score >= 0.0);
2084        // Prediction errors should all be finite
2085        for errors in &result.prediction_errors {
2086            for &e in errors {
2087                assert!(e.is_finite(), "PC prediction error not finite: {e}");
2088            }
2089        }
2090    }
2091
2092    // ── Residual Backward Tests ────────────────────────────────
2093
2094    #[test]
2095    fn test_residual_false_update_identical_to_non_residual() {
2096        let input = vec![1.0, -1.0, 0.5, -0.5, 0.0, 1.0, -1.0, 0.5, -0.5];
2097        let delta = vec![0.1; 9];
2098
2099        let mut rng1 = make_rng();
2100        let mut actor1: PcActor = PcActor::new(two_hidden_config(), &mut rng1).unwrap();
2101        let infer1 = actor1.infer(&input);
2102        actor1.update_weights(&delta, &infer1, &input, 1.0);
2103
2104        let mut rng2 = make_rng();
2105        let config2 = PcActorConfig {
2106            residual: false,
2107            ..two_hidden_config()
2108        };
2109        let mut actor2: PcActor = PcActor::new(config2, &mut rng2).unwrap();
2110        let infer2 = actor2.infer(&input);
2111        actor2.update_weights(&delta, &infer2, &input, 1.0);
2112
2113        for i in 0..actor1.layers.len() {
2114            assert_eq!(actor1.layers[i].weights.data, actor2.layers[i].weights.data);
2115        }
2116    }
2117
2118    #[test]
2119    fn test_residual_update_changes_all_layer_weights() {
2120        let mut rng = make_rng();
2121        let mut actor: PcActor = PcActor::new(residual_two_hidden_config(), &mut rng).unwrap();
2122        let input = vec![0.5; 9];
2123        let infer_result = actor.infer(&input);
2124        let w0 = actor.layers[0].weights.data.clone();
2125        let w1 = actor.layers[1].weights.data.clone();
2126        let w2 = actor.layers[2].weights.data.clone();
2127        actor.update_weights(&[0.1; 9], &infer_result, &input, 1.0);
2128        assert_ne!(actor.layers[0].weights.data, w0, "Layer 0 should change");
2129        assert_ne!(actor.layers[1].weights.data, w1, "Layer 1 should change");
2130        assert_ne!(
2131            actor.layers[2].weights.data, w2,
2132            "Output layer should change"
2133        );
2134    }
2135
2136    #[test]
2137    fn test_residual_update_changes_rezero_alpha() {
2138        let mut rng = make_rng();
2139        let mut actor: PcActor = PcActor::new(residual_two_hidden_config(), &mut rng).unwrap();
2140        let input = vec![0.5; 9];
2141        let infer_result = actor.infer(&input);
2142        let alpha_before = actor.rezero_alpha.clone();
2143        actor.update_weights(&[0.1; 9], &infer_result, &input, 1.0);
2144        assert_ne!(
2145            actor.rezero_alpha, alpha_before,
2146            "rezero_alpha should be updated by backprop"
2147        );
2148    }
2149
2150    #[test]
2151    fn test_residual_update_clips_weights() {
2152        let mut rng = make_rng();
2153        let mut actor: PcActor = PcActor::new(residual_two_hidden_config(), &mut rng).unwrap();
2154        let input = vec![1.0; 9];
2155        let infer_result = actor.infer(&input);
2156        actor.update_weights(&[1e6; 9], &infer_result, &input, 1.0);
2157        for layer in &actor.layers {
2158            for &w in &layer.weights.data {
2159                assert!(
2160                    w.abs() <= WEIGHT_CLIP + 1e-12,
2161                    "Weight {w} exceeds WEIGHT_CLIP"
2162                );
2163            }
2164        }
2165    }
2166
2167    #[test]
2168    fn test_residual_gradient_stronger_than_non_residual() {
2169        let input = vec![1.0, -1.0, 0.5, -0.5, 0.0, 1.0, -1.0, 0.5, -0.5];
2170        let delta = vec![0.1; 9];
2171
2172        // Non-residual 2 hidden layers (27, 27)
2173        let mut rng1 = make_rng();
2174        let config1 = PcActorConfig {
2175            hidden_layers: vec![
2176                LayerDef {
2177                    size: 27,
2178                    activation: Activation::Tanh,
2179                },
2180                LayerDef {
2181                    size: 27,
2182                    activation: Activation::Tanh,
2183                },
2184            ],
2185            ..default_config()
2186        };
2187        let mut actor1: PcActor = PcActor::new(config1, &mut rng1).unwrap();
2188        let w0_before1 = actor1.layers[0].weights.data.clone();
2189        let infer1 = actor1.infer(&input);
2190        actor1.update_weights(&delta, &infer1, &input, 1.0);
2191        let change1: f64 = actor1.layers[0]
2192            .weights
2193            .data
2194            .iter()
2195            .zip(w0_before1.iter())
2196            .map(|(a, b)| (a - b).abs())
2197            .sum();
2198
2199        // Residual 2 hidden layers (27, 27) with rezero_init=1.0
2200        let mut rng2 = make_rng();
2201        let config2 = PcActorConfig {
2202            rezero_init: 1.0,
2203            ..residual_two_hidden_config()
2204        };
2205        let mut actor2: PcActor = PcActor::new(config2, &mut rng2).unwrap();
2206        let w0_before2 = actor2.layers[0].weights.data.clone();
2207        let infer2 = actor2.infer(&input);
2208        actor2.update_weights(&delta, &infer2, &input, 1.0);
2209        let change2: f64 = actor2.layers[0]
2210            .weights
2211            .data
2212            .iter()
2213            .zip(w0_before2.iter())
2214            .map(|(a, b)| (a - b).abs())
2215            .sum();
2216
2217        assert!(
2218            change2 > change1,
2219            "Residual should propagate stronger gradient to layer 0: residual={change2:.6}, non-residual={change1:.6}"
2220        );
2221    }
2222
2223    #[test]
2224    fn test_residual_hybrid_lambda_works() {
2225        let mut rng = make_rng();
2226        let config = PcActorConfig {
2227            local_lambda: 0.99,
2228            ..residual_two_hidden_config()
2229        };
2230        let mut actor: PcActor = PcActor::new(config, &mut rng).unwrap();
2231        let input = vec![0.5; 9];
2232        let infer_result = actor.infer(&input);
2233        let w0_before = actor.layers[0].weights.data.clone();
2234        actor.update_weights(&[0.1; 9], &infer_result, &input, 1.0);
2235        assert_ne!(actor.layers[0].weights.data, w0_before);
2236    }
2237
2238    fn local_learning_config() -> PcActorConfig {
2239        PcActorConfig {
2240            local_lambda: 0.0,
2241            ..default_config()
2242        }
2243    }
2244
2245    #[test]
2246    fn test_infer_prediction_errors_count_matches_hidden_layers() {
2247        let mut rng = make_rng();
2248        let actor: PcActor = PcActor::new(default_config(), &mut rng).unwrap();
2249        let result = actor.infer(&[0.0; 9]);
2250        assert_eq!(result.prediction_errors.len(), 1);
2251    }
2252
2253    #[test]
2254    fn test_infer_prediction_errors_two_hidden() {
2255        let mut rng = make_rng();
2256        let actor: PcActor = PcActor::new(two_hidden_config(), &mut rng).unwrap();
2257        let result = actor.infer(&[0.0; 9]);
2258        assert_eq!(result.prediction_errors.len(), 2);
2259    }
2260
2261    #[test]
2262    fn test_infer_prediction_errors_zero_hidden_is_empty() {
2263        let mut rng = make_rng();
2264        let config = PcActorConfig {
2265            hidden_layers: vec![],
2266            ..default_config()
2267        };
2268        let actor: PcActor = PcActor::new(config, &mut rng).unwrap();
2269        let result = actor.infer(&[0.5; 9]);
2270        assert!(result.prediction_errors.is_empty());
2271    }
2272
2273    #[test]
2274    fn test_infer_prediction_errors_all_finite() {
2275        let mut rng = make_rng();
2276        let actor: PcActor = PcActor::new(default_config(), &mut rng).unwrap();
2277        let result = actor.infer(&[1.0, -1.0, 0.5, -0.5, 0.0, 1.0, -1.0, 0.5, -0.5]);
2278        for errors in &result.prediction_errors {
2279            for &e in errors {
2280                assert!(e.is_finite(), "prediction error not finite: {e}");
2281            }
2282        }
2283    }
2284
2285    #[test]
2286    fn test_infer_prediction_errors_size_matches_hidden_layer_size() {
2287        let mut rng = make_rng();
2288        let actor: PcActor = PcActor::new(default_config(), &mut rng).unwrap();
2289        let result = actor.infer(&[0.0; 9]);
2290        // default_config has one hidden layer of size 18
2291        assert_eq!(result.prediction_errors[0].len(), 18);
2292    }
2293
2294    #[test]
2295    fn test_local_learning_config_accepted() {
2296        let mut rng = make_rng();
2297        let config = local_learning_config();
2298        assert!((config.local_lambda).abs() < f64::EPSILON);
2299        let actor: Result<PcActor, _> = PcActor::new(config, &mut rng);
2300        assert!(actor.is_ok());
2301    }
2302
2303    #[test]
2304    fn test_local_learning_update_changes_weights() {
2305        let mut rng = make_rng();
2306        let mut actor: PcActor = PcActor::new(local_learning_config(), &mut rng).unwrap();
2307        let input = vec![1.0, -1.0, 0.5, -0.5, 0.0, 1.0, -1.0, 0.5, -0.5];
2308        let infer_result = actor.infer(&input);
2309        let weights_before = actor.layers[0].weights.data.clone();
2310        let delta = vec![0.1; 9];
2311        actor.update_weights(&delta, &infer_result, &input, 1.0);
2312        assert_ne!(actor.layers[0].weights.data, weights_before);
2313    }
2314
2315    #[test]
2316    fn test_local_learning_clips_weights() {
2317        let mut rng = make_rng();
2318        let mut actor: PcActor = PcActor::new(local_learning_config(), &mut rng).unwrap();
2319        let input = vec![1.0; 9];
2320        let infer_result = actor.infer(&input);
2321        let delta = vec![1e6; 9];
2322        actor.update_weights(&delta, &infer_result, &input, 1.0);
2323        for layer in &actor.layers {
2324            for &w in &layer.weights.data {
2325                assert!(
2326                    w.abs() <= WEIGHT_CLIP + 1e-12,
2327                    "Weight {w} exceeds WEIGHT_CLIP"
2328                );
2329            }
2330        }
2331    }
2332
2333    #[test]
2334    fn test_local_learning_two_hidden_changes_both() {
2335        let mut rng = make_rng();
2336        let config = PcActorConfig {
2337            local_lambda: 0.0,
2338            ..two_hidden_config()
2339        };
2340        let mut actor: PcActor = PcActor::new(config, &mut rng).unwrap();
2341        let input = vec![0.5; 9];
2342        let infer_result = actor.infer(&input);
2343        let w0_before = actor.layers[0].weights.data.clone();
2344        let w1_before = actor.layers[1].weights.data.clone();
2345        let delta = vec![0.1; 9];
2346        actor.update_weights(&delta, &infer_result, &input, 1.0);
2347        assert_ne!(
2348            actor.layers[0].weights.data, w0_before,
2349            "Layer 0 should change"
2350        );
2351        assert_ne!(
2352            actor.layers[1].weights.data, w1_before,
2353            "Layer 1 should change"
2354        );
2355    }
2356
2357    #[test]
2358    fn test_local_learning_differs_from_backprop() {
2359        let input = vec![1.0, -1.0, 0.5, -0.5, 0.0, 1.0, -1.0, 0.5, -0.5];
2360        let delta = vec![0.1; 9];
2361
2362        // Backprop actor
2363        let mut rng1 = make_rng();
2364        let mut bp_actor: PcActor = PcActor::new(default_config(), &mut rng1).unwrap();
2365        let bp_infer = bp_actor.infer(&input);
2366        bp_actor.update_weights(&delta, &bp_infer, &input, 1.0);
2367
2368        // Local learning actor (same initial weights)
2369        let mut rng2 = make_rng();
2370        let mut ll_actor: PcActor = PcActor::new(local_learning_config(), &mut rng2).unwrap();
2371        let ll_infer = ll_actor.infer(&input);
2372        ll_actor.update_weights(&delta, &ll_infer, &input, 1.0);
2373
2374        // Hidden layer weights should differ between the two approaches
2375        assert_ne!(
2376            bp_actor.layers[0].weights.data, ll_actor.layers[0].weights.data,
2377            "Local learning should produce different weight updates than backprop"
2378        );
2379    }
2380
2381    // ── Hybrid Learning (local_lambda) Tests ────────────────────
2382
2383    fn hybrid_config(lambda: f64) -> PcActorConfig {
2384        PcActorConfig {
2385            local_lambda: lambda,
2386            ..default_config()
2387        }
2388    }
2389
2390    #[test]
2391    fn test_local_lambda_one_equals_backprop() {
2392        let input = vec![1.0, -1.0, 0.5, -0.5, 0.0, 1.0, -1.0, 0.5, -0.5];
2393        let delta = vec![0.1; 9];
2394
2395        // Pure backprop (local_learning=false, default)
2396        let mut rng1 = make_rng();
2397        let mut bp_actor: PcActor = PcActor::new(default_config(), &mut rng1).unwrap();
2398        let bp_infer = bp_actor.infer(&input);
2399        bp_actor.update_weights(&delta, &bp_infer, &input, 1.0);
2400
2401        // lambda=1.0 should be identical to backprop
2402        let mut rng2 = make_rng();
2403        let mut lam_actor: PcActor = PcActor::new(hybrid_config(1.0), &mut rng2).unwrap();
2404        let lam_infer = lam_actor.infer(&input);
2405        lam_actor.update_weights(&delta, &lam_infer, &input, 1.0);
2406
2407        assert_eq!(
2408            bp_actor.layers[0].weights.data, lam_actor.layers[0].weights.data,
2409            "lambda=1.0 should produce identical weights to pure backprop"
2410        );
2411    }
2412
2413    #[test]
2414    fn test_local_lambda_zero_equals_local_learning() {
2415        let input = vec![1.0, -1.0, 0.5, -0.5, 0.0, 1.0, -1.0, 0.5, -0.5];
2416        let delta = vec![0.1; 9];
2417
2418        // Pure local (local_learning=true)
2419        let mut rng1 = make_rng();
2420        let mut ll_actor: PcActor = PcActor::new(local_learning_config(), &mut rng1).unwrap();
2421        let ll_infer = ll_actor.infer(&input);
2422        ll_actor.update_weights(&delta, &ll_infer, &input, 1.0);
2423
2424        // lambda=0.0 should be identical to pure local
2425        let mut rng2 = make_rng();
2426        let mut lam_actor: PcActor = PcActor::new(hybrid_config(0.0), &mut rng2).unwrap();
2427        let lam_infer = lam_actor.infer(&input);
2428        lam_actor.update_weights(&delta, &lam_infer, &input, 1.0);
2429
2430        assert_eq!(
2431            ll_actor.layers[0].weights.data, lam_actor.layers[0].weights.data,
2432            "lambda=0.0 should produce identical weights to pure local learning"
2433        );
2434    }
2435
2436    #[test]
2437    fn test_local_lambda_half_differs_from_both_pure_modes() {
2438        let input = vec![1.0, -1.0, 0.5, -0.5, 0.0, 1.0, -1.0, 0.5, -0.5];
2439        let delta = vec![0.1; 9];
2440
2441        // Pure backprop
2442        let mut rng1 = make_rng();
2443        let mut bp_actor: PcActor = PcActor::new(default_config(), &mut rng1).unwrap();
2444        let bp_infer = bp_actor.infer(&input);
2445        bp_actor.update_weights(&delta, &bp_infer, &input, 1.0);
2446
2447        // Pure local
2448        let mut rng2 = make_rng();
2449        let mut ll_actor: PcActor = PcActor::new(local_learning_config(), &mut rng2).unwrap();
2450        let ll_infer = ll_actor.infer(&input);
2451        ll_actor.update_weights(&delta, &ll_infer, &input, 1.0);
2452
2453        // Hybrid lambda=0.5
2454        let mut rng3 = make_rng();
2455        let mut hy_actor: PcActor = PcActor::new(hybrid_config(0.5), &mut rng3).unwrap();
2456        let hy_infer = hy_actor.infer(&input);
2457        hy_actor.update_weights(&delta, &hy_infer, &input, 1.0);
2458
2459        assert_ne!(
2460            hy_actor.layers[0].weights.data, bp_actor.layers[0].weights.data,
2461            "lambda=0.5 should differ from pure backprop"
2462        );
2463        assert_ne!(
2464            hy_actor.layers[0].weights.data, ll_actor.layers[0].weights.data,
2465            "lambda=0.5 should differ from pure local"
2466        );
2467    }
2468
2469    #[test]
2470    fn test_local_lambda_changes_weights() {
2471        let mut rng = make_rng();
2472        let mut actor: PcActor = PcActor::new(hybrid_config(0.5), &mut rng).unwrap();
2473        let input = vec![1.0, -1.0, 0.5, -0.5, 0.0, 1.0, -1.0, 0.5, -0.5];
2474        let infer_result = actor.infer(&input);
2475        let weights_before = actor.layers[0].weights.data.clone();
2476        let delta = vec![0.1; 9];
2477        actor.update_weights(&delta, &infer_result, &input, 1.0);
2478        assert_ne!(actor.layers[0].weights.data, weights_before);
2479    }
2480
2481    #[test]
2482    fn test_local_lambda_clips_weights() {
2483        let mut rng = make_rng();
2484        let mut actor: PcActor = PcActor::new(hybrid_config(0.5), &mut rng).unwrap();
2485        let input = vec![1.0; 9];
2486        let infer_result = actor.infer(&input);
2487        let delta = vec![1e6; 9];
2488        actor.update_weights(&delta, &infer_result, &input, 1.0);
2489        for layer in &actor.layers {
2490            for &w in &layer.weights.data {
2491                assert!(
2492                    w.abs() <= WEIGHT_CLIP + 1e-12,
2493                    "Weight {w} exceeds WEIGHT_CLIP"
2494                );
2495            }
2496        }
2497    }
2498
2499    #[test]
2500    fn test_local_lambda_negative_returns_error() {
2501        let mut rng = make_rng();
2502        let config = hybrid_config(-0.1);
2503        let result: Result<PcActor, _> = PcActor::new(config, &mut rng);
2504        assert!(result.is_err());
2505    }
2506
2507    #[test]
2508    fn test_local_lambda_above_one_returns_error() {
2509        let mut rng = make_rng();
2510        let config = hybrid_config(1.1);
2511        let result: Result<PcActor, _> = PcActor::new(config, &mut rng);
2512        assert!(result.is_err());
2513    }
2514
2515    // ── Phase 5 Cycle 5.1: Crossover same topology ─────────────
2516
2517    fn crossover_config_27() -> PcActorConfig {
2518        PcActorConfig {
2519            input_size: 9,
2520            hidden_layers: vec![LayerDef {
2521                size: 27,
2522                activation: Activation::Tanh,
2523            }],
2524            output_size: 9,
2525            output_activation: Activation::Linear,
2526            alpha: 0.03,
2527            tol: 0.01,
2528            min_steps: 1,
2529            max_steps: 5,
2530            lr_weights: 0.005,
2531            synchronous: true,
2532            temperature: 1.0,
2533            local_lambda: 0.99,
2534            residual: false,
2535            rezero_init: 0.001,
2536        }
2537    }
2538
2539    fn make_caches_for_actor(actor: &PcActor, batch_size: usize) -> Vec<Vec<Vec<f64>>> {
2540        let num_hidden = actor.config.hidden_layers.len();
2541        let mut layers: Vec<Vec<Vec<f64>>> = (0..num_hidden).map(|_| Vec::new()).collect();
2542        for i in 0..batch_size {
2543            let input: Vec<f64> = (0..actor.config.input_size)
2544                .map(|j| ((i * actor.config.input_size + j) as f64 * 0.01).sin())
2545                .collect();
2546            let result = actor.infer(&input);
2547            for (layer_idx, state) in result.hidden_states.iter().enumerate() {
2548                layers[layer_idx].push(state.clone());
2549            }
2550        }
2551        layers
2552    }
2553
2554    fn build_cache_matrix(
2555        cache_layers: &[Vec<Vec<f64>>],
2556        layer_idx: usize,
2557    ) -> crate::matrix::Matrix {
2558        use crate::linalg::LinAlg;
2559        let samples = &cache_layers[layer_idx];
2560        let batch_size = samples.len();
2561        let n_neurons = samples[0].len();
2562        let mut mat = CpuLinAlg::zeros_mat(batch_size, n_neurons);
2563        for (r, sample) in samples.iter().enumerate() {
2564            for (c, &val) in sample.iter().enumerate() {
2565                CpuLinAlg::mat_set(&mut mat, r, c, val);
2566            }
2567        }
2568        mat
2569    }
2570
2571    #[test]
2572    fn test_crossover_same_topology_produces_valid_actor() {
2573        let mut rng_a = StdRng::seed_from_u64(42);
2574        let mut rng_b = StdRng::seed_from_u64(123);
2575        let config = crossover_config_27();
2576        let actor_a: PcActor = PcActor::new(config.clone(), &mut rng_a).unwrap();
2577        let actor_b: PcActor = PcActor::new(config.clone(), &mut rng_b).unwrap();
2578
2579        let caches_a = make_caches_for_actor(&actor_a, 50);
2580        let caches_b = make_caches_for_actor(&actor_b, 50);
2581        let cache_mats_a: Vec<_> = (0..1).map(|i| build_cache_matrix(&caches_a, i)).collect();
2582        let cache_mats_b: Vec<_> = (0..1).map(|i| build_cache_matrix(&caches_b, i)).collect();
2583
2584        let mut rng_child = StdRng::seed_from_u64(99);
2585        let child: PcActor = PcActor::crossover(
2586            &actor_a,
2587            &actor_b,
2588            &cache_mats_a,
2589            &cache_mats_b,
2590            0.5,
2591            config,
2592            &mut rng_child,
2593        )
2594        .unwrap();
2595
2596        // Child has same topology
2597        assert_eq!(child.layers.len(), actor_a.layers.len());
2598        for (i, layer) in child.layers.iter().enumerate() {
2599            assert_eq!(
2600                CpuLinAlg::mat_rows(&layer.weights),
2601                CpuLinAlg::mat_rows(&actor_a.layers[i].weights)
2602            );
2603            assert_eq!(
2604                CpuLinAlg::mat_cols(&layer.weights),
2605                CpuLinAlg::mat_cols(&actor_a.layers[i].weights)
2606            );
2607        }
2608    }
2609
2610    #[test]
2611    fn test_crossover_same_topology_child_differs_from_parents() {
2612        let mut rng_a = StdRng::seed_from_u64(42);
2613        let mut rng_b = StdRng::seed_from_u64(123);
2614        let config = crossover_config_27();
2615        let actor_a: PcActor = PcActor::new(config.clone(), &mut rng_a).unwrap();
2616        let actor_b: PcActor = PcActor::new(config.clone(), &mut rng_b).unwrap();
2617
2618        let caches_a = make_caches_for_actor(&actor_a, 50);
2619        let caches_b = make_caches_for_actor(&actor_b, 50);
2620        let cache_mats_a: Vec<_> = (0..1).map(|i| build_cache_matrix(&caches_a, i)).collect();
2621        let cache_mats_b: Vec<_> = (0..1).map(|i| build_cache_matrix(&caches_b, i)).collect();
2622
2623        let mut rng_child = StdRng::seed_from_u64(99);
2624        let child: PcActor = PcActor::crossover(
2625            &actor_a,
2626            &actor_b,
2627            &cache_mats_a,
2628            &cache_mats_b,
2629            0.5,
2630            config,
2631            &mut rng_child,
2632        )
2633        .unwrap();
2634
2635        // Child weights differ from both parents (blended)
2636        assert_ne!(child.layers[0].weights.data, actor_a.layers[0].weights.data);
2637        assert_ne!(child.layers[0].weights.data, actor_b.layers[0].weights.data);
2638    }
2639
2640    #[test]
2641    fn test_crossover_alpha_one_approximates_parent_a() {
2642        let mut rng_a = StdRng::seed_from_u64(42);
2643        let mut rng_b = StdRng::seed_from_u64(123);
2644        let config = crossover_config_27();
2645        let actor_a: PcActor = PcActor::new(config.clone(), &mut rng_a).unwrap();
2646        let actor_b: PcActor = PcActor::new(config.clone(), &mut rng_b).unwrap();
2647
2648        let caches_a = make_caches_for_actor(&actor_a, 50);
2649        let caches_b = make_caches_for_actor(&actor_b, 50);
2650        let cache_mats_a: Vec<_> = (0..1).map(|i| build_cache_matrix(&caches_a, i)).collect();
2651        let cache_mats_b: Vec<_> = (0..1).map(|i| build_cache_matrix(&caches_b, i)).collect();
2652
2653        let mut rng_child = StdRng::seed_from_u64(99);
2654        let child: PcActor = PcActor::crossover(
2655            &actor_a,
2656            &actor_b,
2657            &cache_mats_a,
2658            &cache_mats_b,
2659            1.0, // alpha=1.0 → child ≈ parent A
2660            config,
2661            &mut rng_child,
2662        )
2663        .unwrap();
2664
2665        // Input layer (layer 0): positional crossover, should be close to parent A
2666        let a_w = &actor_a.layers[0].weights.data;
2667        let child_w = &child.layers[0].weights.data;
2668        let max_diff: f64 = a_w
2669            .iter()
2670            .zip(child_w.iter())
2671            .map(|(a, c)| (a - c).abs())
2672            .fold(0.0_f64, f64::max);
2673        assert!(
2674            max_diff < 1e-10,
2675            "alpha=1.0: input layer max diff from parent A = {max_diff}"
2676        );
2677    }
2678
2679    #[test]
2680    fn test_crossover_child_weights_finite() {
2681        let mut rng_a = StdRng::seed_from_u64(42);
2682        let mut rng_b = StdRng::seed_from_u64(123);
2683        let config = crossover_config_27();
2684        let actor_a: PcActor = PcActor::new(config.clone(), &mut rng_a).unwrap();
2685        let actor_b: PcActor = PcActor::new(config.clone(), &mut rng_b).unwrap();
2686
2687        let caches_a = make_caches_for_actor(&actor_a, 50);
2688        let caches_b = make_caches_for_actor(&actor_b, 50);
2689        let cache_mats_a: Vec<_> = (0..1).map(|i| build_cache_matrix(&caches_a, i)).collect();
2690        let cache_mats_b: Vec<_> = (0..1).map(|i| build_cache_matrix(&caches_b, i)).collect();
2691
2692        let mut rng_child = StdRng::seed_from_u64(99);
2693        let child: PcActor = PcActor::crossover(
2694            &actor_a,
2695            &actor_b,
2696            &cache_mats_a,
2697            &cache_mats_b,
2698            0.5,
2699            config,
2700            &mut rng_child,
2701        )
2702        .unwrap();
2703
2704        for (i, layer) in child.layers.iter().enumerate() {
2705            for &w in &layer.weights.data {
2706                assert!(w.is_finite(), "NaN/Inf in layer {i} weights");
2707            }
2708            for b in CpuLinAlg::vec_to_vec(&layer.bias) {
2709                assert!(b.is_finite(), "NaN/Inf in layer {i} biases");
2710            }
2711        }
2712    }
2713
2714    // ── Phase 5 Cycle 5.2: Crossover child smaller ──────────────
2715
2716    #[test]
2717    fn test_crossover_child_smaller() {
2718        let mut rng_a = StdRng::seed_from_u64(42);
2719        let mut rng_b = StdRng::seed_from_u64(123);
2720        let config_27 = PcActorConfig {
2721            hidden_layers: vec![
2722                LayerDef {
2723                    size: 27,
2724                    activation: Activation::Tanh,
2725                },
2726                LayerDef {
2727                    size: 27,
2728                    activation: Activation::Tanh,
2729                },
2730            ],
2731            ..crossover_config_27()
2732        };
2733        let actor_a: PcActor = PcActor::new(config_27.clone(), &mut rng_a).unwrap();
2734        let actor_b: PcActor = PcActor::new(config_27, &mut rng_b).unwrap();
2735
2736        let caches_a = make_caches_for_actor(&actor_a, 50);
2737        let caches_b = make_caches_for_actor(&actor_b, 50);
2738        let cache_mats_a: Vec<_> = (0..2).map(|i| build_cache_matrix(&caches_a, i)).collect();
2739        let cache_mats_b: Vec<_> = (0..2).map(|i| build_cache_matrix(&caches_b, i)).collect();
2740
2741        let child_config = PcActorConfig {
2742            hidden_layers: vec![
2743                LayerDef {
2744                    size: 18,
2745                    activation: Activation::Tanh,
2746                },
2747                LayerDef {
2748                    size: 18,
2749                    activation: Activation::Tanh,
2750                },
2751            ],
2752            ..crossover_config_27()
2753        };
2754
2755        let mut rng_child = StdRng::seed_from_u64(99);
2756        let child: PcActor = PcActor::crossover(
2757            &actor_a,
2758            &actor_b,
2759            &cache_mats_a,
2760            &cache_mats_b,
2761            0.5,
2762            child_config,
2763            &mut rng_child,
2764        )
2765        .unwrap();
2766
2767        // Child hidden layers have 18 neurons
2768        use crate::linalg::LinAlg;
2769        assert_eq!(CpuLinAlg::mat_rows(&child.layers[0].weights), 18);
2770        assert_eq!(CpuLinAlg::mat_rows(&child.layers[1].weights), 18);
2771    }
2772
2773    // ── Phase 5 Cycle 5.3: Crossover parents differ ─────────────
2774
2775    #[test]
2776    fn test_crossover_parents_different_sizes() {
2777        let mut rng_a = StdRng::seed_from_u64(42);
2778        let mut rng_b = StdRng::seed_from_u64(123);
2779        let config_a = crossover_config_27(); // [27]
2780        let config_b = PcActorConfig {
2781            hidden_layers: vec![LayerDef {
2782                size: 18,
2783                activation: Activation::Tanh,
2784            }],
2785            ..crossover_config_27()
2786        }; // [18]
2787
2788        let actor_a: PcActor = PcActor::new(config_a, &mut rng_a).unwrap();
2789        let actor_b: PcActor = PcActor::new(config_b, &mut rng_b).unwrap();
2790
2791        let caches_a = make_caches_for_actor(&actor_a, 50);
2792        let caches_b = make_caches_for_actor(&actor_b, 50);
2793        let cache_mats_a: Vec<_> = (0..1).map(|i| build_cache_matrix(&caches_a, i)).collect();
2794        let cache_mats_b: Vec<_> = (0..1).map(|i| build_cache_matrix(&caches_b, i)).collect();
2795
2796        // Child has [27] → blending zone [0..18), copy zone [18..27) from parent A
2797        let child_config = crossover_config_27();
2798        let mut rng_child = StdRng::seed_from_u64(99);
2799        let child: PcActor = PcActor::crossover(
2800            &actor_a,
2801            &actor_b,
2802            &cache_mats_a,
2803            &cache_mats_b,
2804            0.5,
2805            child_config,
2806            &mut rng_child,
2807        )
2808        .unwrap();
2809
2810        use crate::linalg::LinAlg;
2811        // Child has correct dimensions [27]
2812        assert_eq!(CpuLinAlg::mat_rows(&child.layers[0].weights), 27);
2813        // All weights finite
2814        for &w in &child.layers[0].weights.data {
2815            assert!(w.is_finite());
2816        }
2817    }
2818
2819    // ── Phase 5 Cycle 5.4: Crossover child larger ───────────────
2820
2821    #[test]
2822    fn test_crossover_child_larger() {
2823        let mut rng_a = StdRng::seed_from_u64(42);
2824        let mut rng_b = StdRng::seed_from_u64(123);
2825        let config_18 = PcActorConfig {
2826            hidden_layers: vec![LayerDef {
2827                size: 18,
2828                activation: Activation::Tanh,
2829            }],
2830            ..crossover_config_27()
2831        };
2832        let actor_a: PcActor = PcActor::new(config_18.clone(), &mut rng_a).unwrap();
2833        let actor_b: PcActor = PcActor::new(config_18, &mut rng_b).unwrap();
2834
2835        let caches_a = make_caches_for_actor(&actor_a, 50);
2836        let caches_b = make_caches_for_actor(&actor_b, 50);
2837        let cache_mats_a: Vec<_> = (0..1).map(|i| build_cache_matrix(&caches_a, i)).collect();
2838        let cache_mats_b: Vec<_> = (0..1).map(|i| build_cache_matrix(&caches_b, i)).collect();
2839
2840        // Child has [27] → blending zone [0..18), Xavier zone [18..27)
2841        let child_config = crossover_config_27();
2842        let mut rng_child = StdRng::seed_from_u64(99);
2843        let child: PcActor = PcActor::crossover(
2844            &actor_a,
2845            &actor_b,
2846            &cache_mats_a,
2847            &cache_mats_b,
2848            0.5,
2849            child_config,
2850            &mut rng_child,
2851        )
2852        .unwrap();
2853
2854        use crate::linalg::LinAlg;
2855        assert_eq!(CpuLinAlg::mat_rows(&child.layers[0].weights), 27);
2856        // All weights finite
2857        for &w in &child.layers[0].weights.data {
2858            assert!(w.is_finite());
2859        }
2860        // Xavier zone weights are not all zero (random init)
2861        let xavier_zone_nonzero = (18..27).any(|r| {
2862            (0..CpuLinAlg::mat_cols(&child.layers[0].weights))
2863                .any(|c| CpuLinAlg::mat_get(&child.layers[0].weights, r, c).abs() > 1e-15)
2864        });
2865        assert!(
2866            xavier_zone_nonzero,
2867            "Xavier zone [18..27) should have non-zero weights"
2868        );
2869    }
2870
2871    // ── Phase 5 Cycle 5.5: Crossover layer count mismatch ───────
2872
2873    #[test]
2874    fn test_crossover_child_more_layers() {
2875        let mut rng_a = StdRng::seed_from_u64(42);
2876        let mut rng_b = StdRng::seed_from_u64(123);
2877        let config_2l = PcActorConfig {
2878            hidden_layers: vec![
2879                LayerDef {
2880                    size: 27,
2881                    activation: Activation::Tanh,
2882                },
2883                LayerDef {
2884                    size: 27,
2885                    activation: Activation::Tanh,
2886                },
2887            ],
2888            ..crossover_config_27()
2889        };
2890        let actor_a: PcActor = PcActor::new(config_2l.clone(), &mut rng_a).unwrap();
2891        let actor_b: PcActor = PcActor::new(config_2l, &mut rng_b).unwrap();
2892
2893        let caches_a = make_caches_for_actor(&actor_a, 50);
2894        let caches_b = make_caches_for_actor(&actor_b, 50);
2895        let cache_mats_a: Vec<_> = (0..2).map(|i| build_cache_matrix(&caches_a, i)).collect();
2896        let cache_mats_b: Vec<_> = (0..2).map(|i| build_cache_matrix(&caches_b, i)).collect();
2897
2898        // Child has 3 hidden layers → layers 0-1 crossover, layer 2 Xavier
2899        let child_config = PcActorConfig {
2900            hidden_layers: vec![
2901                LayerDef {
2902                    size: 27,
2903                    activation: Activation::Tanh,
2904                },
2905                LayerDef {
2906                    size: 27,
2907                    activation: Activation::Tanh,
2908                },
2909                LayerDef {
2910                    size: 18,
2911                    activation: Activation::Tanh,
2912                },
2913            ],
2914            ..crossover_config_27()
2915        };
2916
2917        let mut rng_child = StdRng::seed_from_u64(99);
2918        let child: PcActor = PcActor::crossover(
2919            &actor_a,
2920            &actor_b,
2921            &cache_mats_a,
2922            &cache_mats_b,
2923            0.5,
2924            child_config,
2925            &mut rng_child,
2926        )
2927        .unwrap();
2928
2929        use crate::linalg::LinAlg;
2930        // Child has 4 layers (3 hidden + 1 output)
2931        assert_eq!(child.layers.len(), 4);
2932        // Layer 2 (new) has 18 rows
2933        assert_eq!(CpuLinAlg::mat_rows(&child.layers[2].weights), 18);
2934        // All weights finite
2935        for (i, layer) in child.layers.iter().enumerate() {
2936            for &w in &layer.weights.data {
2937                assert!(w.is_finite(), "NaN/Inf in layer {i}");
2938            }
2939        }
2940    }
2941
2942    #[test]
2943    fn test_crossover_child_fewer_layers() {
2944        let mut rng_a = StdRng::seed_from_u64(42);
2945        let mut rng_b = StdRng::seed_from_u64(123);
2946        let config_3l = PcActorConfig {
2947            hidden_layers: vec![
2948                LayerDef {
2949                    size: 27,
2950                    activation: Activation::Tanh,
2951                },
2952                LayerDef {
2953                    size: 27,
2954                    activation: Activation::Tanh,
2955                },
2956                LayerDef {
2957                    size: 18,
2958                    activation: Activation::Tanh,
2959                },
2960            ],
2961            ..crossover_config_27()
2962        };
2963        let actor_a: PcActor = PcActor::new(config_3l.clone(), &mut rng_a).unwrap();
2964        let actor_b: PcActor = PcActor::new(config_3l, &mut rng_b).unwrap();
2965
2966        let caches_a = make_caches_for_actor(&actor_a, 50);
2967        let caches_b = make_caches_for_actor(&actor_b, 50);
2968        let cache_mats_a: Vec<_> = (0..3).map(|i| build_cache_matrix(&caches_a, i)).collect();
2969        let cache_mats_b: Vec<_> = (0..3).map(|i| build_cache_matrix(&caches_b, i)).collect();
2970
2971        // Child has 2 hidden layers → layers 0-1 crossover, layer 2 discarded
2972        let child_config = PcActorConfig {
2973            hidden_layers: vec![
2974                LayerDef {
2975                    size: 27,
2976                    activation: Activation::Tanh,
2977                },
2978                LayerDef {
2979                    size: 27,
2980                    activation: Activation::Tanh,
2981                },
2982            ],
2983            ..crossover_config_27()
2984        };
2985
2986        let mut rng_child = StdRng::seed_from_u64(99);
2987        let child: PcActor = PcActor::crossover(
2988            &actor_a,
2989            &actor_b,
2990            &cache_mats_a,
2991            &cache_mats_b,
2992            0.5,
2993            child_config,
2994            &mut rng_child,
2995        )
2996        .unwrap();
2997
2998        use crate::linalg::LinAlg;
2999        // Child has 3 layers (2 hidden + 1 output)
3000        assert_eq!(child.layers.len(), 3);
3001        // Output layer input_size = 27 (last hidden size)
3002        assert_eq!(CpuLinAlg::mat_cols(&child.layers[2].weights), 27);
3003    }
3004
3005    // ── Phase 5 Cycle 5.6: Crossover residual components ────────
3006
3007    #[test]
3008    fn test_crossover_residual_rezero_blended() {
3009        let mut rng_a = StdRng::seed_from_u64(42);
3010        let mut rng_b = StdRng::seed_from_u64(123);
3011        let config = PcActorConfig {
3012            hidden_layers: vec![
3013                LayerDef {
3014                    size: 27,
3015                    activation: Activation::Softsign,
3016                },
3017                LayerDef {
3018                    size: 27,
3019                    activation: Activation::Softsign,
3020                },
3021            ],
3022            residual: true,
3023            rezero_init: 0.1,
3024            ..crossover_config_27()
3025        };
3026        let actor_a: PcActor = PcActor::new(config.clone(), &mut rng_a).unwrap();
3027        let actor_b: PcActor = PcActor::new(config.clone(), &mut rng_b).unwrap();
3028
3029        let caches_a = make_caches_for_actor(&actor_a, 50);
3030        let caches_b = make_caches_for_actor(&actor_b, 50);
3031        let cache_mats_a: Vec<_> = (0..2).map(|i| build_cache_matrix(&caches_a, i)).collect();
3032        let cache_mats_b: Vec<_> = (0..2).map(|i| build_cache_matrix(&caches_b, i)).collect();
3033
3034        let mut rng_child = StdRng::seed_from_u64(99);
3035        let child: PcActor = PcActor::crossover(
3036            &actor_a,
3037            &actor_b,
3038            &cache_mats_a,
3039            &cache_mats_b,
3040            0.5,
3041            config,
3042            &mut rng_child,
3043        )
3044        .unwrap();
3045
3046        // Child has rezero_alpha values
3047        assert!(!child.rezero_alpha.is_empty());
3048        // Blended rezero_alpha: with alpha=0.5 and both parents same init,
3049        // child should be close to parent values
3050        for &rz in &child.rezero_alpha {
3051            assert!(rz.is_finite(), "rezero_alpha is not finite");
3052        }
3053    }
3054
3055    #[test]
3056    fn test_crossover_residual_skip_projections_blended() {
3057        let mut rng_a = StdRng::seed_from_u64(42);
3058        let mut rng_b = StdRng::seed_from_u64(123);
3059        let config = PcActorConfig {
3060            hidden_layers: vec![
3061                LayerDef {
3062                    size: 27,
3063                    activation: Activation::Softsign,
3064                },
3065                LayerDef {
3066                    size: 18,
3067                    activation: Activation::Softsign,
3068                },
3069            ],
3070            residual: true,
3071            rezero_init: 0.1,
3072            ..crossover_config_27()
3073        };
3074        let actor_a: PcActor = PcActor::new(config.clone(), &mut rng_a).unwrap();
3075        let actor_b: PcActor = PcActor::new(config.clone(), &mut rng_b).unwrap();
3076
3077        let caches_a = make_caches_for_actor(&actor_a, 50);
3078        let caches_b = make_caches_for_actor(&actor_b, 50);
3079        let cache_mats_a: Vec<_> = (0..2).map(|i| build_cache_matrix(&caches_a, i)).collect();
3080        let cache_mats_b: Vec<_> = (0..2).map(|i| build_cache_matrix(&caches_b, i)).collect();
3081
3082        let mut rng_child = StdRng::seed_from_u64(99);
3083        let child: PcActor = PcActor::crossover(
3084            &actor_a,
3085            &actor_b,
3086            &cache_mats_a,
3087            &cache_mats_b,
3088            0.5,
3089            config,
3090            &mut rng_child,
3091        )
3092        .unwrap();
3093
3094        // Child should have skip_projections for size mismatch (27→18)
3095        assert!(!child.skip_projections.is_empty());
3096        // At least one projection should be Some (27→18 needs projection)
3097        let has_projection = child.skip_projections.iter().any(|p| p.is_some());
3098        assert!(has_projection, "Expected at least one skip projection");
3099
3100        // Projection weights are finite
3101        for mat in child.skip_projections.iter().flatten() {
3102            for &w in &mat.data {
3103                assert!(w.is_finite(), "NaN/Inf in skip projection");
3104            }
3105        }
3106    }
3107
3108    // ── Fix #1: Column permutation propagation ──────────────────
3109
3110    #[test]
3111    fn test_crossover_multilayer_column_permutation_consistency() {
3112        // Two identical parents → child should be identical regardless of
3113        // CCA permutation (identity) or column ordering. But if we manually
3114        // set parent B = parent A with a known neuron permutation at layer 0,
3115        // the child at alpha=0.5 should produce a network whose layer 1
3116        // columns are also reordered to match.
3117        //
3118        // Strategy: crossover parent A with itself (same weights). The CCA
3119        // permutation should be identity, and the child should equal both
3120        // parents. Then crossover with alpha=0.5 using two different parents.
3121        // Run inference on the child — if column permutation is broken,
3122        // the child's layer 1 receives inputs in the wrong order, and
3123        // inference produces different results than a properly-permuted child.
3124        use crate::linalg::LinAlg;
3125        let mut rng_a = StdRng::seed_from_u64(42);
3126        let mut rng_b = StdRng::seed_from_u64(123);
3127        let config = PcActorConfig {
3128            hidden_layers: vec![
3129                LayerDef {
3130                    size: 8,
3131                    activation: Activation::Tanh,
3132                },
3133                LayerDef {
3134                    size: 8,
3135                    activation: Activation::Tanh,
3136                },
3137            ],
3138            input_size: 4,
3139            output_size: 4,
3140            ..crossover_config_27()
3141        };
3142        let actor_a: PcActor = PcActor::new(config.clone(), &mut rng_a).unwrap();
3143        let actor_b: PcActor = PcActor::new(config.clone(), &mut rng_b).unwrap();
3144
3145        let caches_a = make_caches_for_actor(&actor_a, 100);
3146        let caches_b = make_caches_for_actor(&actor_b, 100);
3147        let cache_mats_a: Vec<_> = (0..2).map(|i| build_cache_matrix(&caches_a, i)).collect();
3148        let cache_mats_b: Vec<_> = (0..2).map(|i| build_cache_matrix(&caches_b, i)).collect();
3149
3150        // Get CCA permutation for layer 0 to check if it's non-trivial
3151        let perm0 =
3152            crate::matrix::cca_neuron_alignment::<CpuLinAlg>(&cache_mats_a[0], &cache_mats_b[0])
3153                .unwrap();
3154        let is_nontrivial = perm0.iter().enumerate().any(|(i, &p)| i != p);
3155
3156        // Only test column propagation if CCA produced a non-trivial permutation
3157        if !is_nontrivial {
3158            // Parents too similar for meaningful test — skip
3159            return;
3160        }
3161
3162        // Crossover with alpha=0.5
3163        let mut rng_child = StdRng::seed_from_u64(99);
3164        let child: PcActor = PcActor::crossover(
3165            &actor_a,
3166            &actor_b,
3167            &cache_mats_a,
3168            &cache_mats_b,
3169            0.5,
3170            config.clone(),
3171            &mut rng_child,
3172        )
3173        .unwrap();
3174
3175        // Verify: layer 1's input columns should be permuted to match layer 0's
3176        // row permutation of parent B. Check that the child's layer 1 column
3177        // ordering is consistent by verifying that inference produces finite,
3178        // non-degenerate output AND that crossover applied the column permutation.
3179        //
3180        // If columns are NOT permuted, parent B's layer 1 columns still reference
3181        // the original neuron positions, but the blended layer 0 has reordered
3182        // neurons. The inconsistency means column c of layer 1 connects to the
3183        // wrong neuron from layer 0.
3184        //
3185        // We verify by checking that the column permutation was actually applied:
3186        // parent B's layer 1 columns should be reordered by perm0.
3187        let b_layer1 = &actor_b.layers[1];
3188        let b_cols = CpuLinAlg::mat_cols(&b_layer1.weights);
3189
3190        // Expected: child layer 1 col[c] = 0.5 * A.layer1.col[c] + 0.5 * B.layer1.col[perm0[c]]
3191        // If column permutation is NOT applied, it would be:
3192        // child layer 1 col[c] = 0.5 * A.layer1.col[c] + 0.5 * B.layer1.col[c]  (wrong!)
3193        let a_layer1 = &actor_a.layers[1];
3194        let child_layer1 = &child.layers[1];
3195        let n_rows = CpuLinAlg::mat_rows(&child_layer1.weights);
3196
3197        let mut has_col_permutation = false;
3198        for (c, &src_col) in perm0.iter().enumerate().take(b_cols.min(perm0.len())) {
3199            if src_col == c {
3200                continue; // Identity position, can't distinguish
3201            }
3202            // Check if child col c matches the permuted blend (correct)
3203            // vs the unpermuted blend (broken)
3204            for r in 0..n_rows {
3205                let a_val = CpuLinAlg::mat_get(&a_layer1.weights, r, c);
3206                let b_val_permuted = CpuLinAlg::mat_get(&b_layer1.weights, r, src_col);
3207                let b_val_unpermuted = CpuLinAlg::mat_get(&b_layer1.weights, r, c);
3208                let child_val = CpuLinAlg::mat_get(&child_layer1.weights, r, c);
3209
3210                let expected_permuted = 0.5 * a_val + 0.5 * b_val_permuted;
3211                let expected_unpermuted = 0.5 * a_val + 0.5 * b_val_unpermuted;
3212
3213                // If column permutation is applied, child matches permuted expectation
3214                if (child_val - expected_permuted).abs() < 1e-10
3215                    && (child_val - expected_unpermuted).abs() > 1e-10
3216                {
3217                    has_col_permutation = true;
3218                }
3219            }
3220        }
3221
3222        assert!(
3223            has_col_permutation,
3224            "Layer 1 columns should be permuted to match layer 0's CCA \
3225             permutation of parent B. perm0={perm0:?}"
3226        );
3227    }
3228
3229    // ── Fix #5: Empty hidden_layers guard ────────────────────────
3230
3231    #[test]
3232    fn test_crossover_empty_hidden_layers_returns_error() {
3233        let mut rng_a = StdRng::seed_from_u64(42);
3234        let mut rng_b = StdRng::seed_from_u64(123);
3235        let config = crossover_config_27();
3236        let actor_a: PcActor = PcActor::new(config.clone(), &mut rng_a).unwrap();
3237        let actor_b: PcActor = PcActor::new(config, &mut rng_b).unwrap();
3238
3239        let caches_a = make_caches_for_actor(&actor_a, 50);
3240        let caches_b = make_caches_for_actor(&actor_b, 50);
3241        let cache_mats_a: Vec<_> = (0..1).map(|i| build_cache_matrix(&caches_a, i)).collect();
3242        let cache_mats_b: Vec<_> = (0..1).map(|i| build_cache_matrix(&caches_b, i)).collect();
3243
3244        // Child config with empty hidden layers should return error, not panic
3245        let empty_config = PcActorConfig {
3246            hidden_layers: vec![],
3247            ..crossover_config_27()
3248        };
3249
3250        let mut rng_child = StdRng::seed_from_u64(99);
3251        let result = PcActor::crossover(
3252            &actor_a,
3253            &actor_b,
3254            &cache_mats_a,
3255            &cache_mats_b,
3256            0.5,
3257            empty_config,
3258            &mut rng_child,
3259        );
3260        assert!(
3261            result.is_err(),
3262            "Crossover with empty hidden_layers should return error"
3263        );
3264    }
3265
3266    // ── from_weights dimension validation tests ──────────────────────
3267
3268    /// Helper: build valid PcActorWeights from a config by constructing
3269    /// an actor and extracting its weights.
3270    fn valid_weights_for(config: &PcActorConfig) -> crate::serializer::PcActorWeights {
3271        let mut rng = make_rng();
3272        let actor = PcActor::<CpuLinAlg>::new(config.clone(), &mut rng).unwrap();
3273        actor.to_weights()
3274    }
3275
3276    #[test]
3277    fn test_from_weights_valid_returns_ok() {
3278        let config = default_config();
3279        let weights = valid_weights_for(&config);
3280        let result = PcActor::<CpuLinAlg>::from_weights(config, weights);
3281        assert!(result.is_ok());
3282    }
3283
3284    #[test]
3285    fn test_from_weights_wrong_weight_rows_returns_err() {
3286        let config = default_config(); // input=9, hidden=[18], output=9
3287        let mut weights = valid_weights_for(&config);
3288        // Layer 0 should be 18x9; corrupt rows to 10x9
3289        weights.layers[0].weights = crate::matrix::Matrix::zeros(10, 9);
3290        weights.layers[0].bias = vec![0.0; 10];
3291        let result = PcActor::<CpuLinAlg>::from_weights(config, weights);
3292        assert!(result.is_err());
3293        let err = result.unwrap_err();
3294        assert!(
3295            matches!(err, PcError::DimensionMismatch { .. }),
3296            "Expected DimensionMismatch, got: {err}"
3297        );
3298    }
3299
3300    #[test]
3301    fn test_from_weights_wrong_weight_cols_returns_err() {
3302        let config = default_config(); // input=9, hidden=[18], output=9
3303        let mut weights = valid_weights_for(&config);
3304        // Layer 0 should be 18x9; corrupt cols to 18x5
3305        weights.layers[0].weights = crate::matrix::Matrix::zeros(18, 5);
3306        let result = PcActor::<CpuLinAlg>::from_weights(config, weights);
3307        assert!(result.is_err());
3308        let err = result.unwrap_err();
3309        assert!(
3310            matches!(err, PcError::DimensionMismatch { .. }),
3311            "Expected DimensionMismatch, got: {err}"
3312        );
3313    }
3314
3315    #[test]
3316    fn test_from_weights_wrong_bias_length_returns_err() {
3317        let config = default_config(); // hidden=[18], so layer 0 bias should be len 18
3318        let mut weights = valid_weights_for(&config);
3319        weights.layers[0].bias = vec![0.0; 5]; // wrong length
3320        let result = PcActor::<CpuLinAlg>::from_weights(config, weights);
3321        assert!(result.is_err());
3322        let err = result.unwrap_err();
3323        assert!(
3324            matches!(err, PcError::DimensionMismatch { .. }),
3325            "Expected DimensionMismatch, got: {err}"
3326        );
3327    }
3328
3329    #[test]
3330    fn test_from_weights_wrong_output_layer_dims_returns_err() {
3331        let config = default_config(); // output layer should be 9x18
3332        let mut weights = valid_weights_for(&config);
3333        let last = weights.layers.len() - 1;
3334        weights.layers[last].weights = crate::matrix::Matrix::zeros(9, 10); // wrong cols
3335        let result = PcActor::<CpuLinAlg>::from_weights(config, weights);
3336        assert!(result.is_err());
3337    }
3338
3339    #[test]
3340    fn test_from_weights_wrong_rezero_alpha_count_returns_err() {
3341        let mut config = default_config();
3342        config.hidden_layers = vec![
3343            LayerDef {
3344                size: 18,
3345                activation: Activation::Tanh,
3346            },
3347            LayerDef {
3348                size: 18,
3349                activation: Activation::Tanh,
3350            },
3351        ];
3352        config.residual = true;
3353        let mut weights = valid_weights_for(&config);
3354        // residual with 2 hidden layers expects 1 rezero_alpha; give 0
3355        weights.rezero_alpha = vec![];
3356        let result = PcActor::<CpuLinAlg>::from_weights(config, weights);
3357        assert!(result.is_err());
3358        let err = result.unwrap_err();
3359        assert!(
3360            matches!(err, PcError::DimensionMismatch { .. }),
3361            "Expected DimensionMismatch, got: {err}"
3362        );
3363    }
3364
3365    #[test]
3366    fn test_from_weights_wrong_skip_projections_count_returns_err() {
3367        let mut config = default_config();
3368        config.hidden_layers = vec![
3369            LayerDef {
3370                size: 18,
3371                activation: Activation::Tanh,
3372            },
3373            LayerDef {
3374                size: 18,
3375                activation: Activation::Tanh,
3376            },
3377        ];
3378        config.residual = true;
3379        let mut weights = valid_weights_for(&config);
3380        // Should have 1 skip_projection; give 3
3381        weights.skip_projections = vec![None, None, None];
3382        let result = PcActor::<CpuLinAlg>::from_weights(config, weights);
3383        assert!(result.is_err());
3384        let err = result.unwrap_err();
3385        assert!(
3386            matches!(err, PcError::DimensionMismatch { .. }),
3387            "Expected DimensionMismatch, got: {err}"
3388        );
3389    }
3390}