diff --git a/src/soup_cli/config/schema.py b/src/soup_cli/config/schema.py index 02c761d..8193477 100644 --- a/src/soup_cli/config/schema.py +++ b/src/soup_cli/config/schema.py @@ -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 diff --git a/tests/test_v07136.py b/tests/test_v07136.py index 0a9af07..84c323f 100644 --- a/tests/test_v07136.py +++ b/tests/test_v07136.py @@ -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