---
phase: 02-core-model
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/pcn_torch/network.py
  - src/pcn_torch/__init__.py
  - tests/test_network.py
autonomous: true

must_haves:
  truths:
    - "PredictiveCodingNetwork(dims=[3072, 500, 500, 10]) constructs a network with 3 PCNLayers and correct weight shapes"
    - "PredictiveCodingNetwork(layers=[...]) constructs from pre-built PCNLayer instances with dimension validation"
    - "init_latents(batch_size) creates latents from N(0,1) with correct shapes stored as network.latents"
    - "compute_errors(x) returns PCNErrors with prediction errors and gain-modulated errors for all layers"
    - "compute_errors(x, y) additionally returns supervised_error and top_error with correct shapes"
    - "predict() returns readout from the top latent via internal nn.Linear(bias=False)"
    - "All parameters (layers + readout) appear in model.parameters() and model.state_dict()"
  artifacts:
    - path: "src/pcn_torch/network.py"
      provides: "PredictiveCodingNetwork class, PCNErrors NamedTuple"
      exports: ["PredictiveCodingNetwork", "PCNErrors"]
    - path: "src/pcn_torch/__init__.py"
      provides: "Public API with PredictiveCodingNetwork and PCNErrors exports"
      contains: "PredictiveCodingNetwork"
    - path: "tests/test_network.py"
      provides: "Tests for construction, latent init, error computation, readout, and supervised error"
      min_lines: 80
  key_links:
    - from: "src/pcn_torch/network.py"
      to: "src/pcn_torch/layers.py"
      via: "import PCNLayer, uses PCNLayer.forward() and PCNLayer.activation_deriv"
      pattern: "from pcn_torch\\.layers import PCNLayer"
    - from: "src/pcn_torch/network.py"
      to: "src/pcn_torch/activations.py"
      via: "get_activation used when constructing from dims list"
      pattern: "from pcn_torch\\.activations import get_activation"
    - from: "src/pcn_torch/__init__.py"
      to: "src/pcn_torch/network.py"
      via: "import and re-export PredictiveCodingNetwork, PCNErrors"
      pattern: "from pcn_torch\\.network import"
---

<objective>
Implement PredictiveCodingNetwork as an nn.Module that manages a stack of PCNLayers, initializes latent variables, computes prediction errors and gain-modulated errors across all layers, and provides a linear readout for output predictions with supervised error projection.

Purpose: This is the core model class that Phase 3 (training loops) will drive. Without it, there is no network to train.
Output: `src/pcn_torch/network.py` with PredictiveCodingNetwork class, updated `__init__.py` exports, and comprehensive tests.
</objective>

<execution_context>
@C:\Users\RDCHLEM1\.claude/get-shit-done/workflows/execute-plan.md
@C:\Users\RDCHLEM1\.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-foundation/01-01-SUMMARY.md
@.planning/phases/02-core-model/02-CONTEXT.md
@.planning/phases/02-core-model/02-RESEARCH.md
@src/pcn_torch/layers.py
@src/pcn_torch/_types.py
@src/pcn_torch/__init__.py
@src/pcn_torch/activations.py
</context>

<tasks>

<task type="auto">
  <name>Task 1: Implement PredictiveCodingNetwork and PCNErrors in network.py</name>
  <files>src/pcn_torch/network.py</files>
  <action>
Create `src/pcn_torch/network.py` with the following components. All math follows arXiv:2506.06332v1 exactly.

**PCNErrors NamedTuple** (discretion: NamedTuple per RESEARCH.md recommendation):
```python
class PCNErrors(NamedTuple):
    errors: list[Tensor]              # eps^(l) for l=0..L-1
    gm_errors: list[Tensor]           # h^(l) for l=0..L-1
    supervised_error: Tensor | None   # eps_sup = y_hat - y
    top_error: Tensor | None          # eps^(L) = eps_sup @ W_out
```

**PredictiveCodingNetwork class** (nn.Module):

Constructor `__init__`:
- Accept `dims: Sequence[int] | None = None` and `layers: Sequence[PCNLayer] | None = None` (mutually exclusive, per locked decision)
- Accept keyword `activation: str = "relu"` (global activation, used only with dims; per locked decision: per-layer override via pre-built layers)
- Accept keyword `output_dim: int | None = None` (defaults to `dims[-1]` or `layers[-1].weight.shape[1]`)
- Accept keyword `mode: str = "classification"` (validate: must be "classification" or "regression")
- Raise `ValueError` if both `dims` and `layers` provided, or neither provided
- Raise `ValueError` if `dims` has fewer than 2 elements or `layers` is empty
- When constructing from `dims`: Layer l is `PCNLayer(in_features=dims[l+1], out_features=dims[l], activation=activation)` -- generative (top-down) direction
- When constructing from `layers`: Reconstruct `_dims` from weight shapes. Validate adjacent layer compatibility: `layers[l].weight.shape[1]` (in_features) must equal `layers[l+1].weight.shape[0]` (out_features) for all adjacent pairs
- Store layers as `self.layers = nn.ModuleList(...)` -- per locked decision: layers exposed publicly as `network.layers`
- Create readout as `self._readout = nn.Linear(d_L, effective_output_dim, bias=False)` -- per locked decision: internal (underscore prefix), nn.Linear with no bias
- Store `self._dims` as `list[int]` for latent init dimension lookup
- Initialize `self.latents: list[Tensor] = []` and `self.errors: PCNErrors | None = None` as empty public attributes

Method `init_latents(self, batch_size: int) -> None`:
- Per locked decision: latents stored as `network.latents` (public, inspectable)
- Per locked decision: paper-prescribed init only -- N(0,1) via `torch.randn`
- Infer device via `next(self.parameters()).device`
- Create latents for layers l=1..L: `torch.randn(batch_size, self._dims[l], device=device)` for l in range(1, len(self._dims))
- Store as `self.latents` (plain list[Tensor], NOT nn.Parameter -- anti-pattern from RESEARCH.md)

Method `predict(self) -> Tensor`:
- Per locked decision: separate `predict()` method for output predictions
- Return `self._readout(self.latents[-1])` -- equivalent to `latents[-1] @ _readout.weight.T`
- Raise `RuntimeError` if `self.latents` is empty (latents not initialized)

Method `compute_errors(self, x: Tensor, y: Tensor | None = None) -> PCNErrors`:
- Per locked decision: pass input and optional target explicitly each call
- Per locked decision: returns errors for all layers at once
- Per locked decision: errors stored on network after computation
- Build full state: `states = [x] + self.latents` (x^(0) is input, clamped)
- For each layer l, use `layer(states[l+1])` to get `(prediction, preactivation)`
- Prediction error: `eps = states[l] - prediction` (shape: (B, d_l))
- Gain-modulated error: `h = layer.activation_deriv(preactivation) * eps` (elementwise, shape: (B, d_l))
- If `y` is provided: compute `y_hat = self.predict()`, then `supervised_error = y_hat - y`, then `top_error = supervised_error @ self._readout.weight` (batch form of W_out^T @ eps_sup)
- Store result as `self.errors` and return it
- Raise `RuntimeError` if `self.latents` is empty (latents not initialized)

Module-level imports:
```python
from __future__ import annotations
from collections.abc import Sequence
from typing import NamedTuple
import torch
from torch import Tensor, nn
from pcn_torch.layers import PCNLayer
```

Follow established codebase patterns:
- `from __future__ import annotations` at top (ruff TCH001 compliance pattern)
- Use `TYPE_CHECKING` guard if any imports are type-only
- Match code style from `layers.py` (docstrings, type annotations)
  </action>
  <verify>
Run: `python -c "from pcn_torch.network import PredictiveCodingNetwork, PCNErrors; net = PredictiveCodingNetwork(dims=[100, 50, 10]); net.init_latents(4); print('layers:', len(net.layers)); print('latents:', [l.shape for l in net.latents]); r = net.compute_errors(torch.randn(4, 100), torch.randn(4, 10)); print('errors:', [e.shape for e in r.errors]); print('gm_errors:', [e.shape for e in r.gm_errors]); print('sup_err:', r.supervised_error.shape); print('top_err:', r.top_error.shape); import torch"` -- must print correct shapes without errors.
Run: `ruff check src/pcn_torch/network.py` -- no lint errors.
Run: `ruff format --check src/pcn_torch/network.py` -- no format changes needed.
Run: `mypy src/pcn_torch/network.py` -- no type errors.
  </verify>
  <done>
`network.py` exists with PredictiveCodingNetwork class and PCNErrors NamedTuple. Construction from dims list works. Construction from pre-built layers works with dimension validation. `init_latents()` creates N(0,1) latents with correct shapes. `predict()` returns readout output. `compute_errors(x)` returns errors and gm_errors for all layers. `compute_errors(x, y)` additionally returns supervised_error and top_error. All linting, formatting, and type checks pass.
  </done>
</task>

<task type="auto">
  <name>Task 2: Update public API exports and write comprehensive tests</name>
  <files>src/pcn_torch/__init__.py, tests/test_network.py</files>
  <action>
**Update `src/pcn_torch/__init__.py`:**
- Add imports: `from pcn_torch.network import PCNErrors, PredictiveCodingNetwork`
- Add `"PCNErrors"` and `"PredictiveCodingNetwork"` to `__all__` list (alphabetical order)

**Create `tests/test_network.py`** with the following test classes. Follow the established test pattern from `tests/test_layers.py`: class-based grouping, `pytest.mark.parametrize` for variants, descriptive method names.

```
class TestPCNErrorsNamedTuple:
    - test_is_named_tuple: PCNErrors is a NamedTuple with 4 fields
    - test_tuple_unpacking: errors, gm_errors, sup_err, top_err = PCNErrors(...)
    - test_named_access: result.errors, result.gm_errors, etc.

class TestNetworkConstruction:
    - test_from_dims_basic: PredictiveCodingNetwork(dims=[100, 50, 10]) creates 2 layers
    - test_from_dims_cifar10: dims=[3072, 500, 500, 10] creates 3 layers with correct weight shapes
    - test_from_dims_layer_weight_shapes: verify each layer has (d_l, d_{l+1}) weight shape
    - test_from_layers_basic: construct from pre-built PCNLayer list
    - test_from_layers_validates_dimensions: mismatched adjacent layers raise ValueError
    - test_both_dims_and_layers_raises: providing both raises ValueError
    - test_neither_dims_nor_layers_raises: providing neither raises ValueError
    - test_dims_too_short_raises: single-element dims raises ValueError
    - test_mode_classification: default mode is "classification"
    - test_mode_regression: mode="regression" accepted
    - test_mode_invalid_raises: invalid mode raises ValueError
    - test_layers_exposed_publicly: network.layers is nn.ModuleList
    - test_readout_is_internal: network._readout is nn.Linear with no bias
    - test_custom_output_dim: output_dim=20 with dims[-1]=10 creates readout with shape (20, 10)
    - test_default_output_dim: no output_dim defaults to dims[-1]
    - test_global_activation: activation="tanh" applies to all layers built from dims

class TestLatentInitialization:
    - test_init_latents_shapes: for dims=[100, 50, 10], latents are [(B,50), (B,10)]
    - test_init_latents_count: len(latents) == len(dims) - 1
    - test_init_latents_are_not_parameters: latents do NOT appear in model.parameters()
    - test_init_latents_device: latents on same device as model parameters
    - test_init_latents_stored_publicly: network.latents is accessible
    - test_init_latents_resets_on_new_batch: calling init_latents again replaces previous latents
    @pytest.mark.parametrize("batch_size", [1, 16, 64])
    - test_init_latents_various_batch_sizes

class TestPredict:
    - test_predict_shape: output shape is (batch_size, output_dim)
    - test_predict_uses_readout: verify predict() == latents[-1] @ _readout.weight.T
    - test_predict_without_init_raises: calling predict before init_latents raises RuntimeError

class TestComputeErrors:
    - test_compute_errors_unsupervised_shapes: errors and gm_errors have correct shapes per layer
    - test_compute_errors_unsupervised_no_supervised: supervised_error and top_error are None when y not provided
    - test_compute_errors_supervised_shapes: supervised_error shape (B, d_out), top_error shape (B, d_L)
    - test_compute_errors_stored_on_network: network.errors is not None after compute_errors()
    - test_compute_errors_prediction_error_formula: eps^(l) = x^(l) - f(W^(l) @ x^(l+1)) manually verified
    - test_compute_errors_gain_modulation_formula: h^(l) = f'(a^(l)) * eps^(l) manually verified
    - test_compute_errors_supervised_error_formula: eps_sup = y_hat - y manually verified
    - test_compute_errors_top_error_formula: top_error = supervised_error @ W_out manually verified for W_out^T projection
    - test_compute_errors_without_init_raises: calling compute_errors before init_latents raises RuntimeError

class TestParameterRegistration:
    - test_all_layer_params_in_model: all layer weights appear in model.parameters()
    - test_readout_params_in_model: readout weight appears in model.parameters()
    - test_state_dict_has_layers_and_readout: model.state_dict() contains layer weights and readout weight
    - test_latents_not_in_state_dict: latents do NOT appear in state_dict
```

For formula verification tests, use `torch.manual_seed(42)` for reproducibility. Construct the expected values manually using raw tensor operations (not the network methods) and compare with `torch.allclose`.
  </action>
  <verify>
Run: `python -m pytest tests/test_network.py -v` -- all tests pass.
Run: `python -m pytest tests/ -v` -- all tests pass (including existing tests).
Run: `python -c "from pcn_torch import PredictiveCodingNetwork, PCNErrors"` -- public API import works.
Run: `ruff check src/pcn_torch/__init__.py tests/test_network.py` -- no lint errors.
Run: `ruff format --check src/pcn_torch/__init__.py tests/test_network.py` -- no format changes needed.
Run: `mypy src/pcn_torch/` -- no type errors across all modules.
  </verify>
  <done>
`PredictiveCodingNetwork` and `PCNErrors` are importable from `pcn_torch`. All tests pass: construction (from dims and layers), latent init (shapes, device, not parameters), predict (shape, formula), compute_errors (unsupervised and supervised, all shapes, formula verification for errors, gain modulation, supervised error, and W_out^T projection), and parameter registration (layers + readout in parameters/state_dict, latents excluded). Full test suite passes with no regressions. All quality gates (ruff, mypy) pass.
  </done>
</task>

</tasks>

<verification>
After both tasks complete:
1. `python -c "from pcn_torch import PredictiveCodingNetwork, PCNErrors; net = PredictiveCodingNetwork(dims=[3072, 500, 500, 10]); net.init_latents(32); r = net.compute_errors(torch.randn(32, 3072), torch.randn(32, 10)); print('OK'); import torch"` -- prints OK
2. `python -m pytest tests/ -v --tb=short` -- all tests pass (existing + new)
3. `ruff check src/pcn_torch/ tests/` -- no lint errors
4. `ruff format --check src/pcn_torch/ tests/` -- no format changes
5. `mypy src/pcn_torch/` -- no type errors

Phase 2 Success Criteria (from ROADMAP.md):
1. `PredictiveCodingNetwork(dims=[3072, 500, 500, 10])` constructs with correct number of layers -- verified by test_from_dims_cifar10
2. `init_latents(batch_size)` produces randomly initialized latent variables -- verified by test_init_latents_shapes
3. `compute_errors()` returns prediction errors and gain-modulated errors with correct shapes -- verified by test_compute_errors_unsupervised_shapes + formula tests
4. Readout layer is `nn.Linear(..., bias=False)` mapping top latent to output -- verified by test_readout_is_internal
5. Supervised error correctly projected back via W_out^T -- verified by test_compute_errors_top_error_formula
</verification>

<success_criteria>
- `from pcn_torch import PredictiveCodingNetwork, PCNErrors` succeeds
- Network constructs from both `dims` list and pre-built `PCNLayer` instances
- `init_latents()` creates N(0,1) latents stored as `network.latents`
- `compute_errors(x)` returns correct prediction errors and gain-modulated errors
- `compute_errors(x, y)` returns correct supervised error and W_out^T top error
- `predict()` returns readout output
- All tests pass, all quality gates pass (ruff check, ruff format, mypy)
</success_criteria>

<output>
After completion, create `.planning/phases/02-core-model/02-01-SUMMARY.md`
</output>
