File size: 7,397 Bytes
37d8122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Phase 2 — tests du transformer texte from scratch.

Tout tourne sur CPU en quelques secondes avec les tailles minuscules de
`configs/model.yaml > test`.
"""

from __future__ import annotations

import pytest
import torch

from src.model.attention import MultiHeadSelfAttention, build_causal_mask
from src.model.block import TransformerBlock
from src.model.ffn import FeedForward
from src.model.rope import apply_rope, build_rope_cache, rotate_half
from src.model.text_model import CortexTextLM, TextConfig

torch.manual_seed(0)

TINY = TextConfig(
    vocab_size=64, n_layer=2, n_head=2, n_embd=32, ctx_len=16, dropout=0.0, tie_embeddings=True
)


# ---------------------------------------------------------------------------
# RoPE
# ---------------------------------------------------------------------------


def test_rope_cache_shapes():
    cos, sin = build_rope_cache(seq_len=8, head_dim=16)
    assert cos.shape == (8, 16) and sin.shape == (8, 16)


def test_rope_cache_rejects_odd_dim():
    with pytest.raises(ValueError):
        build_rope_cache(seq_len=4, head_dim=15)


def test_rotate_half_is_involution_up_to_sign_layout():
    x = torch.arange(8, dtype=torch.float32).view(1, 8)
    y = rotate_half(x)
    assert torch.equal(y, torch.tensor([[-4.0, -5, -6, -7, 0, 1, 2, 3]]))


def test_rope_preserves_norm():
    """RoPE est une rotation : la norme de chaque paire est conservée."""
    q = torch.randn(1, 2, 5, 16)
    k = torch.randn(1, 2, 5, 16)
    cos, sin = build_rope_cache(5, 16)
    q2, k2 = apply_rope(q, k, cos, sin)
    assert torch.allclose(q.norm(dim=-1), q2.norm(dim=-1), atol=1e-5)
    assert torch.allclose(k.norm(dim=-1), k2.norm(dim=-1), atol=1e-5)


def test_rope_position_zero_is_identity():
    q = torch.randn(1, 1, 3, 8)
    k = torch.randn(1, 1, 3, 8)
    cos, sin = build_rope_cache(3, 8)
    q2, _ = apply_rope(q, k, cos, sin)
    assert torch.allclose(q[:, :, 0], q2[:, :, 0], atol=1e-6)


def test_rope_makes_attention_depend_on_relative_position():
    """Le score q·k doit dépendre de l'écart de position, pas de l'absolu."""
    d = 16
    cos, sin = build_rope_cache(8, d)
    base = torch.randn(1, 1, 1, d)
    q = base.repeat(1, 1, 8, 1)
    k = base.repeat(1, 1, 8, 1)
    q_rot, k_rot = apply_rope(q, k, cos, sin)
    scores = (q_rot @ k_rot.transpose(-2, -1))[0, 0]
    # score[i, j] ne dépend que de (i - j)
    assert torch.allclose(scores[0, 1], scores[1, 2], atol=1e-5)
    assert torch.allclose(scores[2, 5], scores[3, 6], atol=1e-5)


# ---------------------------------------------------------------------------
# Masque causal + attention
# ---------------------------------------------------------------------------


def test_causal_mask_shape_and_content():
    mask = build_causal_mask(4)
    assert mask.shape == (4, 4)
    assert mask[0].tolist() == [True, False, False, False]
    assert mask[3].tolist() == [True, True, True, True]


def test_attention_output_shape():
    attn = MultiHeadSelfAttention(32, 2).eval()
    x = torch.randn(2, 7, 32)
    assert attn(x).shape == (2, 7, 32)


def test_attention_is_strictly_causal():
    """Le token t ne dépend pas des tokens > t : on modifie le futur, la sortie passée ne bouge pas."""
    attn = MultiHeadSelfAttention(32, 2, dropout=0.0).eval()
    x = torch.randn(1, 6, 32)
    with torch.no_grad():
        y1 = attn(x)
        x2 = x.clone()
        x2[:, 4:] += 100.0  # on ne modifie QUE le futur
        y2 = attn(x2)
    assert torch.allclose(y1[:, :4], y2[:, :4], atol=1e-5), "fuite d'information du futur"
    assert not torch.allclose(y1[:, 4:], y2[:, 4:])


def test_attention_matches_manual_inference_for_second_token():
    """Vérifie la formule d'attention à la main sur un cas simple."""
    attn = MultiHeadSelfAttention(8, 1, use_rope=False, dropout=0.0).eval()
    x = torch.randn(1, 3, 8)
    with torch.no_grad():
        out = attn(x)
        q = attn.q_proj(x)
        k = attn.k_proj(x)
        v = attn.v_proj(x)
        scores = (q @ k.transpose(-2, -1)) / (8 ** 0.5)
        scores = scores.masked_fill(~build_causal_mask(3), float("-inf"))
        w = torch.softmax(scores, dim=-1)
        manual = w @ v
        manual = attn.out_proj(manual)
    assert torch.allclose(out, manual, atol=1e-5)


# ---------------------------------------------------------------------------
# FFN + bloc
# ---------------------------------------------------------------------------


def test_ffn_shape_and_param_count():
    ffn = FeedForward(32, mult=4, bias=False)
    x = torch.randn(2, 5, 32)
    assert ffn(x).shape == (2, 5, 32)
    # 2 matrices : 32*128 + 128*32
    assert sum(p.numel() for p in ffn.parameters()) == 32 * 128 + 128 * 32


def test_block_preserves_shape():
    block = TransformerBlock(32, 2).eval()
    x = torch.randn(2, 5, 32)
    assert block(x).shape == (2, 5, 32)


def test_block_is_prenorm():
    block = TransformerBlock(32, 2)
    assert hasattr(block, "ln_1") and hasattr(block, "ln_2")


# ---------------------------------------------------------------------------
# Modèle complet
# ---------------------------------------------------------------------------


def test_model_forward_shapes():
    model = CortexTextLM(TINY).eval()
    x = torch.randint(0, 64, (2, 16))
    logits, loss = model(x)
    assert logits.shape == (2, 16, 64)
    assert loss is None


def test_model_loss_is_scalar():
    model = CortexTextLM(TINY).eval()
    x = torch.randint(0, 64, (2, 16))
    _, loss = model(x, targets=x)
    assert loss.dim() == 0
    # À l'initialisation, la perte doit être proche de log(vocab) (distribution quasi uniforme).
    assert abs(loss.item() - torch.log(torch.tensor(64.0)).item()) < 1.0


def test_model_rejects_too_long_sequence():
    model = CortexTextLM(TINY).eval()
    x = torch.randint(0, 64, (1, 17))
    with pytest.raises(ValueError):
        model(x)


def test_weight_tying():
    model = CortexTextLM(TINY)
    assert model.lm_head.weight is model.token_embedding.weight


def test_untied_model_has_more_params():
    tied = CortexTextLM(TINY)
    untied = CortexTextLM(
        TextConfig(**{**TINY.__dict__, "tie_embeddings": False})
    )
    assert untied.count_parameters() > tied.count_parameters()


def test_model_is_causal():
    model = CortexTextLM(TINY).eval()
    x = torch.randint(0, 64, (1, 10))
    with torch.no_grad():
        a, _ = model(x)
        x2 = x.clone()
        x2[:, 5:] = torch.randint(0, 64, (1, 5))
        b, _ = model(x2)
    assert torch.allclose(a[:, :5], b[:, :5], atol=1e-5), "fuite du futur dans les logits"


# ---------------------------------------------------------------------------
# Overfit 1 batch : la loss doit descendre à ~0
# ---------------------------------------------------------------------------


def test_overfit_single_batch():
    torch.manual_seed(0)
    model = CortexTextLM(TINY)
    x = torch.randint(0, 64, (4, 16))
    optimizer = torch.optim.AdamW(model.parameters(), lr=3e-3)

    first_loss = None
    for step in range(200):
        logits, loss = model(x, targets=x)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        if step == 0:
            first_loss = loss.item()

    final_loss = loss.item()
    assert first_loss is not None and first_loss > 1.0
    assert final_loss < 0.05, f"n'a pas overfit : loss finale {final_loss}"
    assert final_loss < first_loss / 20