File size: 10,074 Bytes
c475135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
"""
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

# ---------------------------------------------------------------------------
# Module-level skip: hydra/ must exist as an importable package.
# pytest.importorskip cannot be used at module level without allow_module_level,
# and it doesn't work for relative paths.  We do the check manually.
# ---------------------------------------------------------------------------

_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,
    )

# ---------------------------------------------------------------------------
# Helper: add repo root to sys.path so `import hydra` resolves to the local
# package, not the Apache Hydra framework if installed.
# ---------------------------------------------------------------------------

if _REPO not in sys.path:
    sys.path.insert(0, _REPO)


# ---------------------------------------------------------------------------
# Fixture: ensure 'prepare' stub is available so any transitive imports from
# train.py or hydra/ that do `from prepare import ...` don't crash.
# ---------------------------------------------------------------------------

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()


# ---------------------------------------------------------------------------
# Test 1: public API is importable from the correct sub-modules
# ---------------------------------------------------------------------------

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"
        )


# ---------------------------------------------------------------------------
# Test 2: PostSemClawConfig default construction
# ---------------------------------------------------------------------------

class TestPostSemClawConfig:
    def test_default_instantiation(self):
        """PostSemClawConfig() should instantiate with all defaults."""
        from hydra.config import PostSemClawConfig  # noqa: PLC0415
        cfg = PostSemClawConfig()
        # Verify a few required fields exist and have sane defaults
        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  # noqa: PLC0415
        cfg = PostSemClawConfig(d_model=64, n_layer=2)
        assert cfg.d_model == 64
        assert cfg.n_layer == 2


# ---------------------------------------------------------------------------
# Test 3: PostSemClawModel forward pass with tiny inputs
# ---------------------------------------------------------------------------

class TestPostSemClawModelForward:
    @pytest.fixture
    def tiny_model(self):
        """Construct a tiny PostSemClawModel on CPU."""
        import torch  # noqa: PLC0415
        from hydra.config import PostSemClawConfig  # noqa: PLC0415
        from hydra.model import PostSemClawModel  # noqa: PLC0415

        # Use the smallest possible config that exercises all code paths.
        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  # noqa: PLC0415

        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  # noqa: PLC0415

        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"


# ---------------------------------------------------------------------------
# Test 4: train.py at repo root is still importable (body gated on __main__)
# ---------------------------------------------------------------------------

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,
        )
        # A non-zero exit only means the assert failed, not a parse error —
        # either way we surface stderr for diagnosis.
        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."
        )