Skip to content

Commit 9eb0287

Browse files
committed
feat(v3-2): stage_train consumes real tokens from parquet when supplied
Closes V3-2 / cppmega-mlx-4pp. Fixes B2: stage_train hardcoded a synthetic random-targets tensor, so the parquet shard + tokenizer the user selected in the Data tab had zero effect on training. Now: stage_options.train.parquet_path → _read_first_n_tokens(...) reads the first batch*seq integers from input_ids / token_ids / tokens column (reuses the column-detection convention from data_methods.py), clips mod vocab_size, and uses them as the gradient targets. When the path is absent, malformed, or lacks a token column the stage falls back to the legacy random-targets path so E-4 matrix tests stay green. extras gains: - data_source: "synthetic" | "parquet" - token_count: int (0 in synthetic mode) These let Playwright assert the real-data code path actually executed rather than silently degrading. 5 new pytest. Regression 85/85 across V3-1 / V3-2 / schedules / runner. UI plumbing (passing parquet_path via stage_options.train from the DataInspector selection) is a follow-up — backend exposure first.
1 parent 27e286d commit 9eb0287

2 files changed

Lines changed: 166 additions & 3 deletions

File tree

cppmega_v4/runner/stages.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -415,10 +415,32 @@ def loss_fn(model: nn.Module, emb: mx.array, tgt: mx.array) -> mx.array:
415415
reduction="mean",
416416
)
417417

418-
# Build inputs once: synthetic Gaussian embeddings (no real
419-
# tokenizer dependence — train matrix isolates the gradient path).
418+
# V3-2: prefer real tokens from a parquet shard when opts
419+
# supplies parquet_path. Falls back to synthetic random targets
420+
# when absent (preserves E-4 matrix behaviour). data_source +
421+
# token_count surface in extras so deep e2e can prove the real-
422+
# data path actually executed instead of silently degrading.
420423
rng_key = mx.random.key(0)
421-
targets = mx.random.randint(0, vocab_size, shape=(batch, seq))
424+
data_source = "synthetic"
425+
token_count = 0
426+
parquet_path = opts.get("parquet_path")
427+
if parquet_path:
428+
try:
429+
tokens = _read_first_n_tokens(parquet_path, n=batch * seq)
430+
if len(tokens) >= batch * seq:
431+
tokens = [int(t) % vocab_size for t in tokens[:batch * seq]]
432+
targets = mx.array(tokens, dtype=mx.int32).reshape(
433+
batch, seq)
434+
data_source = "parquet"
435+
token_count = len(tokens)
436+
else:
437+
targets = mx.random.randint(
438+
0, vocab_size, shape=(batch, seq))
439+
except Exception:
440+
targets = mx.random.randint(
441+
0, vocab_size, shape=(batch, seq))
442+
else:
443+
targets = mx.random.randint(0, vocab_size, shape=(batch, seq))
422444
all_modules = nn.Sequential(*modules, lm_head)
423445
opt, optimizer_kind = _build_optimizer(spec_optim, lr)
424446
loss_and_grad = nn.value_and_grad(all_modules, loss_fn)
@@ -508,6 +530,8 @@ def loss_fn(model: nn.Module, emb: mx.array, tgt: mx.array) -> mx.array:
508530
"num_steps": n_steps,
509531
"schedule_kind": schedule_kind_label,
510532
"optimizer_kind": optimizer_kind,
533+
"data_source": data_source,
534+
"token_count": token_count,
511535
"model_summary": _summarize_model(
512536
ctx.spec, optimizer_kind, schedule_kind_label),
513537
},
@@ -521,6 +545,33 @@ def loss_fn(model: nn.Module, emb: mx.array, tgt: mx.array) -> mx.array:
521545
# ---------------------------------------------------------------------------
522546

523547

548+
def _read_first_n_tokens(parquet_path: str, n: int) -> list[int]:
549+
"""V3-2: read the first ``n`` token ids from a parquet shard so
550+
stage_train can train against real corpus tokens instead of a
551+
random target tensor. Reuses the column-detection convention from
552+
cppmega_v4.jsonrpc.data_methods (input_ids / token_ids / tokens)."""
553+
import pyarrow.parquet as pq
554+
table = pq.read_table(parquet_path)
555+
primary = None
556+
for c in ("input_ids", "token_ids", "tokens"):
557+
if c in table.column_names:
558+
primary = c
559+
break
560+
if primary is None:
561+
return []
562+
column = table.column(primary)
563+
out: list[int] = []
564+
for chunk in column.chunks:
565+
for cell in chunk.to_pylist():
566+
if cell is None:
567+
continue
568+
for tok in cell:
569+
out.append(int(tok))
570+
if len(out) >= n:
571+
return out
572+
return out
573+
574+
524575
def _build_optimizer(
525576
spec_optim: OptimSpec | None, base_lr: float,
526577
) -> tuple[Any, str]:

tests/v4/test_stage_train_data.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""V3-2: stage_train consumes real tokens from a parquet shard.
2+
3+
Previously stage_train hardcoded a synthetic random-targets tensor and
4+
silently ignored any parquet/tokenizer the UI selected. Now opts may
5+
supply parquet_path; extras report data_source ('synthetic'|'parquet')
6+
and token_count for assertion.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
import pathlib
13+
14+
import pyarrow as pa
15+
import pyarrow.parquet as pq
16+
import pytest
17+
18+
from cppmega_v4.jsonrpc.schema import VerifyParams
19+
from cppmega_v4.runner import Pipeline, run_pipeline
20+
21+
22+
def _verify_params() -> VerifyParams:
23+
return VerifyParams.model_validate({
24+
"graph": {
25+
"nodes": [
26+
{"id": "attn", "kind": "attention", "params": {}},
27+
{"id": "mlp", "kind": "mlp",
28+
"params": {"intermediate_size": 64, "activation": "swiglu"}},
29+
],
30+
"edges": [{"src": "attn", "dst": "mlp"}],
31+
},
32+
"dim_env": {"B": 1, "S": 8, "H": 32, "nh": 2, "nkv": 1,
33+
"head_dim": 16},
34+
"loss": {"kind": "cross_entropy", "head_outputs": ["mlp"]},
35+
"optim": {"kind": "adamw",
36+
"groups": [{"matcher": "all", "lr": 1e-3,
37+
"weight_decay": 0.01,
38+
"betas": [0.9, 0.95]}]},
39+
})
40+
41+
42+
def _write_parquet(tmp_path: pathlib.Path, column: str = "input_ids",
43+
n_rows: int = 4, n_tokens_per_row: int = 64) -> str:
44+
rows = []
45+
for r in range(n_rows):
46+
rows.append(list(range(r * n_tokens_per_row,
47+
(r + 1) * n_tokens_per_row)))
48+
table = pa.table({column: rows})
49+
tmp_path.mkdir(parents=True, exist_ok=True)
50+
path = tmp_path / "fixture.parquet"
51+
pq.write_table(table, path)
52+
return str(path)
53+
54+
55+
def _run(stage_options: dict) -> dict:
56+
spec = _verify_params()
57+
report = run_pipeline(spec, Pipeline.from_dict({
58+
"stages": ["parse", "verify_build_spec", "build_model", "train"],
59+
"stage_options": stage_options,
60+
}))
61+
train = next(s for s in report.stages if s.name == "train")
62+
assert train.status == "ok", f"stage_train failed: {train.error}"
63+
return train.extras
64+
65+
66+
def test_stage_train_synthetic_fallback_when_no_parquet():
67+
"""No parquet_path → falls back to synthetic random targets.
68+
Backwards compat with E-4 train matrix."""
69+
extras = _run(stage_options={"train": {"num_steps": 2}})
70+
assert extras["data_source"] == "synthetic"
71+
assert extras["token_count"] == 0
72+
73+
74+
def test_stage_train_reads_real_tokens_from_parquet(tmp_path):
75+
"""parquet_path → tokens loaded; extras.data_source='parquet'."""
76+
path = _write_parquet(tmp_path)
77+
extras = _run(stage_options={"train": {
78+
"num_steps": 2, "parquet_path": path,
79+
}})
80+
assert extras["data_source"] == "parquet"
81+
assert extras["token_count"] == 1 * 8 # batch=1, seq=8
82+
83+
84+
def test_stage_train_falls_back_when_parquet_missing_token_column(tmp_path):
85+
"""Parquet without input_ids/token_ids/tokens column → fallback."""
86+
table = pa.table({"some_other_col": [[1, 2], [3, 4]]})
87+
p = tmp_path / "no_tokens.parquet"
88+
pq.write_table(table, p)
89+
extras = _run(stage_options={"train": {
90+
"num_steps": 2, "parquet_path": str(p),
91+
}})
92+
assert extras["data_source"] == "synthetic"
93+
assert extras["token_count"] == 0
94+
95+
96+
def test_stage_train_falls_back_when_parquet_path_invalid():
97+
"""Bad parquet path → synthetic fallback (no exception leaks)."""
98+
extras = _run(stage_options={"train": {
99+
"num_steps": 2, "parquet_path": "/nonexistent/file.parquet",
100+
}})
101+
assert extras["data_source"] == "synthetic"
102+
assert extras["token_count"] == 0
103+
104+
105+
def test_stage_train_uses_alt_column_names(tmp_path):
106+
"""token_ids and tokens are recognised in addition to input_ids."""
107+
for col in ("token_ids", "tokens"):
108+
p = _write_parquet(tmp_path / col, column=col)
109+
extras = _run(stage_options={"train": {
110+
"num_steps": 2, "parquet_path": p,
111+
}})
112+
assert extras["data_source"] == "parquet", f"col={col}"

0 commit comments

Comments
 (0)