SGJM / tests /test_mamba2.py
adampippert's picture
SGJM 2026.6.5 — code/docs
e51ccda verified
Raw
History Blame Contribute Delete
16.4 kB
"""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
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
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,
)
# ---------------------------------------------------------------------------
# 1. Config tests (no MLX required)
# ---------------------------------------------------------------------------
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
# ---------------------------------------------------------------------------
# 2. Layer allocation logic (pure Python, no ML framework required)
# ---------------------------------------------------------------------------
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)]
# layer indices 7, 15 (0-indexed) are attention
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)
# ---------------------------------------------------------------------------
# 3. MLX Mamba2Block tests
# ---------------------------------------------------------------------------
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
# Change positions 8-15 only
x2 = mx.concatenate([x2[:, :8, :], x2[:, 8:, :] + noise], axis=1)
y1 = block(x1)
y2 = block(x2)
mx.eval(y1, y2)
# First 8 positions must be identical
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)
# Process each sample individually
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)
# T=7 is not a multiple of chunk_size=4
x = mx.random.normal((2, 7, 32))
y = block(x)
mx.eval(y)
assert y.shape == (2, 7, 32)
# ---------------------------------------------------------------------------
# 4. MLX SGJM hybrid forward pass tests
# ---------------------------------------------------------------------------
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, # pure transformer
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
# ---------------------------------------------------------------------------
# 5. PyTorch Mamba2Block tests
# ---------------------------------------------------------------------------
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():
# Long sequence to stress the upper-triangle exp overflow path
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"