Skip to content

Commit c3b95e9

Browse files
committed
feat(v7-h37): UI subscribes to /ws/verify/{spec_hash} progress stream
Backend verify() (V7-L45) publishes phase events to verify_event_bus, exposed via the /ws/verify/{spec_hash} WS endpoint. UI had no consumer — long verifies on big graphs looked frozen. - New useVerifyStream hook owns the WS lifecycle keyed by spec_hash. - canonicalJson + computeSpecHash JS helpers match the backend's json.dumps(sort_keys=True, default=str) + sha256 contract bit-exact. - App.tsx computes hash before each verify, flips verifyInFlight, hook opens WS, panel shows 'verify · <latest phase> (N phases)' badge. - finally{} cleans up the stream regardless of verify outcome. bd: cppmega-mlx-lex9 2/2 backend pytest (hash determinism + start/resolve_shapes phases) + 7/7 vitest (canonicalJson + WS lifecycle via MockSocket).
1 parent e3daf43 commit c3b95e9

4 files changed

Lines changed: 330 additions & 0 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""V7-H37: verify() publishes phase events to verify_event_bus so the
2+
UI subscriber on /ws/verify/{spec_hash} sees progress."""
3+
4+
from __future__ import annotations
5+
6+
import threading
7+
import time
8+
9+
import pytest
10+
11+
from cppmega_v4.jsonrpc.methods import verify
12+
from cppmega_v4.jsonrpc.schema import VerifyParams
13+
from cppmega_v4.runtime import verify_event_bus as vb
14+
15+
16+
@pytest.fixture(autouse=True)
17+
def _reset():
18+
vb.reset()
19+
yield
20+
vb.reset()
21+
22+
23+
def _spec_params() -> VerifyParams:
24+
return VerifyParams.model_validate({
25+
"graph": {
26+
"nodes": [
27+
{"id": "attn", "kind": "attention",
28+
"params": {"num_heads": 4, "head_dim": 64}},
29+
{"id": "mlp", "kind": "mlp", "params": {}},
30+
],
31+
"edges": [{"src": "attn", "dst": "mlp"}],
32+
},
33+
"dim_env": {"B": 1, "S": 8, "H": 128,
34+
"nh": 2, "nkv": 1, "head_dim": 64},
35+
"loss": {"kind": "cross_entropy", "head_outputs": ["mlp"]},
36+
"optim": {"kind": "adamw",
37+
"groups": [{"matcher": "all", "lr": 1e-3,
38+
"weight_decay": 0.01,
39+
"betas": [0.9, 0.95]}]},
40+
})
41+
42+
43+
def test_v7_h37_spec_hash_is_stable_for_equivalent_payloads():
44+
p1 = _spec_params()
45+
p2 = _spec_params()
46+
assert vb.spec_hash(p1) == vb.spec_hash(p2)
47+
assert len(vb.spec_hash(p1)) == 64 # sha256 hex
48+
49+
50+
def test_v7_h37_verify_publishes_phase_events_then_finish():
51+
params = _spec_params()
52+
h = vb.spec_hash(params)
53+
54+
collected: list[dict | None] = []
55+
56+
def _collect():
57+
q = vb.subscribe(h)
58+
while True:
59+
try:
60+
ev = q.get(timeout=3.0)
61+
except Exception:
62+
break
63+
collected.append(ev)
64+
if ev is None:
65+
break
66+
67+
t = threading.Thread(target=_collect, daemon=True)
68+
t.start()
69+
time.sleep(0.05) # let subscribe land before publish
70+
71+
verify(params)
72+
# The verify handler doesn't emit a sentinel — the WS endpoint does
73+
# on disconnect. So we wait until at least the start + resolve
74+
# frames land, then drain.
75+
deadline = time.time() + 1.0
76+
while time.time() < deadline and len(collected) < 2:
77+
time.sleep(0.02)
78+
vb.publish(h, None) # close the subscriber loop
79+
t.join(timeout=1.0)
80+
81+
phases = [e["phase"] for e in collected if isinstance(e, dict)]
82+
assert "start" in phases, phases
83+
assert "resolve_shapes" in phases, phases

vbgui/src/App.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { DataInspector } from "@/components/DataInspector";
2727
import { BrickContextPanel } from "@/components/BrickContextPanel";
2828
import { LiveTrainPanel } from "@/components/LiveTrainPanel";
2929
import { useLiveTrainStream } from "@/hooks/useLiveTrainStream";
30+
import { useVerifyStream, computeSpecHash } from "@/hooks/useVerifyStream";
3031

3132
import { useRpc } from "@/hooks/useRpc";
3233
import { useVerifyAfter } from "@/hooks/useVerifyAfter";
@@ -220,13 +221,34 @@ export function App(): JSX.Element {
220221
setTrainSideChannels((prev) => prev.filter((name) => available.has(name)));
221222
}, [availableSideChannels]);
222223

224+
// V7-H37: verify-progress subscriber. computeSpecHash matches the
225+
// backend's verify_event_bus.spec_hash so the UI WS path lines up
226+
// with the publisher inside the verify handler.
227+
const [verifyInFlight, setVerifyInFlight] = useState<boolean>(false);
228+
const [verifySpecHash, setVerifySpecHash] = useState<string | null>(null);
229+
const verifyStream = useVerifyStream(
230+
(import.meta.env.VITE_BACKEND_URL as string | undefined)
231+
?? "http://127.0.0.1:8765",
232+
verifySpecHash, verifyInFlight,
233+
);
234+
223235
const runVerify = useCallback(async () => {
224236
const snap = wireSpecRef.current;
225237
if (snap.nodes.length === 0) return;
226238
const params = buildVerifyParams(
227239
snap.nodes, snap.edges, snap.spec, snap.availableSideChannels,
228240
snap.dimEnv,
229241
);
242+
// V7-H37: open the verify-progress WS *before* the RPC, then close
243+
// it on completion. The hook owns the lifecycle once verifyInFlight
244+
// flips.
245+
try {
246+
const hash = await computeSpecHash(params);
247+
setVerifySpecHash(hash);
248+
setVerifyInFlight(true);
249+
} catch {
250+
// WebCrypto unavailable (jsdom non-secure); skip progress feed.
251+
}
230252
try {
231253
const r = await rpc.call<{
232254
memory_distributed?: { worst_rank?: { total_bytes?: number };
@@ -262,6 +284,9 @@ export function App(): JSX.Element {
262284
}
263285
} catch {
264286
// Backend down or invalid spec; leave state and let user retry.
287+
} finally {
288+
// V7-H37: tear down the progress WS regardless of verify outcome.
289+
setVerifyInFlight(false);
265290
}
266291
}, [rpc]);
267292

@@ -736,6 +761,24 @@ export function App(): JSX.Element {
736761
<ReactFlowProvider>
737762
<div style={{ display: "flex", flexDirection: "column",
738763
height: "100vh", margin: 0 }}>
764+
{/* V7-H37: verify-progress badge — visible while verify is in
765+
flight, shows the most recent phase emitted by the backend
766+
so the user knows a long graph verify is making progress
767+
(not hung). */}
768+
{verifyInFlight && verifyStream.events.length > 0 && (
769+
<div data-testid="verify-progress-badge"
770+
style={{ position: "fixed", top: 50, right: 14,
771+
background: "#fef3c7", color: "#92400e",
772+
padding: "4px 10px", borderRadius: 4,
773+
fontFamily: "monospace", fontSize: 11,
774+
border: "1px solid #fcd34d", zIndex: 900 }}>
775+
verify · {verifyStream.events[verifyStream.events.length - 1]
776+
?.phase}
777+
<span style={{ marginLeft: 6, opacity: 0.7 }}>
778+
({verifyStream.events.length} phases)
779+
</span>
780+
</div>
781+
)}
739782
<TopBar
740783
state={spec}
741784
projectName={projectName}

vbgui/src/hooks/useVerifyStream.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// V7-H37: WebSocket subscriber for /ws/verify/{spec_hash}.
2+
//
3+
// Mirrors useLiveTrainStream — opens the WS when active=true + specHash
4+
// is known, collects phase events ({phase, ...}) into a buffer, surfaces
5+
// the latest one + a count for a progress indicator. Auto-disconnects
6+
// on backend {finish:'ok'} frame.
7+
8+
import { useEffect, useRef, useState } from "react";
9+
10+
export interface VerifyPhaseEvent {
11+
phase: string;
12+
[k: string]: unknown;
13+
}
14+
15+
export interface UseVerifyStreamState {
16+
events: VerifyPhaseEvent[];
17+
finished: boolean;
18+
reset: () => void;
19+
}
20+
21+
export function useVerifyStream(
22+
baseUrl: string,
23+
specHash: string | null,
24+
active: boolean,
25+
): UseVerifyStreamState {
26+
const [events, setEvents] = useState<VerifyPhaseEvent[]>([]);
27+
const [finished, setFinished] = useState<boolean>(false);
28+
const socketRef = useRef<WebSocket | null>(null);
29+
30+
const reset = () => { setEvents([]); setFinished(false); };
31+
32+
useEffect(() => {
33+
if (!active || !specHash) {
34+
socketRef.current?.close();
35+
socketRef.current = null;
36+
return;
37+
}
38+
39+
let cancelled = false;
40+
setEvents([]); setFinished(false);
41+
42+
const url = baseUrl.replace(/^http/, "ws")
43+
+ `/ws/verify/${specHash}`;
44+
let socket: WebSocket;
45+
try {
46+
socket = new WebSocket(url);
47+
} catch {
48+
return;
49+
}
50+
socketRef.current = socket;
51+
52+
socket.onmessage = (msg) => {
53+
if (cancelled) return;
54+
try {
55+
const frame = JSON.parse(msg.data) as
56+
{ event?: VerifyPhaseEvent; finish?: string };
57+
if (frame.event) {
58+
setEvents((prev) => [...prev, frame.event!]);
59+
} else if (frame.finish) {
60+
setFinished(true);
61+
socket.close();
62+
}
63+
} catch { /* ignore malformed */ }
64+
};
65+
66+
socket.onerror = () => { /* swallow; reconnect via effect dep */ };
67+
68+
return () => {
69+
cancelled = true;
70+
try { socket.close(); } catch { /* noop */ }
71+
socketRef.current = null;
72+
};
73+
}, [baseUrl, specHash, active]);
74+
75+
return { events, finished, reset };
76+
}
77+
78+
/**
79+
* Canonical-JSON serializer matching backend cppmega_v4.runtime.
80+
* verify_event_bus.spec_hash (json.dumps sort_keys=True default=str).
81+
*/
82+
export function canonicalJson(value: unknown): string {
83+
if (value === null || typeof value !== "object") {
84+
return JSON.stringify(value);
85+
}
86+
if (Array.isArray(value)) {
87+
return "[" + value.map(canonicalJson).join(",") + "]";
88+
}
89+
const obj = value as Record<string, unknown>;
90+
const keys = Object.keys(obj).sort();
91+
const parts = keys.map(
92+
(k) => JSON.stringify(k) + ":" + canonicalJson(obj[k]));
93+
return "{" + parts.join(",") + "}";
94+
}
95+
96+
/**
97+
* Compute SHA-256 of canonical JSON, hex-encoded. Matches the
98+
* backend's `spec_hash`. Uses WebCrypto so requires HTTPS or
99+
* localhost (jsdom-test environments need a polyfill).
100+
*/
101+
export async function computeSpecHash(value: unknown): Promise<string> {
102+
const buf = new TextEncoder().encode(canonicalJson(value));
103+
const digest = await crypto.subtle.digest("SHA-256", buf);
104+
return Array.from(new Uint8Array(digest))
105+
.map((b) => b.toString(16).padStart(2, "0"))
106+
.join("");
107+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// V7-H37: canonicalJson matches Python json.dumps(sort_keys=True);
2+
// useVerifyStream collects WS events + closes on finish frame.
3+
4+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
5+
import { renderHook, act, waitFor } from "@testing-library/react";
6+
import { canonicalJson, useVerifyStream }
7+
from "@/hooks/useVerifyStream";
8+
9+
describe("V7-H37 canonicalJson", () => {
10+
it("sorts object keys deterministically (matches Python sort_keys)", () => {
11+
const out = canonicalJson({ b: 2, a: { d: 4, c: 3 } });
12+
expect(out).toBe('{"a":{"c":3,"d":4},"b":2}');
13+
});
14+
15+
it("emits arrays in order without key-sort effect", () => {
16+
expect(canonicalJson([3, 1, 2])).toBe("[3,1,2]");
17+
});
18+
19+
it("nests deeply with stable order", () => {
20+
expect(canonicalJson({ z: 1, a: [{ y: 1, x: 2 }] }))
21+
.toBe('{"a":[{"x":2,"y":1}],"z":1}');
22+
});
23+
24+
it("handles primitives + null", () => {
25+
expect(canonicalJson("hi")).toBe('"hi"');
26+
expect(canonicalJson(42)).toBe("42");
27+
expect(canonicalJson(null)).toBe("null");
28+
});
29+
});
30+
31+
// useVerifyStream WS lifecycle — driven by an in-test WS shim that
32+
// captures the constructed URL + lets the test push synthetic frames.
33+
34+
class MockSocket {
35+
static instances: MockSocket[] = [];
36+
static reset(): void { MockSocket.instances = []; }
37+
url: string;
38+
onmessage: ((e: MessageEvent) => void) | null = null;
39+
onerror: ((e: Event) => void) | null = null;
40+
closed = false;
41+
constructor(url: string) {
42+
this.url = url;
43+
MockSocket.instances.push(this);
44+
}
45+
close(): void { this.closed = true; }
46+
push(frame: unknown): void {
47+
this.onmessage?.(new MessageEvent("message",
48+
{ data: JSON.stringify(frame) }));
49+
}
50+
}
51+
52+
describe("V7-H37 useVerifyStream", () => {
53+
beforeEach(() => {
54+
MockSocket.reset();
55+
(globalThis as unknown as { WebSocket: typeof WebSocket })
56+
.WebSocket = MockSocket as unknown as typeof WebSocket;
57+
});
58+
afterEach(() => {
59+
(globalThis as unknown as { WebSocket: typeof WebSocket | null })
60+
.WebSocket = null as unknown as typeof WebSocket;
61+
});
62+
63+
it("opens /ws/verify/{hash} URL with backend base", async () => {
64+
renderHook(() => useVerifyStream(
65+
"http://127.0.0.1:8765", "abc123", true));
66+
await waitFor(() => {
67+
expect(MockSocket.instances.length).toBe(1);
68+
});
69+
expect(MockSocket.instances[0]?.url)
70+
.toBe("ws://127.0.0.1:8765/ws/verify/abc123");
71+
});
72+
73+
it("doesn't open socket while inactive", () => {
74+
renderHook(() => useVerifyStream("http://x", "abc", false));
75+
expect(MockSocket.instances).toEqual([]);
76+
});
77+
78+
it("collects events and flips finished on finish frame", async () => {
79+
const { result } = renderHook(() => useVerifyStream(
80+
"http://x", "abc", true));
81+
await waitFor(() => {
82+
expect(MockSocket.instances.length).toBe(1);
83+
});
84+
const sock = MockSocket.instances[0]!;
85+
act(() => {
86+
sock.push({ event: { phase: "start" }, spec_hash: "abc" });
87+
sock.push({ event: { phase: "resolve_shapes" }, spec_hash: "abc" });
88+
sock.push({ finish: "ok", spec_hash: "abc" });
89+
});
90+
await waitFor(() => {
91+
expect(result.current.finished).toBe(true);
92+
});
93+
expect(result.current.events.map((e) => e.phase))
94+
.toEqual(["start", "resolve_shapes"]);
95+
expect(sock.closed).toBe(true);
96+
});
97+
});

0 commit comments

Comments
 (0)