| """Tests for the hybrid Mamba-2 / full-attention backbone. |
| |
| Written FIRST per TDD mandate. All tests should fail before implementation. |
| """ |
| from __future__ import annotations |
|
|
| import pytest |
|
|
|
|
| |
| |
| |
|
|
|
|
| def _make_mlx_hybrid_config( |
| d_model: int = 32, |
| n_layers: int = 10, |
| n_heads: int = 4, |
| d_ff: int = 64, |
| attn_every_n: int = 8, |
| mamba_state_size: int = 8, |
| mamba_expand: int = 2, |
| mamba_d_conv: int = 4, |
| mamba_head_dim: int = 8, |
| mamba_chunk_size: int = 4, |
| vocab_size: int = 64, |
| max_seq_len: int = 64, |
| ): |
| from sgjm.training.config import ModelConfig |
|
|
| return ModelConfig( |
| vocab_size=vocab_size, |
| d_model=d_model, |
| n_layers=n_layers, |
| n_heads=n_heads, |
| d_ff=d_ff, |
| max_seq_len=max_seq_len, |
| attn_every_n=attn_every_n, |
| mamba_state_size=mamba_state_size, |
| mamba_expand=mamba_expand, |
| mamba_d_conv=mamba_d_conv, |
| mamba_head_dim=mamba_head_dim, |
| mamba_chunk_size=mamba_chunk_size, |
| ) |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestHybridConfig: |
| def test_sgjm_25m_hybrid_config_loads(self): |
| """TrainingConfig.sgjm_25m_hybrid() must load without error.""" |
| from sgjm.training.config import TrainingConfig |
|
|
| cfg = TrainingConfig.sgjm_25m_hybrid() |
| assert cfg is not None |
|
|
| def test_sgjm_25m_hybrid_has_attn_every_n_8(self): |
| """25m hybrid must set attn_every_n=8.""" |
| from sgjm.training.config import TrainingConfig |
|
|
| cfg = TrainingConfig.sgjm_25m_hybrid() |
| assert cfg.model.attn_every_n == 8 |
|
|
| def test_sgjm_25m_hybrid_checkpoint_dir(self): |
| """25m hybrid must use a distinct checkpoint directory.""" |
| from sgjm.training.config import TrainingConfig |
|
|
| cfg = TrainingConfig.sgjm_25m_hybrid() |
| assert "hybrid" in cfg.checkpoint_dir |
|
|
| def test_sgjm_250m_hybrid_config_loads(self): |
| """TrainingConfig.sgjm_250m_hybrid() must load without error.""" |
| from sgjm.training.config import TrainingConfig |
|
|
| cfg = TrainingConfig.sgjm_250m_hybrid() |
| assert cfg is not None |
|
|
| def test_sgjm_250m_hybrid_has_attn_every_n_8(self): |
| """250m hybrid must set attn_every_n=8.""" |
| from sgjm.training.config import TrainingConfig |
|
|
| cfg = TrainingConfig.sgjm_250m_hybrid() |
| assert cfg.model.attn_every_n == 8 |
|
|
| def test_sgjm_250m_hybrid_checkpoint_dir(self): |
| """250m hybrid must use a distinct checkpoint directory.""" |
| from sgjm.training.config import TrainingConfig |
|
|
| cfg = TrainingConfig.sgjm_250m_hybrid() |
| assert "hybrid" in cfg.checkpoint_dir |
|
|
| def test_default_modelconfig_attn_every_n_zero(self): |
| """Default ModelConfig must have attn_every_n=0 (pure transformer).""" |
| from sgjm.training.config import ModelConfig |
|
|
| cfg = ModelConfig() |
| assert cfg.attn_every_n == 0 |
|
|
| def test_mamba_fields_have_defaults(self): |
| """All new mamba fields must have sensible defaults.""" |
| from sgjm.training.config import ModelConfig |
|
|
| cfg = ModelConfig() |
| assert cfg.mamba_state_size == 64 |
| assert cfg.mamba_expand == 2 |
| assert cfg.mamba_d_conv == 4 |
| assert cfg.mamba_head_dim == 64 |
| assert cfg.mamba_chunk_size == 64 |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestLayerAllocation: |
| def test_is_attn_layer_zero_means_all_attention(self): |
| """attn_every_n=0 → is_attn_layer always returns True (pure transformer).""" |
| from sgjm.training.config import is_attn_layer |
|
|
| for i in range(12): |
| assert is_attn_layer(i, 0) is True |
|
|
| def test_is_attn_layer_n8_correct_indices(self): |
| """attn_every_n=8 → attention only at layers where (i+1) % 8 == 0.""" |
| from sgjm.training.config import is_attn_layer |
|
|
| attn_layers = [i for i in range(16) if is_attn_layer(i, 8)] |
| |
| assert attn_layers == [7, 15] |
|
|
| def test_is_attn_layer_n8_ten_layers(self): |
| """With n_layers=10 and attn_every_n=8, exactly one attention layer (idx 7).""" |
| from sgjm.training.config import is_attn_layer |
|
|
| attn_layers = [i for i in range(10) if is_attn_layer(i, 8)] |
| assert attn_layers == [7] |
| assert len(attn_layers) == 1 |
|
|
| def test_mlx_model_exports_is_attn_layer(self): |
| """mlx_backend.model re-exports _is_attn_layer for backward compat.""" |
| pytest.importorskip("mlx.core") |
| from sgjm.training.mlx_backend.model import _is_attn_layer |
|
|
| assert _is_attn_layer(7, 8) is True |
| assert _is_attn_layer(6, 8) is False |
|
|
| def test_hybrid_backbone_block_types(self): |
| """Backbone with attn_every_n=8 has correct ratio of block types.""" |
| pytest.importorskip("mlx.core") |
| from sgjm.training.mlx_backend.mamba2 import Mamba2Block |
| from sgjm.training.mlx_backend.model import Backbone, Block |
|
|
| cfg = _make_mlx_hybrid_config(n_layers=10, attn_every_n=8) |
| backbone = Backbone(cfg) |
| attn_count = sum(1 for b in backbone.blocks if isinstance(b, Block)) |
| mamba_count = sum(1 for b in backbone.blocks if isinstance(b, Mamba2Block)) |
| assert attn_count == 1 |
| assert mamba_count == 9 |
|
|
| def test_pure_transformer_all_attention_blocks(self): |
| """attn_every_n=0 → all blocks are attention blocks.""" |
| pytest.importorskip("mlx.core") |
| from sgjm.training.mlx_backend.model import Backbone, Block |
|
|
| cfg = _make_mlx_hybrid_config(n_layers=4, attn_every_n=0) |
| backbone = Backbone(cfg) |
| assert all(isinstance(b, Block) for b in backbone.blocks) |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestMamba2BlockMLX: |
| def test_mamba2_block_output_shape(self): |
| """Mamba2Block([d_model=32, ...]) with [2, 16, 32] input → [2, 16, 32] output.""" |
| mx = pytest.importorskip("mlx.core") |
| from sgjm.training.mlx_backend.mamba2 import Mamba2Block |
|
|
| block = Mamba2Block( |
| d_model=32, |
| state_size=8, |
| expand=2, |
| d_conv=4, |
| head_dim=8, |
| chunk_size=4, |
| ) |
| x = mx.random.normal((2, 16, 32)) |
| y = block(x) |
| mx.eval(y) |
| assert y.shape == (2, 16, 32) |
|
|
| def test_mamba2_block_causal(self): |
| """Output at position ≤7 is unchanged when position ≥8 input changes.""" |
| mx = pytest.importorskip("mlx.core") |
| from sgjm.training.mlx_backend.mamba2 import Mamba2Block |
|
|
| block = Mamba2Block( |
| d_model=32, |
| state_size=8, |
| expand=2, |
| d_conv=4, |
| head_dim=8, |
| chunk_size=4, |
| ) |
| x1 = mx.random.normal((1, 16, 32)) |
| x2 = mx.array(x1) |
| noise = mx.random.normal((1, 8, 32)) * 0.1 |
| |
| x2 = mx.concatenate([x2[:, :8, :], x2[:, 8:, :] + noise], axis=1) |
|
|
| y1 = block(x1) |
| y2 = block(x2) |
| mx.eval(y1, y2) |
|
|
| |
| diff = mx.abs(y1[:, :8, :] - y2[:, :8, :]).max().item() |
| assert diff < 1e-5, f"Causality violated: max diff = {diff}" |
|
|
| def test_mamba2_block_dtype_preserved(self): |
| """Output dtype matches input dtype.""" |
| mx = pytest.importorskip("mlx.core") |
| from sgjm.training.mlx_backend.mamba2 import Mamba2Block |
|
|
| block = Mamba2Block(d_model=32, state_size=8, expand=2, d_conv=4, head_dim=8, chunk_size=4) |
| x = mx.random.normal((1, 8, 32)).astype(mx.float32) |
| y = block(x) |
| mx.eval(y) |
| assert y.dtype == mx.float32 |
|
|
| def test_mamba2_block_residual_connection(self): |
| """Output has the correct shape (transformation applies without error).""" |
| mx = pytest.importorskip("mlx.core") |
| from sgjm.training.mlx_backend.mamba2 import Mamba2Block |
|
|
| block = Mamba2Block(d_model=32, state_size=8, expand=2, d_conv=4, head_dim=8, chunk_size=4) |
| x = mx.ones((1, 8, 32)) |
| y = block(x) |
| mx.eval(y) |
| assert y.shape == (1, 8, 32) |
|
|
| def test_mamba2_block_batch_independence(self): |
| """Each batch element is processed independently.""" |
| mx = pytest.importorskip("mlx.core") |
| from sgjm.training.mlx_backend.mamba2 import Mamba2Block |
|
|
| block = Mamba2Block(d_model=32, state_size=8, expand=2, d_conv=4, head_dim=8, chunk_size=4) |
| x = mx.random.normal((3, 8, 32)) |
| y_batch = block(x) |
|
|
| |
| y_singles = mx.concatenate([block(x[i : i + 1]) for i in range(3)], axis=0) |
| mx.eval(y_batch, y_singles) |
|
|
| diff = mx.abs(y_batch - y_singles).max().item() |
| assert diff < 1e-4, f"Batch independence violated: max diff = {diff}" |
|
|
| def test_mamba2_block_chunk_boundary(self): |
| """Output shape is correct when T is not a multiple of chunk_size.""" |
| mx = pytest.importorskip("mlx.core") |
| from sgjm.training.mlx_backend.mamba2 import Mamba2Block |
|
|
| block = Mamba2Block(d_model=32, state_size=8, expand=2, d_conv=4, head_dim=8, chunk_size=4) |
| |
| x = mx.random.normal((2, 7, 32)) |
| y = block(x) |
| mx.eval(y) |
| assert y.shape == (2, 7, 32) |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestSGJMHybridMLX: |
| def test_sgjm_hybrid_forward_shape(self): |
| """SGJM with hybrid config produces hidden states [B, T, d_model] and logits [B, T, vocab].""" |
| mx = pytest.importorskip("mlx.core") |
| from sgjm.training.mlx_backend.model import SGJM |
|
|
| cfg = _make_mlx_hybrid_config( |
| d_model=32, |
| n_layers=10, |
| n_heads=4, |
| d_ff=64, |
| attn_every_n=8, |
| mamba_state_size=8, |
| mamba_expand=2, |
| mamba_d_conv=4, |
| mamba_head_dim=8, |
| mamba_chunk_size=4, |
| vocab_size=64, |
| max_seq_len=32, |
| ) |
| model = SGJM(cfg) |
| idx = mx.array([[1, 2, 3, 4, 5, 6, 7, 8]] * 2) |
| h, logits = model(idx) |
| mx.eval(h, logits) |
| assert h.shape == (2, 8, 32) |
| assert logits.shape == (2, 8, 64) |
|
|
| def test_sgjm_pure_transformer_backward_compat(self): |
| """attn_every_n=0 (default) → same shape output as before (backward compat).""" |
| mx = pytest.importorskip("mlx.core") |
| from sgjm.training.mlx_backend.model import SGJM |
|
|
| cfg = _make_mlx_hybrid_config( |
| d_model=32, |
| n_layers=4, |
| n_heads=4, |
| d_ff=64, |
| attn_every_n=0, |
| vocab_size=64, |
| max_seq_len=32, |
| ) |
| model = SGJM(cfg) |
| idx = mx.array([[1, 2, 3, 4, 5, 6, 7, 8]] * 2) |
| h, logits = model(idx) |
| mx.eval(h, logits) |
| assert h.shape == (2, 8, 32) |
| assert logits.shape == (2, 8, 64) |
|
|
| def test_sgjm_25m_hybrid_instantiates(self): |
| """TrainingConfig.sgjm_25m_hybrid() can instantiate SGJM without error.""" |
| pytest.importorskip("mlx.core") |
| from sgjm.training.config import TrainingConfig |
| from sgjm.training.mlx_backend.model import SGJM |
|
|
| cfg = TrainingConfig.sgjm_25m_hybrid() |
| model = SGJM(cfg.model) |
| assert model is not None |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestMamba2BlockTorch: |
| def test_mamba2_block_output_shape_torch(self): |
| """PyTorch Mamba2Block with [2, 16, 32] input → [2, 16, 32] output.""" |
| torch = pytest.importorskip("torch") |
| from sgjm.training.torch_backend.mamba2 import Mamba2Block |
|
|
| block = Mamba2Block( |
| d_model=32, |
| state_size=8, |
| expand=2, |
| d_conv=4, |
| head_dim=8, |
| chunk_size=4, |
| ) |
| x = torch.randn(2, 16, 32) |
| y = block(x) |
| assert y.shape == (2, 16, 32) |
|
|
| def test_mamba2_block_causal_torch(self): |
| """PyTorch Mamba2Block: output at position ≤7 unchanged when position ≥8 input changes.""" |
| torch = pytest.importorskip("torch") |
| from sgjm.training.torch_backend.mamba2 import Mamba2Block |
|
|
| block = Mamba2Block(d_model=32, state_size=8, expand=2, d_conv=4, head_dim=8, chunk_size=4) |
| block.eval() |
| with torch.no_grad(): |
| x1 = torch.randn(1, 16, 32) |
| x2 = x1.clone() |
| x2[:, 8:, :] += torch.randn(1, 8, 32) * 0.1 |
| y1 = block(x1) |
| y2 = block(x2) |
| diff = (y1[:, :8, :] - y2[:, :8, :]).abs().max().item() |
| assert diff < 1e-5, f"Causality violated: max diff = {diff}" |
|
|
| def test_mamba2_block_chunk_boundary_torch(self): |
| """PyTorch Mamba2Block: correct output when T is not a multiple of chunk_size.""" |
| torch = pytest.importorskip("torch") |
| from sgjm.training.torch_backend.mamba2 import Mamba2Block |
|
|
| block = Mamba2Block(d_model=32, state_size=8, expand=2, d_conv=4, head_dim=8, chunk_size=4) |
| x = torch.randn(2, 7, 32) |
| y = block(x) |
| assert y.shape == (2, 7, 32) |
|
|
| def test_torch_hybrid_backbone_forward_shape(self): |
| """PyTorch Backbone with hybrid config produces correct shapes.""" |
| torch = pytest.importorskip("torch") |
| from sgjm.training.config import ModelConfig |
| from sgjm.training.torch_backend.model import Backbone |
|
|
| cfg = ModelConfig( |
| vocab_size=64, |
| d_model=32, |
| n_layers=10, |
| n_heads=4, |
| d_ff=64, |
| max_seq_len=32, |
| attn_every_n=8, |
| mamba_state_size=8, |
| mamba_expand=2, |
| mamba_d_conv=4, |
| mamba_head_dim=8, |
| mamba_chunk_size=4, |
| ) |
| backbone = Backbone(cfg) |
| idx = torch.randint(0, 64, (2, 8)) |
| h, logits = backbone(idx) |
| assert h.shape == (2, 8, 32) |
| assert logits.shape == (2, 8, 64) |
|
|
| def test_torch_is_attn_layer_helper(self): |
| """_is_attn_layer re-exported from torch_backend.model works correctly.""" |
| pytest.importorskip("torch") |
| from sgjm.training.torch_backend.model import _is_attn_layer |
|
|
| assert _is_attn_layer(7, 8) is True |
| assert _is_attn_layer(6, 8) is False |
| assert _is_attn_layer(0, 0) is True |
|
|
| def test_no_nan_in_output_torch(self): |
| """SSD scan must not produce NaN regardless of sequence length.""" |
| torch = pytest.importorskip("torch") |
| from sgjm.training.torch_backend.mamba2 import Mamba2Block |
|
|
| block = Mamba2Block(d_model=32, state_size=8, expand=2, d_conv=4, head_dim=8, chunk_size=4) |
| block.eval() |
| with torch.no_grad(): |
| |
| x = torch.randn(2, 128, 32) |
| y = block(x) |
| assert not torch.isnan(y).any(), "NaN in Mamba2Block output" |
| assert not torch.isinf(y).any(), "Inf in Mamba2Block output" |
|
|
| def test_no_nan_mlx(self): |
| """MLX SSD scan must not produce NaN regardless of sequence length.""" |
| mx = pytest.importorskip("mlx.core") |
| from sgjm.training.mlx_backend.mamba2 import Mamba2Block |
|
|
| block = Mamba2Block(d_model=32, state_size=8, expand=2, d_conv=4, head_dim=8, chunk_size=4) |
| x = mx.random.normal((2, 128, 32)) |
| y = block(x) |
| mx.eval(y) |
| assert not mx.isnan(y).any().item(), "NaN in MLX Mamba2Block output" |
|
|