Skip to content

Commit 5bdfdba

Browse files
committed
feat(v5-g01): MTP_WEIGHTED actually runs K-head weighted loss + extras.mtp
Closes V5-G01 / cppmega-mlx-iry. Math-effect upgrade for V4-7's propagation-theatre LossKind path. Backend (stage_train): - Detect spec.loss.kind == "mtp_weighted"; parse k + per-i betas (uses V4-7 _make_loss broadcast that fills beta_0..beta_{k-1} from UI's flat 'beta', OR explicit beta_i overrides) - Materialise K extra nn.Linear(hidden, vocab) LM heads - loss_fn computes Σ β_i × CE(head_i(features), shift_labels(targets, i)) - inference probe slicing fixed: [:-mtp_k] instead of [:-1] so probe walks only brick layers and calls the primary head - _compute_mtp_extras helper runs one extra forward post-train to populate extras.mtp = {k, betas, per_head_losses} Frontend (RunResultModal): - ExtrasEntry now recurses on nested objects — previously JSON.stringify'd into a single dd, hiding per-index testids. Now extras.mtp.betas[i] and extras.mtp.per_head_losses[i] get their own data-testid for strict e2e assertions. 6 new pytest in test_stage_train_mtp.py — K=1/2/3, CE no-emit, per-head losses distinct (proves K heads see different shifted labels), explicit beta_i overrides flat beta. 3 new e2e in 30_mtp_math.spec.ts — UI LossTab → MTP k=2 β=0.6 → train → extras.mtp populated with 2 distinct head losses. K=3 variant. CE control proves mtp key absent. Regression: 63 pytest (V4 stage_train suite + new) + 183 vitest + 20 e2e (11_/19_/21_) all green. ExtrasEntry recursion change is backwards-compatible — existing tests still pass.
1 parent 531a274 commit 5bdfdba

4 files changed

Lines changed: 324 additions & 23 deletions

File tree

cppmega_v4/runner/stages.py

Lines changed: 109 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -389,8 +389,39 @@ def stage_train(ctx: StageContext) -> StageResult:
389389
if not all(modules):
390390
raise RuntimeError("graph has un-instantiated nodes")
391391

392-
# Synthetic LM head: tied to the last brick's output.
393-
lm_head = nn.Linear(hidden, vocab_size, bias=False)
392+
# G01: detect MTP_WEIGHTED loss kind from spec; build K extra LM
393+
# heads + per-head shifted-label loss. Falls back to single-head
394+
# CE for all other LossKind values (effective math = CE today;
395+
# IFIM/MHC math wiring is G02/G03 follow-up). Reads k + betas
396+
# from spec.loss.params; _make_loss already broadcast-fills
397+
# beta_0..beta_{k-1} from UI's flat `beta` field.
398+
spec_loss = getattr(ctx.spec, "loss", None)
399+
spec_loss_kind = (getattr(spec_loss, "kind", "cross_entropy")
400+
if spec_loss is not None else "cross_entropy")
401+
spec_loss_params = (dict(getattr(spec_loss, "params", {}))
402+
if spec_loss is not None else {})
403+
mtp_k = 1
404+
mtp_betas: list[float] = [1.0]
405+
if spec_loss_kind == "mtp_weighted":
406+
try:
407+
mtp_k = max(1, int(spec_loss_params.get("k", 2)))
408+
except (TypeError, ValueError):
409+
mtp_k = 2
410+
mtp_betas = []
411+
for i in range(mtp_k):
412+
key = f"beta_{i}"
413+
if key in spec_loss_params:
414+
mtp_betas.append(float(spec_loss_params[key]))
415+
elif "beta" in spec_loss_params:
416+
mtp_betas.append(float(spec_loss_params["beta"]))
417+
else:
418+
mtp_betas.append(0.5)
419+
420+
# Synthetic LM head — for MTP we create K of them. lm_head is the
421+
# first one (used by single-head paths and the inference probe).
422+
lm_heads = [nn.Linear(hidden, vocab_size, bias=False)
423+
for _ in range(mtp_k)]
424+
lm_head = lm_heads[0]
394425

395426
def forward_layers(layer_iter, input_embeds: mx.array) -> mx.array:
396427
x = input_embeds
@@ -404,17 +435,41 @@ def forward_layers(layer_iter, input_embeds: mx.array) -> mx.array:
404435
x = out
405436
return x
406437

407-
def loss_fn(model: nn.Module, emb: mx.array, tgt: mx.array) -> mx.array:
408-
layers = list(getattr(model, "layers", model))
409-
features = forward_layers(layers[:-1], emb)
410-
if getattr(features, "shape", None) == emb.shape:
411-
features = features + emb
412-
logits = layers[-1](features)
438+
def _ce(logits: mx.array, tgt: mx.array) -> mx.array:
413439
return nn.losses.cross_entropy(
414440
logits.reshape(-1, vocab_size), tgt.reshape(-1),
415441
reduction="mean",
416442
)
417443

444+
def _shift_labels_local(labels: mx.array, k_offset: int) -> mx.array:
445+
if k_offset == 0:
446+
return labels
447+
B, S = labels.shape
448+
if k_offset >= S:
449+
return mx.zeros_like(labels)
450+
return mx.concatenate(
451+
[labels[:, k_offset:],
452+
mx.broadcast_to(labels[:, -1:], (B, k_offset))],
453+
axis=1,
454+
)
455+
456+
def loss_fn(model: nn.Module, emb: mx.array, tgt: mx.array) -> mx.array:
457+
layers = list(getattr(model, "layers", model))
458+
# Last mtp_k layers are LM heads; preceding layers are bricks.
459+
head_count = mtp_k
460+
brick_layers = layers[:-head_count] if head_count > 0 else layers
461+
features = forward_layers(brick_layers, emb)
462+
if getattr(features, "shape", None) == emb.shape:
463+
features = features + emb
464+
if head_count <= 1:
465+
return _ce(layers[-1](features), tgt)
466+
total = mx.zeros(())
467+
for i in range(head_count):
468+
shifted = _shift_labels_local(tgt, i)
469+
total = total + mtp_betas[i] * _ce(
470+
layers[-head_count + i](features), shifted)
471+
return total
472+
418473
# V3-2: prefer real tokens from a parquet shard when opts
419474
# supplies parquet_path. Falls back to synthetic random targets
420475
# when absent (preserves E-4 matrix behaviour). data_source +
@@ -465,7 +520,7 @@ def loss_fn(model: nn.Module, emb: mx.array, tgt: mx.array) -> mx.array:
465520
token_count = len(tokens)
466521
except Exception:
467522
pass
468-
all_modules = nn.Sequential(*modules, lm_head)
523+
all_modules = nn.Sequential(*modules, *lm_heads)
469524
opt, optimizer_kind = _build_optimizer(spec_optim, lr)
470525
# V4-9: when hybrid, count params routed to each bucket so e2e can
471526
# prove the split predicate actually saw 2D vs 1D/3D parameters.
@@ -489,15 +544,18 @@ def _count(tree: Any) -> int:
489544
# V4-11: inference probe — forward over a fixed-seed input both
490545
# before training and after; report l2 and cosine drift. Proves
491546
# the optimizer's update actually changed observable model output,
492-
# not just internal optimizer state.
547+
# not just internal optimizer state. G01: skip the K-1 extra LM
548+
# heads — probe only the primary head so output shape stays sane
549+
# for mtp_k > 1.
493550
probe_input = mx.random.normal(
494551
shape=(1, seq, hidden), key=mx.random.key(42))
495552
probe_layers_before = list(getattr(all_modules, "layers", all_modules))
553+
_probe_brick_layers = probe_layers_before[:-mtp_k]
496554
probe_features_before = forward_layers(
497-
probe_layers_before[:-1], probe_input)
555+
_probe_brick_layers, probe_input)
498556
if getattr(probe_features_before, "shape", None) == probe_input.shape:
499557
probe_features_before = probe_features_before + probe_input
500-
probe_output_before = probe_layers_before[-1](
558+
probe_output_before = probe_layers_before[-mtp_k](
501559
probe_features_before).reshape(-1)
502560
mx.eval(probe_output_before)
503561
probe_output_before = mx.array(probe_output_before)
@@ -549,10 +607,10 @@ def _count(tree: Any) -> int:
549607
# V4-11: re-run forward on the same fixed-seed input post-training.
550608
probe_layers_after = list(getattr(all_modules, "layers", all_modules))
551609
probe_features_after = forward_layers(
552-
probe_layers_after[:-1], probe_input)
610+
probe_layers_after[:-mtp_k], probe_input)
553611
if getattr(probe_features_after, "shape", None) == probe_input.shape:
554612
probe_features_after = probe_features_after + probe_input
555-
probe_output_after = probe_layers_after[-1](
613+
probe_output_after = probe_layers_after[-mtp_k](
556614
probe_features_after).reshape(-1)
557615
mx.eval(probe_output_after)
558616
diff_vec = probe_output_after - probe_output_before
@@ -619,6 +677,11 @@ def _count(tree: Any) -> int:
619677
"cos_sim": round(cos_sim, 6),
620678
},
621679
"side_channels_observed": side_channels_observed,
680+
"mtp": _compute_mtp_extras(
681+
all_modules, mtp_k, mtp_betas, vocab_size,
682+
batch, seq, hidden, targets,
683+
forward_layers, _ce, _shift_labels_local)
684+
if spec_loss_kind == "mtp_weighted" else None,
622685
"model_summary": _summarize_model(
623686
ctx.spec, optimizer_kind, schedule_kind_label),
624687
},
@@ -669,6 +732,38 @@ def _tokenize_parquet_text(
669732
return [], None
670733

671734

735+
def _compute_mtp_extras(
736+
all_modules: Any, k: int, betas: list[float], vocab_size: int,
737+
batch: int, seq: int, hidden: int, targets: Any,
738+
forward_layers: Any, ce: Any, shift_labels: Any,
739+
) -> dict[str, Any]:
740+
"""G01: produce extras.mtp with per-head CE losses for the same
741+
targets after training. Single extra forward pass — light compared
742+
to N training steps. Reports the final per-head loss so e2e can
743+
assert mtp.k matches selection AND that distinct heads have
744+
distinct losses (proves K heads were actually wired)."""
745+
import mlx.core as mx_local
746+
layers = list(getattr(all_modules, "layers", all_modules))
747+
brick_layers = layers[:-k]
748+
head_layers = layers[-k:]
749+
probe_emb = mx_local.random.normal(
750+
shape=(batch, seq, hidden), key=mx_local.random.key(7))
751+
features = forward_layers(brick_layers, probe_emb)
752+
if getattr(features, "shape", None) == probe_emb.shape:
753+
features = features + probe_emb
754+
per_head_losses: list[float] = []
755+
for i in range(k):
756+
shifted = shift_labels(targets, i)
757+
loss_i = ce(head_layers[i](features), shifted)
758+
mx_local.eval(loss_i)
759+
per_head_losses.append(round(float(loss_i.item()), 4))
760+
return {
761+
"k": k,
762+
"betas": [round(b, 4) for b in betas],
763+
"per_head_losses": per_head_losses,
764+
}
765+
766+
672767
def _read_first_n_tokens(parquet_path: str, n: int) -> list[int]:
673768
"""V3-2: read the first ``n`` token ids from a parquet shard so
674769
stage_train can train against real corpus tokens instead of a

tests/v5/test_stage_train_mtp.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""G01: stage_train honors LossKind.MTP_WEIGHTED with K extra LM heads.
2+
3+
Previously the kind was only echoed in extras; the loss kernel stayed
4+
hardcoded CE. Now stage_train builds K Linear heads, computes
5+
Σ β_i × CE(head_i, shifted_labels(targets, i)), and reports per-head
6+
losses in extras.mtp = {k, betas, per_head_losses}.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import pytest
12+
13+
from cppmega_v4.jsonrpc.schema import VerifyParams
14+
from cppmega_v4.runner import Pipeline, run_pipeline
15+
16+
17+
def _spec_with_loss(loss_payload: dict) -> VerifyParams:
18+
return VerifyParams.model_validate({
19+
"graph": {
20+
"nodes": [
21+
{"id": "attn", "kind": "attention", "params": {}},
22+
{"id": "mlp", "kind": "mlp",
23+
"params": {"intermediate_size": 64, "activation": "swiglu"}},
24+
],
25+
"edges": [{"src": "attn", "dst": "mlp"}],
26+
},
27+
"dim_env": {"B": 1, "S": 8, "H": 32, "nh": 2, "nkv": 1, "head_dim": 16},
28+
"loss": loss_payload,
29+
"optim": {"kind": "adamw",
30+
"groups": [{"matcher": "all", "lr": 1e-3,
31+
"weight_decay": 0.01, "betas": [0.9, 0.95]}]},
32+
})
33+
34+
35+
def _run(loss_payload: dict, num_steps: int = 2) -> dict:
36+
spec = _spec_with_loss(loss_payload)
37+
report = run_pipeline(spec, Pipeline.from_dict({
38+
"stages": ["parse", "verify_build_spec", "build_model", "train"],
39+
"stage_options": {"train": {"num_steps": num_steps}},
40+
}))
41+
train = next(s for s in report.stages if s.name == "train")
42+
assert train.status == "ok", f"stage_train failed: {train.error}"
43+
return train.extras
44+
45+
46+
def test_mtp_extras_populated_for_k2():
47+
"""MTP_WEIGHTED k=2 produces extras.mtp.{k, betas, per_head_losses}."""
48+
extras = _run({
49+
"kind": "mtp_weighted",
50+
"head_outputs": ["mlp"],
51+
"params": {"k": 2, "beta": 0.6},
52+
})
53+
mtp = extras.get("mtp")
54+
assert mtp is not None
55+
assert mtp["k"] == 2
56+
assert len(mtp["betas"]) == 2
57+
assert all(abs(b - 0.6) < 1e-6 for b in mtp["betas"])
58+
assert len(mtp["per_head_losses"]) == 2
59+
assert all(loss > 0 and loss < 1e4 for loss in mtp["per_head_losses"])
60+
61+
62+
def test_mtp_k3_three_heads():
63+
"""K=3 produces 3 heads + 3 betas + 3 per_head_losses."""
64+
extras = _run({
65+
"kind": "mtp_weighted",
66+
"head_outputs": ["mlp"],
67+
"params": {"k": 3, "beta": 0.33},
68+
})
69+
assert extras["mtp"]["k"] == 3
70+
assert len(extras["mtp"]["per_head_losses"]) == 3
71+
72+
73+
def test_mtp_k1_collapses_to_single_head():
74+
"""K=1 is degenerate — runs but produces one head."""
75+
extras = _run({
76+
"kind": "mtp_weighted",
77+
"head_outputs": ["mlp"],
78+
"params": {"k": 1, "beta": 1.0},
79+
})
80+
assert extras["mtp"]["k"] == 1
81+
assert len(extras["mtp"]["per_head_losses"]) == 1
82+
83+
84+
def test_cross_entropy_does_not_emit_mtp_extras():
85+
"""Plain CE → extras.mtp is None."""
86+
extras = _run({
87+
"kind": "cross_entropy",
88+
"head_outputs": ["mlp"],
89+
"params": {},
90+
})
91+
assert extras.get("mtp") is None
92+
93+
94+
def test_mtp_per_head_losses_differ_across_shifts():
95+
"""Each head sees different shifted labels → per-head losses differ
96+
by more than float noise. Proves K heads were actually wired with
97+
distinct supervision, not all targeting the same labels."""
98+
extras = _run({
99+
"kind": "mtp_weighted",
100+
"head_outputs": ["mlp"],
101+
"params": {"k": 3, "beta": 0.5},
102+
}, num_steps=4)
103+
losses = extras["mtp"]["per_head_losses"]
104+
# At least one pair should differ in absolute terms — same head on
105+
# different shifted labels MUST give different CE for random init.
106+
max_diff = max(abs(a - b) for i, a in enumerate(losses)
107+
for b in losses[i + 1:])
108+
assert max_diff > 1e-4, \
109+
f"per_head_losses {losses} too similar — K heads may share labels"
110+
111+
112+
def test_mtp_explicit_beta_i_overrides_flat_beta():
113+
"""When beta_0..beta_{k-1} are explicitly set they override flat beta."""
114+
extras = _run({
115+
"kind": "mtp_weighted",
116+
"head_outputs": ["mlp"],
117+
"params": {"k": 2, "beta_0": 0.8, "beta_1": 0.2},
118+
})
119+
assert extras["mtp"]["betas"] == [0.8, 0.2]
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// G01: LossKind MTP_WEIGHTED actually runs K-head weighted loss.
2+
// V4-7 only proved extras.loss_kind echo; G01 asserts the math:
3+
// extras.mtp.{k, betas, per_head_losses} populated AND per_head_losses
4+
// values differ across the K shifted-label heads (proves distinct
5+
// supervision, not all heads sharing the same labels).
6+
7+
import { test, expect } from "@playwright/test";
8+
import { gotoApp, selectPreset, closeModal } from "../fixtures";
9+
10+
async function configureMTP(page: import("@playwright/test").Page,
11+
k: string, beta: string): Promise<void> {
12+
await page.getByTestId("sidebar-tab-loss").click();
13+
await page.getByTestId("loss-tab").waitFor();
14+
await page.getByTestId("loss-kind").selectOption("mtp_weighted");
15+
await page.getByTestId("loss-mtp-k").fill(k);
16+
await page.getByTestId("loss-mtp-beta").fill(beta);
17+
await page.getByTestId("loss-apply").click();
18+
}
19+
20+
async function trainAndReadMTP(page: import("@playwright/test").Page) {
21+
await page.getByTestId("run-pipeline-toggle").click();
22+
await page.getByTestId("run-pipeline-train").click();
23+
const modal = page.getByTestId("run-result-modal");
24+
await modal.waitFor({ timeout: 60_000 });
25+
await page.getByTestId("run-result-expand-train").click();
26+
const k = parseInt(
27+
(await page.getByTestId("run-result-extras-train-mtp-k")
28+
.textContent()) ?? "0", 10);
29+
const betas: number[] = [];
30+
const heads: number[] = [];
31+
for (let i = 0; i < k; i++) {
32+
const b = parseFloat(
33+
(await page.getByTestId(`run-result-extras-train-mtp-betas-${i}`)
34+
.textContent()) ?? "NaN");
35+
const h = parseFloat(
36+
(await page.getByTestId(`run-result-extras-train-mtp-per_head_losses-${i}`)
37+
.textContent()) ?? "NaN");
38+
betas.push(b);
39+
heads.push(h);
40+
}
41+
return { k, betas, per_head_losses: heads };
42+
}
43+
44+
test("G01: MTP K=2 beta=0.6 produces extras.mtp with 2 distinct head losses",
45+
async ({ page }) => {
46+
test.setTimeout(60_000);
47+
await gotoApp(page);
48+
await selectPreset(page, "llama3_8b");
49+
await configureMTP(page, "2", "0.6");
50+
const mtp = await trainAndReadMTP(page);
51+
expect(mtp.k).toBe(2);
52+
expect(mtp.betas).toHaveLength(2);
53+
expect(mtp.betas[0]).toBeCloseTo(0.6, 4);
54+
expect(mtp.betas[1]).toBeCloseTo(0.6, 4);
55+
expect(mtp.per_head_losses).toHaveLength(2);
56+
// Distinct supervision → losses MUST differ
57+
expect(Math.abs(mtp.per_head_losses[0] - mtp.per_head_losses[1]))
58+
.toBeGreaterThan(1e-4);
59+
await closeModal(page);
60+
});
61+
62+
test("G01: MTP K=3 → 3 heads + 3 betas + 3 per_head_losses", async ({ page }) => {
63+
test.setTimeout(60_000);
64+
await gotoApp(page);
65+
await selectPreset(page, "llama3_8b");
66+
await configureMTP(page, "3", "0.33");
67+
const mtp = await trainAndReadMTP(page);
68+
expect(mtp.k).toBe(3);
69+
expect(mtp.betas).toHaveLength(3);
70+
expect(mtp.per_head_losses).toHaveLength(3);
71+
await closeModal(page);
72+
});
73+
74+
test("G01: cross_entropy does NOT populate extras.mtp", async ({ page }) => {
75+
test.setTimeout(60_000);
76+
await gotoApp(page);
77+
await selectPreset(page, "llama3_8b");
78+
// Default loss kind is cross_entropy; just Train without changing.
79+
await page.getByTestId("run-pipeline-toggle").click();
80+
await page.getByTestId("run-pipeline-train").click();
81+
const modal = page.getByTestId("run-result-modal");
82+
await modal.waitFor({ timeout: 60_000 });
83+
await page.getByTestId("run-result-expand-train").click();
84+
// Expanded extras render dt for primitives + dl/ol for objects/arrays.
85+
// 'mtp' key would appear as a nested object section; assert no
86+
// mtp-k testid present.
87+
const count = await page.locator(
88+
"[data-testid='run-result-extras-train-mtp-k']").count();
89+
expect(count).toBe(0);
90+
await closeModal(page);
91+
});

0 commit comments

Comments
 (0)