Skip to content

Commit 6459d95

Browse files
committed
feat: implement loss_spec and optim_spec modules with validation and factory methods
1 parent 34e3e7a commit 6459d95

1 file changed

Lines changed: 358 additions & 0 deletions

File tree

tests/v4/test_mbspec_stage_a.py

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
"""MBSpec Stage A tests — loss_spec + optim_spec data-layer."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from cppmega_v4.build import (
8+
LOSS_BUILTINS,
9+
LossKind,
10+
LossSpec,
11+
OPTIM_BUILTINS,
12+
OptimKind,
13+
OptimSpec,
14+
ParamGroup,
15+
adamw,
16+
cross_entropy_loss,
17+
custom_loss,
18+
ifim_shaped_loss,
19+
mhc_attn_bias_loss,
20+
mtp_weighted_loss,
21+
muon,
22+
muon_adamw_hybrid,
23+
sgd,
24+
)
25+
26+
27+
# ---------------------------------------------------------------------------
28+
# LossSpec validation
29+
# ---------------------------------------------------------------------------
30+
31+
32+
def test_loss_kind_rejects_non_enum_value():
33+
with pytest.raises(TypeError, match="LossKind"):
34+
LossSpec(kind="cross_entropy", head_outputs=("logits",)) # type: ignore[arg-type]
35+
36+
37+
def test_loss_spec_rejects_empty_head_outputs():
38+
with pytest.raises(ValueError, match="head_outputs must not be empty"):
39+
LossSpec(kind=LossKind.CROSS_ENTROPY, head_outputs=())
40+
41+
42+
def test_loss_spec_rejects_blank_head_output_name():
43+
with pytest.raises(ValueError, match="non-empty str"):
44+
LossSpec(kind=LossKind.CROSS_ENTROPY, head_outputs=("",))
45+
with pytest.raises(ValueError, match="non-empty str"):
46+
LossSpec(kind=LossKind.CROSS_ENTROPY, head_outputs=(" ",))
47+
48+
49+
def test_loss_spec_rejects_bad_reduction():
50+
with pytest.raises(ValueError, match="reduction"):
51+
LossSpec(
52+
kind=LossKind.CROSS_ENTROPY,
53+
head_outputs=("logits",),
54+
reduction="trash",
55+
)
56+
57+
58+
def test_loss_spec_rejects_bad_label_source():
59+
with pytest.raises(ValueError, match="label_source"):
60+
LossSpec(
61+
kind=LossKind.CROSS_ENTROPY,
62+
head_outputs=("logits",),
63+
label_source="fake_source",
64+
)
65+
66+
67+
def test_mtp_weighted_requires_k_in_params():
68+
with pytest.raises(ValueError, match="MTP_WEIGHTED requires params\\['k'\\]"):
69+
LossSpec(
70+
kind=LossKind.MTP_WEIGHTED,
71+
params={},
72+
head_outputs=("logits_0", "logits_1"),
73+
label_source="next_k_tokens",
74+
)
75+
76+
77+
def test_mtp_weighted_requires_head_count_to_match_k():
78+
with pytest.raises(ValueError, match="head_outputs length"):
79+
LossSpec(
80+
kind=LossKind.MTP_WEIGHTED,
81+
params={"k": 3, "beta_0": 1.0, "beta_1": 0.6, "beta_2": 0.4},
82+
head_outputs=("logits_0", "logits_1"), # only 2, need 3
83+
label_source="next_k_tokens",
84+
)
85+
86+
87+
def test_mtp_weighted_requires_each_beta_param():
88+
with pytest.raises(ValueError, match="beta_1"):
89+
LossSpec(
90+
kind=LossKind.MTP_WEIGHTED,
91+
params={"k": 2, "beta_0": 1.0}, # missing beta_1
92+
head_outputs=("logits_0", "logits_1"),
93+
label_source="next_k_tokens",
94+
)
95+
96+
97+
def test_mtp_weighted_rejects_negative_beta():
98+
with pytest.raises(ValueError, match="beta_0"):
99+
LossSpec(
100+
kind=LossKind.MTP_WEIGHTED,
101+
params={"k": 1, "beta_0": -0.1},
102+
head_outputs=("logits_0",),
103+
label_source="next_k_tokens",
104+
)
105+
106+
107+
def test_ifim_requires_lambda_fim():
108+
with pytest.raises(ValueError, match="lambda_fim"):
109+
LossSpec(
110+
kind=LossKind.IFIM_SHAPED,
111+
params={},
112+
head_outputs=("logits",),
113+
)
114+
115+
116+
def test_mhc_requires_lambda_mhc():
117+
with pytest.raises(ValueError, match="lambda_mhc"):
118+
LossSpec(
119+
kind=LossKind.MHC_ATTN_BIAS,
120+
params={},
121+
head_outputs=("logits",),
122+
)
123+
124+
125+
# ---------------------------------------------------------------------------
126+
# LossSpec built-in factories
127+
# ---------------------------------------------------------------------------
128+
129+
130+
def test_cross_entropy_factory_returns_well_formed_spec():
131+
s = cross_entropy_loss()
132+
assert isinstance(s, LossSpec)
133+
assert s.kind is LossKind.CROSS_ENTROPY
134+
assert s.head_outputs == ("logits",)
135+
assert s.label_source == "next_token"
136+
137+
138+
def test_cross_entropy_custom_head_name():
139+
s = cross_entropy_loss("my_head")
140+
assert s.head_outputs == ("my_head",)
141+
142+
143+
def test_mtp_weighted_factory_default_k_2():
144+
s = mtp_weighted_loss()
145+
assert s.kind is LossKind.MTP_WEIGHTED
146+
assert int(s.params["k"]) == 2
147+
assert s.head_outputs == ("logits_0", "logits_1")
148+
assert s.params["beta_0"] == 1.0
149+
assert s.params["beta_1"] == 0.6
150+
assert s.label_source == "next_k_tokens"
151+
152+
153+
def test_mtp_weighted_factory_custom_k_and_beta():
154+
s = mtp_weighted_loss(k=3, beta=(1.0, 0.5, 0.25))
155+
assert int(s.params["k"]) == 3
156+
assert s.head_outputs == ("logits_0", "logits_1", "logits_2")
157+
assert s.params["beta_2"] == 0.25
158+
159+
160+
def test_mtp_weighted_factory_rejects_k_lt_1():
161+
with pytest.raises(ValueError, match="k must be ≥ 1"):
162+
mtp_weighted_loss(k=0)
163+
164+
165+
def test_mtp_weighted_factory_rejects_beta_length_mismatch():
166+
with pytest.raises(ValueError, match="len\\(beta\\)"):
167+
mtp_weighted_loss(k=2, beta=(1.0,))
168+
169+
170+
def test_ifim_factory_well_formed():
171+
s = ifim_shaped_loss(lambda_fim=0.05)
172+
assert s.kind is LossKind.IFIM_SHAPED
173+
assert s.params["lambda_fim"] == 0.05
174+
175+
176+
def test_mhc_factory_well_formed():
177+
s = mhc_attn_bias_loss(lambda_mhc=0.03)
178+
assert s.kind is LossKind.MHC_ATTN_BIAS
179+
assert s.params["lambda_mhc"] == 0.03
180+
181+
182+
def test_custom_loss_factory_accepts_arbitrary_params():
183+
s = custom_loss(("a", "b"), some_param=1.5, other=2.0)
184+
assert s.kind is LossKind.CUSTOM
185+
assert s.head_outputs == ("a", "b")
186+
assert s.params["some_param"] == 1.5
187+
188+
189+
def test_loss_builtins_registry_covers_all_loss_kinds():
190+
"""Every non-CUSTOM LossKind should have a builtin entry."""
191+
for k in LossKind:
192+
if k is LossKind.CUSTOM:
193+
continue
194+
assert k.value in LOSS_BUILTINS, k.value
195+
196+
197+
# ---------------------------------------------------------------------------
198+
# OptimSpec — ParamGroup validation
199+
# ---------------------------------------------------------------------------
200+
201+
202+
def test_param_group_rejects_blank_matcher():
203+
with pytest.raises(ValueError, match="non-empty"):
204+
ParamGroup(matcher="", lr=1e-3)
205+
206+
207+
def test_param_group_rejects_unknown_matcher():
208+
with pytest.raises(ValueError, match="must be one of"):
209+
ParamGroup(matcher="totally_made_up_matcher", lr=1e-3)
210+
211+
212+
def test_param_group_accepts_regex_matcher():
213+
g = ParamGroup(matcher="regex:.*expert.*", lr=1e-4)
214+
assert g.matcher.startswith("regex:")
215+
216+
217+
def test_param_group_rejects_non_positive_lr():
218+
with pytest.raises(ValueError, match="lr"):
219+
ParamGroup(matcher="all", lr=0.0)
220+
with pytest.raises(ValueError, match="lr"):
221+
ParamGroup(matcher="all", lr=-1e-3)
222+
223+
224+
def test_param_group_rejects_negative_wd():
225+
with pytest.raises(ValueError, match="weight_decay"):
226+
ParamGroup(matcher="all", lr=1e-3, weight_decay=-0.01)
227+
228+
229+
def test_param_group_rejects_bad_betas():
230+
with pytest.raises(ValueError, match="betas"):
231+
ParamGroup(matcher="all", lr=1e-3, betas=(1.5, 0.95)) # β1≥1
232+
with pytest.raises(ValueError, match="betas"):
233+
ParamGroup(matcher="all", lr=1e-3, betas=(0.9,)) # type: ignore[arg-type]
234+
235+
236+
def test_param_group_rejects_bad_ns_steps():
237+
with pytest.raises(ValueError, match="ns_steps"):
238+
ParamGroup(matcher="all", lr=1e-3, ns_steps=0)
239+
240+
241+
# ---------------------------------------------------------------------------
242+
# OptimSpec validation
243+
# ---------------------------------------------------------------------------
244+
245+
246+
def test_optim_kind_rejects_non_enum():
247+
with pytest.raises(TypeError, match="OptimKind"):
248+
OptimSpec(
249+
kind="adamw", # type: ignore[arg-type]
250+
groups=(ParamGroup(matcher="all", lr=1e-3, betas=(0.9, 0.95)),),
251+
)
252+
253+
254+
def test_optim_spec_rejects_empty_groups():
255+
with pytest.raises(ValueError, match="groups must not be empty"):
256+
OptimSpec(kind=OptimKind.ADAMW, groups=())
257+
258+
259+
def test_optim_spec_rejects_non_param_group_entries():
260+
with pytest.raises(TypeError, match="ParamGroup"):
261+
OptimSpec(
262+
kind=OptimKind.ADAMW,
263+
groups=({"matcher": "all", "lr": 1e-3},), # type: ignore[arg-type]
264+
)
265+
266+
267+
def test_optim_spec_rejects_bad_gradient_clip_norm():
268+
with pytest.raises(ValueError, match="gradient_clip_norm"):
269+
OptimSpec(
270+
kind=OptimKind.ADAMW,
271+
groups=(ParamGroup(matcher="all", lr=1e-3, betas=(0.9, 0.95)),),
272+
gradient_clip_norm=0.0,
273+
)
274+
275+
276+
def test_adamw_kind_requires_betas_on_every_group():
277+
with pytest.raises(ValueError, match="ADAMW group must declare betas"):
278+
OptimSpec(
279+
kind=OptimKind.ADAMW,
280+
groups=(ParamGroup(matcher="all", lr=1e-3),), # no betas
281+
)
282+
283+
284+
def test_muon_kind_requires_ns_steps_on_every_group():
285+
with pytest.raises(ValueError, match="MUON group must declare ns_steps"):
286+
OptimSpec(
287+
kind=OptimKind.MUON,
288+
groups=(ParamGroup(matcher="all", lr=1e-3),), # no ns_steps
289+
)
290+
291+
292+
# ---------------------------------------------------------------------------
293+
# Built-in factories
294+
# ---------------------------------------------------------------------------
295+
296+
297+
def test_adamw_factory_well_formed():
298+
s = adamw()
299+
assert s.kind is OptimKind.ADAMW
300+
assert len(s.groups) == 1
301+
assert s.groups[0].matcher == "all"
302+
assert s.groups[0].lr == 3e-4
303+
assert s.groups[0].betas == (0.9, 0.95)
304+
305+
306+
def test_muon_factory_well_formed():
307+
s = muon()
308+
assert s.kind is OptimKind.MUON
309+
assert s.groups[0].ns_steps == 5
310+
311+
312+
def test_muon_adamw_hybrid_has_four_groups_in_order():
313+
s = muon_adamw_hybrid()
314+
assert s.kind is OptimKind.MUON_ADAMW_HYBRID
315+
assert len(s.groups) == 4
316+
matchers = [g.matcher for g in s.groups]
317+
assert matchers == ["moe_experts", "embeddings", "head", "all"]
318+
# first three groups are AdamW (carry betas), last is Muon (carries ns_steps)
319+
for g in s.groups[:3]:
320+
assert g.betas is not None
321+
assert g.ns_steps is None
322+
assert s.groups[3].ns_steps is not None
323+
assert s.groups[3].betas is None
324+
325+
326+
def test_sgd_factory_well_formed():
327+
s = sgd()
328+
assert s.kind is OptimKind.SGD
329+
assert s.groups[0].betas is None
330+
assert s.groups[0].ns_steps is None
331+
332+
333+
def test_optim_builtins_registry_covers_all_optim_kinds():
334+
for k in OptimKind:
335+
assert k.value in OPTIM_BUILTINS, k.value
336+
337+
338+
# ---------------------------------------------------------------------------
339+
# Immutability — both specs are frozen
340+
# ---------------------------------------------------------------------------
341+
342+
343+
def test_loss_spec_is_frozen():
344+
s = cross_entropy_loss()
345+
with pytest.raises((AttributeError, TypeError)):
346+
s.reduction = "sum" # type: ignore[misc]
347+
348+
349+
def test_optim_spec_is_frozen():
350+
s = adamw()
351+
with pytest.raises((AttributeError, TypeError)):
352+
s.mixed_precision = False # type: ignore[misc]
353+
354+
355+
def test_param_group_is_frozen():
356+
g = ParamGroup(matcher="all", lr=1e-3, betas=(0.9, 0.95))
357+
with pytest.raises((AttributeError, TypeError)):
358+
g.lr = 2e-3 # type: ignore[misc]

0 commit comments

Comments
 (0)