| """ |
| Regression tests for W1's modularisation of train.py into the hydra/ package. |
| |
| These tests verify that after modularisation: |
| - The expected public symbols are importable from the stated sub-modules. |
| - PostSemClawConfig instantiates with default args. |
| - PostSemClawModel can be constructed, initialised, and produces a scalar |
| loss on tiny inputs (batch=1, seq=32) without error. |
| - train.py at the repo root is still importable as a Python module (i.e. |
| the training-loop body is gated on ``if __name__ == "__main__":`` so a |
| plain ``import`` doesn't execute it). |
| - train.py is under 150 lines after modularisation (the main motiviation for |
| W1's work is a thin orchestrator script, not a 900-line monolith). |
| |
| If the hydra/ package does not exist yet (W1 is still running), every test in |
| this file is gracefully skipped so the test suite remains green. |
| |
| Run: |
| cd /home/mikeb/work/feather |
| .venv/bin/pytest tests/test_hydra_modular.py -v |
| """ |
|
|
| import importlib |
| import os |
| import subprocess |
| import sys |
| import types |
| import pytest |
|
|
| |
| |
| |
| |
| |
|
|
| _REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| _HYDRA_INIT = os.path.join(_REPO, "hydra", "__init__.py") |
|
|
| if not os.path.isfile(_HYDRA_INIT): |
| pytest.skip( |
| "hydra/ package not found — W1 modularisation not yet complete. " |
| "Re-run after hydra/__init__.py exists.", |
| allow_module_level=True, |
| ) |
|
|
| |
| |
| |
| |
|
|
| if _REPO not in sys.path: |
| sys.path.insert(0, _REPO) |
|
|
|
|
| |
| |
| |
| |
|
|
| def _ensure_prepare_stub(): |
| if "prepare" not in sys.modules: |
| fake = types.ModuleType("prepare") |
| fake.MAX_SEQ_LEN = 2048 |
| fake.TIME_BUDGET = 300 |
| fake.Tokenizer = object |
| fake.make_dataloader = lambda *a, **kw: None |
| fake.evaluate_bpb = lambda *a, **kw: 0.0 |
| sys.modules["prepare"] = fake |
|
|
|
|
| _ensure_prepare_stub() |
|
|
|
|
| |
| |
| |
|
|
| class TestHydraPublicAPI: |
| def test_config_importable(self): |
| """PostSemClawConfig is importable from hydra.config.""" |
| mod = importlib.import_module("hydra.config") |
| assert hasattr(mod, "PostSemClawConfig"), ( |
| "hydra.config does not export PostSemClawConfig" |
| ) |
|
|
| def test_model_importable(self): |
| """PostSemClawModel is importable from hydra.model.""" |
| mod = importlib.import_module("hydra.model") |
| assert hasattr(mod, "PostSemClawModel"), ( |
| "hydra.model does not export PostSemClawModel" |
| ) |
|
|
| def test_optimizer_importable(self): |
| """MuonAdamW is importable from hydra.optimizer.""" |
| mod = importlib.import_module("hydra.optimizer") |
| assert hasattr(mod, "MuonAdamW"), ( |
| "hydra.optimizer does not export MuonAdamW" |
| ) |
|
|
| def test_engram_importable(self): |
| """GPUEngram is importable from hydra.engram (if Engram is top-level).""" |
| try: |
| mod = importlib.import_module("hydra.engram") |
| except ImportError: |
| pytest.skip("hydra.engram module does not exist — may be merged into hydra.model") |
| assert hasattr(mod, "GPUEngram"), ( |
| "hydra.engram does not export GPUEngram" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| class TestPostSemClawConfig: |
| def test_default_instantiation(self): |
| """PostSemClawConfig() should instantiate with all defaults.""" |
| from hydra.config import PostSemClawConfig |
| cfg = PostSemClawConfig() |
| |
| assert hasattr(cfg, "d_model"), "PostSemClawConfig missing d_model field" |
| assert hasattr(cfg, "n_layer"), "PostSemClawConfig missing n_layer field" |
| assert hasattr(cfg, "vocab_size"), "PostSemClawConfig missing vocab_size field" |
| assert cfg.d_model > 0 |
| assert cfg.n_layer > 0 |
| assert cfg.vocab_size > 0 |
|
|
| def test_custom_instantiation(self): |
| """PostSemClawConfig accepts keyword overrides.""" |
| from hydra.config import PostSemClawConfig |
| cfg = PostSemClawConfig(d_model=64, n_layer=2) |
| assert cfg.d_model == 64 |
| assert cfg.n_layer == 2 |
|
|
|
|
| |
| |
| |
|
|
| class TestPostSemClawModelForward: |
| @pytest.fixture |
| def tiny_model(self): |
| """Construct a tiny PostSemClawModel on CPU.""" |
| import torch |
| from hydra.config import PostSemClawConfig |
| from hydra.model import PostSemClawModel |
|
|
| |
| cfg = PostSemClawConfig( |
| sequence_len=32, |
| vocab_size=64, |
| n_layer=2, |
| d_model=32, |
| d_state=8, |
| headdim=16, |
| n_heads=2, |
| expand=2, |
| engram_n_columns=16, |
| engram_key_dim=8, |
| engram_layer_idx=0, |
| sdr_n_bits=128, |
| sdr_target_active=3, |
| sdr_delta_rank=4, |
| htm_n_columns=32, |
| htm_cells_per_column=4, |
| ) |
| model = PostSemClawModel(cfg) |
| model.init_weights() |
| model.eval() |
| return model |
|
|
| def test_forward_returns_scalar_loss(self, tiny_model): |
| """model(x, y, reduction='mean') returns a scalar loss.""" |
| import torch |
|
|
| B, T = 1, 32 |
| vocab = tiny_model.config.vocab_size |
| idx = torch.randint(0, vocab, (B, T)) |
| targets = torch.randint(0, vocab, (B, T)) |
|
|
| with torch.no_grad(): |
| loss = tiny_model(idx, targets, reduction="mean") |
|
|
| assert isinstance(loss, torch.Tensor), "forward did not return a tensor" |
| assert loss.ndim == 0, f"expected scalar loss, got shape {loss.shape}" |
| assert torch.isfinite(loss), f"loss is not finite: {loss.item()}" |
|
|
| def test_forward_returns_per_token_loss(self, tiny_model): |
| """model(x, y, reduction='none') returns (B*T,) per-token losses.""" |
| import torch |
|
|
| B, T = 1, 32 |
| vocab = tiny_model.config.vocab_size |
| idx = torch.randint(0, vocab, (B, T)) |
| targets = torch.randint(0, vocab, (B, T)) |
|
|
| with torch.no_grad(): |
| losses = tiny_model(idx, targets, reduction="none") |
|
|
| assert losses.shape == (B * T,), ( |
| f"expected shape ({B * T},), got {losses.shape}" |
| ) |
| assert torch.all(torch.isfinite(losses)), "some per-token losses are not finite" |
|
|
|
|
| |
| |
| |
|
|
| class TestTrainPyImportable: |
| def test_train_py_importable_as_module(self): |
| """ |
| train.py must be importable without executing the training loop. |
| We verify this by running `python -c "import importlib.util; ..."` in a |
| subprocess to get a clean interpreter state, avoiding interference from |
| the test process's already-patched sys.modules. |
| """ |
| train_path = os.path.join(_REPO, "train.py") |
| assert os.path.isfile(train_path), f"train.py not found at {train_path}" |
|
|
| check_script = ( |
| "import importlib.util, sys; " |
| "sys.path.insert(0, repr(_REPO)); " |
| "spec = importlib.util.spec_from_file_location('train', repr(train_path)); " |
| "assert spec is not None, 'spec is None'" |
| ).replace("repr(_REPO)", repr(_REPO)).replace("repr(train_path)", repr(train_path)) |
|
|
| result = subprocess.run( |
| [sys.executable, "-c", check_script], |
| capture_output=True, |
| text=True, |
| timeout=10, |
| ) |
| |
| |
| assert result.returncode == 0, ( |
| f"train.py spec creation failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" |
| ) |
|
|
| def test_train_py_under_150_lines(self): |
| """ |
| After modularisation, train.py should be a thin orchestrator < 150 lines. |
| This asserts the structural goal: all heavy logic lives in hydra/*. |
| """ |
| train_path = os.path.join(_REPO, "train.py") |
| with open(train_path) as fh: |
| lines = fh.readlines() |
| assert len(lines) < 150, ( |
| f"train.py has {len(lines)} lines — expected < 150 after modularisation. " |
| "Move model/optimizer/config definitions to hydra/ sub-modules." |
| ) |
|
|