Skip to content

Commit 6adaa9b

Browse files
committed
feat(v4/spec): VBSpec Stage B — shape resolver + ResolvedBrickGraph
Adds cppmega_v4.spec.resolver, the shape-matching pass GUI calls on every graph mutation (VisualBuilderSpec.md §3.2, §6 B). - resolve_shapes(graph, dim_env, *, strict, available_side_channels) Walks every BrickGraph edge, resolves producer.outputs / consumer.inputs under dim_env via Stage A contracts, classifies mismatches into ShapeDiagnostic records with severity (ERROR/WARNING/INFO), location, suggested fix. Side-channel needs ('doc_ids', 'token_ids') that the caller didn't promise to supply emit WARNINGS. - Two operating modes: strict=True → first ERROR raises ResolveError (CI / sanity) strict=False → diagnostics list populated, GUI consumes (real-time) - Opaque-brick (opaque_shape=True) boundaries downgrade rank-matching declared-shape mismatches to WARNING — the data-dependent sparse family declares "B/S/H preserved, trust me" and resolver honours that. - Heuristic suggested_fix surfaces the right adapter hint per pattern: last-dim mismatch → Linear bridge; rank4↔rank3 with matching B/S → merge_heads / split_heads adapter (full library lands in Stage C). - ResolvedBrickGraph / ResolvedEdge / ShapeDiagnostic / DiagnosticSeverity re-exported from cppmega_v4.spec. Tests (tests/v4/test_vbspec_stage_b.py, 24 cases): - empty / size-1 graph; side-channel warnings appear/disappear based on available_side_channels - clean resolves: Qwen3-Next + Ling 2.6 chains end-to-end - synthetic mismatch contracts: strict raises, lenient collects all, multiple errors per graph - suggested-fix heuristics (Linear-bridge, merge_heads) - opaque-brick boundary → WARNING not ERROR - missing dim_env entry → ResolveError with useful message - system: every preset (12) resolves cleanly under sensible env Full v4 regression: 671 passed / 5 skipped / 0 failed.
1 parent 2e14473 commit 6adaa9b

3 files changed

Lines changed: 715 additions & 0 deletions

File tree

cppmega_v4/spec/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99

1010
from __future__ import annotations
1111

12+
from cppmega_v4.spec.resolver import (
13+
DiagnosticSeverity,
14+
ResolvedBrickGraph,
15+
ResolvedEdge,
16+
ShapeDiagnostic,
17+
resolve_shapes,
18+
)
1219
from cppmega_v4.spec.shape_contract import (
1320
BrickShapeContract,
1421
ResolveError,
@@ -20,9 +27,14 @@
2027

2128
__all__ = [
2229
"BrickShapeContract",
30+
"DiagnosticSeverity",
2331
"ResolveError",
32+
"ResolvedBrickGraph",
33+
"ResolvedEdge",
34+
"ShapeDiagnostic",
2435
"ShapeExpr",
2536
"contract_for",
2637
"register_contract",
2738
"registered_kinds",
39+
"resolve_shapes",
2840
]

cppmega_v4/spec/resolver.py

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
"""Shape resolver — walks a BrickGraph and assigns concrete shapes to
2+
every edge, surfacing mismatches as diagnostics (lenient mode) or
3+
raising (strict mode).
4+
5+
This is the layer the GUI calls on every graph mutation: drop a brick,
6+
draw an edge — resolver runs in <1 ms and tells you whether the edge is
7+
green (shapes match), yellow (need an auto-adapter — see Stage C) or
8+
red (incompatible at this dim_env).
9+
10+
Stage B scope (this commit):
11+
- Detect shape-match / shape-mismatch on every edge under a given
12+
``dim_env``.
13+
- Classify mismatches into structured :class:`ShapeDiagnostic`
14+
records with severity, location, expected vs actual shape.
15+
- Honour ``opaque_shape=True`` contracts (skip per-byte audit;
16+
require only that the side-channel ``B/S/H`` invariant holds).
17+
- Produce :class:`ResolvedBrickGraph` — the BrickGraph plus
18+
per-edge resolved shapes plus the diagnostics list.
19+
- ``strict=True`` → raises :class:`ResolveError` on the first error
20+
diagnostic. ``strict=False`` → returns the graph with diagnostics
21+
list populated (GUI consumes this).
22+
23+
Stage C will plug in the adapter library — when a mismatch is "almost
24+
matched" (same dim count, only layout differs), the resolver will
25+
auto-insert a reshape / permute brick instead of emitting a diagnostic.
26+
"""
27+
28+
from __future__ import annotations
29+
30+
from collections.abc import Mapping
31+
from dataclasses import dataclass, field
32+
from enum import Enum
33+
34+
from cppmega_v4.fusion.brick_graph import BrickGraph, BrickNode
35+
from cppmega_v4.spec.shape_contract import (
36+
BrickShapeContract,
37+
ResolveError,
38+
contract_for,
39+
)
40+
41+
42+
class DiagnosticSeverity(str, Enum):
43+
INFO = "info"
44+
WARNING = "warning"
45+
ERROR = "error"
46+
47+
48+
@dataclass(frozen=True)
49+
class ShapeDiagnostic:
50+
"""One issue surfaced by :func:`resolve_shapes`.
51+
52+
Fields:
53+
severity: INFO | WARNING | ERROR. GUI colour-codes accordingly.
54+
producer / consumer: brick names. None when the diagnostic refers
55+
to a single node (e.g. missing side-channel input).
56+
message: human-readable explanation. Includes the resolved shape
57+
tuples on both sides so the user sees the actual numbers, not
58+
just the symbolic mismatch.
59+
suggested_fix: short hint about which adapter / param change could
60+
resolve it. Stage C will fill this in for concrete patterns.
61+
"""
62+
63+
severity: DiagnosticSeverity
64+
message: str
65+
producer: str | None = None
66+
consumer: str | None = None
67+
suggested_fix: str | None = None
68+
69+
70+
@dataclass(frozen=True)
71+
class ResolvedEdge:
72+
"""One edge in a ResolvedBrickGraph.
73+
74+
For matched edges: ``shape`` is the concrete tuple both sides agree
75+
on, ``producer_shape == consumer_shape == shape``.
76+
77+
For mismatched edges (lenient mode): ``producer_shape`` and
78+
``consumer_shape`` differ; ``shape`` falls back to producer_shape
79+
so downstream consumers can still continue.
80+
"""
81+
82+
producer: str
83+
consumer: str
84+
producer_shape: tuple[int, ...]
85+
consumer_shape: tuple[int, ...]
86+
matched: bool
87+
88+
@property
89+
def shape(self) -> tuple[int, ...]:
90+
return self.producer_shape
91+
92+
93+
@dataclass(frozen=True)
94+
class ResolvedBrickGraph:
95+
"""A BrickGraph with shape information resolved per edge."""
96+
97+
original: BrickGraph
98+
dim_env: Mapping[str, int]
99+
edges: tuple[ResolvedEdge, ...]
100+
diagnostics: tuple[ShapeDiagnostic, ...] = field(default_factory=tuple)
101+
102+
@property
103+
def has_errors(self) -> bool:
104+
return any(
105+
d.severity is DiagnosticSeverity.ERROR for d in self.diagnostics
106+
)
107+
108+
@property
109+
def errors(self) -> tuple[ShapeDiagnostic, ...]:
110+
return tuple(
111+
d for d in self.diagnostics
112+
if d.severity is DiagnosticSeverity.ERROR
113+
)
114+
115+
@property
116+
def warnings(self) -> tuple[ShapeDiagnostic, ...]:
117+
return tuple(
118+
d for d in self.diagnostics
119+
if d.severity is DiagnosticSeverity.WARNING
120+
)
121+
122+
def edge(self, producer: str, consumer: str) -> ResolvedEdge:
123+
for e in self.edges:
124+
if e.producer == producer and e.consumer == consumer:
125+
return e
126+
raise KeyError(
127+
f"no resolved edge ({producer!r} -> {consumer!r}) "
128+
f"in graph with {len(self.edges)} edges"
129+
)
130+
131+
132+
# ---------------------------------------------------------------------------
133+
# Resolution
134+
# ---------------------------------------------------------------------------
135+
136+
137+
_DEFAULT_INPUT_PORT = "x"
138+
_DEFAULT_OUTPUT_PORT = "y"
139+
140+
141+
def _resolve_node_output(
142+
node: BrickNode,
143+
contract: BrickShapeContract,
144+
dim_env: Mapping[str, int],
145+
) -> tuple[int, ...]:
146+
return contract.outputs[_DEFAULT_OUTPUT_PORT].resolve(dim_env)
147+
148+
149+
def _resolve_node_input(
150+
node: BrickNode,
151+
contract: BrickShapeContract,
152+
dim_env: Mapping[str, int],
153+
) -> tuple[int, ...]:
154+
return contract.inputs[_DEFAULT_INPUT_PORT].resolve(dim_env)
155+
156+
157+
def _check_side_channels(
158+
node: BrickNode,
159+
contract: BrickShapeContract,
160+
available_channels: frozenset[str],
161+
) -> list[ShapeDiagnostic]:
162+
diags: list[ShapeDiagnostic] = []
163+
missing = contract.needs - available_channels
164+
for ch in sorted(missing):
165+
diags.append(
166+
ShapeDiagnostic(
167+
severity=DiagnosticSeverity.WARNING,
168+
message=(
169+
f"brick {node.name!r} (kind={node.kind!r}) needs "
170+
f"side-channel {ch!r}; caller must supply it"
171+
),
172+
producer=None,
173+
consumer=node.name,
174+
suggested_fix=f"supply {ch!r} via the model's forward signature",
175+
)
176+
)
177+
return diags
178+
179+
180+
def resolve_shapes(
181+
graph: BrickGraph,
182+
dim_env: Mapping[str, int],
183+
*,
184+
strict: bool = True,
185+
available_side_channels: frozenset[str] = frozenset(),
186+
) -> ResolvedBrickGraph:
187+
"""Resolve every edge in ``graph`` under ``dim_env``.
188+
189+
Args:
190+
graph: the BrickGraph to verify.
191+
dim_env: concrete int values for every named dim the contracts
192+
reference. Use :func:`cppmega_v4.spec.suggest_dim_env` (Stage E)
193+
for a sensible default, or build one from a preset.
194+
strict: when True (default), the first ERROR diagnostic raises
195+
:class:`ResolveError`. When False, all diagnostics are
196+
collected and returned in the ResolvedBrickGraph.
197+
available_side_channels: names of side-channel inputs (``doc_ids``,
198+
``token_ids``, ``kv_cache``) the caller will provide at forward
199+
time. Bricks that ``need`` channels not in this set get a
200+
WARNING (never ERROR — the caller might still supply them).
201+
202+
Returns: ResolvedBrickGraph with one ResolvedEdge per graph edge
203+
and (in lenient mode) any diagnostics encountered.
204+
"""
205+
diagnostics: list[ShapeDiagnostic] = []
206+
resolved_edges: list[ResolvedEdge] = []
207+
208+
# Pre-resolve every node's input and output shapes once, surfacing
209+
# contract-lookup failures and ResolveErrors as ERROR diagnostics.
210+
node_in: dict[str, tuple[int, ...]] = {}
211+
node_out: dict[str, tuple[int, ...]] = {}
212+
node_contracts: dict[str, BrickShapeContract] = {}
213+
214+
for node in graph.nodes:
215+
try:
216+
contract = contract_for(node.kind)
217+
except KeyError as exc:
218+
d = ShapeDiagnostic(
219+
severity=DiagnosticSeverity.ERROR,
220+
message=str(exc),
221+
producer=None,
222+
consumer=node.name,
223+
suggested_fix=(
224+
"register a BrickShapeContract for this kind in "
225+
"cppmega_v4/spec/shape_contract.py"
226+
),
227+
)
228+
diagnostics.append(d)
229+
if strict:
230+
raise ResolveError(d.message) from exc
231+
continue
232+
node_contracts[node.name] = contract
233+
try:
234+
node_in[node.name] = _resolve_node_input(node, contract, dim_env)
235+
node_out[node.name] = _resolve_node_output(node, contract, dim_env)
236+
except ResolveError as exc:
237+
d = ShapeDiagnostic(
238+
severity=DiagnosticSeverity.ERROR,
239+
message=(
240+
f"failed to resolve shape for brick {node.name!r} "
241+
f"(kind={node.kind!r}): {exc}"
242+
),
243+
producer=None,
244+
consumer=node.name,
245+
suggested_fix="add the missing dim to dim_env",
246+
)
247+
diagnostics.append(d)
248+
if strict:
249+
raise
250+
continue
251+
diagnostics.extend(
252+
_check_side_channels(node, contract, available_side_channels)
253+
)
254+
255+
# Now walk edges, comparing producer.out with consumer.in.
256+
for producer_name, consumer_name in graph.edges:
257+
if producer_name not in node_out or consumer_name not in node_in:
258+
# We already emitted an ERROR for the failing node — skip
259+
# the edge to avoid cascading noise.
260+
continue
261+
p_shape = node_out[producer_name]
262+
c_shape = node_in[consumer_name]
263+
264+
p_contract = node_contracts[producer_name]
265+
c_contract = node_contracts[consumer_name]
266+
opaque_either_side = (
267+
p_contract.opaque_shape or c_contract.opaque_shape
268+
)
269+
270+
matched = p_shape == c_shape
271+
resolved_edges.append(
272+
ResolvedEdge(
273+
producer=producer_name,
274+
consumer=consumer_name,
275+
producer_shape=p_shape,
276+
consumer_shape=c_shape,
277+
matched=matched,
278+
)
279+
)
280+
281+
if matched:
282+
continue
283+
284+
# Opaque bricks declare "trust me, B/S/H are preserved". As long
285+
# as the rank matches we downgrade to WARNING — the resolver
286+
# can't introspect what the opaque brick actually returns.
287+
rank_match = len(p_shape) == len(c_shape)
288+
if opaque_either_side and rank_match:
289+
diagnostics.append(
290+
ShapeDiagnostic(
291+
severity=DiagnosticSeverity.WARNING,
292+
message=(
293+
f"opaque-brick boundary {producer_name!r} -> "
294+
f"{consumer_name!r}: declared shapes differ "
295+
f"({p_shape} vs {c_shape}) but at least one side "
296+
"is opaque — caller must verify at runtime"
297+
),
298+
producer=producer_name,
299+
consumer=consumer_name,
300+
suggested_fix=None,
301+
)
302+
)
303+
continue
304+
305+
d = ShapeDiagnostic(
306+
severity=DiagnosticSeverity.ERROR,
307+
message=(
308+
f"shape mismatch on edge {producer_name!r} -> "
309+
f"{consumer_name!r}: producer outputs {p_shape} "
310+
f"({p_contract.outputs[_DEFAULT_OUTPUT_PORT].dims}), "
311+
f"consumer expects {c_shape} "
312+
f"({c_contract.inputs[_DEFAULT_INPUT_PORT].dims})"
313+
),
314+
producer=producer_name,
315+
consumer=consumer_name,
316+
suggested_fix=_suggest_fix(p_shape, c_shape),
317+
)
318+
diagnostics.append(d)
319+
if strict:
320+
raise ResolveError(d.message)
321+
322+
return ResolvedBrickGraph(
323+
original=graph,
324+
dim_env=dict(dim_env),
325+
edges=tuple(resolved_edges),
326+
diagnostics=tuple(diagnostics),
327+
)
328+
329+
330+
def _suggest_fix(
331+
p_shape: tuple[int, ...], c_shape: tuple[int, ...]
332+
) -> str | None:
333+
"""Heuristic adapter suggestion. Stage C will replace this with a
334+
proper :func:`suggest_adapters` lookup."""
335+
if len(p_shape) == len(c_shape):
336+
# Permutation guess: same multiset, different order
337+
if sorted(p_shape) == sorted(c_shape):
338+
return (
339+
"shapes are permutations of each other — try inserting a "
340+
"transpose / permute adapter (Stage C)"
341+
)
342+
# Same rank, last dim differs → linear projection
343+
if p_shape[:-1] == c_shape[:-1]:
344+
return (
345+
f"last-dim mismatch ({p_shape[-1]} != {c_shape[-1]}) — "
346+
f"insert a Linear({p_shape[-1]}, {c_shape[-1]}) bridge"
347+
)
348+
# Rank changes — likely heads merge/split
349+
if (
350+
len(p_shape) == 4 and len(c_shape) == 3
351+
and p_shape[0] == c_shape[0] and p_shape[2] == c_shape[1]
352+
and p_shape[1] * p_shape[3] == c_shape[2]
353+
):
354+
return "merge_heads adapter: (B,nh,S,d) -> (B,S,nh*d)"
355+
if (
356+
len(p_shape) == 3 and len(c_shape) == 4
357+
and p_shape[0] == c_shape[0] and p_shape[1] == c_shape[2]
358+
and p_shape[2] == c_shape[1] * c_shape[3]
359+
):
360+
return "split_heads adapter: (B,S,nh*d) -> (B,nh,S,d)"
361+
return None
362+
363+
364+
__all__ = [
365+
"DiagnosticSeverity",
366+
"ResolvedBrickGraph",
367+
"ResolvedEdge",
368+
"ShapeDiagnostic",
369+
"resolve_shapes",
370+
]

0 commit comments

Comments
 (0)