Skip to content

Commit 22a5a03

Browse files
committed
feat(vbgui-fh): training data inspector — parquet preview + ribbons
Stage F-H of the Visual Builder GUI epic (cppmega-mlx-o0k) — the last open child ticket. Lands the backend handler + frontend panel researchers use to inspect what actually enters model.forward(). Backend (cppmega_v4.jsonrpc.data_methods): - data.preview_parquet — opens a parquet shard via pyarrow, picks the canonical token column (input_ids / token_ids / tokens), surfaces every other column as a side-channel "ribbon" with per- row payload. Pagination via offset+limit; channel filter via optional channels=[...]. Computes bytes/token avg + p95 + max from the encoded id stream (heuristic; precise stats pair with the F-G tokenizer panel). LRU cache hits on identical paginations. - Dispatcher route + METHOD_REGISTRY entry. Frontend (vbgui/src/components/DataInspector.tsx): - Path input + Load button, per-page metrics line, channel toggle chips (color-coded), one row card per packed sequence with the token strip plus a Ribbon per enabled channel (array channels paint per-token cells, scalar channels paint a single badge). - Pagination footer (← / page x / y / →) drives the offset. - Error envelope from RpcClient surfaces inline. Tests: - Python (11): first-page fetch, per-row channel payload, offset pagination, channel filter, bytes/token stats, cache hit, past- end empty, invalid limit, missing token column raises, dispatcher round-trip, METHOD_REGISTRY lock. - TypeScript (6): Load populates rows + channels + metrics, channel toggle hides ribbon, array channel renders as per-token strip, pagination Next/Prev advances offset in the body, error envelope surfaces inline. Full v4 regression: 2090 passed / 5 skipped / 15 xfailed / 0 failed. Vbgui: 77 tests passed (71 + 6 new). Closes cppmega-mlx-o0k.8 and the cppmega-mlx-o0k epic.
1 parent 6421e50 commit 22a5a03

6 files changed

Lines changed: 653 additions & 0 deletions

File tree

cppmega_v4/jsonrpc/data_methods.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Backend data preview — F-H ``data.preview_parquet`` handler.
2+
3+
Loads a parquet shard, samples N rows, surfaces per-row token stream
4+
plus every other column as a side-channel "ribbon". The GUI uses the
5+
result to paint coloured strips under the token row so the researcher
6+
can see exactly what enters ``model.forward()``.
7+
8+
Pyodide-friendly: only uses pyarrow + stdlib. The frontend can swap
9+
to in-browser hyparquet for the same shape later (F-E follow-up).
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import time
15+
from typing import Any
16+
17+
import pyarrow.parquet as pq
18+
from pydantic import BaseModel, ConfigDict, Field
19+
20+
from cppmega_v4.jsonrpc.cache import LRUCache
21+
from cppmega_v4.probe import introspect_parquet
22+
23+
24+
_PRIMARY_TOKEN_COLS: tuple[str, ...] = ("input_ids", "token_ids", "tokens")
25+
26+
27+
# ---------------------------------------------------------------------------
28+
# Schemas
29+
# ---------------------------------------------------------------------------
30+
31+
32+
class PreviewParquetParams(BaseModel):
33+
model_config = ConfigDict(extra="forbid")
34+
path: str
35+
offset: int = 0
36+
limit: int = 32
37+
channels: list[str] | None = None # None → all detected side-channels
38+
39+
40+
class PreviewRow(BaseModel):
41+
model_config = ConfigDict(extra="forbid")
42+
row_index: int
43+
tokens: list[int]
44+
channels: dict[str, Any] = Field(default_factory=dict)
45+
46+
47+
class PreviewParquetResult(BaseModel):
48+
model_config = ConfigDict(extra="forbid")
49+
rows: list[PreviewRow]
50+
token_column: str
51+
available_channels: list[str]
52+
bytes_per_token_avg: float
53+
bytes_per_token_p95: float
54+
bytes_per_token_max: int
55+
total_rows: int
56+
elapsed_ms: float
57+
58+
59+
# ---------------------------------------------------------------------------
60+
# Handler
61+
# ---------------------------------------------------------------------------
62+
63+
64+
def preview_parquet(
65+
params: PreviewParquetParams,
66+
*,
67+
cache: LRUCache | None = None,
68+
) -> PreviewParquetResult:
69+
"""Return ``params.limit`` rows starting at ``params.offset``."""
70+
if params.limit < 1:
71+
raise ValueError(f"limit must be ≥ 1, got {params.limit}")
72+
if params.offset < 0:
73+
raise ValueError(f"offset must be ≥ 0, got {params.offset}")
74+
75+
cache_key = f"preview::{params.path}::{params.offset}::{params.limit}::" \
76+
f"{','.join(sorted(params.channels or []))}"
77+
if cache is not None:
78+
hit = cache.get(cache_key)
79+
if hit is not None:
80+
return hit
81+
82+
t0 = time.perf_counter()
83+
pf = pq.ParquetFile(params.path)
84+
schema_names = [f.name for f in pf.schema_arrow]
85+
token_col = _pick_token_column(schema_names)
86+
if token_col is None:
87+
raise ValueError(
88+
f"parquet shard {params.path!r} has no token column "
89+
f"(expected one of {_PRIMARY_TOKEN_COLS})"
90+
)
91+
92+
# Side-channel pool: every column that isn't the token stream or
93+
# bookkeeping. Caller may further restrict via ``params.channels``.
94+
caps = introspect_parquet(params.path, sample_rows=min(params.limit, 64))
95+
available = sorted(c for c in caps.side_channels if c != token_col)
96+
selected = [c for c in available if not params.channels
97+
or c in params.channels]
98+
cols_to_read = [token_col, *selected]
99+
100+
table = pf.read(columns=cols_to_read)
101+
total_rows = table.num_rows
102+
if params.offset >= total_rows:
103+
sliced = table.slice(0, 0)
104+
else:
105+
n = min(params.limit, total_rows - params.offset)
106+
sliced = table.slice(params.offset, n)
107+
108+
rows: list[PreviewRow] = []
109+
token_lists: list[list[int]] = []
110+
for i in range(sliced.num_rows):
111+
token_val = sliced.column(token_col)[i].as_py() or []
112+
tokens = [int(t) for t in token_val]
113+
token_lists.append(tokens)
114+
channel_payload: dict[str, Any] = {}
115+
for ch in selected:
116+
channel_payload[ch] = sliced.column(ch)[i].as_py()
117+
rows.append(PreviewRow(
118+
row_index=params.offset + i,
119+
tokens=tokens,
120+
channels=channel_payload,
121+
))
122+
123+
bpt_avg, bpt_p95, bpt_max = _bytes_per_token_stats(token_lists)
124+
elapsed = (time.perf_counter() - t0) * 1000.0
125+
out = PreviewParquetResult(
126+
rows=rows,
127+
token_column=token_col,
128+
available_channels=available,
129+
bytes_per_token_avg=round(bpt_avg, 3),
130+
bytes_per_token_p95=round(bpt_p95, 3),
131+
bytes_per_token_max=int(bpt_max),
132+
total_rows=total_rows,
133+
elapsed_ms=elapsed,
134+
)
135+
if cache is not None:
136+
cache.set(cache_key, out)
137+
return out
138+
139+
140+
# ---------------------------------------------------------------------------
141+
# Helpers
142+
# ---------------------------------------------------------------------------
143+
144+
145+
def _pick_token_column(schema_names: list[str]) -> str | None:
146+
for name in _PRIMARY_TOKEN_COLS:
147+
if name in schema_names:
148+
return name
149+
return None
150+
151+
152+
def _bytes_per_token_stats(
153+
token_lists: list[list[int]],
154+
) -> tuple[float, float, int]:
155+
"""Compute per-token byte stats from the encoded id stream."""
156+
if not token_lists:
157+
return 0.0, 0.0, 0
158+
# Each token id encodes to its big-endian byte length — proxy for
159+
# the on-disk bytes/token cost (real bytes depend on vocab + BPE
160+
# merge stats; the GUI uses this as a heuristic, full stats need
161+
# the corresponding tokenizer).
162+
lengths: list[int] = []
163+
for row in token_lists:
164+
for tok in row:
165+
lengths.append(max(1, (tok.bit_length() + 7) // 8))
166+
if not lengths:
167+
return 0.0, 0.0, 0
168+
avg = sum(lengths) / len(lengths)
169+
sorted_lengths = sorted(lengths)
170+
p95_idx = max(0, int(len(sorted_lengths) * 0.95) - 1)
171+
p95 = float(sorted_lengths[p95_idx])
172+
return avg, p95, max(lengths)

cppmega_v4/jsonrpc/dispatcher.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
encode_visualize,
2828
list_presets as tokenizer_list_presets,
2929
)
30+
from cppmega_v4.jsonrpc.data_methods import (
31+
PreviewParquetParams,
32+
preview_parquet,
33+
)
3034
from cppmega_v4.jsonrpc.schema import (
3135
BuildPresetSpecsParams,
3236
ErrorCode,
@@ -76,6 +80,10 @@
7680
EncodeVisualizeParams,
7781
lambda p, c: encode_visualize(p, cache=c),
7882
),
83+
"data.preview_parquet": (
84+
PreviewParquetParams,
85+
lambda p, c: preview_parquet(p, cache=c),
86+
),
7987
}
8088

8189

cppmega_v4/jsonrpc/schema.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,4 +534,5 @@ class PipelineRunResult(BaseModel):
534534
"backend.status",
535535
"tokenizer.encode_visualize",
536536
"tokenizer.list_presets",
537+
"data.preview_parquet",
537538
})

tests/v4/test_data_inspector.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""F-H data inspector tests — preview_parquet end-to-end."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import pyarrow as pa
8+
import pyarrow.parquet as pq
9+
import pytest
10+
11+
from cppmega_v4.jsonrpc import LRUCache, dispatch
12+
from cppmega_v4.jsonrpc.data_methods import (
13+
PreviewParquetParams,
14+
preview_parquet,
15+
)
16+
17+
18+
def _write_full_parquet(p: Path, n_rows: int = 32):
19+
pq.write_table(pa.table({
20+
"input_ids": [list(range(8 + i % 4)) for i in range(n_rows)],
21+
"doc_ids": [i // 4 for i in range(n_rows)],
22+
"loss_mask": [[1] * (8 + i % 4) for i in range(n_rows)],
23+
"call_edges": [[(0, 1)] for _ in range(n_rows)],
24+
"type_edges": [[(0, 2)] for _ in range(n_rows)],
25+
}), p)
26+
27+
28+
# ---------------------------------------------------------------------------
29+
# preview_parquet
30+
# ---------------------------------------------------------------------------
31+
32+
33+
def test_preview_returns_first_page(tmp_path: Path):
34+
p = tmp_path / "shard.parquet"
35+
_write_full_parquet(p, n_rows=16)
36+
r = preview_parquet(PreviewParquetParams(path=str(p), offset=0, limit=4))
37+
assert r.total_rows == 16
38+
assert len(r.rows) == 4
39+
assert r.token_column == "input_ids"
40+
assert "doc_ids" in r.available_channels
41+
assert "call_edges" in r.available_channels
42+
43+
44+
def test_preview_carries_channel_payload_per_row(tmp_path: Path):
45+
p = tmp_path / "shard.parquet"
46+
_write_full_parquet(p, n_rows=8)
47+
r = preview_parquet(PreviewParquetParams(path=str(p), offset=0, limit=2))
48+
first = r.rows[0]
49+
assert first.row_index == 0
50+
assert len(first.tokens) > 0
51+
assert "doc_ids" in first.channels
52+
assert "loss_mask" in first.channels
53+
54+
55+
def test_preview_pagination_offset_works(tmp_path: Path):
56+
p = tmp_path / "shard.parquet"
57+
_write_full_parquet(p, n_rows=10)
58+
r = preview_parquet(PreviewParquetParams(path=str(p), offset=5, limit=4))
59+
assert [row.row_index for row in r.rows] == [5, 6, 7, 8]
60+
61+
62+
def test_preview_channel_filter_restricts_payload(tmp_path: Path):
63+
p = tmp_path / "shard.parquet"
64+
_write_full_parquet(p, n_rows=4)
65+
r = preview_parquet(PreviewParquetParams(
66+
path=str(p), offset=0, limit=2, channels=["doc_ids"],
67+
))
68+
for row in r.rows:
69+
assert set(row.channels.keys()) == {"doc_ids"}
70+
71+
72+
def test_preview_bytes_per_token_stats_present(tmp_path: Path):
73+
p = tmp_path / "shard.parquet"
74+
_write_full_parquet(p, n_rows=4)
75+
r = preview_parquet(PreviewParquetParams(path=str(p), offset=0, limit=4))
76+
assert r.bytes_per_token_avg > 0
77+
assert r.bytes_per_token_max >= r.bytes_per_token_avg
78+
79+
80+
def test_preview_caches_repeat_calls(tmp_path: Path):
81+
p = tmp_path / "shard.parquet"
82+
_write_full_parquet(p, n_rows=4)
83+
cache = LRUCache()
84+
params = PreviewParquetParams(path=str(p), offset=0, limit=2)
85+
preview_parquet(params, cache=cache)
86+
preview_parquet(params, cache=cache)
87+
assert cache.stats()["hits"] >= 1
88+
89+
90+
def test_preview_offset_past_end_returns_empty(tmp_path: Path):
91+
p = tmp_path / "shard.parquet"
92+
_write_full_parquet(p, n_rows=4)
93+
r = preview_parquet(PreviewParquetParams(path=str(p), offset=999, limit=8))
94+
assert r.rows == []
95+
assert r.total_rows == 4
96+
97+
98+
def test_preview_rejects_invalid_limit(tmp_path: Path):
99+
p = tmp_path / "shard.parquet"
100+
_write_full_parquet(p, n_rows=4)
101+
with pytest.raises(ValueError, match="limit"):
102+
preview_parquet(PreviewParquetParams(path=str(p), limit=0))
103+
104+
105+
def test_preview_raises_when_token_column_missing(tmp_path: Path):
106+
p = tmp_path / "no_tokens.parquet"
107+
pq.write_table(pa.table({"some_col": [1, 2, 3]}), p)
108+
with pytest.raises(ValueError, match="no token column"):
109+
preview_parquet(PreviewParquetParams(path=str(p), limit=2))
110+
111+
112+
# ---------------------------------------------------------------------------
113+
# Dispatcher integration
114+
# ---------------------------------------------------------------------------
115+
116+
117+
def test_dispatch_data_preview_parquet_round_trip(tmp_path: Path):
118+
p = tmp_path / "shard.parquet"
119+
_write_full_parquet(p, n_rows=4)
120+
resp = dispatch({
121+
"jsonrpc": "2.0", "id": "d1", "method": "data.preview_parquet",
122+
"params": {"path": str(p), "offset": 0, "limit": 2},
123+
})
124+
assert resp.error is None
125+
assert len(resp.result["rows"]) == 2
126+
assert resp.result["token_column"] == "input_ids"
127+
128+
129+
def test_method_registry_includes_data_preview():
130+
from cppmega_v4.jsonrpc import METHOD_REGISTRY
131+
assert "data.preview_parquet" in METHOD_REGISTRY

0 commit comments

Comments
 (0)