Skip to content

Commit 7ba1661

Browse files
committed
feat(v3-6): TopBar exposes train_num_steps; multi-step convergence test
Closes V3-6 / cppmega-mlx-9wj. (A) TopBar gains a numeric input (data-testid=train-num-steps, default 2, range 1..64) inside the run-pipeline dropdown. Train button propagates {num_steps} via opts to onRunPipeline. (B) App.handleRunPipeline now accepts opts.num_steps and forwards via stage_options.train.num_steps. The matrix tests already worked with the legacy default of 2 — this gates real convergence runs. (C) New spec 12_train_convergence.spec.ts (4 scenarios): - Convergence presets (llama3_8b, mistral_small_3_1): assert that tailAvg < headAvg AND last < first across 8 steps. Proves training math actually learns, not just executes. - Sanity presets (gemma3_270m, tiny_aya): assert 8 steps ran, losses finite, weight_delta_norm > 1e-4. Shallow architectures + synthetic random targets do not have a monotone loss curve, so we only require execution + movement. (D) Updated TopBar.test.tsx assertion to accept the new {num_steps} opts argument. 4/4 e2e green, 166/166 vitest.
1 parent f53da81 commit 7ba1661

4 files changed

Lines changed: 113 additions & 5 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// V3-6: multi-step convergence — train for N=8 steps via the new
2+
// TopBar train-num-steps input, assert that losses actually decrease.
3+
// Closes the "did the model learn anything" gap that 2-step matrix
4+
// runs left open (2 steps proves non-NaN, not learning).
5+
6+
import { test, expect } from "@playwright/test";
7+
import { gotoApp, selectPreset, closeModal } from "../fixtures";
8+
import { readTrainExtras } from "../utils/train_extras";
9+
10+
// Strict-convergence presets (deeper architectures): training math
11+
// must actually move loss downward on average.
12+
const CONVERGENCE_PRESETS = ["llama3_8b", "mistral_small_3_1"];
13+
14+
// Multi-step sanity presets (shallower architectures): assert that 8
15+
// steps execute, weights move, losses stay finite. Synthetic random
16+
// targets + small models make loss-curve direction noisy.
17+
const SANITY_PRESETS = ["gemma3_270m", "tiny_aya"];
18+
19+
for (const preset of CONVERGENCE_PRESETS) {
20+
test(`multi-step convergence (N=8): ${preset} loss decreases`, async ({
21+
page,
22+
}) => {
23+
test.setTimeout(120_000); // 8-step train + RPC + LR head warmup
24+
await gotoApp(page);
25+
await selectPreset(page, preset);
26+
27+
// Open the run dropdown, set num_steps=8, click Train.
28+
await page.getByTestId("run-pipeline-toggle").click();
29+
await page.getByTestId("train-num-steps").fill("8");
30+
await page.getByTestId("run-pipeline-train").click();
31+
32+
const modal = page.getByTestId("run-result-modal");
33+
await modal.waitFor({ timeout: 60_000 });
34+
const extras = await readTrainExtras(page);
35+
36+
// V3-6 acceptance: losses must show a decrease floor.
37+
expect(extras.num_steps).toBe(8);
38+
expect(extras.losses.length).toBe(8);
39+
expect(extras.losses.every(l => Number.isFinite(l))).toBe(true);
40+
41+
// Strict monotone-ish: losses[7] strictly smaller than losses[0]
42+
// and average of last 3 strictly smaller than average of first 3.
43+
// Synthetic Gaussian embeds + random targets do not produce a
44+
// perfect convergence curve, but any genuine training signal
45+
// pushes the trailing window below the leading window.
46+
const first = extras.losses[0];
47+
const last = extras.losses[extras.losses.length - 1];
48+
expect(last).toBeLessThan(first);
49+
50+
const head = extras.losses.slice(0, 3);
51+
const tail = extras.losses.slice(-3);
52+
const headAvg = head.reduce((a, b) => a + b, 0) / head.length;
53+
const tailAvg = tail.reduce((a, b) => a + b, 0) / tail.length;
54+
expect(tailAvg).toBeLessThan(headAvg);
55+
56+
// Weights actually moved (not numerical noise).
57+
expect(extras.weight_delta_norm).toBeGreaterThan(1e-4);
58+
59+
await closeModal(page);
60+
});
61+
}
62+
63+
for (const preset of SANITY_PRESETS) {
64+
test(`multi-step sanity (N=8): ${preset} runs 8 steps with movement`,
65+
async ({ page }) => {
66+
test.setTimeout(120_000);
67+
await gotoApp(page);
68+
await selectPreset(page, preset);
69+
70+
await page.getByTestId("run-pipeline-toggle").click();
71+
await page.getByTestId("train-num-steps").fill("8");
72+
await page.getByTestId("run-pipeline-train").click();
73+
74+
const modal = page.getByTestId("run-result-modal");
75+
await modal.waitFor({ timeout: 60_000 });
76+
const extras = await readTrainExtras(page);
77+
78+
expect(extras.num_steps).toBe(8);
79+
expect(extras.losses.length).toBe(8);
80+
expect(extras.losses.every(l => Number.isFinite(l))).toBe(true);
81+
expect(extras.weight_delta_norm).toBeGreaterThan(1e-4);
82+
83+
await closeModal(page);
84+
});
85+
}

vbgui/src/App.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,9 @@ export function App(): JSX.Element {
261261
void requestSuggestSharding();
262262
}, [requestSuggestSharding, spec.sharding.topology, nodes.length]);
263263

264-
const handleRunPipeline = useCallback(async (mode: RunMode) => {
264+
const handleRunPipeline = useCallback(async (
265+
mode: RunMode, opts?: { num_steps?: number },
266+
) => {
265267
const snap = wireSpecRef.current;
266268
if (snap.nodes.length === 0) {
267269
setRunError("canvas is empty — drop bricks or pick a preset first");
@@ -272,10 +274,15 @@ export function App(): JSX.Element {
272274
setRunReport(null);
273275
const stages = mode === "smoke" ? SMOKE_STAGES
274276
: mode === "full" ? FULL_STAGES : TRAIN_STAGES;
277+
// V3-6: TopBar exposes train_num_steps; thread it via stage_options.
278+
const stage_options: Record<string, Record<string, unknown>> = {};
279+
if (mode === "train" && typeof opts?.num_steps === "number") {
280+
stage_options.train = { num_steps: opts.num_steps };
281+
}
275282
try {
276283
const r = await rpc.call<RunReport>("pipeline.run", {
277284
spec: buildVerifyParams(snap.nodes, snap.edges, snap.spec),
278-
pipeline: { stages, stage_options: {} },
285+
pipeline: { stages, stage_options },
279286
});
280287
setRunReport(r);
281288
} catch (e) {

vbgui/src/components/TopBar.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ export interface TopBarProps {
1313
onPresetDrop: (name: string) => void;
1414
onTopologyChange: (t: TopologyFactory) => void;
1515
onCompileModeChange: (m: SpecState["sharding"]["compile_mode"]) => void;
16-
onRunPipeline: (mode: RunMode) => void;
16+
onRunPipeline: (mode: RunMode, opts?: { num_steps?: number }) => void;
1717
}
1818

1919
export function TopBar(p: TopBarProps): JSX.Element {
2020
const [open, setOpen] = useState(false);
21+
const [trainNumSteps, setTrainNumSteps] = useState<number>(2);
2122
return (
2223
<header data-testid="top-bar"
2324
style={{ height: 56, display: "flex", alignItems: "center",
@@ -71,8 +72,22 @@ export function TopBar(p: TopBarProps): JSX.Element {
7172
<button data-testid="run-pipeline-full"
7273
onClick={() => { setOpen(false); p.onRunPipeline("full"); }}
7374
style={menuItem}>Full validate</button>
75+
<div style={{ padding: "6px 12px", display: "flex",
76+
alignItems: "center", gap: 6 }}>
77+
<span style={{ fontSize: 11, color: "#6b7280" }}>
78+
train steps:</span>
79+
<input data-testid="train-num-steps"
80+
type="number" min={1} max={64}
81+
value={trainNumSteps}
82+
onChange={(e) =>
83+
setTrainNumSteps(Math.max(1, parseInt(
84+
e.target.value || "1", 10)))}
85+
style={{ width: 50 }} />
86+
</div>
7487
<button data-testid="run-pipeline-train"
75-
onClick={() => { setOpen(false); p.onRunPipeline("train"); }}
88+
onClick={() => { setOpen(false);
89+
p.onRunPipeline("train",
90+
{ num_steps: trainNumSteps }); }}
7691
style={menuItem}>Train</button>
7792
</div>
7893
)}

vbgui/tests/TopBar.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ describe("TopBar", () => {
5757
expect(onRunPipeline).toHaveBeenCalledWith("full");
5858
fireEvent.click(screen.getByTestId("run-pipeline-toggle"));
5959
fireEvent.click(screen.getByTestId("run-pipeline-train"));
60-
expect(onRunPipeline).toHaveBeenCalledWith("train");
60+
expect(onRunPipeline).toHaveBeenCalledWith("train",
61+
expect.objectContaining({ num_steps: expect.any(Number) }));
6162
});
6263

6364
it("topology selector emits onTopologyChange", () => {

0 commit comments

Comments
 (0)