Skip to content

Commit e5c30bb

Browse files
committed
feat(v7-g02,v7-g04): doc_id helper + corpus_stats sidecar in DataInspector
Two honest-closure data-pipeline gaps: V7-G02: doc_id_assignment helper lived at cppmega_v4/data/doc_id_assignment.py but clang_enriched_to_parquet fell back to a per-source-file synthesized id when source_doc_id was missing. Same logical doc across shards got different ids. V7-G04: compute_corpus_stats existed but the parquet emit path never wrote a sidecar, so DataInspector had nothing to surface. - scripts/nanochat_data/clang_enriched_to_parquet.py: when source_doc_id is missing, prefer stable_doc_signature(record) from the helper. Falls back to the legacy filename:index on exception. Also writes {shard}.corpus_stats.json next to each parquet shard. - jsonrpc/data_methods.py PreviewParquetResult: new corpus_stats: dict | None field; _read_corpus_stats_sidecar loads the JSON if present, returns None for legacy shards. - vbgui/components/DataInspector.tsx: CorpusStats interface + data-corpus-stats block rendering token_coverage_pct, doc-length p50/p90/p99, n_docs, long_tail_count. - tests/v4/test_corpus_stats_sidecar.py: sidecar present → field populated; missing → field is null. - e2e 86: visual assertion — pyarrow stub-shard + synthetic sidecar → DataInspector block shows '42.50%', '3/4/5', '7'.
1 parent f3e8a6c commit e5c30bb

5 files changed

Lines changed: 252 additions & 2 deletions

File tree

cppmega_v4/jsonrpc/data_methods.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ class PreviewParquetResult(BaseModel):
112112
bytes_per_token_max: int
113113
total_rows: int
114114
elapsed_ms: float
115+
# V7-G04: corpus stats sidecar (token coverage / doc-length / vocab).
116+
# Populated when the shard was emitted by clang_enriched_to_parquet
117+
# with token_ids materialized; absent for legacy shards.
118+
corpus_stats: dict | None = None
115119

116120

117121
# ---------------------------------------------------------------------------
@@ -214,12 +218,31 @@ def preview_parquet(
214218
bytes_per_token_max=int(bpt_max),
215219
total_rows=total_rows,
216220
elapsed_ms=elapsed,
221+
corpus_stats=_read_corpus_stats_sidecar(preview_path),
217222
)
218223
if cache is not None:
219224
cache.set(cache_key, out)
220225
return out
221226

222227

228+
def _read_corpus_stats_sidecar(parquet_path) -> dict | None:
229+
"""V7-G04: load the {parquet}.corpus_stats.json sidecar emitted by
230+
clang_enriched_to_parquet. Returns None when missing or malformed."""
231+
import json as _json
232+
import os
233+
sidecar = str(parquet_path) + ".corpus_stats.json"
234+
if not os.path.exists(sidecar):
235+
return None
236+
try:
237+
with open(sidecar) as f:
238+
data = _json.load(f)
239+
if isinstance(data, dict):
240+
return data
241+
except Exception:
242+
return None
243+
return None
244+
245+
223246
# ---------------------------------------------------------------------------
224247
# Helpers
225248
# ---------------------------------------------------------------------------

scripts/nanochat_data/clang_enriched_to_parquet.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -933,7 +933,19 @@ def flush_rows() -> None:
933933
max_tokens,
934934
overflow_policy=overflow_policy,
935935
)
936-
source_doc_id = record.get("source_doc_id", f"{source.name}:{docs_in}")
936+
# V7-G02: prefer the helper's stable signature so the
937+
# same logical document gets the same id even if
938+
# shards are reordered later. Fall back to the legacy
939+
# name:index when the helper rejects the row.
940+
source_doc_id = record.get("source_doc_id")
941+
if not source_doc_id:
942+
try:
943+
from cppmega_v4.data.doc_id_assignment import (
944+
stable_doc_signature)
945+
sig = stable_doc_signature(record)
946+
source_doc_id = sig or f"{source.name}:{docs_in}"
947+
except Exception:
948+
source_doc_id = f"{source.name}:{docs_in}"
937949
for sub_doc in sub_docs:
938950
sub_doc.setdefault("source_doc_id", source_doc_id)
939951
sub_doc.setdefault(
@@ -1023,7 +1035,17 @@ def process_input_file(gcs_uri: str, tmpdir: str,
10231035
max_tokens,
10241036
overflow_policy=overflow_policy,
10251037
)
1026-
source_doc_id = record.get("source_doc_id", f"{fname}:{docs_in}")
1038+
# V7-G02: same fallback as the bucket path above — use the
1039+
# stable doc signature so cross-shard duplicates collapse.
1040+
source_doc_id = record.get("source_doc_id")
1041+
if not source_doc_id:
1042+
try:
1043+
from cppmega_v4.data.doc_id_assignment import (
1044+
stable_doc_signature)
1045+
sig = stable_doc_signature(record)
1046+
source_doc_id = sig or f"{fname}:{docs_in}"
1047+
except Exception:
1048+
source_doc_id = f"{fname}:{docs_in}"
10271049
for sub_doc in sub_docs:
10281050
sub_doc.setdefault("source_doc_id", source_doc_id)
10291051
sub_doc.setdefault(
@@ -1081,6 +1103,39 @@ def _flush_shard(
10811103
check_memory_limit(_MEMORY_LIMIT_GB, label="clang_enriched_to_parquet")
10821104
pq.write_table(table, local_path, compression="snappy")
10831105
check_memory_limit(_MEMORY_LIMIT_GB, label="clang_enriched_to_parquet")
1106+
# V7-G04: emit a sidecar corpus-stats JSON next to the parquet shard
1107+
# so the UI DataInspector can render token coverage / doc-length
1108+
# percentiles / vocab usage without rescanning the shard. The helper
1109+
# is no-op safe when token_ids are absent (token_id_lists empty).
1110+
try:
1111+
from cppmega_v4.data.corpus_stats import compute_corpus_stats
1112+
import json as _json
1113+
token_lists: list[list[int]] = []
1114+
try:
1115+
for col_token_ids in table.column(TOKEN_IDS_COLUMN).to_pylist():
1116+
if col_token_ids:
1117+
token_lists.append(list(col_token_ids))
1118+
except (KeyError, Exception):
1119+
token_lists = []
1120+
if token_lists and _TOKENIZED_ENRICHED_TOKENIZER is not None:
1121+
stats = compute_corpus_stats(
1122+
token_lists,
1123+
vocab_size=int(
1124+
_TOKENIZED_ENRICHED_TOKENIZER.get_vocab_size()),
1125+
)
1126+
stats_path = local_path + ".corpus_stats.json"
1127+
with open(stats_path, "w") as _f:
1128+
_json.dump(stats, _f)
1129+
try:
1130+
gcs_upload(stats_path, gcs_uri + ".corpus_stats.json")
1131+
except Exception:
1132+
pass
1133+
try:
1134+
os.unlink(stats_path)
1135+
except Exception:
1136+
pass
1137+
except Exception as _exc:
1138+
log.warning("corpus_stats emit failed: %s", _exc)
10841139
gcs_upload(local_path, gcs_uri)
10851140
os.unlink(local_path)
10861141
log.info("Uploaded shard %d (%d rows, %.1f MB)",
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""V7-G04: data.preview_parquet surfaces the corpus_stats sidecar.
2+
3+
Honest-closure: compute_corpus_stats existed in
4+
cppmega_v4/data/corpus_stats.py but clang_enriched_to_parquet never
5+
wrote its output. After wiring, the sidecar JSON lands next to the
6+
shard and preview_parquet returns it under corpus_stats.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
from pathlib import Path
13+
14+
import numpy as np
15+
import pyarrow as pa
16+
import pyarrow.parquet as pq
17+
import pytest
18+
from fastapi.testclient import TestClient
19+
20+
from cppmega_v4.jsonrpc import create_app
21+
22+
23+
@pytest.fixture
24+
def client():
25+
return TestClient(create_app(cache_capacity=2))
26+
27+
28+
def _write_mini_shard(parquet_path: Path) -> None:
29+
schema = pa.schema([
30+
pa.field("token_ids", pa.list_(pa.int64())),
31+
])
32+
table = pa.table({
33+
"token_ids": [[1, 2, 3], [2, 3, 4, 5], [1, 1, 5]],
34+
}, schema=schema)
35+
pq.write_table(table, parquet_path, compression="snappy")
36+
37+
38+
def test_corpus_stats_field_returned_when_sidecar_present(client, tmp_path):
39+
p = tmp_path / "shard.parquet"
40+
_write_mini_shard(p)
41+
# Sidecar a synthetic stats blob.
42+
sidecar = p.with_suffix(".parquet.corpus_stats.json")
43+
sidecar.write_text(json.dumps({
44+
"token_coverage_pct": 12.5,
45+
"doc_length_p50": 3,
46+
"vocab_usage_topk": [[1, 3], [2, 2]],
47+
}))
48+
49+
payload = {
50+
"jsonrpc": "2.0", "id": "g1", "method": "data.preview_parquet",
51+
"params": {"path": str(p), "limit": 5, "offset": 0},
52+
}
53+
r = client.post("/rpc", json=payload)
54+
assert r.status_code == 200
55+
body = r.json()
56+
assert "error" not in body, body
57+
cs = body["result"]["corpus_stats"]
58+
assert cs is not None
59+
assert cs["token_coverage_pct"] == 12.5
60+
assert cs["doc_length_p50"] == 3
61+
62+
63+
def test_corpus_stats_field_null_when_sidecar_missing(client, tmp_path):
64+
p = tmp_path / "shard.parquet"
65+
_write_mini_shard(p)
66+
# No sidecar written.
67+
68+
payload = {
69+
"jsonrpc": "2.0", "id": "g2", "method": "data.preview_parquet",
70+
"params": {"path": str(p), "limit": 5, "offset": 0},
71+
}
72+
r = client.post("/rpc", json=payload)
73+
body = r.json()
74+
assert body["result"]["corpus_stats"] is None
75+
76+
77+
# Silence unused-import warning — numpy is imported in case future tests
78+
# extend the shard schema.
79+
_ = np
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// V7-G04: DataInspector renders the corpus_stats sidecar emitted by
2+
// clang_enriched_to_parquet. This test creates a tiny parquet shard,
3+
// writes a synthetic sidecar JSON, opens DataInspector via the UI,
4+
// and asserts the corpus-stats block surfaces token coverage,
5+
// doc-length percentiles, and long-tail count.
6+
7+
import { test, expect } from "@playwright/test";
8+
import path from "node:path";
9+
import os from "node:os";
10+
import fs from "node:fs";
11+
import { execSync } from "node:child_process";
12+
import { gotoApp, clickTab } from "../fixtures";
13+
14+
function tmpDir(): string {
15+
return fs.mkdtempSync(path.join(os.tmpdir(), "v7g04-"));
16+
}
17+
18+
test("V7-G04: corpus_stats sidecar renders in DataInspector",
19+
async ({ page }) => {
20+
test.setTimeout(60_000);
21+
const dir = tmpDir();
22+
const parquet = path.join(dir, "shard.parquet");
23+
// Use the same Python venv to build a one-row parquet + sidecar.
24+
const repoRoot = path.resolve(__dirname, "../../..");
25+
execSync(
26+
`${repoRoot}/.venv/bin/python -c "import pyarrow as pa, pyarrow.parquet as pq, json; ` +
27+
`pq.write_table(pa.table({'token_ids':[[1,2,3]]}), '${parquet}'); ` +
28+
`open('${parquet}.corpus_stats.json','w').write(` +
29+
`json.dumps({'token_coverage_pct': 42.5, 'doc_length_p50': 3, ` +
30+
`'doc_length_p90': 4, 'doc_length_p99': 5, 'n_docs': 1, ` +
31+
`'long_tail_count': 7}))"`,
32+
{ encoding: "utf-8" },
33+
);
34+
35+
await gotoApp(page);
36+
await clickTab(page, "data");
37+
await page.getByTestId("data-source-path").fill(parquet);
38+
await page.getByTestId("data-load").click();
39+
40+
const block = page.getByTestId("data-corpus-stats");
41+
await expect(block).toBeVisible({ timeout: 10_000 });
42+
43+
await expect(page.getByTestId("data-corpus-stats-token-coverage"))
44+
.toContainText("42.50%");
45+
await expect(page.getByTestId("data-corpus-stats-doc-length"))
46+
.toContainText("3/4/5");
47+
await expect(page.getByTestId("data-corpus-stats-long-tail"))
48+
.toContainText("7");
49+
await expect(page.getByTestId("data-corpus-stats-n-docs"))
50+
.toContainText("1");
51+
});

vbgui/src/components/DataInspector.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@ export interface PreviewParquetResult {
1919
bytes_per_token_max: number;
2020
total_rows: number;
2121
elapsed_ms: number;
22+
/** V7-G04: corpus_stats sidecar emitted by clang_enriched_to_parquet
23+
* (compute_corpus_stats output). Null for legacy shards. */
24+
corpus_stats?: CorpusStats | null;
25+
}
26+
27+
export interface CorpusStats {
28+
token_coverage_pct?: number;
29+
doc_length_p50?: number;
30+
doc_length_p90?: number;
31+
doc_length_p99?: number;
32+
doc_length_histogram?: Array<[number, number]>;
33+
vocab_usage_topk?: Array<[number, number]>;
34+
long_tail_count?: number;
35+
n_docs?: number;
2236
}
2337

2438
export interface SideChannelFamilyCoverage {
@@ -241,6 +255,34 @@ export function DataInspector({
241255
{" "}max {result.bytes_per_token_max}
242256
</div>
243257

258+
{result.corpus_stats && (
259+
<div data-testid="data-corpus-stats"
260+
style={{ border: "1px solid #d1d5db", borderRadius: 4,
261+
padding: 8, fontSize: 11,
262+
background: "#f9fafb",
263+
fontFamily: "monospace" }}>
264+
<div style={{ fontWeight: 600, marginBottom: 4 }}>
265+
corpus stats (V7-G04)
266+
</div>
267+
<div data-testid="data-corpus-stats-token-coverage">
268+
token coverage:{" "}
269+
{result.corpus_stats.token_coverage_pct?.toFixed(2)}%
270+
</div>
271+
<div data-testid="data-corpus-stats-doc-length">
272+
doc length p50/p90/p99:{" "}
273+
{result.corpus_stats.doc_length_p50 ?? "?"}/
274+
{result.corpus_stats.doc_length_p90 ?? "?"}/
275+
{result.corpus_stats.doc_length_p99 ?? "?"}
276+
</div>
277+
<div data-testid="data-corpus-stats-n-docs">
278+
docs: {result.corpus_stats.n_docs ?? 0}
279+
</div>
280+
<div data-testid="data-corpus-stats-long-tail">
281+
long-tail tokens (≤1 use):{" "}
282+
{result.corpus_stats.long_tail_count ?? 0}
283+
</div>
284+
</div>
285+
)}
244286
{result.shards && result.shards.length > 0 && (
245287
<div data-testid="data-shards"
246288
style={{ display: "grid",

0 commit comments

Comments
 (0)