Skip to content

Commit af1f62b

Browse files
committed
feat(e-audit-02): FlowCanvas isValidConnection from compatible_edges RPC
Closes cppmega-mlx-ofd2 (E-AUDIT-02): FlowCanvas accepted an isValidConnection prop but App.tsx never threaded one — incompatible brick edges only failed at server-side verify. - cppmega_v4/spec/shape_contract.py: new compatible_edges() helper enumerates (src_kind, dst_kind) pairs the contract layer considers well-typed (src.outputs non-empty AND dst.inputs non-empty; opaque shapes universally compatible). Exported in __all__. - cppmega_v4/jsonrpc/catalog_methods.py: catalog.list_options grows a 'compatible_edges' branch that returns one option per pair with name='src->dst', paper_ref=src, summary=dst. LRU-cached. - vbgui/src/hooks/useCompatibleEdges.ts: fetches the pair set once on mount; makeIsValidConnection(pairs, brickKindOf) returns a React-Flow-compatible predicate (allows everything when fetch fails — server-side verify is the fallback). - vbgui/src/App.tsx: imports the hook + isValidConnection callback; threads it into <FlowCanvas isValidConnection={...} />. Already declared in FlowCanvasProps. - tests/v4/test_compatible_edges_catalog.py: helper round-trip, attention→mlp pair present, RPC envelope shape. - vbgui/tests/useCompatibleEdges.test.ts: 4 vitest cases covering empty-set fallback, reject path, accept path, unknown-id reject. - vbgui/e2e/scenarios/90_compatible_edges.spec.ts: real RPC call returns non-empty options; preset load + hook fetch fires no page errors.
1 parent a9c10a6 commit af1f62b

7 files changed

Lines changed: 254 additions & 1 deletion

File tree

cppmega_v4/jsonrpc/catalog_methods.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,34 @@ def catalog_list_options(
6363
*,
6464
cache: LRUCache | None = None,
6565
) -> CatalogListOptionsResult:
66-
"""Return compact summaries for every entry in ``params.category``."""
66+
"""Return compact summaries for every entry in ``params.category``.
67+
68+
Special category ``compatible_edges`` (V7-E-AUDIT-02): returns one
69+
option per (src_kind, dst_kind) pair where the shape contracts
70+
indicate the edge is well-typed. Each option's ``name`` is
71+
``"src_kind->dst_kind"``, ``summary`` is ``dst_kind``, and
72+
``paper_ref`` is ``src_kind`` so the UI can split with a single
73+
str.split('->').
74+
"""
6775
cache_key = ("catalog.list_options", params.category)
6876
if cache is not None:
6977
hit = cache.get(cache_key)
7078
if hit is not None:
7179
return hit # type: ignore[return-value]
7280

81+
if params.category == "compatible_edges":
82+
from cppmega_v4.spec.shape_contract import compatible_edges
83+
pairs = compatible_edges()
84+
result = CatalogListOptionsResult(options=[
85+
CatalogOptionSummary(
86+
name=f"{src}->{dst}", summary=dst, paper_ref=src,
87+
)
88+
for src, dst in pairs
89+
])
90+
if cache is not None:
91+
cache.set(cache_key, result)
92+
return result
93+
7394
entries = list_options(params.category)
7495
result = CatalogListOptionsResult(options=[
7596
CatalogOptionSummary(

cppmega_v4/spec/shape_contract.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,10 +547,39 @@ def registered_kinds() -> tuple[str, ...]:
547547
return tuple(sorted(_CONTRACTS.keys()))
548548

549549

550+
def compatible_edges() -> tuple[tuple[str, str], ...]:
551+
"""V7-E-AUDIT-02: enumerate (src_kind, dst_kind) pairs that the
552+
shape-contract layer considers well-typed.
553+
554+
Rule: edge is compatible iff src declares at least one output AND
555+
dst declares at least one input. cppmega bricks use a canonical
556+
``x → y`` channel naming convention; every transformer-block brick
557+
can feed every other at the contract layer. Numerical compatibility
558+
(dim_env, head_dim, ...) is enforced separately by
559+
``verify_and_estimate``; this helper answers the UI's coarse
560+
'can I drop an edge here' question to reject obvious mis-wiring
561+
(e.g. dropping an edge into a brick with no inputs).
562+
Opaque-shape bricks are universally compatible.
563+
"""
564+
pairs: list[tuple[str, str]] = []
565+
kinds = sorted(_CONTRACTS.keys())
566+
for src in kinds:
567+
src_c = _CONTRACTS[src]
568+
for dst in kinds:
569+
dst_c = _CONTRACTS[dst]
570+
if src_c.opaque_shape or dst_c.opaque_shape:
571+
pairs.append((src, dst))
572+
continue
573+
if src_c.outputs and dst_c.inputs:
574+
pairs.append((src, dst))
575+
return tuple(pairs)
576+
577+
550578
__all__ = [
551579
"BrickShapeContract",
552580
"ResolveError",
553581
"ShapeExpr",
582+
"compatible_edges",
554583
"contract_for",
555584
"register_contract",
556585
"registered_kinds",
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""E-AUDIT-02 backend: catalog.list_options('compatible_edges')."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
from fastapi.testclient import TestClient
7+
8+
from cppmega_v4.jsonrpc import create_app
9+
from cppmega_v4.spec.shape_contract import (
10+
compatible_edges, registered_kinds,
11+
)
12+
13+
14+
@pytest.fixture
15+
def client():
16+
return TestClient(create_app(cache_capacity=2))
17+
18+
19+
def test_compatible_edges_helper_nonempty_and_well_typed():
20+
pairs = compatible_edges()
21+
assert len(pairs) > 0
22+
kinds = set(registered_kinds())
23+
for src, dst in pairs:
24+
assert src in kinds
25+
assert dst in kinds
26+
27+
28+
def test_compatible_edges_includes_attention_to_mlp():
29+
"""attention.outputs and mlp.inputs share the canonical hidden
30+
state channel — the most common edge in any transformer block."""
31+
pairs = set(compatible_edges())
32+
assert ("attention", "mlp") in pairs
33+
34+
35+
def test_catalog_endpoint_returns_pair_encoded_options(client):
36+
payload = {"jsonrpc": "2.0", "id": "e1",
37+
"method": "catalog.list_options",
38+
"params": {"category": "compatible_edges"}}
39+
r = client.post("/rpc", json=payload)
40+
assert r.status_code == 200
41+
body = r.json()
42+
assert "error" not in body, body
43+
opts = body["result"]["options"]
44+
assert len(opts) > 0
45+
for o in opts[:5]:
46+
assert "->" in o["name"]
47+
src, dst = o["name"].split("->")
48+
assert o["paper_ref"] == src
49+
assert o["summary"] == dst
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// E-AUDIT-02: FlowCanvas isValidConnection wired via
2+
// catalog.list_options('compatible_edges'). After preset load, the
3+
// edge-compatibility hook has fetched the pair set; FlowCanvas
4+
// passes it to React Flow's isValidConnection prop. We assert that
5+
// the catalog endpoint returns at least one pair and that no
6+
// runtime errors leak into the page (incompatible-drop UI behavior
7+
// is covered by the vitest unit test).
8+
9+
import { test, expect } from "@playwright/test";
10+
import { gotoApp, selectPreset } from "../fixtures";
11+
12+
test("E-AUDIT-02: compatible_edges populated after preset load",
13+
async ({ page }) => {
14+
test.setTimeout(30_000);
15+
const errors: string[] = [];
16+
page.on("pageerror", (e) => errors.push(String(e)));
17+
await gotoApp(page);
18+
19+
const catalogResp = await page.evaluate(async () => {
20+
const r = await fetch("http://127.0.0.1:8767/rpc", {
21+
method: "POST",
22+
headers: { "content-type": "application/json" },
23+
body: JSON.stringify({
24+
jsonrpc: "2.0", id: "c1",
25+
method: "catalog.list_options",
26+
params: { category: "compatible_edges" },
27+
}),
28+
});
29+
return await r.json();
30+
});
31+
expect(catalogResp.error).toBeUndefined();
32+
const opts = catalogResp.result.options as Array<{
33+
name: string; summary: string; paper_ref: string;
34+
}>;
35+
expect(opts.length).toBeGreaterThan(0);
36+
expect(opts[0].name).toContain("->");
37+
expect(opts[0].paper_ref).toBe(opts[0].name.split("->")[0]);
38+
39+
await selectPreset(page, "llama3_8b");
40+
// No page errors after preset load + hook fetch.
41+
expect(errors).toEqual([]);
42+
});

vbgui/src/App.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import { DataInspector } from "@/components/DataInspector";
2828
import { BrickContextPanel } from "@/components/BrickContextPanel";
2929
import { LiveTrainPanel } from "@/components/LiveTrainPanel";
3030
import { useLiveTrainStream } from "@/hooks/useLiveTrainStream";
31+
import { useCompatibleEdges, makeIsValidConnection }
32+
from "@/hooks/useCompatibleEdges";
3133
import { useVerifyStream, computeSpecHash } from "@/hooks/useVerifyStream";
3234

3335
import { useRpc } from "@/hooks/useRpc";
@@ -197,6 +199,22 @@ export function App(): JSX.Element {
197199
// build_id change so a restart with new presets propagates to UI.
198200
const PRESETS = usePresets(rpc, backendBuildId);
199201

202+
// V7-E-AUDIT-02: compatible (src_kind, dst_kind) pairs from
203+
// catalog.list_options('compatible_edges'). FlowCanvas consults the
204+
// resulting predicate to reject incompatible drags client-side.
205+
const compatibleEdgePairs = useCompatibleEdges(rpc);
206+
const isValidConnection = useCallback(
207+
makeIsValidConnection(
208+
compatibleEdgePairs,
209+
(nodeId: string) => {
210+
const n = nodes.find((nn) => nn.id === nodeId);
211+
const data = n?.data as { kind?: string } | undefined;
212+
return data?.kind ?? null;
213+
},
214+
),
215+
[compatibleEdgePairs, nodes],
216+
);
217+
200218
// V7-F56b / V7-F53: editable dim_env (was a constant MINI_DIM_ENV).
201219
// Threaded into the verify trigger so changes to H/nh/head_dim
202220
// re-run verify and surface the F56b mismatch warning.
@@ -1215,6 +1233,7 @@ export function App(): JSX.Element {
12151233
onConnect={handleConnect}
12161234
onDropBrick={handleDropBrick}
12171235
onNodeClick={setSelectedBrickId}
1236+
isValidConnection={isValidConnection}
12181237
/>
12191238
</div>
12201239
{selectedBrickId && (() => {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// V7-E-AUDIT-02: client-side cache of compatible (src_kind, dst_kind)
2+
// pairs fetched once from catalog.list_options('compatible_edges').
3+
// FlowCanvas's isValidConnection callback consults this set to reject
4+
// incompatible drags client-side (no server round-trip).
5+
6+
import { useEffect, useState } from "react";
7+
import type { RpcClient } from "@/lib/rpc";
8+
9+
export function useCompatibleEdges(rpc: RpcClient): Set<string> {
10+
const [pairs, setPairs] = useState<Set<string>>(new Set());
11+
12+
useEffect(() => {
13+
let cancelled = false;
14+
(async () => {
15+
try {
16+
const r = await rpc.call<{
17+
options: Array<{ name: string; summary: string;
18+
paper_ref: string }>
19+
}>("catalog.list_options",
20+
{ category: "compatible_edges" });
21+
if (cancelled) return;
22+
const s = new Set<string>();
23+
for (const opt of r.options) {
24+
// name is "src->dst"; paper_ref=src, summary=dst.
25+
s.add(`${opt.paper_ref}${opt.summary}`);
26+
}
27+
setPairs(s);
28+
} catch {
29+
// Empty set => isValidConnection conservatively allows
30+
// everything, falling back to server-side verify.
31+
}
32+
})();
33+
return () => { cancelled = true; };
34+
}, [rpc]);
35+
36+
return pairs;
37+
}
38+
39+
/** isValidConnection predicate built from a compatible-edges set.
40+
* Returns true when the set is empty (load-failure fallback) or when
41+
* the (src, dst) pair is in the set. */
42+
export function makeIsValidConnection(
43+
pairs: Set<string>,
44+
brickKindOf: (nodeId: string) => string | null,
45+
): (conn: { source: string | null; target: string | null }) => boolean {
46+
return (conn) => {
47+
if (pairs.size === 0) return true; // server fallback
48+
const src = conn.source ? brickKindOf(conn.source) : null;
49+
const dst = conn.target ? brickKindOf(conn.target) : null;
50+
if (!src || !dst) return false;
51+
return pairs.has(`${src}${dst}`);
52+
};
53+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// E-AUDIT-02 vitest: isValidConnection predicate semantics.
2+
3+
import { describe, expect, it } from "vitest";
4+
import { makeIsValidConnection } from "../src/hooks/useCompatibleEdges";
5+
6+
7+
describe("makeIsValidConnection", () => {
8+
it("allows everything when pair set is empty (server fallback)", () => {
9+
const isValid = makeIsValidConnection(
10+
new Set(),
11+
() => "mlp",
12+
);
13+
expect(isValid({ source: "n1", target: "n2" })).toBe(true);
14+
});
15+
16+
it("rejects when pair is not in the compatible set", () => {
17+
const isValid = makeIsValidConnection(
18+
new Set(["attention→mlp"]),
19+
(id) => (id === "src" ? "attention" : "tokenizer"),
20+
);
21+
expect(isValid({ source: "src", target: "dst" })).toBe(false);
22+
});
23+
24+
it("accepts when pair is in the compatible set", () => {
25+
const isValid = makeIsValidConnection(
26+
new Set(["attention→mlp"]),
27+
(id) => (id === "src" ? "attention" : "mlp"),
28+
);
29+
expect(isValid({ source: "src", target: "dst" })).toBe(true);
30+
});
31+
32+
it("rejects when source or target is unknown", () => {
33+
const isValid = makeIsValidConnection(
34+
new Set(["attention→mlp"]),
35+
() => null,
36+
);
37+
expect(isValid({ source: "x", target: "y" })).toBe(false);
38+
expect(isValid({ source: null, target: "y" })).toBe(false);
39+
});
40+
});

0 commit comments

Comments
 (0)