Skip to content

Commit 912bef3

Browse files
committed
feat(v7-l37..45): live WS sparkline + overflow markers + dead-man-switch + reconnect + verify stream
Closes the 9-item live-WS gap audit: L37 LossChart now drives off liveTrainEvents — sparkline grows as events arrive, not only on pipeline.run resolve. L38 overflowSteps prop on LossChart renders red dashed bars at the exact x positions of overflow events on the live curve. L39 LiveTrainPanel polls Date.now() per second while in-flight; when (now - last.ts) > stallSeconds (default 8s) it surfaces '⚠ stalled X.Xs' so a hung backend is visible. L40 finish:'ok' frame raises a one-shot toast 'train done' with a dismiss button. L41 useLiveTrainStream hook reconnects with capped exponential backoff (500ms..5s) on unexpected socket close; tracks attempts so the UI shows '↻ reconnect #N'. Skipped after the finish frame. L42 stage_train per-step publish now carries grad_norms dict (per-brick L2 norms via _safe_per_brick_grads). L43 mem_mb on each event from mx.metal.get_peak_memory(). L44 expert_load array on each event when an MoE module is present (last_router_output.load). L45 /ws/verify/{spec_hash} WS endpoint + verify_event_bus + phase publishes inside verify() (start → graph_built → resolve_shapes → memory_estimated → done). spec_hash helper sha256s canonical VerifyParams JSON. - tests/v4/test_verify_event_bus.py (4 cases): stable hash, phase emit, no-subscriber no-op, route presence. - tests/v5/test_train_event_bus_payload.py: per-step payload has grad_norms/mem_mb/expert_load/ts fields. - e2e 89: real pipeline.run + WS sparkline visible, finish toast, overflow path on fp16. page.on('console') capture annotated into test.info() so flaky runs leave diagnostic frames in the report.
1 parent 8f7aa8d commit 912bef3

11 files changed

Lines changed: 735 additions & 30 deletions

File tree

cppmega_v4/jsonrpc/methods.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,10 +298,23 @@ def verify(params: VerifyParams, *, cache: LRUCache | None = None) -> VerifyResu
298298
if hit is not None:
299299
return hit
300300

301+
# V7-L45: stream progress so /ws/verify/{spec_hash} subscribers see
302+
# phase transitions on long verifies.
303+
from cppmega_v4.runtime import verify_event_bus as _vb
304+
_vh = _vb.spec_hash(params)
305+
306+
def _emit(phase: str, extra: dict | None = None) -> None:
307+
try:
308+
_vb.publish(_vh, {"phase": phase, **(extra or {})})
309+
except Exception:
310+
pass
311+
312+
_emit("start")
301313
t0 = time.perf_counter()
302314
specs = _graph_to_specs(params.graph)
303315
hidden = params.dim_env.get("H", 64)
304316
graph = from_block_specs(specs, hidden_size=hidden, instantiate=False)
317+
_emit("graph_built", {"node_count": len(specs)})
305318

306319
available = frozenset(params.available_side_channels)
307320
resolved = resolve_shapes(
@@ -310,12 +323,14 @@ def verify(params: VerifyParams, *, cache: LRUCache | None = None) -> VerifyResu
310323
)
311324
fusion_plan = tuple(plan_fusion_regions(graph))
312325

326+
_emit("resolve_shapes")
313327
result_one = verify_and_estimate(
314328
graph,
315329
dim_env=params.dim_env,
316330
training=params.training,
317331
available_side_channels=available,
318332
)
333+
_emit("memory_estimated")
319334

320335
mem = result_one.memory
321336
per_brick: dict[str, PerBrickMemory] = {}
@@ -462,6 +477,12 @@ def verify(params: VerifyParams, *, cache: LRUCache | None = None) -> VerifyResu
462477
elapsed_ms=elapsed_ms,
463478
)
464479
_cache_store(cache, key, out)
480+
_emit("done", {"elapsed_ms": round(
481+
(time.perf_counter() - t0) * 1000.0, 4)})
482+
try:
483+
_vb.publish(_vh, None) # sentinel marks completion to subs
484+
except Exception:
485+
pass
465486
return out
466487

467488

cppmega_v4/jsonrpc/server.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,42 @@ def _try_get(timeout: float = 0.2):
152152
finally:
153153
train_event_bus.unsubscribe(run_id, q)
154154

155+
@app.websocket("/ws/verify/{spec_hash}")
156+
async def ws_verify(socket: WebSocket, spec_hash: str) -> None:
157+
"""V7-L45: live verify progress stream.
158+
159+
Client subscribes by spec_hash (sha256 of the canonical
160+
VerifyParams JSON) *before* calling verify. The handler emits
161+
{phase} frames as it walks resolve/memory/distributed checks
162+
and a final {finish:'ok'} when verify returns."""
163+
from cppmega_v4.runtime import verify_event_bus
164+
import queue as _queue
165+
await socket.accept()
166+
q = verify_event_bus.subscribe(spec_hash)
167+
168+
def _try_get(timeout: float = 0.2):
169+
try:
170+
return q.get(timeout=timeout)
171+
except _queue.Empty:
172+
return _SENTINEL_EMPTY
173+
174+
try:
175+
while True:
176+
ev = await asyncio.to_thread(_try_get, 0.2)
177+
if ev is _SENTINEL_EMPTY:
178+
await asyncio.sleep(0)
179+
continue
180+
if ev is None:
181+
await socket.send_json({"finish": "ok",
182+
"spec_hash": spec_hash})
183+
return
184+
await socket.send_json({"spec_hash": spec_hash,
185+
"event": ev})
186+
except WebSocketDisconnect:
187+
pass
188+
finally:
189+
verify_event_bus.unsubscribe(spec_hash, q)
190+
155191
@app.websocket("/ws")
156192
async def ws(socket: WebSocket) -> None:
157193
await socket.accept()

cppmega_v4/runner/stages.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,10 +206,20 @@ def stage_verify_build_spec(ctx: StageContext) -> StageResult:
206206
_tok = _stage_abort_token(ctx, "verify_build_spec")
207207
if _tok and _tok in _ABORT_TOKENS:
208208
clear_abort(_tok)
209+
# V7-H06b/H10: mirror the train-side contract so the UI sees
210+
# extras.aborted=True on the cancelled stage; mark the run as
211+
# aborted in the registry so pipeline.status reflects the
212+
# cancel even when train never ran.
213+
try:
214+
from cppmega_v4.runtime import run_registry as _rr
215+
_rr.mark_aborted(_tok)
216+
except Exception:
217+
pass
209218
return StageResult(
210219
name="verify_build_spec", status="cancelled",
211220
elapsed_ms=(time.perf_counter() - t0) * 1000.0,
212221
error={"type": "Aborted", "abort_token": _tok},
222+
extras={"aborted": True, "abort_token": _tok},
213223
)
214224
try:
215225
if ctx.build_spec is None:
@@ -362,10 +372,16 @@ def stage_dry_forward(ctx: StageContext) -> StageResult:
362372
_tok = _stage_abort_token(ctx, "dry_forward")
363373
if _tok and _tok in _ABORT_TOKENS:
364374
clear_abort(_tok)
375+
try:
376+
from cppmega_v4.runtime import run_registry as _rr
377+
_rr.mark_aborted(_tok)
378+
except Exception:
379+
pass
365380
return StageResult(
366381
name="dry_forward", status="cancelled",
367382
elapsed_ms=(time.perf_counter() - t0) * 1000.0,
368383
error={"type": "Aborted", "abort_token": _tok},
384+
extras={"aborted": True, "abort_token": _tok},
369385
)
370386
try:
371387
opts = ctx.opts("dry_forward")
@@ -1573,9 +1589,51 @@ def _base(k: str) -> str | None:
15731589
_run_registry.mark_step(_registry_key, step, float(losses[-1]))
15741590
# V7-H05: publish per-step event to the train_event_bus so
15751591
# WS subscribers (UI) see loss/lr update live, not only on
1576-
# pipeline completion.
1592+
# pipeline completion. V7-L37..44 expansion: payload now also
1593+
# carries per-brick grad-norms, current memory peak, and
1594+
# per-expert load (when MoE is present) so the UI can render
1595+
# live timelines instead of waiting for completion.
15771596
try:
15781597
from cppmega_v4.runtime import train_event_bus
1598+
# Per-brick grad-norms (cheap: norm over already-flat
1599+
# grad tree — see _safe_per_brick_grads).
1600+
try:
1601+
step_grad_norms = _safe_per_brick_grads(
1602+
all_modules, grads)
1603+
except Exception:
1604+
step_grad_norms = {}
1605+
# Per-step memory peak (best-effort; mx.metal may be
1606+
# unavailable on non-Apple hosts).
1607+
step_mem_mb: float | None = None
1608+
try:
1609+
if hasattr(mx, "metal"):
1610+
step_mem_mb = round(
1611+
int(mx.metal.get_peak_memory()) / (1024 * 1024),
1612+
3)
1613+
except Exception:
1614+
step_mem_mb = None
1615+
# Per-step expert load when an MoE brick is in the model.
1616+
step_expert_load: list[float] | None = None
1617+
try:
1618+
if 'moe_module' in dir() and moe_module is not None:
1619+
last_load = getattr(
1620+
getattr(moe_module, 'last_router_output',
1621+
None), 'load', None)
1622+
if last_load is None:
1623+
# Fallback: recompute the router load over the
1624+
# cached output if the module exposes it.
1625+
ro = getattr(moe_module, 'last_router_output',
1626+
None)
1627+
if ro is not None:
1628+
last_load = ro.load
1629+
if last_load is not None:
1630+
step_expert_load = [
1631+
round(float(v), 6)
1632+
for v in last_load.tolist()
1633+
]
1634+
except Exception:
1635+
step_expert_load = None
1636+
15791637
train_event_bus.publish(
15801638
opts.get("run_id") or opts.get("abort_token"),
15811639
{"step": int(step),
@@ -1584,7 +1642,11 @@ def _base(k: str) -> str | None:
15841642
if lr_trajectory else None,
15851643
"overflow":
15861644
bool(_scaler_overflow_this_step
1587-
if loss_scaler is not None else False)},
1645+
if loss_scaler is not None else False),
1646+
"grad_norms": step_grad_norms,
1647+
"mem_mb": step_mem_mb,
1648+
"expert_load": step_expert_load,
1649+
"ts": float(time.time())},
15881650
)
15891651
except Exception:
15901652
pass
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""V7-L45: cross-thread pub/sub for live verify progress events.
2+
3+
Mirrors train_event_bus + gen_event_bus, keyed by a stable spec_hash
4+
(sha256 of the canonical JSON of VerifyParams). Used so a long-running
5+
verify can stream progress to the UI via /ws/verify/{spec_hash}.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import hashlib
11+
import json as _json
12+
import queue
13+
import threading
14+
from typing import Any
15+
16+
17+
_LOCK = threading.Lock()
18+
_QUEUES: dict[str, list[queue.Queue]] = {}
19+
20+
21+
def spec_hash(spec_payload: Any) -> str:
22+
"""Stable sha256 over the canonical JSON of a VerifyParams-like dict.
23+
24+
Accepts a dict or a Pydantic-ish object via model_dump(). Used both
25+
by the UI (to build the WS path) and by the verify handler (to know
26+
which subscribers to notify)."""
27+
if hasattr(spec_payload, "model_dump"):
28+
payload = spec_payload.model_dump(mode="json")
29+
else:
30+
payload = spec_payload
31+
return hashlib.sha256(
32+
_json.dumps(payload, sort_keys=True,
33+
default=str).encode("utf-8")
34+
).hexdigest()
35+
36+
37+
def publish(key: str | None, event: dict[str, Any] | None) -> None:
38+
if not key:
39+
return
40+
with _LOCK:
41+
subs = list(_QUEUES.get(key, []))
42+
for q in subs:
43+
try:
44+
q.put_nowait(event)
45+
except Exception:
46+
pass
47+
48+
49+
def subscribe(key: str) -> queue.Queue:
50+
q: queue.Queue = queue.Queue(maxsize=256)
51+
with _LOCK:
52+
_QUEUES.setdefault(key, []).append(q)
53+
return q
54+
55+
56+
def unsubscribe(key: str, q: queue.Queue) -> None:
57+
with _LOCK:
58+
subs = _QUEUES.get(key, [])
59+
if q in subs:
60+
subs.remove(q)
61+
if not subs:
62+
_QUEUES.pop(key, None)
63+
64+
65+
def reset() -> None:
66+
with _LOCK:
67+
_QUEUES.clear()
68+
69+
70+
def subscriber_count(key: str) -> int:
71+
with _LOCK:
72+
return len(_QUEUES.get(key, []))
73+
74+
75+
__all__ = ["spec_hash", "publish", "subscribe", "unsubscribe", "reset",
76+
"subscriber_count"]

tests/v4/test_verify_event_bus.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""V7-L45: verify_event_bus + verify() publish progress events."""
2+
3+
from __future__ import annotations
4+
5+
import queue
6+
7+
import pytest
8+
from fastapi.testclient import TestClient
9+
10+
from cppmega_v4.jsonrpc import create_app
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 bus
14+
15+
16+
@pytest.fixture(autouse=True)
17+
def _clean():
18+
bus.reset()
19+
yield
20+
bus.reset()
21+
22+
23+
def _spec_payload() -> dict:
24+
return {
25+
"graph": {
26+
"nodes": [
27+
{"id": "attn", "kind": "attention", "params": {}},
28+
{"id": "mlp", "kind": "mlp",
29+
"params": {"intermediate_size": 64, "activation": "swiglu"}},
30+
],
31+
"edges": [{"src": "attn", "dst": "mlp"}],
32+
},
33+
"dim_env": {"B": 1, "S": 8, "H": 32, "nh": 2, "nkv": 1,
34+
"head_dim": 16},
35+
"loss": {"kind": "cross_entropy", "head_outputs": ["mlp"]},
36+
"optim": {"kind": "adamw",
37+
"groups": [{"matcher": "all", "lr": 1e-3,
38+
"weight_decay": 0.01, "betas": [0.9, 0.95]}]},
39+
}
40+
41+
42+
def test_spec_hash_stable_across_equivalent_dicts():
43+
h1 = bus.spec_hash(_spec_payload())
44+
h2 = bus.spec_hash(_spec_payload())
45+
assert h1 == h2
46+
assert len(h1) == 64
47+
48+
49+
def test_verify_publishes_phase_events_to_subscribers():
50+
params = VerifyParams.model_validate(_spec_payload())
51+
h = bus.spec_hash(params)
52+
q = bus.subscribe(h)
53+
verify(params)
54+
phases: list[str] = []
55+
while True:
56+
try:
57+
ev = q.get_nowait()
58+
except queue.Empty:
59+
break
60+
if ev is None:
61+
phases.append("__finish__")
62+
break
63+
phases.append(ev["phase"])
64+
# The handler emits at least start → graph_built → resolve_shapes
65+
# → memory_estimated → done, plus the sentinel.
66+
for required in ("start", "graph_built", "memory_estimated",
67+
"done", "__finish__"):
68+
assert required in phases, phases
69+
70+
71+
def test_verify_no_subscriber_is_a_noop():
72+
params = VerifyParams.model_validate(_spec_payload())
73+
out = verify(params)
74+
assert out.elapsed_ms >= 0
75+
76+
77+
def test_ws_verify_endpoint_appears_in_routes():
78+
app = create_app(cache_capacity=2)
79+
paths = {r.path for r in app.routes
80+
if hasattr(r, "path") and getattr(r, "path", "")}
81+
assert "/ws/verify/{spec_hash}" in paths

0 commit comments

Comments
 (0)