|
| 1 | +"""V4-2: stage_train tokenizes parquet text column when tokenizer_path supplied. |
| 2 | +
|
| 3 | +The previous V3-2 path only read raw input_ids columns. Now if the |
| 4 | +parquet has a 'text' column and a tokenizer_path is supplied, |
| 5 | +stage_train encodes via the tokenizer and reports |
| 6 | +extras.data_source='parquet_tokenized' + extras.tokenizer_used. |
| 7 | +Falls back cleanly to V3-2 raw-int path or synthetic. |
| 8 | +""" |
| 9 | + |
| 10 | +from __future__ import annotations |
| 11 | + |
| 12 | +import pathlib |
| 13 | + |
| 14 | +import pyarrow as pa |
| 15 | +import pyarrow.parquet as pq |
| 16 | +import pytest |
| 17 | +from tokenizers import Tokenizer, models, pre_tokenizers, trainers |
| 18 | + |
| 19 | +from cppmega_v4.jsonrpc.schema import VerifyParams |
| 20 | +from cppmega_v4.runner import Pipeline, run_pipeline |
| 21 | + |
| 22 | + |
| 23 | +def _verify_params() -> VerifyParams: |
| 24 | + return VerifyParams.model_validate({ |
| 25 | + "graph": { |
| 26 | + "nodes": [ |
| 27 | + {"id": "attn", "kind": "attention", "params": {}}, |
| 28 | + {"id": "mlp", "kind": "mlp", |
| 29 | + "params": {"intermediate_size": 64, "activation": "swiglu"}}, |
| 30 | + ], |
| 31 | + "edges": [{"src": "attn", "dst": "mlp"}], |
| 32 | + }, |
| 33 | + "dim_env": {"B": 1, "S": 8, "H": 32, "nh": 2, "nkv": 1, |
| 34 | + "head_dim": 16}, |
| 35 | + "loss": {"kind": "cross_entropy", "head_outputs": ["mlp"]}, |
| 36 | + "optim": {"kind": "adamw", |
| 37 | + "groups": [{"matcher": "all", "lr": 1e-3, |
| 38 | + "weight_decay": 0.01, |
| 39 | + "betas": [0.9, 0.95]}]}, |
| 40 | + }) |
| 41 | + |
| 42 | + |
| 43 | +@pytest.fixture(scope="module") |
| 44 | +def trained_tokenizer(tmp_path_factory) -> str: |
| 45 | + """Tiny BPE tokenizer trained on a 4-line synthetic corpus. |
| 46 | + Returns path to tokenizer.json. Vocab ≈ 64 tokens.""" |
| 47 | + tmp = tmp_path_factory.mktemp("tok") |
| 48 | + corpus = tmp / "corpus.txt" |
| 49 | + corpus.write_text( |
| 50 | + "hello world foo bar baz qux\n" |
| 51 | + "the quick brown fox jumps over the lazy dog\n" |
| 52 | + "lorem ipsum dolor sit amet consectetur\n" |
| 53 | + "abcdefghij klmnopqrst uvwxyz\n" |
| 54 | + ) |
| 55 | + tok = Tokenizer(models.BPE(unk_token="<unk>")) |
| 56 | + tok.pre_tokenizer = pre_tokenizers.Whitespace() |
| 57 | + trainer = trainers.BpeTrainer( |
| 58 | + vocab_size=64, min_frequency=1, |
| 59 | + special_tokens=["<unk>"], |
| 60 | + ) |
| 61 | + tok.train([str(corpus)], trainer) |
| 62 | + out = tmp / "tokenizer.json" |
| 63 | + tok.save(str(out)) |
| 64 | + return str(out) |
| 65 | + |
| 66 | + |
| 67 | +def _write_text_parquet(tmp_path: pathlib.Path, |
| 68 | + rows: list[str] | None = None) -> str: |
| 69 | + rows = rows or [ |
| 70 | + "hello world hello world hello world hello world hello world", |
| 71 | + "the quick brown fox jumps over the lazy dog the quick brown", |
| 72 | + "lorem ipsum dolor sit amet consectetur adipiscing elit sed do", |
| 73 | + "foo bar baz qux foo bar baz qux foo bar baz qux foo bar baz", |
| 74 | + ] |
| 75 | + table = pa.table({"text": rows}) |
| 76 | + tmp_path.mkdir(parents=True, exist_ok=True) |
| 77 | + path = tmp_path / "text.parquet" |
| 78 | + pq.write_table(table, path) |
| 79 | + return str(path) |
| 80 | + |
| 81 | + |
| 82 | +def _run(stage_options: dict) -> dict: |
| 83 | + spec = _verify_params() |
| 84 | + report = run_pipeline(spec, Pipeline.from_dict({ |
| 85 | + "stages": ["parse", "verify_build_spec", "build_model", "train"], |
| 86 | + "stage_options": stage_options, |
| 87 | + })) |
| 88 | + train = next(s for s in report.stages if s.name == "train") |
| 89 | + assert train.status == "ok", f"stage_train failed: {train.error}" |
| 90 | + return train.extras |
| 91 | + |
| 92 | + |
| 93 | +def test_stage_train_tokenizes_text_column(tmp_path, trained_tokenizer): |
| 94 | + """tokenizer_path + parquet[text] → data_source='parquet_tokenized' |
| 95 | + + tokenizer_used reports the tokenizer basename.""" |
| 96 | + parquet = _write_text_parquet(tmp_path) |
| 97 | + extras = _run(stage_options={"train": { |
| 98 | + "num_steps": 2, |
| 99 | + "parquet_path": parquet, |
| 100 | + "tokenizer_path": trained_tokenizer, |
| 101 | + }}) |
| 102 | + assert extras["data_source"] == "parquet_tokenized" |
| 103 | + assert extras["tokenizer_used"] == "tokenizer.json" |
| 104 | + assert extras["token_count"] >= 8 # batch*seq = 1*8 |
| 105 | + |
| 106 | + |
| 107 | +def test_stage_train_falls_back_to_raw_when_no_text_column( |
| 108 | + tmp_path, trained_tokenizer, |
| 109 | +): |
| 110 | + """No 'text' column → falls through to V3-2 input_ids path.""" |
| 111 | + table = pa.table({"input_ids": [list(range(64))]}) |
| 112 | + tmp_path.mkdir(parents=True, exist_ok=True) |
| 113 | + p = tmp_path / "ids.parquet" |
| 114 | + pq.write_table(table, p) |
| 115 | + extras = _run(stage_options={"train": { |
| 116 | + "num_steps": 2, |
| 117 | + "parquet_path": str(p), |
| 118 | + "tokenizer_path": trained_tokenizer, |
| 119 | + }}) |
| 120 | + # Tokenizer was supplied but text column absent → V3-2 path wins. |
| 121 | + assert extras["data_source"] == "parquet" |
| 122 | + assert extras["tokenizer_used"] is None |
| 123 | + assert extras["token_count"] == 8 |
| 124 | + |
| 125 | + |
| 126 | +def test_stage_train_falls_back_to_synthetic_when_tokenizer_missing( |
| 127 | + tmp_path, |
| 128 | +): |
| 129 | + """Bad tokenizer_path AND no input_ids column → synthetic.""" |
| 130 | + parquet = _write_text_parquet(tmp_path) |
| 131 | + extras = _run(stage_options={"train": { |
| 132 | + "num_steps": 2, |
| 133 | + "parquet_path": parquet, |
| 134 | + "tokenizer_path": "/nonexistent/tok.json", |
| 135 | + }}) |
| 136 | + assert extras["data_source"] == "synthetic" |
| 137 | + assert extras["tokenizer_used"] is None |
| 138 | + |
| 139 | + |
| 140 | +def test_stage_train_falls_back_when_corrupted_parquet( |
| 141 | + tmp_path, trained_tokenizer, |
| 142 | +): |
| 143 | + """Corrupt parquet bytes → synthetic; no crash.""" |
| 144 | + tmp_path.mkdir(parents=True, exist_ok=True) |
| 145 | + bad = tmp_path / "bad.parquet" |
| 146 | + bad.write_bytes(b"not a parquet file") |
| 147 | + extras = _run(stage_options={"train": { |
| 148 | + "num_steps": 2, |
| 149 | + "parquet_path": str(bad), |
| 150 | + "tokenizer_path": trained_tokenizer, |
| 151 | + }}) |
| 152 | + assert extras["data_source"] == "synthetic" |
| 153 | + assert extras["tokenizer_used"] is None |
| 154 | + |
| 155 | + |
| 156 | +def test_stage_train_clips_token_ids_to_vocab(tmp_path, trained_tokenizer): |
| 157 | + """Vocab clip applies to tokenized ids — large id values wrap mod vocab.""" |
| 158 | + parquet = _write_text_parquet(tmp_path) |
| 159 | + extras = _run(stage_options={"train": { |
| 160 | + "num_steps": 2, "vocab_size": 32, # smaller than tokenizer vocab |
| 161 | + "parquet_path": parquet, |
| 162 | + "tokenizer_path": trained_tokenizer, |
| 163 | + }}) |
| 164 | + assert extras["data_source"] == "parquet_tokenized" |
| 165 | + # Loss must stay finite even though tokens were clipped. |
| 166 | + assert all(l == l and -1e10 < l < 1e10 for l in extras["losses"]) |
0 commit comments