import pytest import torch from microgpt.model import GPT, GPTConfig, apply_rope, rope_tables CFG = GPTConfig(vocab_size=50, block_size=32, n_layer=2, n_head=4, n_embd=32) @pytest.fixture() def model(): torch.manual_seed(0) return GPT(CFG).eval() def test_output_shapes(model): idx = torch.randint(0, CFG.vocab_size, (3, 10)) logits, loss, cache = model(idx) assert logits.shape == (3, 10, CFG.vocab_size) assert loss is None assert len(cache) == CFG.n_layer and cache[0][0].shape == (3, CFG.n_head, 10, 8) def test_weights_are_tied(model): assert model.lm_head.weight is model.tok_emb.weight def test_no_future_leakage(model): """Changing a later token must not change logits at earlier positions.""" a = torch.randint(0, CFG.vocab_size, (1, 12)) b = a.clone() b[0, 8:] = (b[0, 8:] + 1) % CFG.vocab_size with torch.no_grad(): la, lb = model(a)[0], model(b)[0] assert torch.allclose(la[:, :8], lb[:, :8], atol=1e-6) assert not torch.allclose(la[:, 8:], lb[:, 8:], atol=1e-6) def test_kv_cache_matches_full_forward(model): """Prefill + one-token-at-a-time decoding == a single full forward pass.""" idx = torch.randint(0, CFG.vocab_size, (2, 20)) with torch.no_grad(): full, _, _ = model(idx) logits, _, cache = model(idx[:, :7]) steps = [logits] for t in range(7, 20): step, _, cache = model(idx[:, t : t + 1], cache=cache) steps.append(step) assert torch.allclose(full, torch.cat(steps, dim=1), atol=1e-5) def test_cache_chunked_prefill_matches_full(model): """Multi-token continuation exercises the non-trivial causal-mask offset.""" idx = torch.randint(0, CFG.vocab_size, (1, 18)) with torch.no_grad(): full, _, _ = model(idx) l1, _, cache = model(idx[:, :6]) l2, _, _ = model(idx[:, 6:], cache=cache) assert torch.allclose(full, torch.cat([l1, l2], dim=1), atol=1e-5) def test_greedy_generation_identical_with_and_without_cache(model): prompt = torch.randint(0, CFG.vocab_size, (1, 5)) a = model.generate(prompt, 20, temperature=0, use_cache=True) b = model.generate(prompt, 20, temperature=0, use_cache=False) assert torch.equal(a, b) def test_generation_past_block_size(model): prompt = torch.randint(0, CFG.vocab_size, (1, 5)) out = model.generate(prompt, CFG.block_size * 2, temperature=1.0, top_k=10) assert out.shape == (1, 5 + CFG.block_size * 2) assert int(out.max()) < CFG.vocab_size def test_sequence_longer_than_block_size_is_rejected(model): with pytest.raises(ValueError): model(torch.zeros(1, CFG.block_size + 1, dtype=torch.long)) def test_seeded_sampling_is_reproducible(model): prompt = torch.randint(0, CFG.vocab_size, (1, 4)) runs = [ model.generate(prompt, 15, top_p=0.9, generator=torch.Generator().manual_seed(7)) for _ in range(2) ] assert torch.equal(runs[0], runs[1]) def test_rope_is_norm_preserving(): cos, sin = rope_tables(16, 40, 10000.0) x = torch.randn(2, 3, 40, 16) y = apply_rope(x, cos, sin) assert torch.allclose(x.norm(dim=-1), y.norm(dim=-1), atol=1e-5) def test_rope_scores_depend_only_on_relative_position(): """ must equal .""" cos, sin = rope_tables(16, 64, 10000.0) q, k = torch.randn(1, 1, 1, 16), torch.randn(1, 1, 1, 16) def score(m: int, n: int) -> torch.Tensor: qm = apply_rope(q, cos[m : m + 1], sin[m : m + 1]) kn = apply_rope(k, cos[n : n + 1], sin[n : n + 1]) return (qm * kn).sum() assert torch.allclose(score(5, 2), score(25, 22), atol=1e-4) assert torch.allclose(score(0, 9), score(30, 39), atol=1e-4) assert not torch.allclose(score(5, 2), score(5, 3), atol=1e-4) def test_can_overfit_a_tiny_batch(): torch.manual_seed(0) m = GPT(CFG) x = torch.randint(0, CFG.vocab_size, (4, 16)) y = torch.roll(x, -1, dims=1) opt = torch.optim.AdamW(m.parameters(), lr=3e-3) first = None for _ in range(80): loss = m(x, y)[1] first = first if first is not None else loss.item() opt.zero_grad() loss.backward() opt.step() assert loss.item() < 0.3 * first