Skip to content

Commit 52bfde3

Browse files
committed
feat(vbgui-e0): wire App.tsx — preset launcher / run pipeline / heartbeat / tabs
Stage E-0 of the E2E Coverage Matrix epic (cppmega-mlx-pa3.1). Pre-condition for all Playwright work: previously App.tsx had stub handlers for handlePresetDrop / handleRunPipeline / handleShardingAccept and TokenizerPlayground+DataInspector were never mounted. New modules: - src/hooks/useRpc.ts — singleton RpcClient + optional WS subscription to /ws for backend.status heartbeats. onBackendStatus callback is read through a ref so its identity drift across renders does NOT re-run the WS effect (would otherwise reconnect on every render). - src/hooks/useVerifyAfter.ts — generic debounced runner (default 200 ms). Runner reads payload from a ref so the latest snapshot is passed; cancel() drops a pending invocation. - src/components/RunResultModal.tsx — backdrop + stage table with ✓/✗/skipped icons, expandable detail per failed stage, close on backdrop click or × button. - src/components/AppTabs.tsx — Canvas / Tokenizer / Data switcher with aria-selected wiring. App.tsx rewrite: - handlePresetDrop now calls build_preset_specs (mini-spec H=128, num_layers=2 per E2EMatrix.md §3.1) and fans the returned specs into Node[] + Edge[] in a left-to-right grid. - handleRunPipeline calls pipeline.run with smoke/full/train stage lists and opens the RunResultModal with the outcome. - handleShardingAccept triggers a re-verify with a brief status note. - Verify runs on every user-visible mutation via useVerifyAfter, but the effect's deps are *structural keys* (nodesKey / edgesKey / lossKey / etc) rather than full state objects — the verify response writes back into spec / edges and depending on those would loop. - WS heartbeat dispatches backend.status to the spec reducer. - Tabs render TokenizerPlayground or DataInspector instead of canvas when active. Tests (+27 new vitest, 104 total in vbgui): - useRpc (6): stable RpcClient, opens WS only when requested, reports reconnecting on attach + connected on open, forwards backend.status frames, http -> ws URL translation, close on unmount. - useVerifyAfter (5): single fire after debounce, collapses rapid schedules, cancel, enabled=false short-circuit, latest payload passed via ref. - RunResultModal (8): null on empty inputs, overall + elapsed in title, one row per stage, close on backdrop / button, click inside does not close, expand failed-stage detail, error envelope. - AppTabs (3): renders three tabs, aria-selected, onChange. - App.integration (5): preset launcher fires build_preset_specs with hidden=128/num_layers=2, Smoke fires pipeline.run with the smoke stage list and opens modal with overall=ok, Smoke on empty canvas shows error in modal without calling pipeline.run, Tokenizer tab mounts TokenizerPlayground, Data tab mounts DataInspector. Full vbgui: 104 passed (was 77). Full v4 python regression: 2103 passed / 5 skipped / 15 xfailed / 0 failed. Closes cppmega-mlx-pa3.1.
1 parent 38e2b55 commit 52bfde3

10 files changed

Lines changed: 1061 additions & 33 deletions

vbgui/src/App.tsx

Lines changed: 316 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
1-
import { useCallback, useReducer, useState } from "react";
2-
import { ReactFlowProvider, type Edge, type Node } from "@xyflow/react";
1+
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
2+
import {
3+
ReactFlowProvider, type Edge, type Node,
4+
} from "@xyflow/react";
35

46
import { FlowCanvas } from "@/components/FlowCanvas";
57
import { Palette } from "@/components/Palette";
68
import { Sidebar } from "@/components/Sidebar";
79
import { TopBar, type RunMode } from "@/components/TopBar";
810
import { BottomStrip } from "@/components/BottomStrip";
11+
import { AppTabs, type AppTab } from "@/components/AppTabs";
12+
import { RunResultModal, type RunReport } from "@/components/RunResultModal";
13+
import { TokenizerPlayground } from "@/components/TokenizerPlayground";
14+
import { DataInspector } from "@/components/DataInspector";
15+
16+
import { useRpc } from "@/hooks/useRpc";
17+
import { useVerifyAfter } from "@/hooks/useVerifyAfter";
918

1019
import {
1120
INITIAL_SPEC, specReducer, type TopologyFactory,
@@ -23,12 +32,138 @@ const TOPOLOGIES: readonly TopologyFactory[] = [
2332
"gb10_quarter", "tpu_v6e_8", "tpu_v5p_4", "m3_ultra_solo",
2433
];
2534

35+
// Mini-spec used for preset expansion in the GUI. Matches E2EMatrix.md §3.1.
36+
const MINI_HIDDEN = 128;
37+
const MINI_DEPTH = 2;
38+
const MINI_DIM_ENV = {
39+
B: 1, S: 64, H: MINI_HIDDEN,
40+
nh: 2, nkv: 1, head_dim: 64,
41+
num_experts: 4, top_k: 2,
42+
};
43+
44+
const SMOKE_STAGES = [
45+
"parse", "verify_build_spec", "apply_rewrites", "resolve_shapes",
46+
"estimate_memory", "check_gotchas", "build_model", "dry_forward",
47+
];
48+
const FULL_STAGES = [
49+
...SMOKE_STAGES, "input_parity_check", "loss_smoke", "optimizer_smoke",
50+
];
51+
const TRAIN_STAGES = [...FULL_STAGES, "train"];
52+
53+
interface BrickSpec {
54+
kind: string;
55+
name?: string;
56+
params?: Record<string, unknown>;
57+
}
58+
59+
function presetSpecsToNodes(specs: BrickSpec[]): { nodes: Node[]; edges: Edge[] } {
60+
const nodes: Node[] = [];
61+
const edges: Edge[] = [];
62+
let x = 60, y = 60;
63+
let lastName: string | null = null;
64+
for (const s of specs) {
65+
const name = s.name ?? `${s.kind}_${nodes.length}`;
66+
nodes.push({
67+
id: name,
68+
type: "brick",
69+
position: { x, y },
70+
data: { kind: s.kind, params: s.params ?? {} } as never,
71+
});
72+
if (lastName) {
73+
edges.push({
74+
id: `${lastName}->${name}`,
75+
source: lastName,
76+
target: name,
77+
data: { severity: "info" },
78+
});
79+
}
80+
lastName = name;
81+
x += 220;
82+
if (x > 980) { x = 60; y += 140; }
83+
}
84+
return { nodes, edges };
85+
}
86+
2687
export function App(): JSX.Element {
2788
const [nodes, setNodes] = useState<Node[]>([]);
2889
const [edges, setEdges] = useState<Edge[]>([]);
2990
const [projectName, setProjectName] = useState("untitled");
30-
const [proposals] = useState<ShardingProposalView[]>([]);
91+
const [proposals, setProposals] = useState<ShardingProposalView[]>([]);
3192
const [spec, dispatch] = useReducer(specReducer, INITIAL_SPEC);
93+
const [activeTab, setActiveTab] = useState<AppTab>("canvas");
94+
const [runReport, setRunReport] = useState<RunReport | null>(null);
95+
const [runError, setRunError] = useState<string | null>(null);
96+
97+
const rpc = useRpc({
98+
enableWs: true,
99+
onBackendStatus: (s) => dispatch({ type: "backend.status", status: s }),
100+
});
101+
102+
// Keep one stable spec snapshot for the verify debouncer to read.
103+
const wireSpecRef = useRef({ nodes, edges, spec });
104+
useEffect(() => {
105+
wireSpecRef.current = { nodes, edges, spec };
106+
}, [nodes, edges, spec]);
107+
108+
const runVerify = useCallback(async () => {
109+
const snap = wireSpecRef.current;
110+
if (snap.nodes.length === 0) return;
111+
const params = buildVerifyParams(snap.nodes, snap.edges, snap.spec);
112+
try {
113+
const r = await rpc.call<{
114+
memory_distributed?: { worst_rank?: { total_bytes?: number };
115+
fits_on_topology?: boolean };
116+
memory_per_brick?: Record<string, { params_bytes: number;
117+
activations_bytes: number;
118+
kv_cache_bytes: number }>;
119+
gotchas?: { id: string; severity: "info" | "warning" | "error";
120+
message: string; reference?: string }[];
121+
elapsed_ms: number;
122+
resolved?: { edges?: { src: string; dst: string;
123+
matched: boolean;
124+
severity: "info" | "warning" | "error" }[] };
125+
}>("verify", params);
126+
127+
// Aggregate per-brick to a worst-rank-bytes proxy when no sharding.
128+
const total = sumPerBrick(r.memory_per_brick);
129+
const worst = r.memory_distributed?.worst_rank?.total_bytes ?? total;
130+
dispatch({ type: "memory.set", worst_rank_bytes: worst });
131+
dispatch({ type: "verify.complete",
132+
elapsed_ms: r.elapsed_ms,
133+
brick_count: snap.nodes.length });
134+
if (r.gotchas) {
135+
dispatch({ type: "gotchas.set", gotchas: r.gotchas });
136+
}
137+
if (r.resolved?.edges) {
138+
setEdges((prev) => recolorEdges(prev, r.resolved!.edges!));
139+
}
140+
} catch {
141+
// Backend down or invalid spec; leave state and let user retry.
142+
}
143+
}, [rpc]);
144+
145+
const { schedule: scheduleVerify } = useVerifyAfter(
146+
wireSpecRef, runVerify, { debounceMs: 200 },
147+
);
148+
149+
// Trigger verify only on inputs that the user controls. The verify
150+
// response writes back into spec AND edge severity — depending on
151+
// either of those in the effect would loop. Use structural keys.
152+
const nodesKey = nodes.map((n) => `${n.id}:${(n.data as { kind?: string })
153+
?.kind ?? ""}`).join("|");
154+
const edgesKey = edges.map((e) => `${e.source}>${e.target}`).join("|");
155+
const lossKey = `${spec.loss.kind}::${spec.loss.head_outputs.join(",")}`;
156+
const optimKey = `${spec.optim.kind}::${spec.optim.groups.length}`;
157+
const shardingKey =
158+
`${spec.sharding.topology}::${spec.sharding.compile_mode}` +
159+
`::${spec.sharding.axis_assignments.length}` +
160+
`::${spec.sharding.fp8_enabled ? 1 : 0}`;
161+
const rewriterKey = spec.rewriters.map((r) => r.name).join(",");
162+
useEffect(() => { scheduleVerify(); },
163+
[nodesKey, edgesKey, lossKey, optimKey, shardingKey,
164+
rewriterKey, scheduleVerify]);
165+
166+
// ----- Handlers ----------------------------------------------------------
32167

33168
const handleDropBrick = useCallback(
34169
(kind: string, position: { x: number; y: number }) => {
@@ -48,14 +183,85 @@ export function App(): JSX.Element {
48183
]);
49184
}, []);
50185

51-
const handlePresetDrop = useCallback((_name: string) => {
52-
// F-A.2/F-D will resolve preset → specs via build_preset_specs RPC and
53-
// fan out into Node[] additions. Stub for now.
54-
}, []);
186+
const handlePresetDrop = useCallback(async (name: string) => {
187+
try {
188+
const r = await rpc.call<{ specs: BrickSpec[]; preset_name: string }>(
189+
"build_preset_specs",
190+
{ preset_name: name, hidden_size: MINI_HIDDEN,
191+
num_layers: MINI_DEPTH },
192+
);
193+
const { nodes: ns, edges: es } = presetSpecsToNodes(r.specs);
194+
setNodes(ns);
195+
setEdges(es);
196+
} catch (e) {
197+
setRunError(String(e));
198+
}
199+
}, [rpc]);
200+
201+
const requestSuggestSharding = useCallback(async () => {
202+
const snap = wireSpecRef.current;
203+
if (snap.nodes.length === 0) return;
204+
try {
205+
const r = await rpc.call<{
206+
proposals: { strategy_name: string; fits: boolean;
207+
estimated_per_rank_bytes: number; reason: string }[];
208+
}>("suggest_sharding", {
209+
graph: nodesToGraph(snap.nodes, snap.edges),
210+
dim_env: MINI_DIM_ENV,
211+
loss: { kind: snap.spec.loss.kind,
212+
head_outputs: snap.spec.loss.head_outputs },
213+
optim: { kind: snap.spec.optim.kind,
214+
groups: snap.spec.optim.groups.map((g) => ({
215+
matcher: g.matcher, lr: g.lr,
216+
weight_decay: g.weight_decay,
217+
betas: g.betas,
218+
})) },
219+
topology: { factory: snap.spec.sharding.topology, kwargs: {} },
220+
});
221+
setProposals(r.proposals.map((p) => ({
222+
strategy_name: p.strategy_name, fits: p.fits,
223+
estimated_per_rank_bytes: p.estimated_per_rank_bytes,
224+
reason: p.reason,
225+
})));
226+
} catch { /* keep prior proposals on failure */ }
227+
}, [rpc]);
228+
229+
useEffect(() => {
230+
void requestSuggestSharding();
231+
}, [requestSuggestSharding, spec.sharding.topology, nodes.length]);
55232

56-
const handleRunPipeline = useCallback((_mode: RunMode) => {
57-
// F-D wires this to JSON-RPC pipeline.run; for now we only flag intent.
58-
}, []);
233+
const handleRunPipeline = useCallback(async (mode: RunMode) => {
234+
const snap = wireSpecRef.current;
235+
if (snap.nodes.length === 0) {
236+
setRunError("canvas is empty — drop bricks or pick a preset first");
237+
setRunReport(null);
238+
return;
239+
}
240+
setRunError(null);
241+
setRunReport(null);
242+
const stages = mode === "smoke" ? SMOKE_STAGES
243+
: mode === "full" ? FULL_STAGES : TRAIN_STAGES;
244+
try {
245+
const r = await rpc.call<RunReport>("pipeline.run", {
246+
spec: buildVerifyParams(snap.nodes, snap.edges, snap.spec),
247+
pipeline: { stages, stage_options: {} },
248+
});
249+
setRunReport(r);
250+
} catch (e) {
251+
setRunError(String(e));
252+
}
253+
}, [rpc]);
254+
255+
const handleShardingAccept = useCallback((idx: number) => {
256+
const chosen = proposals[idx];
257+
if (!chosen) return;
258+
// The proposal carries strategy + reason; backend already knows the
259+
// axis-assignments. We re-run verify so the new memory bar reflects.
260+
void scheduleVerify();
261+
setRunReport(null);
262+
setRunError(`sharding proposal "${chosen.strategy_name}" applied — re-verifying`);
263+
setTimeout(() => setRunError(null), 2000);
264+
}, [proposals, scheduleVerify]);
59265

60266
return (
61267
<ReactFlowProvider>
@@ -74,33 +280,110 @@ export function App(): JSX.Element {
74280
sharding: { ...spec.sharding, compile_mode: m } })}
75281
onRunPipeline={handleRunPipeline}
76282
/>
283+
<AppTabs active={activeTab} onChange={setActiveTab} />
77284
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
78-
<Palette />
79-
<FlowCanvas
80-
nodes={nodes} edges={edges}
81-
onConnect={handleConnect}
82-
onDropBrick={handleDropBrick}
83-
/>
84-
<Sidebar
85-
loss={spec.loss}
86-
optim={spec.optim}
87-
rewriters={spec.rewriters}
88-
sharding={spec.sharding}
89-
gotchas={spec.gotchas}
90-
proposals={proposals}
91-
onLossApply={(l) => dispatch({ type: "loss.set", loss: l })}
92-
onOptimApply={(o) => dispatch({ type: "optim.set", optim: o })}
93-
onRewriterAdd={(r) => dispatch({ type: "rewriters.add", rewriter: r })}
94-
onRewriterRemove={(i) => dispatch({ type: "rewriters.remove", index: i })}
95-
onRewriterReorder={(f, t) =>
96-
dispatch({ type: "rewriters.reorder", from: f, to: t })}
97-
onShardingChange={(s) =>
98-
dispatch({ type: "sharding.set", sharding: s })}
99-
onShardingAccept={(_idx) => { /* F-D wires accept → topology update */ }}
100-
/>
285+
{activeTab === "canvas" && (
286+
<>
287+
<Palette />
288+
<FlowCanvas
289+
nodes={nodes} edges={edges}
290+
onConnect={handleConnect}
291+
onDropBrick={handleDropBrick}
292+
/>
293+
<Sidebar
294+
loss={spec.loss}
295+
optim={spec.optim}
296+
rewriters={spec.rewriters}
297+
sharding={spec.sharding}
298+
gotchas={spec.gotchas}
299+
proposals={proposals}
300+
onLossApply={(l) => dispatch({ type: "loss.set", loss: l })}
301+
onOptimApply={(o) => dispatch({ type: "optim.set", optim: o })}
302+
onRewriterAdd={(r) =>
303+
dispatch({ type: "rewriters.add", rewriter: r })}
304+
onRewriterRemove={(i) =>
305+
dispatch({ type: "rewriters.remove", index: i })}
306+
onRewriterReorder={(f, t) =>
307+
dispatch({ type: "rewriters.reorder", from: f, to: t })}
308+
onShardingChange={(s) =>
309+
dispatch({ type: "sharding.set", sharding: s })}
310+
onShardingAccept={handleShardingAccept}
311+
/>
312+
</>
313+
)}
314+
{activeTab === "tokenizer" && (
315+
<TokenizerPlayground rpc={rpc} />
316+
)}
317+
{activeTab === "data" && (
318+
<DataInspector rpc={rpc} />
319+
)}
101320
</div>
102321
<BottomStrip state={spec} fusedRegionCount={0} />
322+
<RunResultModal report={runReport} error={runError}
323+
onClose={() => { setRunReport(null);
324+
setRunError(null); }} />
103325
</div>
104326
</ReactFlowProvider>
105327
);
106328
}
329+
330+
// ---------------------------------------------------------------------------
331+
// Helpers
332+
// ---------------------------------------------------------------------------
333+
334+
function nodesToGraph(nodes: Node[], edges: Edge[]) {
335+
return {
336+
nodes: nodes.map((n) => {
337+
const data = n.data as { kind?: string; params?: Record<string, unknown> };
338+
return { id: n.id, kind: data.kind ?? "mlp", params: data.params ?? {} };
339+
}),
340+
edges: edges.map((e) => ({ src: e.source, dst: e.target })),
341+
};
342+
}
343+
344+
function buildVerifyParams(nodes: Node[], edges: Edge[],
345+
spec: ReturnType<typeof specReducer>) {
346+
return {
347+
graph: nodesToGraph(nodes, edges),
348+
dim_env: MINI_DIM_ENV,
349+
loss: { kind: spec.loss.kind, head_outputs: spec.loss.head_outputs,
350+
params: spec.loss.params },
351+
optim: { kind: spec.optim.kind,
352+
groups: spec.optim.groups.map((g) => ({
353+
matcher: g.matcher, lr: g.lr,
354+
weight_decay: g.weight_decay, betas: g.betas,
355+
})) },
356+
rewriters: spec.rewriters.map((r) => ({ name: r.name, params: r.params })),
357+
sharding: {
358+
topology: { factory: spec.sharding.topology, kwargs: {} },
359+
axis_assignments: spec.sharding.axis_assignments,
360+
compile_mode: spec.sharding.compile_mode,
361+
fp8_enabled: spec.sharding.fp8_enabled,
362+
},
363+
training: true,
364+
available_side_channels: ["doc_ids", "token_ids"],
365+
};
366+
}
367+
368+
function sumPerBrick(per?: Record<string, { params_bytes: number;
369+
activations_bytes: number;
370+
kv_cache_bytes: number }>): number {
371+
if (!per) return 0;
372+
return Object.values(per).reduce(
373+
(acc, r) => acc + r.params_bytes + r.activations_bytes + r.kv_cache_bytes,
374+
0,
375+
);
376+
}
377+
378+
function recolorEdges(prev: Edge[],
379+
resolved: { src: string; dst: string;
380+
severity: "info" | "warning" | "error";
381+
matched: boolean }[]): Edge[] {
382+
const map = new Map<string, { severity: "info" | "warning" | "error" }>();
383+
for (const e of resolved) map.set(`${e.src}->${e.dst}`, { severity: e.severity });
384+
return prev.map((e) => {
385+
const m = map.get(`${e.source}->${e.target}`);
386+
if (!m) return e;
387+
return { ...e, data: { ...(e.data ?? {}), severity: m.severity } };
388+
});
389+
}

0 commit comments

Comments
 (0)