feat(schema): DataConfig.replay* + _validate_replay_compat (v0.71.36 Part E)

data.replay / replay_ratio / replay_seed for continual-learning rehearsal,
plus the cross-validator gate. 540 existing schema tests still green.

Ratio semantics are pinned in the field description and by test: r is the
fraction of the FINAL mixed train set, so n_replay = round(r/(1-r)*n_new).
At r=0.1 over 1000 new rows that is 111 replay rows -> 1111 total -> 10.0%.
The naive r*n_new gives 9.09% and is wrong.

Gates, each mutation-verified to fail a named test:
- task in {sft, pretrain}: replay on dpo would be silently ignored
- packing / multipack rejected: both concatenate rows into fixed blocks, so
  the ratio stops being meaningful at block boundaries -- reject rather than
  silently mis-mix
- footgun: replay_ratio/replay_seed without data.replay silently no-op

Provenance needs no extra plumbing: data.replay* rides model_dump(), so the
experiment tracker, the registry's config_json, soup card and the repro
receipt already capture it. Per-row _replay keys would be a coin-flip bug --
sft.py computes remove_columns from dataset["train"][0], so whether the
column survives into TRL depends on whether row 0 happens to be a replay row.
This commit is contained in:
Alpamys 2026-07-16 17:21:39 +05:00
parent 72c1d4aa7f
commit 686e885858
2 changed files with 237 additions and 0 deletions

View File

@ -288,6 +288,68 @@ class DataConfig(BaseModel):
),
)
# --- v0.71.36 Data Moat II: continual-learning rehearsal ---------------
replay: Optional[str] = Field(
default=None,
description=(
"Path to an OLD dataset to interleave into training as "
"continual-learning rehearsal, so fine-tuning on a new task does "
"not erase the previous one. Rows are mixed into train ONLY "
"(never val, which stays pure new-task). sft / pretrain only; "
"incompatible with packing / multipack. (v0.71.36)"
),
)
replay_ratio: float = Field(
default=0.1,
gt=0.0,
le=0.5,
description=(
"Fraction of the FINAL mixed train set that is replay rows: "
"n_replay = round(r/(1-r) * n_new). At 0.1 over 1000 new rows "
"that is 111 replay rows -> 1111 total -> 10.0%. (v0.71.36)"
),
)
replay_seed: Optional[int] = Field(
default=None,
ge=0,
le=2_147_483_647,
description=(
"Seed for the replay sample + interleave. None = seed 0. "
"(v0.71.36)"
),
)
@field_validator("replay")
@classmethod
def _validate_replay_path(cls, v):
if v is None:
return None
if not isinstance(v, str):
raise ValueError("data.replay must be a string path")
cleaned = v.strip()
if not cleaned:
raise ValueError("data.replay must be a non-empty path")
if "\x00" in cleaned:
raise ValueError("data.replay must not contain null bytes")
if len(cleaned) > 4096:
raise ValueError("data.replay path too long (max 4096 chars)")
return cleaned
@field_validator("replay_ratio", mode="before")
@classmethod
def _validate_replay_ratio(cls, v):
# Bool is a subclass of int/float — reject before coercion.
if isinstance(v, bool):
raise ValueError("data.replay_ratio must not be a bool")
return v
@field_validator("replay_seed", mode="before")
@classmethod
def _validate_replay_seed(cls, v):
if isinstance(v, bool):
raise ValueError("data.replay_seed must not be a bool")
return v
# --- v0.42.0 Data Pipeline Pro -----------------------------------------
video_dir: Optional[str] = Field(
default=None,
@ -4592,6 +4654,47 @@ class SoupConfig(BaseModel):
)
return self
@model_validator(mode="after")
def _validate_replay_compat(self) -> "SoupConfig":
"""v0.71.36 — continual-learning rehearsal gate.
Replay interleaves rows from an OLD dataset into train so the model
does not forget the previous task. v1 covers the plain
instruction / continued-pretraining paths only.
packing / multipack concatenate rows into fixed-length blocks, so
the replay ratio stops being meaningful at block boundaries
reject rather than silently mis-mix. Setting replay_ratio /
replay_seed without data.replay silently no-ops, so reject that as
a footgun.
"""
data = self.data
replay_knobs_set = (
data.replay_ratio != 0.1 or data.replay_seed is not None
)
if data.replay is not None:
if self.task not in ("sft", "pretrain"):
raise ValueError(
"data.replay requires task='sft' or task='pretrain'; "
f"got task={self.task!r}"
)
if self.training.packing:
raise ValueError(
"data.replay is incompatible with training.packing "
"(packing concatenates rows into fixed blocks, so the "
"replay ratio stops being meaningful)"
)
if getattr(self.training, "multipack", False):
raise ValueError(
"data.replay is incompatible with training.multipack "
"(bin-packing breaks the replay ratio)"
)
elif replay_knobs_set:
raise ValueError(
"data.replay_ratio / data.replay_seed require data.replay"
)
return self
@model_validator(mode="after")
def _validate_vllm_sleep_mode(self) -> "SoupConfig":
"""v0.50.0 Part B — ``vllm_sleep_mode`` requires task='grpo' and a

View File

@ -1939,3 +1939,137 @@ class TestDataCanaryCli:
)
assert res.exit_code == 1
assert "no canaries" in _clean(res.output).lower()
_REPLAY_YAML = """
base: HuggingFaceTB/SmolLM2-135M-Instruct
task: sft
data:
train: train.jsonl
replay: old.jsonl
replay_ratio: 0.2
training:
epochs: 1
"""
_PLAIN_YAML = (
"base: m\ntask: sft\ndata:\n train: t.jsonl\ntraining:\n epochs: 1\n"
)
class TestReplaySchema:
def _load(self, yaml_str):
from soup_cli.config.loader import load_config_from_string
return load_config_from_string(yaml_str)
def test_happy_path(self):
cfg = self._load(_REPLAY_YAML)
assert cfg.data.replay == "old.jsonl"
assert cfg.data.replay_ratio == 0.2
def test_default_is_off(self):
cfg = self._load(_PLAIN_YAML)
assert cfg.data.replay is None
assert cfg.data.replay_ratio == 0.1
assert cfg.data.replay_seed is None
def test_pretrain_allowed(self):
cfg = self._load(
_REPLAY_YAML.replace("task: sft", "task: pretrain").replace(
" train: train.jsonl",
" train: train.jsonl\n format: plaintext",
)
)
assert cfg.data.replay == "old.jsonl"
def test_rejected_on_dpo(self):
with pytest.raises(Exception, match="replay"):
self._load(
_REPLAY_YAML.replace("task: sft", "task: dpo").replace(
" train: train.jsonl",
" train: train.jsonl\n format: dpo",
)
)
def test_footgun_ratio_without_replay(self):
yaml_str = (
"base: m\ntask: sft\ndata:\n train: t.jsonl\n"
" replay_ratio: 0.3\ntraining:\n epochs: 1\n"
)
with pytest.raises(Exception, match="data.replay"):
self._load(yaml_str)
def test_footgun_seed_without_replay(self):
yaml_str = (
"base: m\ntask: sft\ndata:\n train: t.jsonl\n"
" replay_seed: 7\ntraining:\n epochs: 1\n"
)
with pytest.raises(Exception, match="data.replay"):
self._load(yaml_str)
def test_mutually_exclusive_with_packing(self):
yaml_str = _REPLAY_YAML.replace(
"training:\n epochs: 1", "training:\n epochs: 1\n packing: true"
)
with pytest.raises(Exception, match="packing"):
self._load(yaml_str)
def test_mutually_exclusive_with_multipack(self):
yaml_str = _REPLAY_YAML.replace(
"training:\n epochs: 1",
"training:\n epochs: 1\n multipack: true",
)
with pytest.raises(Exception, match="multipack"):
self._load(yaml_str)
@pytest.mark.parametrize("bad", ["0.0", "0.6", "1.0", "-0.1"])
def test_ratio_bounds(self, bad):
with pytest.raises(Exception):
self._load(
_REPLAY_YAML.replace("replay_ratio: 0.2", f"replay_ratio: {bad}")
)
def test_ratio_boundary_0_5_allowed(self):
cfg = self._load(
_REPLAY_YAML.replace("replay_ratio: 0.2", "replay_ratio: 0.5")
)
assert cfg.data.replay_ratio == 0.5
@pytest.mark.parametrize("bad", ['""', '" "'])
def test_replay_field_validator_rejects_blank(self, bad):
with pytest.raises(Exception):
self._load(_REPLAY_YAML.replace("replay: old.jsonl", f"replay: {bad}"))
def test_replay_rejects_null_byte(self):
from soup_cli.config.schema import DataConfig
with pytest.raises(Exception, match="null"):
DataConfig(train="t.jsonl", replay="a\x00b")
def test_replay_rejects_overlong_path(self):
from soup_cli.config.schema import DataConfig
with pytest.raises(Exception, match="too long"):
DataConfig(train="t.jsonl", replay="x" * 5000)
def test_ratio_rejects_bool(self):
with pytest.raises(Exception):
self._load(
_REPLAY_YAML.replace("replay_ratio: 0.2", "replay_ratio: true")
)
def test_seed_rejects_bool(self):
with pytest.raises(Exception):
self._load(
_REPLAY_YAML.replace(
"replay_ratio: 0.2", "replay_seed: true"
)
)
def test_replay_survives_model_dump(self):
"""Provenance rides the schema — the tracker/registry capture it."""
cfg = self._load(_REPLAY_YAML)
dumped = cfg.model_dump()
assert dumped["data"]["replay"] == "old.jsonl"
assert dumped["data"]["replay_ratio"] == 0.2