Skip to content

Commit 968786f

Browse files
committed
feat(v4/buildspec): MBSpec Stage D — IFIMRewriter + MHCRewriter + composition
Adds two more rewriters and the composition machinery (ModelBuildSpec.md §3.4, §6 D). IFIMRewriter(lambda_fim=0.1, aux_node_name='ifim_aux'): - Inverse Fisher-Information Matrix shaping. Doesn't touch backbone topology — adds one virtual aux brick downstream of the head(s) carrying ``params={'is_ifim_aux': True, 'lambda_fim': λ}`` and wires it from every existing head_output. - Rewrites loss CE → ifim_shaped (single head) or MTP → IFIM-shaped multi-head when the prior loss is mtp_weighted. - No required preconditions — runs after MTP or before; works either way. Postcondition: 'ifim_added'. - Double-apply raises IFIMCompositionError (would compound λ unsafely). - Aux node name auto-suffixed on collision. MHCRewriter(num_copies=2, lambda_mhc=0.05): - Multi-Head Copy. For every attention brick (one of 10 recognised attention kinds: attention/gated_attention/gqa_sliding/cca_attention/ mla/mla_absorb/mistral4_mla/bailing_mla/dsv4_attention/nsa/csa_hca), materialises num_copies-1 clones named '<orig>_mhc_<i>' that share the module reference (weight sharing happens at build time). - num_copies=1 = no-op (advertises no postcondition). - Rewires producer edges so every clone gets the same input. - Rewrites loss → mhc_attn_bias preserving prior head_outputs. - Postcondition: 'mhc_copies_added'. - Double-apply raises MHCCompositionError. Composition rules (Rewriter Protocol precondition simulation, already shipped in Stage B's verify_build_spec): - MTP→IFIM: clean (IFIM accepts mtp_k_heads state, keeps K head names in the IFIM loss). - MTP→MHC: clean (both state tokens land in result). - Double-IFIM or double-MHC in chain: ERROR at apply time, surfaced by verify_build_spec ahead of time when the chain is parsed. Tests (tests/v4/test_mbspec_stage_d.py, 25 cases): IFIM: - protocol conformance; rejects negative λ and blank aux name; no preconditions - aux node added with edge from head; auto-suffix on name collision; loss rewritten to ifim_shaped; lambda metadata on aux node - composition after MTP keeps K head names + adds ifim_added token - double-apply raises MHC: - protocol conformance; rejects num_copies<1 and negative λ; num_copies=1 is no-op - clones every attention brick correctly; higher num_copies scales linearly; clones inherit module reference for weight sharing - loss rewritten to mhc_attn_bias; no-op on no-attention graph - double-apply raises Composition: - MTP→IFIM verify-clean rewrite chain - MTP→MHC chains both state tokens - chained double-IFIM raises at apply - system: Qwen3-Next preset + head + MTP + MHC end-to-end clean Full v4 regression: 883 passed / 5 skipped / 0 failed.
1 parent da6f862 commit 968786f

3 files changed

Lines changed: 576 additions & 1 deletion

File tree

cppmega_v4/buildspec/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@
3737
)
3838
from cppmega_v4.buildspec.rewriters import (
3939
HeadDetectionError,
40+
IFIMCompositionError,
41+
IFIMRewriter,
4042
LossRewriteError,
43+
MHCCompositionError,
44+
MHCRewriter,
4145
MTPRewriter,
4246
)
4347
from cppmega_v4.buildspec.optim_spec import (
@@ -56,8 +60,12 @@
5660
"BuildDiagnosticSeverity",
5761
"BuildDiagnostics",
5862
"HeadDetectionError",
63+
"IFIMCompositionError",
64+
"IFIMRewriter",
5965
"LOSS_BUILTINS",
6066
"LossRewriteError",
67+
"MHCCompositionError",
68+
"MHCRewriter",
6169
"MTPRewriter",
6270
"LossKind",
6371
"LossSpec",

cppmega_v4/buildspec/rewriters.py

Lines changed: 236 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Concrete graph rewriters.
22
3-
Stage C scope (this commit): :class:`MTPRewriter`.
3+
Stage C + D scope (this commit family): :class:`MTPRewriter`,
4+
:class:`IFIMRewriter`, :class:`MHCRewriter`.
45
56
A rewriter is a callable ``(ModelBuildSpec) -> ModelBuildSpec`` that
67
matches the :class:`cppmega_v4.buildspec.Rewriter` Protocol. It:
@@ -39,6 +40,8 @@
3940
from cppmega_v4.buildspec.loss_spec import (
4041
LossKind,
4142
LossSpec,
43+
ifim_shaped_loss,
44+
mhc_attn_bias_loss,
4245
mtp_weighted_loss,
4346
)
4447
from cppmega_v4.buildspec.model_build_spec import (
@@ -269,8 +272,240 @@ class LossRewriteError(RuntimeError):
269272
"""Raised by MTPRewriter when the input loss can't be safely converted."""
270273

271274

275+
# ---------------------------------------------------------------------------
276+
# IFIMRewriter
277+
# ---------------------------------------------------------------------------
278+
279+
280+
_ATTENTION_KINDS: Final[frozenset[str]] = frozenset({
281+
"attention", "gated_attention", "gqa_sliding", "cca_attention",
282+
"mla", "mla_absorb", "mistral4_mla", "bailing_mla",
283+
"dsv4_attention", "nsa", "csa_hca",
284+
})
285+
286+
287+
@dataclass(frozen=True)
288+
class IFIMRewriter:
289+
"""Inverse Fisher Information Matrix shaping rewriter.
290+
291+
Adds a virtual ``ifim_aux`` brick downstream of the (post-MTP if
292+
present) head and rewrites the loss to :func:`ifim_shaped_loss`
293+
with ``lambda_fim`` weighting.
294+
295+
Pre-condition: ``"single_head"`` OR ``"mtp_k_heads"`` (works either
296+
way — when MTP rewrote first, IFIM attaches to every head clone via
297+
the loss aggregator; the graph stays simple and only one aux node
298+
appears).
299+
300+
Post-condition: adds ``"ifim_added"`` to the state-token set.
301+
"""
302+
303+
lambda_fim: float = 0.1
304+
aux_node_name: str = "ifim_aux"
305+
306+
# Rewriter protocol
307+
name: str = field(init=False)
308+
required_preconditions: frozenset[str] = field(init=False)
309+
provided_postconditions: frozenset[str] = field(init=False)
310+
311+
def __post_init__(self) -> None:
312+
if self.lambda_fim < 0:
313+
raise ValueError(
314+
f"IFIMRewriter.lambda_fim must be ≥ 0, got {self.lambda_fim}"
315+
)
316+
if not self.aux_node_name.strip():
317+
raise ValueError("IFIMRewriter.aux_node_name must be non-empty")
318+
object.__setattr__(
319+
self, "name", f"IFIMRewriter(λ={self.lambda_fim})",
320+
)
321+
# Accept either single-head CE base or post-MTP K-head — runtime
322+
# picks the right loss aggregator.
323+
object.__setattr__(
324+
self, "required_preconditions", frozenset(),
325+
)
326+
object.__setattr__(
327+
self, "provided_postconditions", frozenset({"ifim_added"}),
328+
)
329+
330+
def __call__(self, spec: ModelBuildSpec) -> ModelBuildSpec:
331+
if "ifim_added" in spec.state_tokens:
332+
raise IFIMCompositionError(
333+
"IFIMRewriter already applied to this spec; double-apply "
334+
"would compound the lambda_fim weight unsafely"
335+
)
336+
337+
# Don't touch the underlying graph topology — IFIM is a
338+
# loss-side rewrite. We add the aux node so downstream consumers
339+
# (memory_report, GUI) can see it in the brick list. Edges
340+
# connect it as a leaf consumer of the existing head(s).
341+
existing_names = {n.name for n in spec.graph.nodes}
342+
aux_name = self.aux_node_name
343+
suffix = 0
344+
while aux_name in existing_names:
345+
suffix += 1
346+
aux_name = f"{self.aux_node_name}_{suffix}"
347+
348+
# Pick the head(s) to feed: prefer the loss spec's declared
349+
# head_outputs; fall back to the last node when none match.
350+
head_names = [
351+
n.name for n in spec.graph.nodes
352+
if n.name in spec.loss.head_outputs
353+
]
354+
if not head_names:
355+
head_names = [spec.graph.nodes[-1].name] if spec.graph.nodes else []
356+
357+
aux_node = BrickNode(
358+
kind="mlp", # safe stand-in shape contract; real impl is loss-side
359+
name=aux_name,
360+
params={"is_ifim_aux": True, "lambda_fim": self.lambda_fim},
361+
module=None,
362+
)
363+
new_nodes = (*spec.graph.nodes, aux_node)
364+
new_edges = (
365+
*spec.graph.edges,
366+
*[(h, aux_name) for h in head_names],
367+
)
368+
new_graph = BrickGraph(nodes=new_nodes, edges=new_edges)
369+
370+
# Rewrite loss: preserve original head_outputs; switch to IFIM kind.
371+
new_loss = ifim_shaped_loss(
372+
lambda_fim=self.lambda_fim,
373+
head_output_name=spec.loss.head_outputs[0],
374+
)
375+
# If the input was MTP-weighted, keep the multi-head structure:
376+
# promote IFIM into a "MTP-with-IFIM" combined spec by reusing
377+
# the MTP head_outputs as the IFIM heads.
378+
if spec.loss.kind is LossKind.MTP_WEIGHTED:
379+
new_loss = LossSpec(
380+
kind=LossKind.IFIM_SHAPED,
381+
params={"lambda_fim": float(self.lambda_fim)},
382+
head_outputs=spec.loss.head_outputs,
383+
label_source="next_token",
384+
)
385+
386+
return spec.replace(graph=new_graph, loss=new_loss)
387+
388+
389+
class IFIMCompositionError(RuntimeError):
390+
"""Raised by :class:`IFIMRewriter` when applied twice to the same spec."""
391+
392+
393+
# ---------------------------------------------------------------------------
394+
# MHCRewriter
395+
# ---------------------------------------------------------------------------
396+
397+
398+
@dataclass(frozen=True)
399+
class MHCRewriter:
400+
"""Multi-Head-Copy attention bias rewriter.
401+
402+
For every attention brick in the graph, materialises ``num_copies-1``
403+
additional weight-shared copies and adds an auxiliary
404+
:func:`mhc_attn_bias_loss` term with ``lambda_mhc`` weighting.
405+
406+
The copies are NAMED ``<orig>_mhc_<i>`` and reuse the original
407+
module reference (weight sharing happens at build time — Stage E
408+
wires them into one nn.Linear with cached forward).
409+
410+
Post-condition: adds ``"mhc_copies_added"``.
411+
"""
412+
413+
num_copies: int = 2
414+
lambda_mhc: float = 0.05
415+
416+
# Rewriter protocol
417+
name: str = field(init=False)
418+
required_preconditions: frozenset[str] = field(init=False)
419+
provided_postconditions: frozenset[str] = field(init=False)
420+
421+
def __post_init__(self) -> None:
422+
if self.num_copies < 1:
423+
raise ValueError(
424+
f"MHCRewriter.num_copies must be ≥ 1, got {self.num_copies}"
425+
)
426+
if self.lambda_mhc < 0:
427+
raise ValueError(
428+
f"MHCRewriter.lambda_mhc must be ≥ 0, got {self.lambda_mhc}"
429+
)
430+
object.__setattr__(
431+
self, "name",
432+
f"MHCRewriter(copies={self.num_copies}, λ={self.lambda_mhc})",
433+
)
434+
object.__setattr__(
435+
self, "required_preconditions", frozenset(),
436+
)
437+
object.__setattr__(
438+
self, "provided_postconditions",
439+
frozenset() if self.num_copies == 1
440+
else frozenset({"mhc_copies_added"}),
441+
)
442+
443+
def __call__(self, spec: ModelBuildSpec) -> ModelBuildSpec:
444+
if self.num_copies == 1:
445+
return spec
446+
if "mhc_copies_added" in spec.state_tokens:
447+
raise MHCCompositionError(
448+
"MHCRewriter already applied to this spec; double-apply "
449+
"would multiply attention copies geometrically"
450+
)
451+
452+
new_nodes: list[BrickNode] = list(spec.graph.nodes)
453+
new_edges: list[tuple[str, str]] = list(spec.graph.edges)
454+
existing_names = {n.name for n in spec.graph.nodes}
455+
456+
attention_nodes = [
457+
n for n in spec.graph.nodes if n.kind in _ATTENTION_KINDS
458+
]
459+
if not attention_nodes:
460+
# No-op: graph has no attention bricks; advertise the
461+
# postcondition anyway so chained rewriters can rely on it.
462+
return spec.replace(graph=spec.graph)
463+
464+
for attn in attention_nodes:
465+
for i in range(1, self.num_copies):
466+
clone_name = f"{attn.name}_mhc_{i}"
467+
suffix = 0
468+
while clone_name in existing_names:
469+
suffix += 1
470+
clone_name = f"{attn.name}_mhc_{i}_{suffix}"
471+
existing_names.add(clone_name)
472+
clone = BrickNode(
473+
kind=attn.kind,
474+
name=clone_name,
475+
params={**attn.params, "is_mhc_copy": True,
476+
"mhc_source": attn.name},
477+
module=attn.module, # weight-shared at build time
478+
)
479+
new_nodes.append(clone)
480+
# Wire identically: every producer of attn also feeds
481+
# the copy (so it sees the same input). The output of
482+
# the copy is consumed by the auxiliary loss only.
483+
for p, c in spec.graph.edges:
484+
if c == attn.name:
485+
new_edges.append((p, clone_name))
486+
487+
new_graph = BrickGraph(nodes=tuple(new_nodes), edges=tuple(new_edges))
488+
489+
# Switch loss to MHC_ATTN_BIAS (keep head_outputs from previous loss).
490+
new_loss = LossSpec(
491+
kind=LossKind.MHC_ATTN_BIAS,
492+
params={"lambda_mhc": float(self.lambda_mhc)},
493+
head_outputs=spec.loss.head_outputs,
494+
label_source=spec.loss.label_source,
495+
)
496+
return spec.replace(graph=new_graph, loss=new_loss)
497+
498+
499+
class MHCCompositionError(RuntimeError):
500+
"""Raised by :class:`MHCRewriter` when applied twice to the same spec."""
501+
502+
272503
__all__ = [
273504
"HeadDetectionError",
505+
"IFIMCompositionError",
506+
"IFIMRewriter",
274507
"LossRewriteError",
508+
"MHCCompositionError",
509+
"MHCRewriter",
275510
"MTPRewriter",
276511
]

0 commit comments

Comments
 (0)