"""Tests for PPO / Full RLHF Pipeline — config, data prep, template, routing, sweep.""" import pytest import yaml from soup_cli.config.schema import TEMPLATES, SoupConfig # ─── PPO Config Tests ────────────────────────────────────────────────────── class TestPPOConfig: """Test PPO task config validation.""" def test_ppo_task_accepted(self): """PPO task should be a valid task type.""" cfg = SoupConfig( base="some-model", task="ppo", data={"train": "./data.jsonl"}, ) assert cfg.task == "ppo" def test_ppo_epochs_default(self): """ppo_epochs should default to 4.""" cfg = SoupConfig( base="some-model", task="ppo", data={"train": "./data.jsonl"}, ) assert cfg.training.ppo_epochs == 4 def test_ppo_epochs_custom(self): """Custom ppo_epochs should be accepted.""" cfg = SoupConfig( base="some-model", task="ppo", data={"train": "./data.jsonl"}, training={"ppo_epochs": 8}, ) assert cfg.training.ppo_epochs == 8 def test_ppo_epochs_minimum(self): """ppo_epochs must be >= 1.""" with pytest.raises(Exception): SoupConfig( base="some-model", task="ppo", data={"train": "./data.jsonl"}, training={"ppo_epochs": 0}, ) def test_ppo_clip_ratio_default(self): """ppo_clip_ratio should default to 0.2.""" cfg = SoupConfig( base="some-model", task="ppo", data={"train": "./data.jsonl"}, ) assert cfg.training.ppo_clip_ratio == pytest.approx(0.2) def test_ppo_clip_ratio_custom(self): """Custom ppo_clip_ratio should be accepted.""" cfg = SoupConfig( base="some-model", task="ppo", data={"train": "./data.jsonl"}, training={"ppo_clip_ratio": 0.1}, ) assert cfg.training.ppo_clip_ratio == pytest.approx(0.1) def test_ppo_clip_ratio_must_be_positive(self): """ppo_clip_ratio must be > 0.""" with pytest.raises(Exception): SoupConfig( base="some-model", task="ppo", data={"train": "./data.jsonl"}, training={"ppo_clip_ratio": 0}, ) def test_ppo_clip_ratio_max_one(self): """ppo_clip_ratio must be <= 1.0.""" with pytest.raises(Exception): SoupConfig( base="some-model", task="ppo", data={"train": "./data.jsonl"}, training={"ppo_clip_ratio": 1.5}, ) def test_ppo_kl_penalty_default(self): """ppo_kl_penalty should default to 0.05.""" cfg = SoupConfig( base="some-model", task="ppo", data={"train": "./data.jsonl"}, ) assert cfg.training.ppo_kl_penalty == pytest.approx(0.05) def test_ppo_kl_penalty_custom(self): """Custom ppo_kl_penalty should be accepted.""" cfg = SoupConfig( base="some-model", task="ppo", data={"train": "./data.jsonl"}, training={"ppo_kl_penalty": 0.1}, ) assert cfg.training.ppo_kl_penalty == pytest.approx(0.1) def test_ppo_kl_penalty_zero_allowed(self): """ppo_kl_penalty can be 0 (no KL penalty).""" cfg = SoupConfig( base="some-model", task="ppo", data={"train": "./data.jsonl"}, training={"ppo_kl_penalty": 0}, ) assert cfg.training.ppo_kl_penalty == 0 def test_reward_model_default_none(self): """reward_model should default to None.""" cfg = SoupConfig( base="some-model", task="ppo", data={"train": "./data.jsonl"}, ) assert cfg.training.reward_model is None def test_reward_model_custom_path(self): """reward_model should accept a path.""" cfg = SoupConfig( base="some-model", task="ppo", data={"train": "./data.jsonl"}, training={"reward_model": "./output_rm"}, ) assert cfg.training.reward_model == "./output_rm" def test_reward_model_hf_id(self): """reward_model should accept an HF model ID.""" cfg = SoupConfig( base="some-model", task="ppo", data={"train": "./data.jsonl"}, training={"reward_model": "OpenAssistant/reward-model-deberta-v3-large-v2"}, ) assert "reward-model" in cfg.training.reward_model def test_ppo_full_config(self): """Full PPO config should validate correctly.""" cfg = SoupConfig( base="meta-llama/Llama-3.1-8B-Instruct", task="ppo", data={"train": "./data.jsonl", "format": "chatml", "max_length": 2048}, training={ "epochs": 1, "lr": 1e-6, "ppo_epochs": 4, "ppo_clip_ratio": 0.2, "ppo_kl_penalty": 0.05, "reward_model": "./output_rm", "lora": {"r": 64, "alpha": 16}, "quantization": "4bit", }, ) assert cfg.task == "ppo" assert cfg.training.ppo_epochs == 4 assert cfg.training.reward_model == "./output_rm" assert cfg.data.max_length == 2048 # ─── Reward Model Config Tests ──────────────────────────────────────────── class TestRewardModelConfig: """Test reward_model task config validation.""" def test_reward_model_task_accepted(self): """reward_model task should be a valid task type.""" cfg = SoupConfig( base="some-model", task="reward_model", data={"train": "./data.jsonl"}, ) assert cfg.task == "reward_model" def test_reward_model_with_dpo_format(self): """reward_model should work with DPO data format.""" cfg = SoupConfig( base="some-model", task="reward_model", data={"train": "./data.jsonl", "format": "dpo"}, ) assert cfg.task == "reward_model" assert cfg.data.format == "dpo" def test_reward_model_full_config(self): """Full reward model config should validate correctly.""" cfg = SoupConfig( base="meta-llama/Llama-3.1-8B-Instruct", task="reward_model", data={"train": "./pref_data.jsonl", "format": "dpo"}, training={ "epochs": 1, "lr": 1e-5, "lora": {"r": 32, "alpha": 16}, "quantization": "4bit", }, output="./output_rm", ) assert cfg.task == "reward_model" assert cfg.output == "./output_rm" # ─── PPO Data Preparation Tests ─────────────────────────────────────────── class TestPreparePPODataset: """Test PPO dataset preparation.""" def test_from_prompt_string(self): from soup_cli.trainer.ppo import _prepare_ppo_dataset data = [{"prompt": "What is 2+2?", "answer": "4"}] result = _prepare_ppo_dataset(data) assert len(result) == 1 assert result[0]["prompt_text"] == "What is 2+2?" assert result[0]["answer"] == "4" def test_from_messages(self): from soup_cli.trainer.ppo import _prepare_ppo_dataset data = [ { "messages": [ {"role": "system", "content": "You are helpful."}, {"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi!"}, ] } ] result = _prepare_ppo_dataset(data) assert len(result) == 1 # Should join system + user content assert "You are helpful." in result[0]["prompt_text"] assert "Hello" in result[0]["prompt_text"] def test_from_prompt_message_list(self): from soup_cli.trainer.ppo import _prepare_ppo_dataset data = [ { "prompt": [{"role": "user", "content": "What is 2+2?"}], "answer": "4", } ] result = _prepare_ppo_dataset(data) assert "What is 2+2?" in result[0]["prompt_text"] assert result[0]["answer"] == "4" def test_from_alpaca_format(self): from soup_cli.trainer.ppo import _prepare_ppo_dataset data = [{"instruction": "Translate hello", "input": "", "output": "hola"}] result = _prepare_ppo_dataset(data) assert result[0]["prompt_text"] == "Translate hello" assert result[0]["answer"] == "hola" def test_multiple_rows(self): from soup_cli.trainer.ppo import _prepare_ppo_dataset data = [ {"prompt": "Q1", "answer": "A1"}, {"prompt": "Q2", "answer": "A2"}, {"prompt": "Q3", "answer": "A3"}, ] result = _prepare_ppo_dataset(data) assert len(result) == 3 def test_prompt_without_answer(self): from soup_cli.trainer.ppo import _prepare_ppo_dataset data = [{"prompt": "Tell me a joke"}] result = _prepare_ppo_dataset(data) assert result[0]["prompt_text"] == "Tell me a joke" assert "answer" not in result[0] # ─── Reward Model Data Preparation Tests ────────────────────────────────── class TestPrepareRewardDataset: """Test reward model dataset preparation.""" def test_from_dpo_format(self): from soup_cli.trainer.reward_model import _prepare_reward_dataset data = [{"prompt": "What is AI?", "chosen": "AI is...", "rejected": "I dunno"}] result = _prepare_reward_dataset(data) assert len(result) == 1 assert "What is AI?" in result[0]["chosen"] assert "AI is..." in result[0]["chosen"] assert "What is AI?" in result[0]["rejected"] assert "I dunno" in result[0]["rejected"] def test_chosen_rejected_message_lists(self): from soup_cli.trainer.reward_model import _prepare_reward_dataset data = [ { "prompt": "Hello", "chosen": [{"role": "assistant", "content": "Hi there!"}], "rejected": [{"role": "assistant", "content": "Go away"}], } ] result = _prepare_reward_dataset(data) assert "Hi there!" in result[0]["chosen"] assert "Go away" in result[0]["rejected"] def test_without_prompt(self): from soup_cli.trainer.reward_model import _prepare_reward_dataset data = [{"chosen": "Good answer", "rejected": "Bad answer"}] result = _prepare_reward_dataset(data) assert result[0]["chosen"] == "Good answer" assert result[0]["rejected"] == "Bad answer" def test_multiple_rows(self): from soup_cli.trainer.reward_model import _prepare_reward_dataset data = [ {"prompt": "Q1", "chosen": "Good1", "rejected": "Bad1"}, {"prompt": "Q2", "chosen": "Good2", "rejected": "Bad2"}, ] result = _prepare_reward_dataset(data) assert len(result) == 2 def test_prompt_as_message_list(self): from soup_cli.trainer.reward_model import _prepare_reward_dataset data = [ { "prompt": [{"role": "user", "content": "Hello"}], "chosen": "Hi!", "rejected": "Bye", } ] result = _prepare_reward_dataset(data) assert "Hello" in result[0]["chosen"] # ─── RLHF Template Tests ───────────────────────────────────────────────── class TestRLHFTemplate: """Test the RLHF template.""" def test_rlhf_template_exists(self): assert "rlhf" in TEMPLATES def test_rlhf_template_valid_yaml(self): config = yaml.safe_load(TEMPLATES["rlhf"]) assert config["task"] == "ppo" assert config["training"]["ppo_epochs"] == 4 assert config["training"]["ppo_clip_ratio"] == 0.2 assert config["training"]["ppo_kl_penalty"] == 0.05 assert config["training"]["reward_model"] == "./output_rm" def test_rlhf_template_valid_config(self): raw = yaml.safe_load(TEMPLATES["rlhf"]) cfg = SoupConfig(**raw) assert cfg.task == "ppo" assert cfg.training.ppo_epochs == 4 assert cfg.training.reward_model == "./output_rm" # ─── Train Command Routing Tests ───────────────────────────────────────── class TestPPOTrainRouting: """Test that train command routes to PPO trainer.""" def test_ppo_import_exists(self): """PPOTrainerWrapper should be importable.""" from soup_cli.trainer.ppo import PPOTrainerWrapper assert PPOTrainerWrapper is not None def test_ppo_wrapper_init(self): """PPOTrainerWrapper should initialize without error.""" from soup_cli.trainer.ppo import PPOTrainerWrapper cfg = SoupConfig( base="some-model", task="ppo", data={"train": "./data.jsonl"}, ) wrapper = PPOTrainerWrapper(cfg, device="cpu") assert wrapper.config.task == "ppo" assert wrapper.device == "cpu" assert wrapper.model is None assert wrapper.trainer is None def test_ppo_wrapper_with_reward_config(self): """PPOTrainerWrapper should accept reward model config.""" from soup_cli.trainer.ppo import PPOTrainerWrapper cfg = SoupConfig( base="some-model", task="ppo", data={"train": "./data.jsonl"}, training={"reward_model": "./output_rm"}, ) wrapper = PPOTrainerWrapper(cfg, device="cpu") assert wrapper.config.training.reward_model == "./output_rm" def test_ppo_wrapper_deepspeed(self): """PPOTrainerWrapper should accept deepspeed config.""" from soup_cli.trainer.ppo import PPOTrainerWrapper cfg = SoupConfig( base="some-model", task="ppo", data={"train": "./data.jsonl"}, ) wrapper = PPOTrainerWrapper(cfg, device="cuda", deepspeed_config="/tmp/ds.json") assert wrapper.deepspeed_config == "/tmp/ds.json" class TestRewardModelTrainRouting: """Test that train command routes to RewardModel trainer.""" def test_reward_model_import_exists(self): """RewardModelTrainerWrapper should be importable.""" from soup_cli.trainer.reward_model import RewardModelTrainerWrapper assert RewardModelTrainerWrapper is not None def test_reward_model_wrapper_init(self): """RewardModelTrainerWrapper should initialize without error.""" from soup_cli.trainer.reward_model import RewardModelTrainerWrapper cfg = SoupConfig( base="some-model", task="reward_model", data={"train": "./data.jsonl"}, ) wrapper = RewardModelTrainerWrapper(cfg, device="cpu") assert wrapper.config.task == "reward_model" assert wrapper.device == "cpu" assert wrapper.model is None assert wrapper.trainer is None # ─── Sweep Shortcut Tests ──────────────────────────────────────────────── class TestPPOSweepParams: """Test PPO parameter shortcuts in sweep.""" def test_ppo_epochs_shortcut(self): from soup_cli.commands.sweep import _set_nested_param config = {"training": {"ppo_epochs": 4}} _set_nested_param(config, "ppo_epochs", 8) assert config["training"]["ppo_epochs"] == 8 def test_ppo_clip_ratio_shortcut(self): from soup_cli.commands.sweep import _set_nested_param config = {"training": {"ppo_clip_ratio": 0.2}} _set_nested_param(config, "ppo_clip_ratio", 0.1) assert config["training"]["ppo_clip_ratio"] == 0.1 def test_ppo_kl_penalty_shortcut(self): from soup_cli.commands.sweep import _set_nested_param config = {"training": {"ppo_kl_penalty": 0.05}} _set_nested_param(config, "ppo_kl_penalty", 0.1) assert config["training"]["ppo_kl_penalty"] == 0.1 def test_reward_model_shortcut(self): from soup_cli.commands.sweep import _set_nested_param config = {"training": {"reward_model": None}} _set_nested_param(config, "reward_model", "./my_rm") assert config["training"]["reward_model"] == "./my_rm" # ─── Init Command Tests ────────────────────────────────────────────────── class TestInitRLHF: """Test init command with RLHF template.""" @staticmethod def _strip_ansi(text: str) -> str: """Remove ANSI escape codes from text.""" import re return re.sub(r"\x1b\[[0-9;]*m", "", text) def test_init_rlhf_template(self, tmp_path): """soup init --template rlhf should create a valid config.""" from typer.testing import CliRunner from soup_cli.cli import app runner = CliRunner() output_file = tmp_path / "soup_ppo.yaml" result = runner.invoke(app, ["init", "--template", "rlhf", "--output", str(output_file)]) assert result.exit_code == 0 assert output_file.exists() config = yaml.safe_load(output_file.read_text()) assert config["task"] == "ppo" def test_init_help_shows_rlhf(self): """soup init --help should mention rlhf template.""" from typer.testing import CliRunner from soup_cli.cli import app runner = CliRunner() result = runner.invoke(app, ["init", "--help"]) assert result.exit_code == 0 clean = self._strip_ansi(result.output) assert "rlhf" in clean # ─── CLI Registration Tests ───────────────────────────────────────────── class TestTrainCliRegistration: """Test train command handles PPO and reward_model tasks.""" @staticmethod def _strip_ansi(text: str) -> str: """Remove ANSI escape codes from text.""" import re return re.sub(r"\x1b\[[0-9;]*m", "", text) def test_train_help_shows_config(self): """soup train --help should show --config option.""" from typer.testing import CliRunner from soup_cli.cli import app runner = CliRunner() result = runner.invoke(app, ["train", "--help"]) assert result.exit_code == 0 clean = self._strip_ansi(result.output) assert "--config" in clean # ─── Edge Cases ────────────────────────────────────────────────────────── class TestPPOEdgeCases: """Test edge cases for PPO configuration.""" def test_ppo_with_reward_fn_and_no_model(self): """PPO with reward_fn but no reward_model should be valid.""" cfg = SoupConfig( base="some-model", task="ppo", data={"train": "./data.jsonl"}, training={"reward_fn": "format"}, ) assert cfg.training.reward_fn == "format" assert cfg.training.reward_model is None def test_ppo_with_both_reward_sources(self): """PPO with both reward_model and reward_fn should be valid.""" cfg = SoupConfig( base="some-model", task="ppo", data={"train": "./data.jsonl"}, training={"reward_model": "./output_rm", "reward_fn": "format"}, ) assert cfg.training.reward_model == "./output_rm" assert cfg.training.reward_fn == "format" def test_ppo_with_unsloth_backend(self): """PPO should accept unsloth backend.""" cfg = SoupConfig( base="some-model", task="ppo", backend="unsloth", data={"train": "./data.jsonl"}, ) assert cfg.backend == "unsloth" def test_reward_model_with_quantization(self): """Reward model should accept quantization settings.""" cfg = SoupConfig( base="some-model", task="reward_model", data={"train": "./data.jsonl"}, training={"quantization": "8bit"}, ) assert cfg.training.quantization == "8bit" def test_all_five_tasks(self): """All five task types should be valid.""" for task in ["sft", "dpo", "grpo", "ppo", "reward_model"]: cfg = SoupConfig( base="some-model", task=task, data={"train": "./data.jsonl"}, ) assert cfg.task == task def test_invalid_task_rejected(self): """Invalid task should be rejected by validation.""" with pytest.raises(Exception): SoupConfig( base="some-model", task="invalid_task", data={"train": "./data.jsonl"}, )