Skip to content

Commit 96ee5a6

Browse files
committed
feat(vbgui-fd): anywidget shim + cppmega_v4.widget package
Stage F-D of the Visual Builder GUI epic (cppmega-mlx-o0k). The React/TypeScript canvas + sidebar + chrome now mount inside a Jupyter notebook via anywidget, with no HTTP round-trip — the kernel hosts the JSON-RPC dispatcher directly. - vbgui/src/anywidget.tsx: ESM entry exposing { render } per anywidget contract. Wraps App in StrictMode, manages React roots via a WeakMap so multiple widget instances coexist. - vbgui/vite.widget.config.ts: library-mode build that emits cppmega_v4/widget/static/widget.mjs (1.1 MB, gzip 263 kB) + widget.css (16 kB, gzip 2.6 kB). - cppmega_v4/widget/widget.py: VisualBuilderWidget(AnyWidget). Loads the bundle at __init__, exposes spec / last_result / schema_version traitlets, routes incoming custom_msg envelopes through cppmega_v4.jsonrpc.dispatch with a per-widget LRU cache, and emits the response back via self.send. - pyproject.toml: new "widget" optional-deps group (anywidget / traitlets / ipywidgets) and package-data declaration so the built static bundle ships in the wheel. - .gitignore: static/ bundle is a build artefact — regenerated by `npm run build:widget` in vbgui/. Tests (8): asset presence, instantiation with bundle loaded, schema_version surfaced, custom-msg routing to backend.status, error envelope on bad params, non-dict drop, cache_stats shape, schema_version read-only. Full v4 regression: 2062 passed / 5 skipped / 15 xfailed / 0 failed. Closes cppmega-mlx-o0k.4.
1 parent 2669d02 commit 96ee5a6

8 files changed

Lines changed: 296 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ build/
1717
cppmega_mlx/training/native_optim/*.so
1818
cppmega_mlx/training/native_optim/*.dylib
1919
cppmega_mlx/training/native_optim/*.metallib
20+
cppmega_v4/widget/static/
2021

2122
# Local Metal GPU traces (multi-hundred-MB Apple Xcode capture bundles).
2223
*.gputrace

cppmega_v4/widget/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Jupyter anywidget for the cppmega Visual Builder.
2+
3+
See ``VisualBuilderPlan.md`` §3.5 for the design.
4+
5+
Stage F-D surface (this commit):
6+
- widget: VisualBuilderWidget — anywidget.AnyWidget subclass that
7+
bundles the React/TypeScript canvas + sidebar + chrome and
8+
exposes the JSON-RPC dispatch through the kernel directly (no
9+
HTTP round-trip; the widget runs in-process).
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from cppmega_v4.widget.widget import (
15+
STATIC_DIR,
16+
VisualBuilderWidget,
17+
widget_assets_exist,
18+
)
19+
20+
__all__ = [
21+
"STATIC_DIR",
22+
"VisualBuilderWidget",
23+
"widget_assets_exist",
24+
]

cppmega_v4/widget/widget.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""anywidget shim for the Visual Builder.
2+
3+
The widget mounts the bundled React app (built by ``npm run build:widget``
4+
in ``vbgui/``) and routes JSON-RPC envelopes through the in-kernel
5+
dispatcher — no HTTP server needed when running inside a notebook.
6+
7+
Traitlet contract (kernel ↔ frontend):
8+
- ``spec`` (Dict): the canonical model spec — graph, loss, optim,
9+
sharding, rewriters. Frontend mutates, kernel observes.
10+
- ``last_result`` (Dict): the most recent verify/probe/pipeline
11+
response, written by the kernel.
12+
- ``schema_version`` (Unicode, read-only): bumps when the wire
13+
format changes; frontend reloads.
14+
15+
Frontend-initiated work travels as ``custom_msg`` envelopes shaped like
16+
JSON-RPC 2.0 requests; the kernel runs the same
17+
:func:`cppmega_v4.jsonrpc.dispatch` and sends back a JsonRpcResponse via
18+
``self.send``.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import logging
24+
from pathlib import Path
25+
from typing import Any
26+
27+
import anywidget
28+
import traitlets
29+
30+
from cppmega_v4.jsonrpc import SCHEMA_VERSION, LRUCache, dispatch
31+
32+
_log = logging.getLogger(__name__)
33+
34+
35+
STATIC_DIR: Path = Path(__file__).parent / "static"
36+
_ESM_PATH = STATIC_DIR / "widget.mjs"
37+
_CSS_PATH = STATIC_DIR / "widget.css"
38+
39+
40+
def widget_assets_exist() -> bool:
41+
"""True iff the Vite-built widget bundle is present on disk."""
42+
return _ESM_PATH.is_file() and _CSS_PATH.is_file()
43+
44+
45+
def _load_text(path: Path, missing_hint: str) -> str:
46+
if not path.is_file():
47+
raise FileNotFoundError(
48+
f"widget asset not found: {path}\n"
49+
f"hint: {missing_hint}",
50+
)
51+
return path.read_text(encoding="utf-8")
52+
53+
54+
class VisualBuilderWidget(anywidget.AnyWidget):
55+
"""Jupyter widget wrapping the cppmega Visual Builder UI."""
56+
57+
_esm = traitlets.Unicode().tag(sync=False)
58+
_css = traitlets.Unicode().tag(sync=False)
59+
60+
spec = traitlets.Dict().tag(sync=True)
61+
last_result = traitlets.Dict().tag(sync=True)
62+
schema_version = traitlets.Unicode(SCHEMA_VERSION, read_only=True).tag(sync=True)
63+
64+
def __init__(
65+
self,
66+
*,
67+
spec: dict[str, Any] | None = None,
68+
cache_capacity: int = 50,
69+
**kwargs: Any,
70+
) -> None:
71+
# Read bundle once at construction; raises with a useful hint if
72+
# the Vite build hasn't been run.
73+
kwargs.setdefault("_esm", _load_text(
74+
_ESM_PATH,
75+
"run `npm run build:widget` from vbgui/ to populate "
76+
"cppmega_v4/widget/static/widget.mjs",
77+
))
78+
kwargs.setdefault("_css", _load_text(
79+
_CSS_PATH,
80+
"run `npm run build:widget` from vbgui/ to populate "
81+
"cppmega_v4/widget/static/widget.css",
82+
))
83+
super().__init__(**kwargs)
84+
if spec is not None:
85+
self.spec = dict(spec)
86+
self._cache = LRUCache(capacity=cache_capacity)
87+
self.on_msg(self._on_msg)
88+
89+
def _on_msg(self, _: object, content: Any, _buffers: list[bytes]) -> None:
90+
"""Route a kernel-bound JSON-RPC envelope to the dispatcher."""
91+
if not isinstance(content, dict):
92+
_log.warning("VisualBuilderWidget: dropped non-dict msg %r", content)
93+
return
94+
response = dispatch(content, cache=self._cache)
95+
self.send(response.model_dump(mode="json", exclude_none=True))
96+
97+
@property
98+
def cache_stats(self) -> dict[str, int]:
99+
return self._cache.stats()

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,21 @@ gui = [
2626
"httpx>=0.27",
2727
"jsonschema>=4.20",
2828
]
29+
widget = [
30+
"anywidget>=0.9",
31+
"traitlets>=5.0",
32+
"ipywidgets>=8.0",
33+
]
2934

3035
[project.scripts]
3136
cppmega-run = "cppmega_v4.runner.cli:main"
3237

3338
[tool.setuptools.packages.find]
3439
include = ["cppmega_mlx*", "cppmega_v4*"]
3540

41+
[tool.setuptools.package-data]
42+
"cppmega_v4.widget" = ["static/*.mjs", "static/*.css"]
43+
3644
[tool.pytest.ini_options]
3745
testpaths = ["tests"]
3846
pythonpath = ["."]

tests/v4/test_widget.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""F-D widget tests — anywidget shim + JSON-RPC bridge over custom_msg."""
2+
3+
from __future__ import annotations
4+
5+
from cppmega_v4.jsonrpc import SCHEMA_VERSION
6+
from cppmega_v4.widget import (
7+
STATIC_DIR,
8+
VisualBuilderWidget,
9+
widget_assets_exist,
10+
)
11+
12+
13+
def test_widget_assets_present_after_build():
14+
"""`npm run build:widget` (vbgui/) populates the static dir."""
15+
assert STATIC_DIR.is_dir()
16+
assert widget_assets_exist(), (
17+
"widget assets missing — run `npm run build:widget` in vbgui/"
18+
)
19+
assert (STATIC_DIR / "widget.mjs").stat().st_size > 0
20+
assert (STATIC_DIR / "widget.css").stat().st_size > 0
21+
22+
23+
def test_widget_instantiates_with_bundle_loaded():
24+
w = VisualBuilderWidget()
25+
assert w._esm
26+
assert w._css
27+
assert w.schema_version == SCHEMA_VERSION
28+
29+
30+
def test_widget_accepts_initial_spec():
31+
seed = {"graph": {"nodes": [], "edges": []}}
32+
w = VisualBuilderWidget(spec=seed)
33+
assert w.spec == seed
34+
35+
36+
def test_widget_responds_to_backend_status_msg():
37+
"""Custom-msg envelope routes through the dispatcher and `send`s a reply."""
38+
w = VisualBuilderWidget()
39+
captured: list[object] = []
40+
w.send = lambda payload, buffers=None: captured.append(payload) # type: ignore[method-assign]
41+
w._on_msg(None, {
42+
"jsonrpc": "2.0", "id": "ws_1", "method": "backend.status",
43+
}, [])
44+
assert captured, "no reply emitted"
45+
reply = captured[0]
46+
assert reply["id"] == "ws_1"
47+
assert reply["result"] == {"status": "ok"}
48+
49+
50+
def test_widget_returns_invalid_params_on_bad_envelope():
51+
w = VisualBuilderWidget()
52+
captured: list[object] = []
53+
w.send = lambda payload, buffers=None: captured.append(payload) # type: ignore[method-assign]
54+
w._on_msg(None, {
55+
"jsonrpc": "2.0", "id": 1, "method": "verify",
56+
"params": {"graph": {"nodes": []}}, # missing required fields
57+
}, [])
58+
assert captured[0]["error"]["code"] == -32602
59+
60+
61+
def test_widget_drops_non_dict_messages_silently():
62+
w = VisualBuilderWidget()
63+
captured: list[object] = []
64+
w.send = lambda payload, buffers=None: captured.append(payload) # type: ignore[method-assign]
65+
w._on_msg(None, "not a dict", []) # type: ignore[arg-type]
66+
assert captured == []
67+
68+
69+
def test_widget_cache_stats_expose_lru_state():
70+
w = VisualBuilderWidget()
71+
captured: list[object] = []
72+
w.send = lambda payload, buffers=None: captured.append(payload) # type: ignore[method-assign]
73+
# Dispatch the same backend.status twice; backend.status is short-
74+
# circuited before cache, so the cache stays empty — verify shape:
75+
w._on_msg(None, {"jsonrpc": "2.0", "id": 1, "method": "backend.status"}, [])
76+
stats = w.cache_stats
77+
assert "size" in stats and "capacity" in stats
78+
assert stats["capacity"] == 50
79+
80+
81+
def test_widget_schema_version_is_read_only():
82+
w = VisualBuilderWidget()
83+
try:
84+
w.schema_version = "9.9.9"
85+
except (traitlets.TraitError if (traitlets := __import__("traitlets"))
86+
else Exception):
87+
pass
88+
# Either it raised or it silently rejected; in both cases, value unchanged.
89+
assert w.schema_version == SCHEMA_VERSION

vbgui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"scripts": {
88
"dev": "vite",
99
"build": "vite build",
10+
"build:widget": "vite build -c vite.widget.config.ts",
1011
"preview": "vite preview",
1112
"typecheck": "tsc --noEmit",
1213
"lint": "eslint src --max-warnings=0",

vbgui/src/anywidget.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Anywidget ESM entry — mounts the React App into the host element.
2+
//
3+
// Anywidget API contract: this module's `default.render(model, el)`
4+
// is invoked by the kernel-side AnyWidget. The model is an
5+
// AnyModel<traitlets>; the el is the container <div> in the kernel
6+
// frontend (Jupyter, JupyterLab, VS Code).
7+
//
8+
// We sync the canvas + spec via traitlets. The kernel side owns the
9+
// authoritative state; we re-render whenever a traitlet changes.
10+
11+
import { StrictMode } from "react";
12+
import { createRoot, type Root } from "react-dom/client";
13+
import { App } from "./App";
14+
import "@xyflow/react/dist/style.css";
15+
16+
interface AnyModel {
17+
get<T = unknown>(key: string): T;
18+
set<T = unknown>(key: string, value: T): void;
19+
save_changes(): void;
20+
on(event: string, cb: () => void): void;
21+
off(event: string, cb: () => void): void;
22+
}
23+
24+
interface RenderContext {
25+
model: AnyModel;
26+
el: HTMLElement;
27+
}
28+
29+
const roots = new WeakMap<HTMLElement, Root>();
30+
31+
function render({ el }: RenderContext): () => void {
32+
let root = roots.get(el);
33+
if (!root) {
34+
root = createRoot(el);
35+
roots.set(el, root);
36+
}
37+
root.render(
38+
<StrictMode>
39+
<App />
40+
</StrictMode>,
41+
);
42+
// Cleanup function — anywidget invokes this when the widget unmounts.
43+
return () => {
44+
root?.unmount();
45+
roots.delete(el);
46+
};
47+
}
48+
49+
export default { render };

vbgui/vite.widget.config.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Library-mode build that produces a single ESM bundle consumable by
2+
// anywidget. Output goes to ../cppmega_v4/widget/static/ so the Python
3+
// package can ship it as package_data.
4+
5+
import { defineConfig } from "vite";
6+
import react from "@vitejs/plugin-react";
7+
import path from "node:path";
8+
9+
export default defineConfig({
10+
plugins: [react()],
11+
resolve: { alias: { "@": path.resolve(__dirname, "src") } },
12+
build: {
13+
outDir: path.resolve(__dirname, "../cppmega_v4/widget/static"),
14+
emptyOutDir: true,
15+
lib: {
16+
entry: path.resolve(__dirname, "src/anywidget.tsx"),
17+
formats: ["es"],
18+
fileName: () => "widget.mjs",
19+
},
20+
rollupOptions: {
21+
output: { assetFileNames: "widget.[ext]" },
22+
},
23+
cssCodeSplit: false,
24+
},
25+
});

0 commit comments

Comments
 (0)