"""Tests for KTO 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 TestKTOConfig: """Test KTO task config validation.""" def test_kto_task_accepted(self): """KTO task should be a valid task type.""" cfg = SoupConfig( base="some-model", task="kto", data={"train": "./data.jsonl"}, ) assert cfg.task == "kto" def test_kto_beta_default(self): """kto_beta should default to 0.1.""" cfg = SoupConfig( base="some-model", task="kto", data={"train": "./data.jsonl"}, ) assert cfg.training.kto_beta == 0.1 def test_kto_beta_custom(self): """Custom kto_beta should be accepted.""" cfg = SoupConfig( base="some-model", task="kto", data={"train": "./data.jsonl"}, training={"kto_beta": 0.05}, ) assert cfg.training.kto_beta == pytest.approx(0.05) def test_kto_beta_must_be_positive(self): """kto_beta must be > 0.""" with pytest.raises(ValidationError): SoupConfig( base="some-model", task="kto", data={"train": "./data.jsonl"}, training={"kto_beta": 0}, ) def test_kto_full_config(self): """Full KTO config should validate correctly.""" cfg = SoupConfig( base="meta-llama/Llama-3.1-8B-Instruct", task="kto", data={"train": "./data.jsonl", "format": "kto", "max_length": 2048}, training={ "epochs": 3, "lr": 1e-5, "kto_beta": 0.2, "lora": {"r": 64, "alpha": 16}, "quantization": "4bit", }, ) assert cfg.task == "kto" assert cfg.training.kto_beta == pytest.approx(0.2) assert cfg.data.max_length == 2048 def test_kto_data_format_accepted(self): """KTO format should be accepted in DataConfig.""" cfg = SoupConfig( base="some-model", task="kto", data={"train": "./data.jsonl", "format": "kto"}, ) assert cfg.data.format == "kto" # ─── Data Format Tests ───────────────────────────────────────────────────── class TestKTODataFormat: """Test KTO data format detection and conversion.""" def test_format_signature_exists(self): """KTO format signature should be registered.""" from soup_cli.data.formats import FORMAT_SIGNATURES assert "kto" in FORMAT_SIGNATURES assert FORMAT_SIGNATURES["kto"] == {"prompt", "completion", "label"} def test_detect_kto_format(self): """Should auto-detect KTO format from data keys.""" from soup_cli.data.formats import detect_format data = [{"prompt": "Q", "completion": "A", "label": True}] assert detect_format(data) == "kto" def test_detect_kto_with_extra_keys(self): """Should detect KTO format even with extra keys.""" from soup_cli.data.formats import detect_format data = [{"prompt": "Q", "completion": "A", "label": False, "id": 1}] assert detect_format(data) == "kto" def test_convert_kto_desirable(self): """Should convert desirable KTO row correctly.""" from soup_cli.data.formats import format_to_messages row = {"prompt": "What is 2+2?", "completion": "4", "label": True} result = format_to_messages(row, "kto") assert result["prompt"] == "What is 2+2?" assert result["completion"] == "4" assert result["label"] is True def test_convert_kto_undesirable(self): """Should convert undesirable KTO row correctly.""" from soup_cli.data.formats import format_to_messages row = {"prompt": "What is 2+2?", "completion": "Fish", "label": False} result = format_to_messages(row, "kto") assert result["prompt"] == "What is 2+2?" assert result["completion"] == "Fish" assert result["label"] is False def test_convert_kto_label_coerced_to_bool(self): """Integer labels should be coerced to boolean.""" from soup_cli.data.formats import format_to_messages row = {"prompt": "Q", "completion": "A", "label": 1} result = format_to_messages(row, "kto") assert result["label"] is True row_false = {"prompt": "Q", "completion": "A", "label": 0} result_false = format_to_messages(row_false, "kto") assert result_false["label"] is False def test_convert_kto_string_label_true(self): """String 'true'/'yes'/'1' should parse as True.""" from soup_cli.data.formats import format_to_messages for val in ("true", "True", "TRUE", "yes", "1"): row = {"prompt": "Q", "completion": "A", "label": val} result = format_to_messages(row, "kto") assert result["label"] is True, f"Expected True for label={val!r}" def test_convert_kto_string_label_false(self): """String 'false'/'no'/'0' should parse as False (not truthy coercion).""" from soup_cli.data.formats import format_to_messages for val in ("false", "False", "FALSE", "no", "0"): row = {"prompt": "Q", "completion": "A", "label": val} result = format_to_messages(row, "kto") assert result["label"] is False, f"Expected False for label={val!r}" def test_convert_kto_string_label_invalid_returns_none(self): """Invalid string label should cause conversion to return None.""" from soup_cli.data.formats import format_to_messages row = {"prompt": "Q", "completion": "A", "label": "maybe"} result = format_to_messages(row, "kto") assert result is None # ─── Template Tests ────────────────────────────────────────────────────────── class TestKTOTemplate: """Test the KTO template.""" def test_kto_template_exists(self): assert "kto" in TEMPLATES def test_kto_template_valid_yaml(self): import yaml config = yaml.safe_load(TEMPLATES["kto"]) assert config["task"] == "kto" assert config["training"]["kto_beta"] == 0.1 assert config["data"]["format"] == "kto" def test_kto_template_valid_config(self): import yaml raw = yaml.safe_load(TEMPLATES["kto"]) cfg = SoupConfig(**raw) assert cfg.task == "kto" assert cfg.training.kto_beta == 0.1 # ─── Train Command Routing Tests ───────────────────────────────────────────── class TestKTOTrainRouting: """Test that train command routes to KTO trainer.""" def test_kto_import_exists(self): """KTOTrainerWrapper should be importable.""" from soup_cli.trainer.kto import KTOTrainerWrapper assert KTOTrainerWrapper is not None def test_kto_wrapper_init(self): """KTOTrainerWrapper should initialize without error.""" from soup_cli.trainer.kto import KTOTrainerWrapper cfg = SoupConfig( base="some-model", task="kto", data={"train": "./data.jsonl"}, ) wrapper = KTOTrainerWrapper(cfg, device="cpu") assert wrapper.config.task == "kto" assert wrapper.device == "cpu" assert wrapper.model is None assert wrapper.trainer is None def test_kto_wrapper_init_with_options(self): """KTOTrainerWrapper should accept all constructor options.""" from soup_cli.trainer.kto import KTOTrainerWrapper cfg = SoupConfig( base="some-model", task="kto", data={"train": "./data.jsonl"}, ) wrapper = KTOTrainerWrapper( 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 TestKTOSweepParams: """Test KTO parameter shortcuts in sweep.""" def test_kto_beta_shortcut(self): from soup_cli.commands.sweep import _set_nested_param config = {"training": {"kto_beta": 0.1}} _set_nested_param(config, "kto_beta", 0.05) assert config["training"]["kto_beta"] == 0.05 def test_kto_beta_shortcut_creates_nested_key(self): """kto_beta shortcut should create nested training dict if missing.""" from soup_cli.commands.sweep import _set_nested_param config = {} _set_nested_param(config, "kto_beta", 0.2) assert config["training"]["kto_beta"] == pytest.approx(0.2) def test_sweep_run_single_routes_to_kto_trainer(self): """_run_single should instantiate KTOTrainerWrapper for kto task.""" from soup_cli.commands.sweep import _run_single cfg = SoupConfig( base="some-model", task="kto", data={"train": "./data.jsonl"}, ) fake_dataset = { "train": [ {"prompt": "Q?", "completion": "A", "label": True}, ] } 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.kto.KTOTrainerWrapper.setup"), \ mock_patch( "soup_cli.trainer.kto.KTOTrainerWrapper.train", return_value=fake_result ) as mock_train: mock_tracker = MagicMock() mock_tracker.start_run.return_value = "run-kto-1" mock_tracker_cls.return_value = mock_tracker result = _run_single(cfg, {}, "kto_run_1", None) mock_train.assert_called_once() assert result["run_id"] == "run-kto-1" # ─── Config Validation Edge Cases ──────────────────────────────────────────── class TestKTOConfigEdgeCases: """Additional config validation edge cases for KTO.""" def test_kto_beta_negative_rejected(self): """Negative kto_beta should be rejected.""" with pytest.raises(ValidationError): SoupConfig( base="some-model", task="kto", data={"train": "./data.jsonl"}, training={"kto_beta": -0.1}, ) def test_kto_beta_very_large_accepted(self): """Very large kto_beta values should be accepted (no upper bound).""" cfg = SoupConfig( base="some-model", task="kto", data={"train": "./data.jsonl"}, training={"kto_beta": 10.0}, ) assert cfg.training.kto_beta == pytest.approx(10.0) def test_kto_task_with_non_kto_format_accepted(self): """KTO task with a non-kto format in data config should be accepted by schema.""" cfg = SoupConfig( base="some-model", task="kto", data={"train": "./data.jsonl", "format": "auto"}, ) assert cfg.task == "kto" assert cfg.data.format == "auto" def test_kto_config_unsloth_backend(self): """KTO task with unsloth backend should validate correctly.""" cfg = SoupConfig( base="some-model", task="kto", backend="unsloth", data={"train": "./data.jsonl"}, ) assert cfg.backend == "unsloth" assert cfg.task == "kto" def test_kto_tokenizer_stored_as_none_before_setup(self): """tokenizer attribute should be None before setup is called.""" from soup_cli.trainer.kto import KTOTrainerWrapper cfg = SoupConfig( base="some-model", task="kto", data={"train": "./data.jsonl"}, ) wrapper = KTOTrainerWrapper(cfg) assert wrapper.tokenizer is None def test_kto_output_dir_stored_as_none_before_setup(self): """_output_dir attribute should be None before setup is called.""" from soup_cli.trainer.kto import KTOTrainerWrapper cfg = SoupConfig( base="some-model", task="kto", data={"train": "./data.jsonl"}, ) wrapper = KTOTrainerWrapper(cfg) assert wrapper._output_dir is None # ─── Data Format Detection Edge Cases ──────────────────────────────────────── class TestKTODataFormatEdgeCases: """Edge cases for KTO data format detection.""" def test_detect_empty_dataset_raises(self): """detect_format on empty list should raise ValueError.""" from soup_cli.data.formats import detect_format with pytest.raises(ValueError, match="Empty dataset"): detect_format([]) def test_dpo_keys_do_not_match_kto(self): """Data with DPO keys (chosen, rejected) should not be detected as KTO.""" from soup_cli.data.formats import detect_format data = [{"prompt": "Q", "chosen": "A", "rejected": "B"}] assert detect_format(data) == "dpo" def test_kto_keys_do_not_match_dpo(self): """Data with KTO keys (completion, label) should not be detected as DPO.""" from soup_cli.data.formats import detect_format data = [{"prompt": "Q", "completion": "A", "label": True}] assert detect_format(data) == "kto" def test_kto_checked_before_dpo_in_order(self): """KTO appears before DPO in check_order so KTO takes priority when keys overlap.""" from soup_cli.data.formats import detect_format # A row that has KTO keys but NOT the DPO-only keys (chosen, rejected) data = [{"prompt": "Q", "completion": "A", "label": False}] result = detect_format(data) assert result == "kto" def test_detect_format_unknown_keys_raises(self): """Data with unrecognised keys should raise ValueError.""" from soup_cli.data.formats import detect_format data = [{"question": "Q", "answer": "A"}] with pytest.raises(ValueError, match="Cannot detect format"): detect_format(data) def test_format_to_messages_unknown_format_raises(self): """format_to_messages with an unknown format name should raise ValueError.""" from soup_cli.data.formats import format_to_messages with pytest.raises(ValueError, match="Unknown format"): format_to_messages({"prompt": "Q", "completion": "A", "label": True}, "kto_v2") def test_convert_kto_missing_prompt_returns_none(self): """Row missing required 'prompt' key should return None (exception caught).""" from soup_cli.data.formats import format_to_messages row = {"completion": "A", "label": True} result = format_to_messages(row, "kto") assert result is None def test_convert_kto_missing_completion_returns_none(self): """Row missing required 'completion' key should return None.""" from soup_cli.data.formats import format_to_messages row = {"prompt": "Q", "label": True} result = format_to_messages(row, "kto") assert result is None def test_convert_kto_missing_label_returns_none(self): """Row missing required 'label' key should return None.""" from soup_cli.data.formats import format_to_messages row = {"prompt": "Q", "completion": "A"} result = format_to_messages(row, "kto") assert result is None def test_convert_kto_string_label_whitespace_stripped(self): """String labels with surrounding whitespace should be stripped and parsed.""" from soup_cli.data.formats import format_to_messages row_true = {"prompt": "Q", "completion": "A", "label": " true "} result_true = format_to_messages(row_true, "kto") assert result_true["label"] is True row_false = {"prompt": "Q", "completion": "A", "label": " false "} result_false = format_to_messages(row_false, "kto") assert result_false["label"] is False def test_convert_kto_preserves_extra_keys_not_passed_through(self): """Extra keys in the row are not forwarded — output has exactly prompt/completion/label.""" from soup_cli.data.formats import format_to_messages row = {"prompt": "Q", "completion": "A", "label": True, "id": 42, "source": "web"} result = format_to_messages(row, "kto") assert set(result.keys()) == {"prompt", "completion", "label"} def test_convert_kto_none_label_coerced_to_false(self): """None label should be coerced to False via bool().""" from soup_cli.data.formats import format_to_messages row = {"prompt": "Q", "completion": "A", "label": None} result = format_to_messages(row, "kto") assert result["label"] is False # ─── Train Method Guard Test ────────────────────────────────────────────────── class TestKTOTrainGuard: """Test the RuntimeError guard when train() is called before setup().""" def test_train_before_setup_raises_runtime_error(self): """Calling train() before setup() should raise RuntimeError.""" from soup_cli.trainer.kto import KTOTrainerWrapper cfg = SoupConfig( base="some-model", task="kto", data={"train": "./data.jsonl"}, ) wrapper = KTOTrainerWrapper(cfg) with pytest.raises(RuntimeError, match="setup\\(dataset\\) first"): wrapper.train() def test_train_error_message_mentions_setup(self): """RuntimeError message should mention setup().""" from soup_cli.trainer.kto import KTOTrainerWrapper cfg = SoupConfig( base="some-model", task="kto", data={"train": "./data.jsonl"}, ) wrapper = KTOTrainerWrapper(cfg) with pytest.raises(RuntimeError) as exc_info: wrapper.train() assert "setup" in str(exc_info.value).lower() # ─── Train Method Result Structure ─────────────────────────────────────────── class TestKTOTrainResults: """Test the result dict returned by train() using a mocked trainer.""" def _make_wrapper_with_mock_trainer(self, log_history=None, global_step=20): """Helper: return a KTOTrainerWrapper with trainer pre-injected.""" from soup_cli.trainer.kto import KTOTrainerWrapper cfg = SoupConfig( base="some-model", task="kto", data={"train": "./data.jsonl"}, output="./output", ) wrapper = KTOTrainerWrapper(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): """train() result dict must contain all expected keys.""" 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): """initial_loss and final_loss should come from trainer log_history.""" 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_returns_zero_losses(self): """When log_history has no 'loss' entries, losses should be 0.""" 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_result_total_steps_from_trainer_state(self): """total_steps should match trainer.state.global_step.""" wrapper, _ = self._make_wrapper_with_mock_trainer( log_history=[{"loss": 1.0}], global_step=42 ) result = wrapper.train() assert result["total_steps"] == 42 def test_train_result_output_dir_matches(self): """output_dir in result should match wrapper._output_dir.""" wrapper, _ = self._make_wrapper_with_mock_trainer() result = wrapper.train() assert result["output_dir"] == "./output" def test_train_result_duration_minutes_format(self): """Short durations (<1h) should produce 'Xm' format.""" wrapper, mock_trainer = self._make_wrapper_with_mock_trainer( log_history=[{"loss": 1.0}], global_step=5 ) # Patch time.time to control elapsed duration: 90 seconds call_count = [0] def fake_time(): call_count[0] += 1 return 0 if call_count[0] == 1 else 90 with mock_patch("soup_cli.trainer.kto.time.time", side_effect=fake_time): result = wrapper.train() assert result["duration"] == "1m" def test_train_result_duration_hours_format(self): """Long durations (>=1h) should produce 'Xh Ym' format.""" wrapper, _ = self._make_wrapper_with_mock_trainer( log_history=[{"loss": 1.0}], global_step=100 ) call_count = [0] def fake_time(): call_count[0] += 1 return 0 if call_count[0] == 1 else 3720 # 1h 2m with mock_patch("soup_cli.trainer.kto.time.time", side_effect=fake_time): result = wrapper.train() assert result["duration"] == "1h 2m" def test_train_calls_save_model(self): """train() should call trainer.save_model with output_dir.""" 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_calls_tokenizer_save_pretrained(self): """train() should call tokenizer.save_pretrained with output_dir.""" wrapper, _ = self._make_wrapper_with_mock_trainer( log_history=[{"loss": 1.0}], global_step=5 ) wrapper.train() wrapper.tokenizer.save_pretrained.assert_called_once_with("./output") def test_train_passes_resume_checkpoint_to_trainer(self): """train() should forward resume_from_checkpoint to trainer.train().""" 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" ) def test_train_log_history_skips_non_loss_entries(self): """Log entries without 'loss' key should not be counted in train_losses.""" wrapper, _ = self._make_wrapper_with_mock_trainer( log_history=[ {"eval_loss": 2.5}, {"loss": 1.0}, {"eval_loss": 1.2}, {"loss": 0.6}, ], global_step=4, ) result = wrapper.train() assert result["initial_loss"] == pytest.approx(1.0) assert result["final_loss"] == pytest.approx(0.6) # ─── CLI Init KTO Template Tests ───────────────────────────────────────────── class TestKTOInitTemplate: """Test that soup init produces correct output for KTO.""" def test_init_kto_template_creates_file(self, tmp_path): """soup init --template kto should write a file with kto task.""" from typer.testing import CliRunner from soup_cli.cli import app runner = CliRunner() output = tmp_path / "soup.yaml" result = runner.invoke(app, ["init", "--template", "kto", "--output", str(output)]) assert result.exit_code == 0 assert output.exists() content = output.read_text() assert "task: kto" in content assert "kto_beta" in content assert "format: kto" in content def test_init_kto_template_produces_valid_config(self, tmp_path): """The file written by soup init --template kto should parse to a valid SoupConfig.""" 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", "kto", "--output", str(output)]) cfg = load_config(Path(output)) assert cfg.task == "kto" assert cfg.training.kto_beta == pytest.approx(0.1) assert cfg.data.format == "kto" # ─── Wizard KTO Path Tests ──────────────────────────────────────────────────── class TestKTOWizardPath: """Test the interactive wizard auto-sets format for KTO task.""" def test_wizard_kto_task_sets_kto_format(self): """When the wizard receives task=kto, data format should be forced to 'kto'.""" from soup_cli.commands.init import _interactive_wizard with mock_patch("soup_cli.commands.init.Prompt.ask", side_effect=[ "some-model", "kto", "./data.jsonl", "3", "yes", ]): config_text = _interactive_wizard() assert "task: kto" in config_text assert "format: kto" in config_text assert "kto_beta: 0.1" in config_text def test_wizard_kto_does_not_prompt_for_format(self): """The wizard should NOT ask for data format when task=kto.""" 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": "kto", "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): config_text = _interactive_wizard() # "Data format" prompt should not appear when task is kto assert not any("format" in call.lower() for call in prompt_calls) assert "format: kto" in config_text # ─── Config Loader Round-trip Tests ────────────────────────────────────────── class TestKTOConfigLoaderRoundTrip: """Test KTO template YAML survives round-trip through load_config_from_string.""" def test_kto_template_round_trip(self): """TEMPLATES['kto'] should parse via load_config_from_string without error.""" from soup_cli.config.loader import load_config_from_string cfg = load_config_from_string(TEMPLATES["kto"]) assert cfg.task == "kto" assert cfg.training.kto_beta == pytest.approx(0.1) assert cfg.data.format == "kto" def test_kto_custom_yaml_round_trip(self): """Custom KTO YAML string should round-trip correctly.""" from soup_cli.config.loader import load_config_from_string yaml_str = """ base: custom-model/llama-7b task: kto data: train: ./kto_data.jsonl format: kto max_length: 1024 training: epochs: 5 lr: 5e-6 kto_beta: 0.05 quantization: none output: ./kto_output """ cfg = load_config_from_string(yaml_str) assert cfg.task == "kto" assert cfg.training.kto_beta == pytest.approx(0.05) assert cfg.training.epochs == 5 assert cfg.data.max_length == 1024 assert cfg.output == "./kto_output" def test_kto_invalid_beta_in_yaml_raises_value_error(self): """YAML with invalid kto_beta should raise ValueError from load_config_from_string.""" from soup_cli.config.loader import load_config_from_string yaml_str = """ base: some-model task: kto data: train: ./data.jsonl training: kto_beta: -1.0 """ with pytest.raises(ValueError): load_config_from_string(yaml_str)