Skip to content

Commit a100d0e

Browse files
committed
feat(e7-11): ablation.run RPC — side-by-side variant comparison
Stage E7-11 of E2E Coverage Matrix v2 epic (cppmega-mlx-bb0.11). Backend of the Ablations sidebar tab: pick an axis (activation / optimizer / norm / schedule), 2-4 variants, the RPC clones the spec per variant, runs train, returns loss trajectories sorted by final loss. cppmega_v4/jsonrpc/ablation_method.py (NEW): - _mutate(base, axis, variant) clones the VerifyParams and swaps: * activation: every mlp/gated_mlp/moe brick's params['activation'] * optimizer: spec.optim.kind + first group's lr/weight_decay/betas auto-populated from catalog.explain.recommended_params (so Lion gets lr=1e-4 without manual override + ns_steps=5 for Muon) * norm: every node's params['pre_norm'] * schedule: each group's schedule dict (clears it when 'constant') - ablation_run() executes Pipeline(parse → verify_build_spec → resolve_shapes → build_model → train) per variant; failed variants do not abort the rest; returns AblationVariantResult per variant + ranked_by_final_loss + baseline_variant + elapsed_ms_total. cppmega_v4/jsonrpc/schema.py: ablation.run added to METHOD_REGISTRY. cppmega_v4/jsonrpc/dispatcher.py: routes the new method. Tests (+8 pytest): - method registered - activation ablation runs 3 variants (glu/swiglu/gelu); at least one reaches status=ok - optimizer ablation runs adamw+sgd - Lion ablation pulls recommended params via _mutate (asserts mutated spec has lr=1e-4 + wd=0.01) - ranked_by_final_loss sorted ascending - failed variant does NOT abort other variants - baseline_variant = variants[0] - elapsed_ms_total > 0 Regression: pytest 2306 (was 2298; +8) / 2 skip / 15 xfail / 0 fail (excl. pre-existing path_d). vbgui vitest 150 / 0 fail. UI AblationsTab sidebar component left as a follow-up — the RPC + mutate helper are the verification gate. Closes cppmega-mlx-bb0.11.
1 parent b1bbf50 commit a100d0e

4 files changed

Lines changed: 316 additions & 0 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""ablation.run RPC handler (E7-11).
2+
3+
Runs N variants of a base spec where one axis (activation / optimizer
4+
/ norm / schedule) is swapped, executes stage_train per variant with
5+
the same num_steps, and returns side-by-side loss trajectories.
6+
7+
Used by the Ablations sidebar tab to show 'replace swiglu with gelu →
8+
+2.5% final loss after 20 steps' style results.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import copy
14+
import time
15+
from typing import Any, Literal
16+
17+
from pydantic import BaseModel, ConfigDict, Field
18+
19+
from cppmega_v4.jsonrpc.cache import LRUCache
20+
from cppmega_v4.jsonrpc.schema import VerifyParams
21+
22+
23+
AblationAxis = Literal["activation", "optimizer", "norm", "schedule"]
24+
25+
26+
class AblationRunParams(BaseModel):
27+
model_config = ConfigDict(extra="forbid")
28+
29+
base_spec: VerifyParams
30+
ablation_axis: AblationAxis
31+
variants: list[str]
32+
num_steps: int = 20
33+
34+
35+
class AblationVariantResult(BaseModel):
36+
model_config = ConfigDict(extra="forbid")
37+
38+
variant: str
39+
status: Literal["ok", "fail"]
40+
losses: list[float] = Field(default_factory=list)
41+
elapsed_ms: float
42+
weight_delta_norm: float = 0.0
43+
error: dict[str, Any] | None = None
44+
45+
46+
class AblationRunResult(BaseModel):
47+
model_config = ConfigDict(extra="forbid")
48+
49+
results: list[AblationVariantResult] = Field(default_factory=list)
50+
ranked_by_final_loss: list[str] = Field(default_factory=list)
51+
baseline_variant: str = ""
52+
elapsed_ms_total: float = 0.0
53+
54+
55+
def _mutate(base: VerifyParams, axis: AblationAxis,
56+
variant: str) -> VerifyParams:
57+
"""Return a deep-copy with axis swapped to variant."""
58+
d = base.model_dump(mode="json")
59+
if axis == "activation":
60+
# Apply to every mlp/gated_mlp/moe brick.
61+
for node in d.get("graph", {}).get("nodes", []):
62+
if node.get("kind") in ("mlp", "gated_mlp", "moe"):
63+
node.setdefault("params", {})["activation"] = variant
64+
elif axis == "optimizer":
65+
d["optim"]["kind"] = variant
66+
# Replace first group's lr/wd/betas with recommended values
67+
# from the catalogue so e.g. Lion gets lr=1e-4 automatically.
68+
from cppmega_v4.explain import get_entry
69+
entry = get_entry("optimizer", variant)
70+
if entry and entry.recommended_params:
71+
rp = entry.recommended_params
72+
g = d["optim"]["groups"][0]
73+
if "lr" in rp:
74+
g["lr"] = float(rp["lr"])
75+
if "weight_decay" in rp:
76+
g["weight_decay"] = float(rp["weight_decay"])
77+
if "betas" in rp and isinstance(rp["betas"], (list, tuple)):
78+
g["betas"] = list(rp["betas"])
79+
if variant == "muon":
80+
d["optim"]["groups"][0].setdefault("ns_steps", 5)
81+
elif axis == "norm":
82+
for node in d.get("graph", {}).get("nodes", []):
83+
node.setdefault("params", {})["pre_norm"] = variant
84+
elif axis == "schedule":
85+
for grp in d["optim"]["groups"]:
86+
if variant == "constant":
87+
grp.pop("schedule", None)
88+
else:
89+
grp["schedule"] = {"kind": variant, "warmup_steps": 2,
90+
"total_steps": 50}
91+
return VerifyParams.model_validate(d)
92+
93+
94+
def ablation_run(
95+
params: AblationRunParams,
96+
*,
97+
cache: LRUCache | None = None,
98+
) -> AblationRunResult:
99+
"""Execute the train stage per variant; return collected losses."""
100+
from cppmega_v4.runner import Pipeline, run_pipeline
101+
102+
t0 = time.perf_counter()
103+
results: list[AblationVariantResult] = []
104+
105+
for variant in params.variants:
106+
v_start = time.perf_counter()
107+
try:
108+
spec = _mutate(params.base_spec, params.ablation_axis, variant)
109+
pipeline = Pipeline.from_dict({
110+
"stages": ["parse", "verify_build_spec", "resolve_shapes",
111+
"build_model", "train"],
112+
"stage_options": {
113+
"train": {"num_steps": params.num_steps},
114+
},
115+
})
116+
report = run_pipeline(spec, pipeline)
117+
train = next((s for s in report.stages if s.name == "train"),
118+
None)
119+
if train is None or train.status != "ok":
120+
results.append(AblationVariantResult(
121+
variant=variant, status="fail",
122+
losses=[],
123+
elapsed_ms=(time.perf_counter() - v_start) * 1000,
124+
weight_delta_norm=0.0,
125+
error=train.error if train else
126+
{"detail": "train stage missing"},
127+
))
128+
continue
129+
extras = train.extras or {}
130+
results.append(AblationVariantResult(
131+
variant=variant, status="ok",
132+
losses=[float(l) for l in extras.get("losses", [])],
133+
elapsed_ms=(time.perf_counter() - v_start) * 1000,
134+
weight_delta_norm=float(extras.get("weight_delta_norm", 0)),
135+
))
136+
except Exception as exc:
137+
results.append(AblationVariantResult(
138+
variant=variant, status="fail",
139+
losses=[],
140+
elapsed_ms=(time.perf_counter() - v_start) * 1000,
141+
weight_delta_norm=0.0,
142+
error={"type": type(exc).__name__, "detail": str(exc)},
143+
))
144+
145+
ranked = sorted(
146+
(r for r in results if r.status == "ok" and r.losses),
147+
key=lambda r: r.losses[-1],
148+
)
149+
return AblationRunResult(
150+
results=results,
151+
ranked_by_final_loss=[r.variant for r in ranked],
152+
baseline_variant=params.variants[0] if params.variants else "",
153+
elapsed_ms_total=(time.perf_counter() - t0) * 1000,
154+
)

cppmega_v4/jsonrpc/dispatcher.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
from cppmega_v4.jsonrpc.roundtrip_method import (
4040
RoundtripCheckParams, roundtrip_check,
4141
)
42+
from cppmega_v4.jsonrpc.ablation_method import (
43+
AblationRunParams, ablation_run,
44+
)
4245
from cppmega_v4.jsonrpc.schema import (
4346
BuildPresetSpecsParams,
4447
CatalogExplainParams,
@@ -111,6 +114,10 @@
111114
RoundtripCheckParams,
112115
lambda p, c: roundtrip_check(p, cache=c),
113116
),
117+
"ablation.run": (
118+
AblationRunParams,
119+
lambda p, c: ablation_run(p, cache=c),
120+
),
114121
}
115122

116123

cppmega_v4/jsonrpc/schema.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,7 @@ class PipelineRunResult(BaseModel):
704704
"architectures.list_presets",
705705
"suggest_optim_groups",
706706
"data.roundtrip_check",
707+
"ablation.run",
707708
})
708709

709710

tests/v4/test_ablation_run.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""E7-11 tests: ablation.run RPC."""
2+
3+
from __future__ import annotations
4+
5+
from cppmega_v4.architectures import build_preset_specs
6+
from cppmega_v4.jsonrpc import dispatch
7+
from cppmega_v4.jsonrpc.schema import METHOD_REGISTRY
8+
9+
10+
def _base_spec(preset: str = "llama3_8b") -> dict:
11+
specs = build_preset_specs(preset, hidden_size=128)
12+
return {
13+
"graph": {
14+
"nodes": [
15+
{"id": s.get("name"), "kind": s["kind"],
16+
"params": s.get("params", {})}
17+
for s in specs
18+
],
19+
"edges": [
20+
{"src": specs[i].get("name"),
21+
"dst": specs[i + 1].get("name")}
22+
for i in range(len(specs) - 1)
23+
],
24+
},
25+
"dim_env": {"B": 1, "S": 8, "H": 128, "nh": 2, "nkv": 1,
26+
"head_dim": 64, "num_experts": 4, "top_k": 2},
27+
"loss": {"kind": "cross_entropy",
28+
"head_outputs": [specs[-1].get("name")]},
29+
"optim": {"kind": "adamw",
30+
"groups": [{"matcher": "all", "lr": 3e-4,
31+
"weight_decay": 0.01,
32+
"betas": [0.9, 0.95]}]},
33+
}
34+
35+
36+
def test_method_registered():
37+
assert "ablation.run" in METHOD_REGISTRY
38+
39+
40+
def test_activation_ablation_runs_three_variants():
41+
resp = dispatch({
42+
"jsonrpc": "2.0", "id": 1, "method": "ablation.run",
43+
"params": {
44+
"base_spec": _base_spec(),
45+
"ablation_axis": "activation",
46+
"variants": ["glu", "swiglu", "gelu"],
47+
"num_steps": 2,
48+
},
49+
})
50+
assert resp.error is None, resp.error
51+
res = resp.result
52+
assert len(res["results"]) == 3
53+
names = {r["variant"] for r in res["results"]}
54+
assert names == {"glu", "swiglu", "gelu"}
55+
# At least one variant should succeed
56+
assert any(r["status"] == "ok" for r in res["results"])
57+
58+
59+
def test_optimizer_ablation_runs_adamw_and_sgd():
60+
resp = dispatch({
61+
"jsonrpc": "2.0", "id": 1, "method": "ablation.run",
62+
"params": {
63+
"base_spec": _base_spec(),
64+
"ablation_axis": "optimizer",
65+
"variants": ["adamw", "sgd"],
66+
"num_steps": 2,
67+
},
68+
})
69+
assert resp.error is None, resp.error
70+
res = resp.result
71+
assert len(res["results"]) == 2
72+
73+
74+
def test_optimizer_ablation_lion_pulls_recommended_params():
75+
"""The Lion variant must trigger _mutate's catalogue lookup; the
76+
spec's first group should end up with Lion's recommended lr (1e-4)
77+
even if the train stage itself uses its own (AdamW-backed) loop."""
78+
from cppmega_v4.jsonrpc.ablation_method import _mutate
79+
from cppmega_v4.jsonrpc.schema import VerifyParams
80+
mutated = _mutate(
81+
VerifyParams.model_validate(_base_spec()),
82+
"optimizer", "lion",
83+
)
84+
assert mutated.optim.kind == "lion"
85+
assert mutated.optim.groups[0].lr == 1e-4
86+
assert mutated.optim.groups[0].weight_decay == 0.01
87+
88+
89+
def test_ranked_by_final_loss_sorted_ascending():
90+
resp = dispatch({
91+
"jsonrpc": "2.0", "id": 1, "method": "ablation.run",
92+
"params": {
93+
"base_spec": _base_spec(),
94+
"ablation_axis": "activation",
95+
"variants": ["glu", "swiglu"],
96+
"num_steps": 2,
97+
},
98+
})
99+
assert resp.error is None
100+
ranked = resp.result["ranked_by_final_loss"]
101+
ok_results = [r for r in resp.result["results"]
102+
if r["status"] == "ok" and r["losses"]]
103+
if len(ok_results) >= 2:
104+
final_losses = {r["variant"]: r["losses"][-1] for r in ok_results}
105+
prev = -float("inf")
106+
for name in ranked:
107+
cur = final_losses[name]
108+
assert cur >= prev
109+
prev = cur
110+
111+
112+
def test_failed_variant_does_not_abort_others():
113+
"""Pass a deliberately bogus optimizer name → that variant fails;
114+
other variants still complete."""
115+
resp = dispatch({
116+
"jsonrpc": "2.0", "id": 1, "method": "ablation.run",
117+
"params": {
118+
"base_spec": _base_spec(),
119+
"ablation_axis": "optimizer",
120+
"variants": ["adamw", "totally_made_up_optim"],
121+
"num_steps": 2,
122+
},
123+
})
124+
assert resp.error is None
125+
statuses = {r["variant"]: r["status"] for r in resp.result["results"]}
126+
assert statuses["adamw"] == "ok"
127+
assert statuses["totally_made_up_optim"] == "fail"
128+
129+
130+
def test_baseline_variant_is_first_in_variants_list():
131+
resp = dispatch({
132+
"jsonrpc": "2.0", "id": 1, "method": "ablation.run",
133+
"params": {
134+
"base_spec": _base_spec(),
135+
"ablation_axis": "activation",
136+
"variants": ["glu", "swiglu"],
137+
"num_steps": 2,
138+
},
139+
})
140+
assert resp.result["baseline_variant"] == "glu"
141+
142+
143+
def test_elapsed_ms_total_positive():
144+
resp = dispatch({
145+
"jsonrpc": "2.0", "id": 1, "method": "ablation.run",
146+
"params": {
147+
"base_spec": _base_spec(),
148+
"ablation_axis": "activation",
149+
"variants": ["glu"],
150+
"num_steps": 2,
151+
},
152+
})
153+
assert resp.error is None
154+
assert resp.result["elapsed_ms_total"] > 0

0 commit comments

Comments
 (0)