Skip to content

Commit e512f7f

Browse files
committed
feat(probe-c): JSON wire format + exported schema for GUI/CLI hand-off
Stage C of the Contract Probe epic (cppmega-mlx-728). Stabilises the wire format the Visual Builder GUI (cppmega-mlx-o0k) and the upcoming cppmega-run CLI (cppmega-mlx-o0k.6) will consume. - probe/serialize.py: to_dict / from_dict round-trip, SCHEMA_VERSION constant (1.0.0), json_schema() emitting Draft 2020-12. - docs/contract_probe_schema.json: exported schema bundled in repo; parity gate test (test_exported_schema_matches_live_schema) catches drift between code and exported artefact. Tests (11): round-trip identity on clean + dirty reports, schema validity check, exported-schema parity, JSON dumpability, is_clean preservation across round-trip. Full v4 regression: 1938 passed / 5 skipped / 15 xfailed / 0 failed. Closes cppmega-mlx-728.3 and the cppmega-mlx-728 epic. Note: CLI subcommand `cppmega-run probe ...` deferred to VBGui F-A.2 (cppmega-mlx-o0k.6) where the cppmega-run binary lands. The Python API + JSON schema in this commit are stable surfaces for that work.
1 parent 61d145d commit e512f7f

4 files changed

Lines changed: 863 additions & 0 deletions

File tree

cppmega_v4/probe/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@
2828
LOSS_REQUIREMENTS,
2929
DataRequirement,
3030
)
31+
from cppmega_v4.probe.serialize import (
32+
SCHEMA_VERSION,
33+
from_dict,
34+
json_schema,
35+
to_dict,
36+
)
3137

3238
__all__ = [
3339
"Alternative",
@@ -39,10 +45,14 @@
3945
"LOSS_REQUIREMENTS",
4046
"ParquetCapabilities",
4147
"ProbeFinding",
48+
"SCHEMA_VERSION",
4249
"TokenizerCapabilities",
4350
"contract_probe",
4451
"dry_forward",
52+
"from_dict",
4553
"generate_alternatives",
4654
"introspect_parquet",
4755
"introspect_tokenizer",
56+
"json_schema",
57+
"to_dict",
4858
]

cppmega_v4/probe/serialize.py

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
"""JSON serialisation surface for ContractProbeReport.
2+
3+
Stable wire format for GUI/CLI consumers (Stage C of the Contract
4+
Probe epic). The schema is also written out to
5+
``docs/contract_probe_schema.json`` and kept in sync via the
6+
``test_schema_matches_export`` test in tests/v4/test_probe_stage_c.py.
7+
8+
Round-trip identity is guaranteed for clean (no-warning) reports —
9+
``from_dict(to_dict(r)) == r``.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from dataclasses import asdict
15+
from typing import Any, Mapping
16+
17+
from cppmega_v4.buildspec.loss_spec import LossKind
18+
from cppmega_v4.probe.alternatives import Alternative
19+
from cppmega_v4.probe.capabilities import (
20+
ColumnSpec,
21+
ParquetCapabilities,
22+
TokenizerCapabilities,
23+
)
24+
from cppmega_v4.probe.probe import ContractProbeReport, ProbeFinding
25+
from cppmega_v4.probe.requirements import DataRequirement
26+
27+
28+
SCHEMA_VERSION: str = "1.0.0"
29+
30+
31+
def to_dict(report: ContractProbeReport) -> dict[str, Any]:
32+
"""Convert a ContractProbeReport to a plain JSON-compatible dict."""
33+
d: dict[str, Any] = {
34+
"schema_version": SCHEMA_VERSION,
35+
"tokenizer": _tokenizer_to_dict(report.tokenizer),
36+
"parquet": _parquet_to_dict(report.parquet),
37+
"findings": [_finding_to_dict(f) for f in report.findings],
38+
"elapsed_ms": report.elapsed_ms,
39+
"probe_hidden_size": report.probe_hidden_size,
40+
"dry_forward_verdict": report.dry_forward_verdict,
41+
"dry_forward_detail": report.dry_forward_detail,
42+
"is_clean": report.is_clean,
43+
}
44+
return d
45+
46+
47+
def from_dict(data: Mapping[str, Any]) -> ContractProbeReport:
48+
"""Reconstruct a ContractProbeReport from :func:`to_dict` output."""
49+
version = data.get("schema_version")
50+
if version != SCHEMA_VERSION:
51+
raise ValueError(
52+
f"schema_version mismatch: got {version!r}, "
53+
f"this build expects {SCHEMA_VERSION!r}"
54+
)
55+
return ContractProbeReport(
56+
tokenizer=_tokenizer_from_dict(data["tokenizer"]),
57+
parquet=_parquet_from_dict(data["parquet"]),
58+
findings=tuple(_finding_from_dict(f) for f in data["findings"]),
59+
elapsed_ms=float(data["elapsed_ms"]),
60+
probe_hidden_size=int(data["probe_hidden_size"]),
61+
dry_forward_verdict=data["dry_forward_verdict"],
62+
dry_forward_detail=data.get("dry_forward_detail", ""),
63+
)
64+
65+
66+
def json_schema() -> dict[str, Any]:
67+
"""Return the JSON-Schema (draft 2020-12) for ContractProbeReport."""
68+
return {
69+
"$schema": "https://json-schema.org/draft/2020-12/schema",
70+
"$id": "https://cppmega.mlx/schemas/contract_probe_report.json",
71+
"title": "ContractProbeReport",
72+
"type": "object",
73+
"required": [
74+
"schema_version", "tokenizer", "parquet", "findings",
75+
"elapsed_ms", "probe_hidden_size",
76+
"dry_forward_verdict", "is_clean",
77+
],
78+
"properties": {
79+
"schema_version": {"const": SCHEMA_VERSION},
80+
"tokenizer": {"$ref": "#/$defs/TokenizerCapabilities"},
81+
"parquet": {"$ref": "#/$defs/ParquetCapabilities"},
82+
"findings": {
83+
"type": "array",
84+
"items": {"$ref": "#/$defs/ProbeFinding"},
85+
},
86+
"elapsed_ms": {"type": "number", "minimum": 0},
87+
"probe_hidden_size": {"type": "integer", "minimum": 1},
88+
"dry_forward_verdict": {
89+
"enum": ["ok", "shape_mismatch", "exception", "skipped"],
90+
},
91+
"dry_forward_detail": {"type": "string"},
92+
"is_clean": {"type": "boolean"},
93+
},
94+
"$defs": {
95+
"TokenizerCapabilities": {
96+
"type": "object",
97+
"required": [
98+
"vocab_size", "special_ids", "has_fim", "has_space_nl",
99+
"has_code_start", "has_instruction",
100+
"byte_roundtrip", "decoder_kind", "source",
101+
],
102+
"properties": {
103+
"vocab_size": {"type": "integer", "minimum": 0},
104+
"special_ids": {
105+
"type": "object",
106+
"additionalProperties": {"type": "integer"},
107+
},
108+
"has_fim": {"type": "boolean"},
109+
"has_space_nl": {"type": "boolean"},
110+
"has_code_start": {"type": "boolean"},
111+
"has_instruction": {"type": "boolean"},
112+
"byte_roundtrip": {"enum": ["exact", "approx", "none"]},
113+
"decoder_kind": {"enum": ["custom", "hf", "none"]},
114+
"source": {"type": "string"},
115+
},
116+
},
117+
"ParquetCapabilities": {
118+
"type": "object",
119+
"required": [
120+
"schema_columns", "row_count", "total_bytes",
121+
"has_token_ids", "has_doc_ids", "has_chunk_spans",
122+
"has_call_edges", "has_type_edges", "has_provenance",
123+
"side_channels", "sample_seq_lens", "source",
124+
],
125+
"properties": {
126+
"schema_columns": {
127+
"type": "array",
128+
"items": {"$ref": "#/$defs/ColumnSpec"},
129+
},
130+
"row_count": {"type": "integer", "minimum": 0},
131+
"total_bytes": {"type": "integer", "minimum": 0},
132+
"has_token_ids": {"type": "boolean"},
133+
"has_doc_ids": {"type": "boolean"},
134+
"has_chunk_spans": {"type": "boolean"},
135+
"has_call_edges": {"type": "boolean"},
136+
"has_type_edges": {"type": "boolean"},
137+
"has_provenance": {"type": "boolean"},
138+
"side_channels": {
139+
"type": "array",
140+
"items": {"type": "string"},
141+
},
142+
"sample_seq_lens": {
143+
"type": "array",
144+
"items": {"type": "integer", "minimum": 0},
145+
},
146+
"source": {"type": "string"},
147+
},
148+
},
149+
"ColumnSpec": {
150+
"type": "object",
151+
"required": ["name", "arrow_dtype", "nullable", "non_null_ratio"],
152+
"properties": {
153+
"name": {"type": "string"},
154+
"arrow_dtype": {"type": "string"},
155+
"nullable": {"type": "boolean"},
156+
"non_null_ratio": {"type": "number", "minimum": 0, "maximum": 1},
157+
},
158+
},
159+
"DataRequirement": {
160+
"type": "object",
161+
"required": ["key", "origin", "required", "reason", "satisfied_by"],
162+
"properties": {
163+
"key": {"type": "string"},
164+
"origin": {"enum": ["tokenizer", "parquet", "derived"]},
165+
"required": {"type": "boolean"},
166+
"reason": {"type": "string"},
167+
"satisfied_by": {
168+
"type": "array",
169+
"items": {"type": "string"},
170+
},
171+
},
172+
},
173+
"Alternative": {
174+
"type": "object",
175+
"required": ["action", "target", "diff", "cost", "reason"],
176+
"properties": {
177+
"action": {
178+
"enum": ["swap_loss", "swap_tokenizer", "add_column",
179+
"drop_brick", "relax_requirement"],
180+
},
181+
"target": {"type": "string"},
182+
"diff": {"type": "object"},
183+
"cost": {"enum": ["low", "medium", "high"]},
184+
"reason": {"type": "string"},
185+
},
186+
},
187+
"ProbeFinding": {
188+
"type": "object",
189+
"required": ["kind", "component", "requirement",
190+
"message", "alternatives"],
191+
"properties": {
192+
"kind": {"enum": ["satisfied", "unsatisfied", "warning"]},
193+
"component": {"type": "string"},
194+
"requirement": {"$ref": "#/$defs/DataRequirement"},
195+
"message": {"type": "string"},
196+
"alternatives": {
197+
"type": "array",
198+
"items": {"$ref": "#/$defs/Alternative"},
199+
},
200+
},
201+
},
202+
},
203+
}
204+
205+
206+
# ---------------------------------------------------------------------------
207+
# Per-type helpers (kept private; consumers go through to_dict/from_dict).
208+
# ---------------------------------------------------------------------------
209+
210+
211+
def _tokenizer_to_dict(t: TokenizerCapabilities) -> dict[str, Any]:
212+
return {
213+
"vocab_size": t.vocab_size,
214+
"special_ids": dict(t.special_ids),
215+
"has_fim": t.has_fim,
216+
"has_space_nl": t.has_space_nl,
217+
"has_code_start": t.has_code_start,
218+
"has_instruction": t.has_instruction,
219+
"byte_roundtrip": t.byte_roundtrip,
220+
"decoder_kind": t.decoder_kind,
221+
"source": t.source,
222+
}
223+
224+
225+
def _tokenizer_from_dict(d: Mapping[str, Any]) -> TokenizerCapabilities:
226+
return TokenizerCapabilities(
227+
vocab_size=int(d["vocab_size"]),
228+
special_ids=dict(d["special_ids"]),
229+
has_fim=bool(d["has_fim"]),
230+
has_space_nl=bool(d["has_space_nl"]),
231+
has_code_start=bool(d["has_code_start"]),
232+
has_instruction=bool(d["has_instruction"]),
233+
byte_roundtrip=d["byte_roundtrip"],
234+
decoder_kind=d["decoder_kind"],
235+
source=d["source"],
236+
)
237+
238+
239+
def _column_to_dict(c: ColumnSpec) -> dict[str, Any]:
240+
return asdict(c)
241+
242+
243+
def _column_from_dict(d: Mapping[str, Any]) -> ColumnSpec:
244+
return ColumnSpec(
245+
name=d["name"],
246+
arrow_dtype=d["arrow_dtype"],
247+
nullable=bool(d["nullable"]),
248+
non_null_ratio=float(d["non_null_ratio"]),
249+
)
250+
251+
252+
def _parquet_to_dict(p: ParquetCapabilities) -> dict[str, Any]:
253+
return {
254+
"schema_columns": [_column_to_dict(c) for c in p.schema_columns],
255+
"row_count": p.row_count,
256+
"total_bytes": p.total_bytes,
257+
"has_token_ids": p.has_token_ids,
258+
"has_doc_ids": p.has_doc_ids,
259+
"has_chunk_spans": p.has_chunk_spans,
260+
"has_call_edges": p.has_call_edges,
261+
"has_type_edges": p.has_type_edges,
262+
"has_provenance": p.has_provenance,
263+
"side_channels": sorted(p.side_channels),
264+
"sample_seq_lens": list(p.sample_seq_lens),
265+
"source": p.source,
266+
}
267+
268+
269+
def _parquet_from_dict(d: Mapping[str, Any]) -> ParquetCapabilities:
270+
return ParquetCapabilities(
271+
schema_columns=tuple(_column_from_dict(c) for c in d["schema_columns"]),
272+
row_count=int(d["row_count"]),
273+
total_bytes=int(d["total_bytes"]),
274+
has_token_ids=bool(d["has_token_ids"]),
275+
has_doc_ids=bool(d["has_doc_ids"]),
276+
has_chunk_spans=bool(d["has_chunk_spans"]),
277+
has_call_edges=bool(d["has_call_edges"]),
278+
has_type_edges=bool(d["has_type_edges"]),
279+
has_provenance=bool(d["has_provenance"]),
280+
side_channels=frozenset(d["side_channels"]),
281+
sample_seq_lens=tuple(int(x) for x in d["sample_seq_lens"]),
282+
source=d["source"],
283+
)
284+
285+
286+
def _req_to_dict(r: DataRequirement) -> dict[str, Any]:
287+
return {
288+
"key": r.key,
289+
"origin": r.origin,
290+
"required": r.required,
291+
"reason": r.reason,
292+
"satisfied_by": list(r.satisfied_by),
293+
}
294+
295+
296+
def _req_from_dict(d: Mapping[str, Any]) -> DataRequirement:
297+
return DataRequirement(
298+
key=d["key"],
299+
origin=d["origin"],
300+
required=bool(d["required"]),
301+
reason=d["reason"],
302+
satisfied_by=tuple(d.get("satisfied_by", ())),
303+
)
304+
305+
306+
def _alt_to_dict(a: Alternative) -> dict[str, Any]:
307+
return {
308+
"action": a.action,
309+
"target": a.target,
310+
"diff": dict(a.diff),
311+
"cost": a.cost,
312+
"reason": a.reason,
313+
}
314+
315+
316+
def _alt_from_dict(d: Mapping[str, Any]) -> Alternative:
317+
return Alternative(
318+
action=d["action"],
319+
target=d["target"],
320+
diff=dict(d["diff"]),
321+
cost=d["cost"],
322+
reason=d["reason"],
323+
)
324+
325+
326+
def _finding_to_dict(f: ProbeFinding) -> dict[str, Any]:
327+
return {
328+
"kind": f.kind,
329+
"component": f.component,
330+
"requirement": _req_to_dict(f.requirement),
331+
"message": f.message,
332+
"alternatives": [_alt_to_dict(a) for a in f.alternatives],
333+
}
334+
335+
336+
def _finding_from_dict(d: Mapping[str, Any]) -> ProbeFinding:
337+
return ProbeFinding(
338+
kind=d["kind"],
339+
component=d["component"],
340+
requirement=_req_from_dict(d["requirement"]),
341+
message=d["message"],
342+
alternatives=tuple(_alt_from_dict(a) for a in d["alternatives"]),
343+
)

0 commit comments

Comments
 (0)