mirror of https://github.com/razor-ai/soup.git
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:
parent
72c1d4aa7f
commit
686e885858
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue