Skip to content

Commit 12129b4

Browse files
committed
feat(probe-a): tokenizer + parquet capability introspection
Stage A of the Contract Probe epic (cppmega-mlx-728). Introduces a new read-only cppmega_v4.probe package with two introspectors: - introspect_tokenizer reads a tokenizer.json and extracts the nanochat 11-special-id contract (PAD/UNK/BOS/EOS/FIM trio/CODE_START/ FIM_INSTRUCTION/SPACE/NL) by literal-string match, infers decoder kind + byte_roundtrip from decoder field presence. - introspect_parquet opens a shard with pyarrow, samples up to 256 rows for non-null ratios + token-stream seq lengths, flags the canonical side-channels (doc_ids / chunk_boundaries / call_edges / type_edges / constituent_provenance*). Tests: 17 passed (vendored tokenizer.json contract + 8 parquet fixtures written to tmp_path). Performance gates: <200ms each. Full v4 regression: 1888 passed / 5 skipped / 15 xfailed / 0 failed. Closes cppmega-mlx-728.1.
1 parent d47fc46 commit 12129b4

3 files changed

Lines changed: 508 additions & 0 deletions

File tree

cppmega_v4/probe/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Contract Probe — dry-run capability check.
2+
3+
See ``ContractProbe.md`` (repo root) for the design.
4+
5+
Stage A surface (this commit):
6+
- capabilities: TokenizerCapabilities / ParquetCapabilities / ColumnSpec
7+
+ introspect_tokenizer / introspect_parquet
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from cppmega_v4.probe.capabilities import (
13+
ColumnSpec,
14+
ParquetCapabilities,
15+
TokenizerCapabilities,
16+
introspect_parquet,
17+
introspect_tokenizer,
18+
)
19+
20+
__all__ = [
21+
"ColumnSpec",
22+
"ParquetCapabilities",
23+
"TokenizerCapabilities",
24+
"introspect_parquet",
25+
"introspect_tokenizer",
26+
]

cppmega_v4/probe/capabilities.py

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
"""Read-only introspection of tokenizers and parquet datasets.
2+
3+
Both ``introspect_tokenizer`` and ``introspect_parquet`` return frozen
4+
dataclass snapshots. They never mutate the source artifact and never
5+
write to disk.
6+
7+
Tokenizer special-id contract (vendored nanochat BPE convention):
8+
0=PAD 1=UNK 2=BOS 3=EOS 4=FIM_PREFIX 5=FIM_MIDDLE 6=FIM_SUFFIX
9+
7=CODE_START 45=FIM_INSTRUCTION 46=SPACE 47=NL
10+
We probe by the angle-bracketed literal — ``<FIM_PREFIX>`` etc. — so the
11+
contract survives id renumbering as long as token strings stay stable.
12+
13+
Side-channel detection in parquet is purely name-driven: a column is
14+
considered a side-channel if its name is NOT in :data:`_PARQUET_NON_SIDE`.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import json
20+
from dataclasses import dataclass
21+
from pathlib import Path
22+
from typing import Literal, Mapping
23+
24+
import pyarrow.parquet as pq
25+
26+
27+
# ---------------------------------------------------------------------------
28+
# Tokenizer side
29+
# ---------------------------------------------------------------------------
30+
31+
32+
_TOKENIZER_PROBES: Mapping[str, str] = {
33+
"PAD": "<PAD>",
34+
"UNK": "<UNK>",
35+
"BOS": "<BOS>",
36+
"EOS": "<EOS>",
37+
"FIM_PREFIX": "<FIM_PREFIX>",
38+
"FIM_MIDDLE": "<FIM_MIDDLE>",
39+
"FIM_SUFFIX": "<FIM_SUFFIX>",
40+
"CODE_START": "<CODE_START>",
41+
"FIM_INSTRUCTION": "<FIM_INSTRUCTION>",
42+
"SPACE": "<SPACE>",
43+
"NL": "<NL>",
44+
}
45+
46+
47+
@dataclass(frozen=True)
48+
class TokenizerCapabilities:
49+
"""Snapshot of what a tokenizer offers — read-only."""
50+
51+
vocab_size: int
52+
special_ids: Mapping[str, int]
53+
has_fim: bool
54+
has_space_nl: bool
55+
has_code_start: bool
56+
has_instruction: bool
57+
byte_roundtrip: Literal["exact", "approx", "none"]
58+
decoder_kind: Literal["custom", "hf", "none"]
59+
source: str
60+
61+
def has(self, *names: str) -> bool:
62+
"""True iff every name is present in :attr:`special_ids`."""
63+
return all(name in self.special_ids for name in names)
64+
65+
66+
def introspect_tokenizer(source: str | Path) -> TokenizerCapabilities:
67+
"""Load a ``tokenizer.json`` and return its capability snapshot.
68+
69+
Args:
70+
source: path to a Hugging Face-style ``tokenizer.json`` file.
71+
72+
The function is purely declarative — no encode/decode round-trip is
73+
performed. ``byte_roundtrip`` is inferred from decoder presence:
74+
a custom decoder (our nanochat ``cpp_tokenizer.py``) is ``"approx"``;
75+
a built-in HF decoder is ``"exact"``; absent decoder is ``"none"``.
76+
"""
77+
path = Path(source)
78+
if not path.exists():
79+
raise FileNotFoundError(f"tokenizer source not found: {path}")
80+
raw = json.loads(path.read_text())
81+
82+
vocab = raw.get("model", {}).get("vocab", {}) or {}
83+
vocab_size = len(vocab)
84+
added = raw.get("added_tokens") or []
85+
86+
found: dict[str, int] = {}
87+
for tok in added:
88+
content = tok.get("content")
89+
tid = tok.get("id")
90+
if not isinstance(content, str) or not isinstance(tid, int):
91+
continue
92+
for canonical, literal in _TOKENIZER_PROBES.items():
93+
if content == literal and canonical not in found:
94+
found[canonical] = tid
95+
96+
decoder = raw.get("decoder")
97+
if decoder is None:
98+
decoder_kind: Literal["custom", "hf", "none"] = "custom"
99+
byte_roundtrip: Literal["exact", "approx", "none"] = "approx"
100+
elif isinstance(decoder, dict) and decoder.get("type"):
101+
decoder_kind = "hf"
102+
byte_roundtrip = "exact"
103+
else:
104+
decoder_kind = "none"
105+
byte_roundtrip = "none"
106+
107+
return TokenizerCapabilities(
108+
vocab_size=vocab_size,
109+
special_ids=dict(found),
110+
has_fim=all(k in found for k in ("FIM_PREFIX", "FIM_MIDDLE", "FIM_SUFFIX")),
111+
has_space_nl="SPACE" in found and "NL" in found,
112+
has_code_start="CODE_START" in found,
113+
has_instruction="FIM_INSTRUCTION" in found,
114+
byte_roundtrip=byte_roundtrip,
115+
decoder_kind=decoder_kind,
116+
source=str(path),
117+
)
118+
119+
120+
# ---------------------------------------------------------------------------
121+
# Parquet side
122+
# ---------------------------------------------------------------------------
123+
124+
125+
# Columns that are NOT side-channels: the canonical text/token streams
126+
# and bookkeeping the trainer always expects. Everything else in the
127+
# schema is a side-channel that some brick or loss may consume.
128+
_PARQUET_NON_SIDE: frozenset[str] = frozenset({
129+
"input_ids", "token_ids", "tokens",
130+
"text", "raw_text",
131+
"labels",
132+
"attention_mask",
133+
"row_id", "shard_id", "source_path",
134+
})
135+
136+
# Known side-channel column names — used by *_has_* booleans.
137+
_PARQUET_KNOWN: Mapping[str, str] = {
138+
"doc_ids": "has_doc_ids",
139+
"chunk_boundaries": "has_chunk_spans",
140+
"call_edges": "has_call_edges",
141+
"type_edges": "has_type_edges",
142+
}
143+
144+
145+
@dataclass(frozen=True)
146+
class ColumnSpec:
147+
"""One column of a parquet schema."""
148+
149+
name: str
150+
arrow_dtype: str
151+
nullable: bool
152+
non_null_ratio: float
153+
154+
155+
@dataclass(frozen=True)
156+
class ParquetCapabilities:
157+
"""Snapshot of what a parquet shard offers — read-only."""
158+
159+
schema_columns: tuple[ColumnSpec, ...]
160+
row_count: int
161+
total_bytes: int
162+
has_token_ids: bool
163+
has_doc_ids: bool
164+
has_chunk_spans: bool
165+
has_call_edges: bool
166+
has_type_edges: bool
167+
has_provenance: bool
168+
side_channels: frozenset[str]
169+
sample_seq_lens: tuple[int, ...]
170+
source: str
171+
172+
def column(self, name: str) -> ColumnSpec | None:
173+
for c in self.schema_columns:
174+
if c.name == name:
175+
return c
176+
return None
177+
178+
179+
def introspect_parquet(
180+
path: str | Path,
181+
*,
182+
sample_rows: int = 256,
183+
) -> ParquetCapabilities:
184+
"""Open a parquet file and return its capability snapshot.
185+
186+
Args:
187+
path: path to a single ``.parquet`` shard.
188+
sample_rows: how many rows to read for non-null ratios and sample
189+
sequence lengths. Default 256 keeps the probe sub-second on
190+
production-sized shards.
191+
"""
192+
p = Path(path)
193+
if not p.exists():
194+
raise FileNotFoundError(f"parquet source not found: {p}")
195+
196+
pf = pq.ParquetFile(p)
197+
schema = pf.schema_arrow
198+
row_count = pf.metadata.num_rows
199+
total_bytes = p.stat().st_size
200+
201+
sample_size = min(sample_rows, row_count) if row_count > 0 else 0
202+
if sample_size > 0:
203+
sample_table = pf.read_row_group(0).slice(0, sample_size)
204+
else:
205+
sample_table = pf.read().slice(0, 0)
206+
207+
columns: list[ColumnSpec] = []
208+
for field in schema:
209+
col_name = field.name
210+
if col_name in sample_table.column_names and sample_size > 0:
211+
arr = sample_table.column(col_name)
212+
non_null = arr.length() - arr.null_count
213+
ratio = non_null / arr.length() if arr.length() else 0.0
214+
else:
215+
ratio = 0.0
216+
columns.append(
217+
ColumnSpec(
218+
name=col_name,
219+
arrow_dtype=str(field.type),
220+
nullable=field.nullable,
221+
non_null_ratio=float(ratio),
222+
)
223+
)
224+
225+
schema_names = {c.name for c in columns}
226+
side_channels = frozenset(n for n in schema_names if n not in _PARQUET_NON_SIDE)
227+
228+
# Sample seq lens — first sample-rows entries of the token stream.
229+
seq_lens: list[int] = []
230+
token_col = "input_ids" if "input_ids" in schema_names else (
231+
"token_ids" if "token_ids" in schema_names else None
232+
)
233+
if token_col is not None and sample_size > 0:
234+
for row in sample_table.column(token_col).to_pylist():
235+
seq_lens.append(len(row) if row is not None else 0)
236+
237+
has_provenance = any(
238+
n.startswith("constituent_provenance") for n in schema_names
239+
)
240+
241+
return ParquetCapabilities(
242+
schema_columns=tuple(columns),
243+
row_count=row_count,
244+
total_bytes=total_bytes,
245+
has_token_ids=token_col is not None,
246+
has_doc_ids="doc_ids" in schema_names,
247+
has_chunk_spans="chunk_boundaries" in schema_names,
248+
has_call_edges="call_edges" in schema_names,
249+
has_type_edges="type_edges" in schema_names,
250+
has_provenance=has_provenance,
251+
side_channels=side_channels,
252+
sample_seq_lens=tuple(seq_lens),
253+
source=str(p),
254+
)

0 commit comments

Comments
 (0)