Skip to content

Commit e2a52fb

Browse files
committed
feat(vbgui-fa): JSON-RPC 2.0 contract + FastAPI server + LRU cache
Stage F-A of the Visual Builder GUI epic (cppmega-mlx-o0k). New cppmega_v4.jsonrpc package exposes the backend stack (VBSpec / MBSpec / PSpec / Probe) over a single JSON-RPC 2.0 wire format per VisualBuilderPlan.md §5. - schema.py: Pydantic v2 models for the wire format. JsonRpcRequest/ Response/Error envelopes, all extra="forbid" for GUI-stable shape. Covers verify/suggest_sharding/suggest_adapters/build_preset_specs/ probe.run params + results, pipeline.run skeleton, event taxonomy + method registry constants, ErrorCode reservations (-32700..-32099). - cache.py: LRU(50) keyed on SHA-256 of canonical_json(payload) with layout fields (x/y/position) stripped before hashing — drag ops never bust the cache. Thread-safe via Lock; hits/misses tracked. - methods.py: pure-Python handlers wrapping verify_and_estimate, suggest_sharding, suggest_adapters, build_preset_specs and contract_probe. Each handler accepts an optional cache and emits a Pydantic result model. - dispatcher.py: transport-agnostic router. Folds ValidationError / ValueError / Exception into JSON-RPC error envelopes per VBPlan §5.3; never raises. - server.py: FastAPI app with POST /rpc and WS /ws transports plus /health, /schema/methods, /cache/stats, /cache/clear. WS owns a 1 Hz backend.status heartbeat task scoped to the connection. Tests: 70 across 5 files (schema, cache, methods, dispatcher, server) — Pydantic round-trips, LRU semantics, golden verify result, sub-100ms latency, distributed-memory branch with sharding, cache invariance to layout drift, HTTP + WebSocket transports. Perf measured: verify cold 0.22ms / warm 0.025ms (target <100ms). Full v4 regression: 2008 passed / 5 skipped / 15 xfailed / 0 failed. pyproject.toml: new optional-deps group "gui" pulls in pydantic / fastapi / uvicorn / httpx / jsonschema. Closes cppmega-mlx-o0k.1.
1 parent e9c8c77 commit e2a52fb

9 files changed

Lines changed: 1212 additions & 6 deletions

File tree

cppmega_v4/jsonrpc/__init__.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""JSON-RPC 2.0 contract + Python server for the Visual Builder GUI.
2+
3+
See ``VisualBuilderPlan.md`` §3.3 and §5 for the design.
4+
5+
Stage F-A surface (this commit):
6+
- schema: Pydantic v2 models for all request/response payloads
7+
- cache: LRU(50) keyed on canonical sha256(spec without layout)
8+
- methods: pure-Python handlers (verify / suggest_sharding /
9+
suggest_adapters / build_preset_specs / probe.run)
10+
- dispatcher: transport-agnostic JSON-RPC 2.0 router
11+
- server: FastAPI app exposing /rpc + /ws + heartbeat
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from cppmega_v4.jsonrpc.cache import LRUCache, canonical_json, canonical_sha256
17+
from cppmega_v4.jsonrpc.dispatcher import dispatch
18+
from cppmega_v4.jsonrpc.schema import (
19+
EVENT_TAXONOMY,
20+
METHOD_REGISTRY,
21+
SCHEMA_VERSION,
22+
BuildPresetSpecsParams,
23+
BuildPresetSpecsResult,
24+
ErrorCode,
25+
JsonRpcError,
26+
JsonRpcRequest,
27+
JsonRpcResponse,
28+
ProbeRunParams,
29+
ProbeRunResult,
30+
SuggestAdaptersParams,
31+
SuggestAdaptersResult,
32+
SuggestShardingParams,
33+
SuggestShardingResult,
34+
VerifyParams,
35+
VerifyResult,
36+
)
37+
from cppmega_v4.jsonrpc.server import create_app, serve
38+
39+
__all__ = [
40+
"BuildPresetSpecsParams",
41+
"BuildPresetSpecsResult",
42+
"EVENT_TAXONOMY",
43+
"ErrorCode",
44+
"JsonRpcError",
45+
"JsonRpcRequest",
46+
"JsonRpcResponse",
47+
"LRUCache",
48+
"METHOD_REGISTRY",
49+
"ProbeRunParams",
50+
"ProbeRunResult",
51+
"SCHEMA_VERSION",
52+
"SuggestAdaptersParams",
53+
"SuggestAdaptersResult",
54+
"SuggestShardingParams",
55+
"SuggestShardingResult",
56+
"VerifyParams",
57+
"VerifyResult",
58+
"canonical_json",
59+
"canonical_sha256",
60+
"create_app",
61+
"dispatch",
62+
"serve",
63+
]

cppmega_v4/jsonrpc/dispatcher.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""Transport-agnostic JSON-RPC 2.0 dispatcher.
2+
3+
One :func:`dispatch` entry point handles both the HTTP and WebSocket
4+
transports. Validates the envelope, routes the method, coerces params
5+
through the matching Pydantic model, calls the handler, packs the
6+
result back into a JsonRpcResponse, and folds exceptions into the
7+
error envelope per VBPlan §5.3.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import logging
13+
from typing import Any, Callable, Mapping
14+
15+
from pydantic import BaseModel, ValidationError
16+
17+
from cppmega_v4.jsonrpc.cache import LRUCache
18+
from cppmega_v4.jsonrpc.methods import (
19+
build_preset_specs,
20+
probe_run,
21+
suggest_adapters,
22+
suggest_sharding,
23+
verify,
24+
)
25+
from cppmega_v4.jsonrpc.schema import (
26+
BuildPresetSpecsParams,
27+
ErrorCode,
28+
JsonRpcError,
29+
JsonRpcRequest,
30+
JsonRpcResponse,
31+
ProbeRunParams,
32+
SuggestAdaptersParams,
33+
SuggestShardingParams,
34+
VerifyParams,
35+
)
36+
37+
_log = logging.getLogger(__name__)
38+
39+
40+
_Handler = Callable[[BaseModel, LRUCache | None], BaseModel]
41+
42+
43+
_ROUTES: Mapping[str, tuple[type[BaseModel], _Handler]] = {
44+
"verify": (
45+
VerifyParams,
46+
lambda p, c: verify(p, cache=c),
47+
),
48+
"suggest_sharding": (
49+
SuggestShardingParams,
50+
lambda p, c: suggest_sharding(p, cache=c),
51+
),
52+
"suggest_adapters": (
53+
SuggestAdaptersParams,
54+
lambda p, c: suggest_adapters(p, cache=c),
55+
),
56+
"build_preset_specs": (
57+
BuildPresetSpecsParams,
58+
lambda p, c: build_preset_specs(p, cache=c),
59+
),
60+
"probe.run": (
61+
ProbeRunParams,
62+
lambda p, c: probe_run(p, cache=c),
63+
),
64+
}
65+
66+
67+
def dispatch(
68+
payload: Mapping[str, Any] | JsonRpcRequest,
69+
*,
70+
cache: LRUCache | None = None,
71+
) -> JsonRpcResponse:
72+
"""Route one JSON-RPC envelope to its handler.
73+
74+
Never raises; all errors are folded into ``JsonRpcResponse.error``.
75+
"""
76+
try:
77+
request = (
78+
payload if isinstance(payload, JsonRpcRequest)
79+
else JsonRpcRequest.model_validate(payload)
80+
)
81+
except ValidationError as exc:
82+
return _error_response(
83+
request_id=_safe_id(payload),
84+
code=ErrorCode.INVALID_REQUEST,
85+
message="Invalid JSON-RPC envelope",
86+
data={"errors": exc.errors()},
87+
)
88+
except Exception as exc:
89+
return _error_response(
90+
request_id=_safe_id(payload),
91+
code=ErrorCode.PARSE_ERROR,
92+
message="Parse error",
93+
data={"type": type(exc).__name__, "detail": str(exc)},
94+
)
95+
96+
if request.method == "backend.status":
97+
return JsonRpcResponse(id=request.id, result={"status": "ok"})
98+
99+
route = _ROUTES.get(request.method)
100+
if route is None:
101+
return _error_response(
102+
request_id=request.id,
103+
code=ErrorCode.METHOD_NOT_FOUND,
104+
message=f"Method {request.method!r} not found",
105+
data={"available": sorted(_ROUTES)},
106+
)
107+
108+
params_model, handler = route
109+
try:
110+
params = params_model.model_validate(request.params)
111+
except ValidationError as exc:
112+
return _error_response(
113+
request_id=request.id,
114+
code=ErrorCode.INVALID_PARAMS,
115+
message="Invalid params",
116+
data={"type": "ValidationError", "errors": exc.errors()},
117+
)
118+
119+
try:
120+
result = handler(params, cache)
121+
except ValueError as exc:
122+
return _error_response(
123+
request_id=request.id,
124+
code=ErrorCode.INVALID_PARAMS,
125+
message="Invalid params",
126+
data={"type": type(exc).__name__, "detail": str(exc)},
127+
)
128+
except Exception as exc:
129+
_log.exception("dispatch handler %s raised", request.method)
130+
return _error_response(
131+
request_id=request.id,
132+
code=ErrorCode.INTERNAL_ERROR,
133+
message="Internal error",
134+
data={"type": type(exc).__name__, "detail": str(exc)},
135+
)
136+
137+
return JsonRpcResponse(id=request.id, result=result.model_dump(mode="json"))
138+
139+
140+
def _safe_id(payload: Any) -> str | int | None:
141+
try:
142+
if isinstance(payload, Mapping):
143+
v = payload.get("id")
144+
if isinstance(v, (str, int)):
145+
return v
146+
except Exception:
147+
pass
148+
return None
149+
150+
151+
def _error_response(
152+
*, request_id: str | int | None, code: int, message: str,
153+
data: Mapping[str, Any] | None = None,
154+
) -> JsonRpcResponse:
155+
return JsonRpcResponse(
156+
id=request_id,
157+
error=JsonRpcError(code=code, message=message, data=dict(data) if data else None),
158+
)

cppmega_v4/jsonrpc/methods.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,29 @@
3232
AxisAssignment,
3333
ParallelismKind,
3434
ShardingSpec,
35-
TOPOLOGY_BUILTINS,
35+
a100_8x,
36+
b100_8x,
37+
gb10_quarter,
38+
h100_8x,
39+
h200_8x,
40+
m3_ultra_solo,
3641
suggest_sharding as _suggest_sharding,
42+
tpu_v5p_4,
43+
tpu_v6e_8,
3744
verify_distributed_plan,
3845
)
46+
47+
48+
_TOPOLOGY_FACTORIES = {
49+
"h100_8x": h100_8x,
50+
"h200_8x": h200_8x,
51+
"a100_8x": a100_8x,
52+
"b100_8x": b100_8x,
53+
"gb10_quarter": gb10_quarter,
54+
"tpu_v5p_4": tpu_v5p_4,
55+
"tpu_v6e_8": tpu_v6e_8,
56+
"m3_ultra_solo": m3_ultra_solo,
57+
}
3958
from cppmega_v4.probe import contract_probe as _contract_probe
4059
from cppmega_v4.probe import to_dict as _probe_to_dict
4160
from cppmega_v4.spec import (
@@ -117,11 +136,11 @@ def _make_optim(payload: OptimSpecPayload) -> OptimSpec:
117136

118137

119138
def _make_topology(payload: TopologyPayload):
120-
factory = TOPOLOGY_BUILTINS.get(payload.factory)
139+
factory = _TOPOLOGY_FACTORIES.get(payload.factory)
121140
if factory is None:
122141
raise ValueError(
123142
f"unknown topology factory {payload.factory!r}; "
124-
f"available: {sorted(TOPOLOGY_BUILTINS)}"
143+
f"available: {sorted(_TOPOLOGY_FACTORIES)}"
125144
)
126145
return factory(**payload.kwargs)
127146

@@ -249,9 +268,11 @@ def verify(params: VerifyParams, *, cache: LRUCache | None = None) -> VerifyResu
249268
)
250269
gotcha_payloads = [
251270
GotchaPayload(
252-
id=g.id, severity=g.severity.value if hasattr(g.severity, "value")
253-
else str(g.severity),
254-
message=g.message, reference=getattr(g, "reference", None),
271+
id=g.gotcha_id,
272+
severity=g.severity.value if hasattr(g.severity, "value")
273+
else str(g.severity),
274+
message=g.message,
275+
reference=getattr(g, "reference", None),
255276
)
256277
for g in dverify.gotchas
257278
]

0 commit comments

Comments
 (0)