"""Tests for ORPO training — config, data format, template, routing, sweep.""" from unittest.mock import MagicMock from unittest.mock import patch as mock_patch import pytest from pydantic import ValidationError from soup_cli.config.schema import TEMPLATES, SoupConfig # ─── Config Tests ─────────────────────────────────────────────────────────── class TestORPOConfig: """Test ORPO task config validation.""" def test_orpo_task_accepted(self): """ORPO task should be a valid task type.""" cfg = SoupConfig( base="some-model", task="orpo", data={"train": "./data.jsonl"}, ) assert cfg.task == "orpo" def test_orpo_beta_default(self): """orpo_beta should default to 0.1.""" cfg = SoupConfig( base="some-model", task="orpo", data={"train": "./data.jsonl"}, ) assert cfg.training.orpo_beta == 0.1 def test_orpo_beta_custom(self): """Custom orpo_beta should be accepted.""" cfg = SoupConfig( base="some-model", task="orpo", data={"train": "./data.jsonl"}, training={"orpo_beta": 0.05}, ) assert cfg.training.orpo_beta == pytest.approx(0.05) def test_orpo_beta_must_be_positive(self): """orpo_beta must be > 0.""" with pytest.raises(ValidationError): SoupConfig( base="some-model", task="orpo", data={"train": "./data.jsonl"}, training={"orpo_beta": 0}, ) def test_orpo_full_config(self): """Full ORPO config should validate correctly.""" cfg = SoupConfig( base="meta-llama/Llama-3.1-8B-Instruct", task="orpo", data={"train": "./data.jsonl", "format": "dpo", "max_length": 2048}, training={ "epochs": 3, "lr": 1e-5, "orpo_beta": 0.2, "lora": {"r": 64, "alpha": 16}, "quantization": "4bit", }, ) assert cfg.task == "orpo" assert cfg.training.orpo_beta == pytest.approx(0.2) assert cfg.data.max_length == 2048 def test_orpo_uses_dpo_data_format(self): """ORPO should work with the DPO data format.""" cfg = SoupConfig( base="some-model", task="orpo", data={"train": "./data.jsonl", "format": "dpo"}, ) assert cfg.data.format == "dpo" # ─── Data Format Tests ───────────────────────────────────────────────────── class TestORPODataFormat: """Test that ORPO uses DPO data format correctly.""" def test_dpo_format_works_for_orpo(self): """DPO format signature should detect preference data.""" from soup_cli.data.formats import detect_format data = [{"prompt": "Q", "chosen": "A", "rejected": "B"}] assert detect_format(data) == "dpo" def test_convert_dpo_row_for_orpo(self): """format_to_messages should convert DPO rows correctly.""" from soup_cli.data.formats import format_to_messages row = {"prompt": "What is 2+2?", "chosen": "4", "rejected": "Fish"} result = format_to_messages(row, "dpo") assert result["prompt"] == "What is 2+2?" assert result["chosen"] == "4" assert result["rejected"] == "Fish" # ─── Template Tests ────────────────────────────────────────────────────────── class TestORPOTemplate: """Test the ORPO template.""" def test_orpo_template_exists(self): assert "orpo" in TEMPLATES def test_orpo_template_valid_yaml(self): import yaml config = yaml.safe_load(TEMPLATES["orpo"]) assert config["task"] == "orpo" assert config["training"]["orpo_beta"] == 0.1 assert config["data"]["format"] == "dpo" def test_orpo_template_valid_config(self): import yaml raw = yaml.safe_load(TEMPLATES["orpo"]) cfg = SoupConfig(**raw) assert cfg.task == "orpo" assert cfg.training.orpo_beta == 0.1 # ─── Train Command Routing Tests ───────────────────────────────────────────── class TestORPOTrainRouting: """Test that train command routes to ORPO trainer.""" def test_orpo_import_exists(self): """ORPOTrainerWrapper should be importable.""" from soup_cli.trainer.orpo import ORPOTrainerWrapper assert ORPOTrainerWrapper is not None def test_orpo_wrapper_init(self): """ORPOTrainerWrapper should initialize without error.""" from soup_cli.trainer.orpo import ORPOTrainerWrapper cfg = SoupConfig( base="some-model", task="orpo", data={"train": "./data.jsonl"}, ) wrapper = ORPOTrainerWrapper(cfg, device="cpu") assert wrapper.config.task == "orpo" assert wrapper.device == "cpu" assert wrapper.model is None assert wrapper.trainer is None def test_orpo_wrapper_init_with_options(self): """ORPOTrainerWrapper should accept all constructor options.""" from soup_cli.trainer.orpo import ORPOTrainerWrapper cfg = SoupConfig( base="some-model", task="orpo", data={"train": "./data.jsonl"}, ) wrapper = ORPOTrainerWrapper( cfg, device="cuda", report_to="wandb", deepspeed_config="ds.json", ) assert wrapper.report_to == "wandb" assert wrapper.deepspeed_config == "ds.json" # ─── Sweep Shortcut Tests ──────────────────────────────────────────────────── class TestORPOSweepParams: """Test ORPO parameter shortcuts in sweep.""" def test_orpo_beta_shortcut(self): from soup_cli.commands.sweep import _set_nested_param config = {"training": {"orpo_beta": 0.1}} _set_nested_param(config, "orpo_beta", 0.05) assert config["training"]["orpo_beta"] == 0.05 def test_orpo_beta_shortcut_creates_nested_key(self): from soup_cli.commands.sweep import _set_nested_param config = {} _set_nested_param(config, "orpo_beta", 0.2) assert config["training"]["orpo_beta"] == pytest.approx(0.2) def test_sweep_run_single_routes_to_orpo_trainer(self): """_run_single should instantiate ORPOTrainerWrapper for orpo task.""" from soup_cli.commands.sweep import _run_single cfg = SoupConfig( base="some-model", task="orpo", data={"train": "./data.jsonl"}, ) fake_dataset = { "train": [ {"prompt": "Q?", "chosen": "A", "rejected": "B"}, ] } fake_result = { "initial_loss": 1.0, "final_loss": 0.5, "total_steps": 10, "duration_secs": 60.0, "output_dir": "./output", "duration": "1m", } fake_gpu_info = {"memory_total": "0 MB", "memory_total_bytes": 0} with mock_patch("soup_cli.data.loader.load_dataset", return_value=fake_dataset), \ mock_patch("soup_cli.utils.gpu.detect_device", return_value=("cpu", "CPU")), \ mock_patch("soup_cli.utils.gpu.get_gpu_info", return_value=fake_gpu_info), \ mock_patch("soup_cli.experiment.tracker.ExperimentTracker") as mock_tracker_cls, \ mock_patch("soup_cli.monitoring.display.TrainingDisplay"), \ mock_patch("soup_cli.trainer.orpo.ORPOTrainerWrapper.setup"), \ mock_patch( "soup_cli.trainer.orpo.ORPOTrainerWrapper.train", return_value=fake_result ) as mock_train: mock_tracker = MagicMock() mock_tracker.start_run.return_value = "run-orpo-1" mock_tracker_cls.return_value = mock_tracker result = _run_single(cfg, {}, "orpo_run_1", None) mock_train.assert_called_once() assert result["run_id"] == "run-orpo-1" # ─── Config Validation Edge Cases ──────────────────────────────────────────── class TestORPOConfigEdgeCases: """Additional config validation edge cases for ORPO.""" def test_orpo_beta_negative_rejected(self): with pytest.raises(ValidationError): SoupConfig( base="some-model", task="orpo", data={"train": "./data.jsonl"}, training={"orpo_beta": -0.1}, ) def test_orpo_beta_very_large_accepted(self): cfg = SoupConfig( base="some-model", task="orpo", data={"train": "./data.jsonl"}, training={"orpo_beta": 10.0}, ) assert cfg.training.orpo_beta == pytest.approx(10.0) def test_orpo_config_unsloth_backend(self): cfg = SoupConfig( base="some-model", task="orpo", backend="unsloth", data={"train": "./data.jsonl"}, ) assert cfg.backend == "unsloth" assert cfg.task == "orpo" def test_orpo_tokenizer_none_before_setup(self): from soup_cli.trainer.orpo import ORPOTrainerWrapper cfg = SoupConfig( base="some-model", task="orpo", data={"train": "./data.jsonl"}, ) wrapper = ORPOTrainerWrapper(cfg) assert wrapper.tokenizer is None def test_orpo_output_dir_none_before_setup(self): from soup_cli.trainer.orpo import ORPOTrainerWrapper cfg = SoupConfig( base="some-model", task="orpo", data={"train": "./data.jsonl"}, ) wrapper = ORPOTrainerWrapper(cfg) assert wrapper._output_dir is None # ─── Train Method Guard Test ────────────────────────────────────────────────── class TestORPOTrainGuard: """Test the RuntimeError guard when train() is called before setup().""" def test_train_before_setup_raises_runtime_error(self): from soup_cli.trainer.orpo import ORPOTrainerWrapper cfg = SoupConfig( base="some-model", task="orpo", data={"train": "./data.jsonl"}, ) wrapper = ORPOTrainerWrapper(cfg) with pytest.raises(RuntimeError, match="setup\\(dataset\\) first"): wrapper.train() # ─── Train Method Result Structure ─────────────────────────────────────────── class TestORPOTrainResults: """Test the result dict returned by train() using a mocked trainer.""" def _make_wrapper_with_mock_trainer(self, log_history=None, global_step=20): from soup_cli.trainer.orpo import ORPOTrainerWrapper cfg = SoupConfig( base="some-model", task="orpo", data={"train": "./data.jsonl"}, output="./output", ) wrapper = ORPOTrainerWrapper(cfg, device="cpu") mock_trainer = MagicMock() mock_trainer.train = MagicMock() mock_trainer.state.log_history = log_history if log_history is not None else [] mock_trainer.state.global_step = global_step mock_trainer.save_model = MagicMock() wrapper.trainer = mock_trainer wrapper.tokenizer = MagicMock() wrapper._output_dir = "./output" return wrapper, mock_trainer def test_train_returns_expected_keys(self): wrapper, _ = self._make_wrapper_with_mock_trainer( log_history=[{"loss": 1.5}, {"loss": 0.8}], global_step=10 ) result = wrapper.train() assert "initial_loss" in result assert "final_loss" in result assert "duration" in result assert "duration_secs" in result assert "output_dir" in result assert "total_steps" in result def test_train_result_losses_from_log_history(self): wrapper, _ = self._make_wrapper_with_mock_trainer( log_history=[{"loss": 2.0}, {"loss": 1.0}, {"loss": 0.5}], global_step=30 ) result = wrapper.train() assert result["initial_loss"] == pytest.approx(2.0) assert result["final_loss"] == pytest.approx(0.5) def test_train_result_empty_log_history(self): wrapper, _ = self._make_wrapper_with_mock_trainer(log_history=[], global_step=0) result = wrapper.train() assert result["initial_loss"] == 0 assert result["final_loss"] == 0 def test_train_calls_save_model(self): wrapper, mock_trainer = self._make_wrapper_with_mock_trainer( log_history=[{"loss": 1.0}], global_step=5 ) wrapper.train() mock_trainer.save_model.assert_called_once_with("./output") def test_train_passes_resume_checkpoint(self): wrapper, mock_trainer = self._make_wrapper_with_mock_trainer( log_history=[{"loss": 1.0}], global_step=5 ) wrapper.train(resume_from_checkpoint="/ckpt/checkpoint-50") mock_trainer.train.assert_called_once_with( resume_from_checkpoint="/ckpt/checkpoint-50" ) # ─── CLI Init ORPO Template Tests ───────────────────────────────────────────── class TestORPOInitTemplate: """Test that soup init produces correct output for ORPO.""" def test_init_orpo_template_creates_file(self, tmp_path): from typer.testing import CliRunner from soup_cli.cli import app runner = CliRunner() output = tmp_path / "soup.yaml" result = runner.invoke(app, ["init", "--template", "orpo", "--output", str(output)]) assert result.exit_code == 0 assert output.exists() content = output.read_text() assert "task: orpo" in content assert "orpo_beta" in content assert "format: dpo" in content def test_init_orpo_template_produces_valid_config(self, tmp_path): from pathlib import Path from typer.testing import CliRunner from soup_cli.cli import app from soup_cli.config.loader import load_config runner = CliRunner() output = tmp_path / "soup.yaml" runner.invoke(app, ["init", "--template", "orpo", "--output", str(output)]) cfg = load_config(Path(output)) assert cfg.task == "orpo" assert cfg.training.orpo_beta == pytest.approx(0.1) # ─── Wizard ORPO Path Tests ──────────────────────────────────────────────────── class TestORPOWizardPath: """Test the interactive wizard auto-sets format for ORPO task.""" def test_wizard_orpo_task_sets_dpo_format(self): from soup_cli.commands.init import _interactive_wizard with mock_patch("soup_cli.commands.init.Prompt.ask", side_effect=[ "some-model", "orpo", "./data.jsonl", "3", "yes", ]): config_text = _interactive_wizard() assert "task: orpo" in config_text assert "format: dpo" in config_text assert "orpo_beta: 0.1" in config_text def test_wizard_orpo_does_not_prompt_for_format(self): from soup_cli.commands.init import _interactive_wizard prompt_calls = [] def record_prompt(question, **kwargs): prompt_calls.append(question) answers = { "Base model": "some-model", "Task": "orpo", "Training data path": "./data.jsonl", "Epochs": "3", "Use QLoRA (4-bit)?": "yes", } return answers.get(question, kwargs.get("default", "")) with mock_patch("soup_cli.commands.init.Prompt.ask", side_effect=record_prompt): _interactive_wizard() assert not any("format" in call.lower() for call in prompt_calls) # ─── Config Loader Round-trip Tests ────────────────────────────────────────── class TestORPOConfigLoaderRoundTrip: """Test ORPO template YAML survives round-trip.""" def test_orpo_template_round_trip(self): from soup_cli.config.loader import load_config_from_string cfg = load_config_from_string(TEMPLATES["orpo"]) assert cfg.task == "orpo" assert cfg.training.orpo_beta == pytest.approx(0.1) assert cfg.data.format == "dpo" def test_orpo_custom_yaml_round_trip(self): from soup_cli.config.loader import load_config_from_string yaml_str = """ base: custom-model/llama-7b task: orpo data: train: ./orpo_data.jsonl format: dpo max_length: 1024 training: epochs: 5 lr: 5e-6 orpo_beta: 0.05 quantization: none output: ./orpo_output """ cfg = load_config_from_string(yaml_str) assert cfg.task == "orpo" assert cfg.training.orpo_beta == pytest.approx(0.05) assert cfg.training.epochs == 5