Skip to content

Commit 6314c9f

Browse files
committed
feat(v4/buildspec): MBSpec Stage B — ModelBuildSpec composer + verify_build_spec
Adds the composer and coherence checker layer (ModelBuildSpec.md §3.3, §6 B). cppmega_v4/buildspec/model_build_spec.py: - Rewriter Protocol: name + required_preconditions + provided_postconditions + __call__. Forward-ref so model_build_spec.py stays decoupled from the concrete rewriters that land in Stage C. - ModelBuildSpec frozen dataclass: graph + loss + optim + rewrites + dim_env + state_tokens (defaults to {"single_head"}). .replace() returns a new spec with field overrides (immutable). .apply_rewrites() walks the chain in declared order, checks each rewriter's required_preconditions against the running state_tokens set, runs it, and merges provided_postconditions into the result. - RewriteOrderError for chain-order violations at apply time. cppmega_v4/buildspec/diagnostics.py: - BuildDiagnosticSeverity / BuildDiagnostic / BuildDiagnostics - verify_build_spec(spec, *, check_shapes=True) runs four checks: 1. Rewrite-chain ordering simulation (precondition satisfaction + duplicate-name warning) 2. Loss head_outputs vs (simulated) post-rewrite brick names — when rewrites are present, the loss's declared head names are treated as future-valid (defers final check to apply time) 3. Optimizer matcher coverage (warns on a group whose matcher selects zero brick names — dead group = silent param skip) 4. Shape coherence via cppmega_v4.spec.resolve_shapes (no-op when dim_env is empty) - All severities mirror cppmega_v4.spec.DiagnosticSeverity so the GUI can use one legend. Tests (tests/v4/test_mbspec_stage_b.py, 22 cases): - ModelBuildSpec rejection: non-BrickGraph / non-LossSpec / non-OptimSpec / rewriter missing required attribute - default state_token / replace() immutability - apply_rewrites: empty chain identity; postcondition added; missing precondition raises RewriteOrderError; chained pre→post satisfaction - verify happy paths: matching head brick = clean; summary dict shape; Qwen-preset without head brick surfaces the right loss error - verify warning surfaces: dead optim matcher (regex matches nothing); "all" matcher never flagged; rewrite duplicate name warning - verify error surfaces: rewrite-chain bad order; resolver errors surfaced as component=shape; check_shapes=False skips - system: MTP-style head naming (logits_0 / logits_1) accepted when rewriters are present in the chain Full v4 regression: 833 passed / 5 skipped / 0 failed.
1 parent 5975307 commit 6314c9f

4 files changed

Lines changed: 877 additions & 3 deletions

File tree

cppmega_v4/buildspec/__init__.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,24 @@
22
33
See ``ModelBuildSpec.md`` (repo root) for the design.
44
5-
Stage A surface (this commit):
6-
- loss_spec.LossKind / LossSpec / cross_entropy_loss / mtp_weighted_loss
5+
Stage A+B surface:
6+
- loss_spec: LossKind / LossSpec / cross_entropy_loss / mtp_weighted_loss
77
/ ifim_shaped_loss / mhc_attn_bias_loss / custom_loss / LOSS_BUILTINS
8-
- optim_spec.OptimKind / OptimSpec / ParamGroup / adamw / muon /
8+
- optim_spec: OptimKind / OptimSpec / ParamGroup / adamw / muon /
99
muon_adamw_hybrid / sgd / OPTIM_BUILTINS
10+
- model_build_spec: ModelBuildSpec / Rewriter / RewriteOrderError
11+
- diagnostics: BuildDiagnostic / BuildDiagnosticSeverity /
12+
BuildDiagnostics / verify_build_spec
1013
"""
1114

1215
from __future__ import annotations
1316

17+
from cppmega_v4.buildspec.diagnostics import (
18+
BuildDiagnostic,
19+
BuildDiagnosticSeverity,
20+
BuildDiagnostics,
21+
verify_build_spec,
22+
)
1423
from cppmega_v4.buildspec.loss_spec import (
1524
LOSS_BUILTINS,
1625
LossKind,
@@ -21,6 +30,11 @@
2130
mhc_attn_bias_loss,
2231
mtp_weighted_loss,
2332
)
33+
from cppmega_v4.buildspec.model_build_spec import (
34+
ModelBuildSpec,
35+
RewriteOrderError,
36+
Rewriter,
37+
)
2438
from cppmega_v4.buildspec.optim_spec import (
2539
OPTIM_BUILTINS,
2640
OptimKind,
@@ -33,13 +47,19 @@
3347
)
3448

3549
__all__ = [
50+
"BuildDiagnostic",
51+
"BuildDiagnosticSeverity",
52+
"BuildDiagnostics",
3653
"LOSS_BUILTINS",
3754
"LossKind",
3855
"LossSpec",
56+
"ModelBuildSpec",
3957
"OPTIM_BUILTINS",
4058
"OptimKind",
4159
"OptimSpec",
4260
"ParamGroup",
61+
"RewriteOrderError",
62+
"Rewriter",
4363
"adamw",
4464
"cross_entropy_loss",
4565
"custom_loss",
@@ -49,4 +69,5 @@
4969
"muon",
5070
"muon_adamw_hybrid",
5171
"sgd",
72+
"verify_build_spec",
5273
]
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
"""Coherence checker for :class:`ModelBuildSpec`.
2+
3+
Walks the spec and reports any incoherence between its three components
4+
plus the rewrite chain:
5+
6+
- LossSpec.head_outputs ⊆ post-rewrite graph.names
7+
(the loss reads from brick outputs that must actually exist after
8+
all rewrites apply)
9+
- OptimSpec.groups[*].matcher matches at least one parameter on the
10+
post-rewrite graph (no dead matchers)
11+
- Rewrite chain preconditions are satisfied at each step (dry-run via
12+
state-token simulation, without actually invoking the rewriters)
13+
- Shape contracts on the post-rewrite graph still resolve cleanly
14+
under ``spec.dim_env`` (delegates to cppmega_v4.spec.resolve_shapes)
15+
16+
Severities mirror cppmega_v4.spec.DiagnosticSeverity (ERROR / WARNING /
17+
INFO) so the GUI can render them with the same legend.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from dataclasses import dataclass, field
23+
from enum import Enum
24+
from typing import Iterable
25+
26+
from cppmega_v4.buildspec.model_build_spec import (
27+
ModelBuildSpec,
28+
RewriteOrderError,
29+
)
30+
from cppmega_v4.buildspec.optim_spec import ParamGroup
31+
32+
33+
class BuildDiagnosticSeverity(str, Enum):
34+
INFO = "info"
35+
WARNING = "warning"
36+
ERROR = "error"
37+
38+
39+
@dataclass(frozen=True)
40+
class BuildDiagnostic:
41+
"""One issue surfaced by :func:`verify_build_spec`."""
42+
43+
severity: BuildDiagnosticSeverity
44+
component: str # "loss" | "optim" | "graph" | "rewrites" | "shape"
45+
message: str
46+
suggested_fix: str | None = None
47+
48+
49+
@dataclass(frozen=True)
50+
class BuildDiagnostics:
51+
"""Bundle returned by :func:`verify_build_spec`."""
52+
53+
diagnostics: tuple[BuildDiagnostic, ...] = field(default_factory=tuple)
54+
55+
@property
56+
def errors(self) -> tuple[BuildDiagnostic, ...]:
57+
return tuple(
58+
d for d in self.diagnostics
59+
if d.severity is BuildDiagnosticSeverity.ERROR
60+
)
61+
62+
@property
63+
def warnings(self) -> tuple[BuildDiagnostic, ...]:
64+
return tuple(
65+
d for d in self.diagnostics
66+
if d.severity is BuildDiagnosticSeverity.WARNING
67+
)
68+
69+
@property
70+
def has_errors(self) -> bool:
71+
return any(
72+
d.severity is BuildDiagnosticSeverity.ERROR
73+
for d in self.diagnostics
74+
)
75+
76+
def summary(self) -> dict[str, int]:
77+
return {
78+
"errors": len(self.errors),
79+
"warnings": len(self.warnings),
80+
"total": len(self.diagnostics),
81+
}
82+
83+
84+
# ---------------------------------------------------------------------------
85+
# Per-component checkers
86+
# ---------------------------------------------------------------------------
87+
88+
89+
def _check_rewrite_chain(spec: ModelBuildSpec) -> list[BuildDiagnostic]:
90+
"""Simulate rewrite preconditions without invoking the rewriters.
91+
92+
Anti-cycle: a token added by an earlier rewriter and then removed
93+
isn't a thing (postconditions are additive). We catch ordering
94+
errors by simulating the state-token set as we walk."""
95+
diags: list[BuildDiagnostic] = []
96+
if not spec.rewrites:
97+
return diags
98+
state = set(spec.state_tokens)
99+
names_seen: set[str] = set()
100+
for r in spec.rewrites:
101+
if r.name in names_seen:
102+
diags.append(
103+
BuildDiagnostic(
104+
severity=BuildDiagnosticSeverity.WARNING,
105+
component="rewrites",
106+
message=(
107+
f"rewriter {r.name!r} appears more than once in the "
108+
"chain; later instances may be no-ops or contradict "
109+
"earlier ones"
110+
),
111+
suggested_fix="dedupe the rewrite chain",
112+
)
113+
)
114+
names_seen.add(r.name)
115+
missing = r.required_preconditions - state
116+
if missing:
117+
diags.append(
118+
BuildDiagnostic(
119+
severity=BuildDiagnosticSeverity.ERROR,
120+
component="rewrites",
121+
message=(
122+
f"rewriter {r.name!r} requires preconditions "
123+
f"{sorted(missing)} but the chain reaches it with "
124+
f"state {sorted(state)}"
125+
),
126+
suggested_fix=(
127+
"reorder the rewrite chain so a rewriter providing "
128+
f"{sorted(missing)} runs first"
129+
),
130+
)
131+
)
132+
state |= r.provided_postconditions
133+
return diags
134+
135+
136+
def _matcher_matches_any(
137+
matcher: str, param_names: Iterable[str],
138+
) -> bool:
139+
"""Return True if ``matcher`` selects at least one of ``param_names``.
140+
141+
Mirrors the runtime matcher logic; kept here so the verifier doesn't
142+
need to materialise an nn.Module. Built-in matchers map to substring
143+
rules; ``regex:<pattern>`` compiles a Python regex."""
144+
if matcher == "all":
145+
return True
146+
if matcher.startswith("regex:"):
147+
import re
148+
pattern = re.compile(matcher.removeprefix("regex:"))
149+
return any(pattern.search(n) for n in param_names)
150+
needle = {
151+
"moe_experts": "expert",
152+
"embeddings": "embed",
153+
"attention": "attn",
154+
"mlp": "mlp",
155+
"head": "head",
156+
}.get(matcher)
157+
if needle is None:
158+
return False
159+
return any(needle in n for n in param_names)
160+
161+
162+
def _check_optim_matchers(spec: ModelBuildSpec) -> list[BuildDiagnostic]:
163+
"""Warn on optimizer matchers that don't select any brick name.
164+
165+
The brick names are a coarse proxy for parameter names, but it's
166+
enough to catch the common typo cases. A matcher selecting zero
167+
params silently drops weight updates — surface that loudly."""
168+
diags: list[BuildDiagnostic] = []
169+
brick_names = [n.name for n in spec.graph.nodes]
170+
if not brick_names:
171+
return diags
172+
for g in spec.optim.groups:
173+
if g.matcher == "all":
174+
continue
175+
if not _matcher_matches_any(g.matcher, brick_names):
176+
diags.append(
177+
BuildDiagnostic(
178+
severity=BuildDiagnosticSeverity.WARNING,
179+
component="optim",
180+
message=(
181+
f"optimizer matcher {g.matcher!r} (lr={g.lr}) "
182+
"matches no brick names in the graph; the group "
183+
"will receive no parameters"
184+
),
185+
suggested_fix=(
186+
"rename the matcher to 'all' or check the brick "
187+
"names match the matcher's pattern"
188+
),
189+
)
190+
)
191+
return diags
192+
193+
194+
def _check_loss_head_outputs(
195+
spec: ModelBuildSpec, post_rewrite_names: set[str],
196+
) -> list[BuildDiagnostic]:
197+
diags: list[BuildDiagnostic] = []
198+
for head in spec.loss.head_outputs:
199+
# The MTP head naming convention is "<prefix>_<i>"; before
200+
# rewrites are applied, the bare "logits" name is what bricks
201+
# produce. We accept either:
202+
# - exact match in post-rewrite brick names
203+
# - prefix match (head_output starts with a brick name)
204+
if head in post_rewrite_names:
205+
continue
206+
if any(head.startswith(n + "_") or n.startswith(head + "_")
207+
for n in post_rewrite_names):
208+
continue
209+
diags.append(
210+
BuildDiagnostic(
211+
severity=BuildDiagnosticSeverity.ERROR,
212+
component="loss",
213+
message=(
214+
f"LossSpec.head_outputs entry {head!r} does not match "
215+
"any brick name in the post-rewrite graph "
216+
f"(have: {sorted(post_rewrite_names)[:5]}{'...' if len(post_rewrite_names) > 5 else ''})"
217+
),
218+
suggested_fix=(
219+
"add a head brick with this name OR apply MTPRewriter "
220+
"before verifying"
221+
),
222+
)
223+
)
224+
return diags
225+
226+
227+
def _check_shape_coherence(spec: ModelBuildSpec) -> list[BuildDiagnostic]:
228+
"""Defer to cppmega_v4.spec.resolve_shapes (lenient). Surface any
229+
ERROR diagnostics as 'shape' build-diagnostics."""
230+
if not spec.dim_env:
231+
return []
232+
diags: list[BuildDiagnostic] = []
233+
try:
234+
from cppmega_v4.spec import resolve_shapes
235+
except ImportError:
236+
return [] # spec layer not present — skip silently
237+
try:
238+
resolved = resolve_shapes(
239+
spec.graph, spec.dim_env, strict=False,
240+
available_side_channels=frozenset({"doc_ids", "token_ids"}),
241+
)
242+
except Exception as exc:
243+
return [
244+
BuildDiagnostic(
245+
severity=BuildDiagnosticSeverity.ERROR,
246+
component="shape",
247+
message=f"shape resolution raised: {exc}",
248+
suggested_fix="fix the graph or supply missing dim_env entries",
249+
)
250+
]
251+
for d in resolved.errors:
252+
diags.append(
253+
BuildDiagnostic(
254+
severity=BuildDiagnosticSeverity.ERROR,
255+
component="shape",
256+
message=d.message,
257+
suggested_fix=d.suggested_fix,
258+
)
259+
)
260+
for d in resolved.warnings:
261+
diags.append(
262+
BuildDiagnostic(
263+
severity=BuildDiagnosticSeverity.WARNING,
264+
component="shape",
265+
message=d.message,
266+
suggested_fix=d.suggested_fix,
267+
)
268+
)
269+
return diags
270+
271+
272+
# ---------------------------------------------------------------------------
273+
# Public API
274+
# ---------------------------------------------------------------------------
275+
276+
277+
def verify_build_spec(
278+
spec: ModelBuildSpec,
279+
*,
280+
check_shapes: bool = True,
281+
) -> BuildDiagnostics:
282+
"""Run all coherence checks; return a :class:`BuildDiagnostics`.
283+
284+
Args:
285+
spec: the spec to verify.
286+
check_shapes: when True (default), runs shape coherence via
287+
cppmega_v4.spec.resolve_shapes (no-op if dim_env is empty).
288+
"""
289+
diags: list[BuildDiagnostic] = []
290+
291+
# 1. Rewrite-chain ordering
292+
diags.extend(_check_rewrite_chain(spec))
293+
294+
# 2. Loss head_outputs vs (simulated) post-rewrite graph names.
295+
# We simulate the post-rewrite names by walking the rewrite chain
296+
# WITHOUT actually invoking the rewriters — instead, we add the
297+
# head-output names declared by the loss spec to the brick-name
298+
# universe. This is a lenient check: the actual post-rewrite names
299+
# land at apply_rewrites time. We only ERROR when the head_output
300+
# references something neither the graph nor the rewrites can
301+
# plausibly produce.
302+
post_rewrite_names = {n.name for n in spec.graph.nodes}
303+
if spec.rewrites:
304+
# The MTPRewriter (and similar) declare provided_postconditions
305+
# that hint at the head naming convention; we accept all
306+
# head_outputs of the loss spec as future-valid.
307+
post_rewrite_names |= set(spec.loss.head_outputs)
308+
diags.extend(_check_loss_head_outputs(spec, post_rewrite_names))
309+
310+
# 3. Optimizer matcher coverage
311+
diags.extend(_check_optim_matchers(spec))
312+
313+
# 4. Shape coherence
314+
if check_shapes:
315+
diags.extend(_check_shape_coherence(spec))
316+
317+
return BuildDiagnostics(diagnostics=tuple(diags))
318+
319+
320+
__all__ = [
321+
"BuildDiagnostic",
322+
"BuildDiagnosticSeverity",
323+
"BuildDiagnostics",
324+
"verify_build_spec",
325+
]

0 commit comments

Comments
 (0)