"""Tests for v0.50.0 Part E — PRM task + Vision RL.""" from __future__ import annotations import pytest from pydantic import ValidationError from soup_cli.config.loader import load_config_from_string from soup_cli.config.schema import TrainingConfig from soup_cli.utils.prm import ( build_prm_trainer, validate_prm_compat, validate_vision_grpo_compat, ) # --------------------------------------------------------------------------- # validate_prm_compat # --------------------------------------------------------------------------- def test_prm_compat_happy_prm_format(): validate_prm_compat(task="prm", data_format="prm", backend="transformers", modality="text") def test_prm_compat_happy_auto_format(): validate_prm_compat(task="prm", data_format="auto", backend="transformers", modality="text") def test_prm_compat_rejects_wrong_format(): with pytest.raises(ValueError, match="format"): validate_prm_compat( task="prm", data_format="alpaca", backend="transformers", modality="text", ) def test_prm_compat_rejects_mlx(): with pytest.raises(ValueError, match="mlx"): validate_prm_compat(task="prm", data_format="prm", backend="mlx", modality="text") def test_prm_compat_rejects_vision_modality(): with pytest.raises(ValueError, match="modality"): validate_prm_compat( task="prm", data_format="prm", backend="transformers", modality="vision", ) def test_prm_compat_rejects_audio_modality(): with pytest.raises(ValueError, match="modality"): validate_prm_compat( task="prm", data_format="prm", backend="transformers", modality="audio", ) def test_prm_compat_rejects_non_prm_task(): with pytest.raises(ValueError, match="prm"): validate_prm_compat( task="sft", data_format="prm", backend="transformers", modality="text", ) def test_prm_compat_empty_task(): with pytest.raises(ValueError, match="task"): validate_prm_compat(task="", data_format="prm", backend="transformers", modality="text") def test_prm_compat_empty_format(): with pytest.raises(ValueError, match="format"): validate_prm_compat(task="prm", data_format="", backend="transformers", modality="text") def test_prm_compat_none_task(): """tdd-guide MEDIUM fix: non-string task rejection.""" with pytest.raises(ValueError, match="task"): validate_prm_compat( task=None, # type: ignore[arg-type] data_format="prm", backend="transformers", modality="text", ) def test_prm_compat_none_format(): """tdd-guide MEDIUM fix: non-string data_format rejection.""" with pytest.raises(ValueError, match="format"): validate_prm_compat( task="prm", data_format=None, # type: ignore[arg-type] backend="transformers", modality="text", ) def test_vision_grpo_soupconfig_ppo_happy(): """tdd-guide MEDIUM fix: confirm ppo path is wired at SoupConfig level. v0.53.3 #129 — uses a known VLM base so the new name-regex probe passes. """ yaml = """ base: Qwen/Qwen2-VL-7B-Instruct task: ppo modality: vision data: train: ./data.jsonl format: chatml output: ./out training: epochs: 1 lr: 1e-4 vision_grpo: true """ cfg = load_config_from_string(yaml) assert cfg.training.vision_grpo is True assert cfg.task == "ppo" def test_build_prm_trainer_now_live(): """v0.53.11 #126 lifted the stub — factory now returns a PRMTrainerWrapper.""" from soup_cli.trainer.prm import PRMTrainerWrapper class _Cfg: base = "hf-internal-testing/tiny-random-gpt2" task = "prm" wrapper = build_prm_trainer(config=_Cfg()) assert isinstance(wrapper, PRMTrainerWrapper) def test_build_prm_trainer_rejects_unknown_kwargs(): """v0.53.11 #126 — explicit factory contract.""" class _Cfg: base = "test" with pytest.raises(TypeError, match="unexpected"): build_prm_trainer(config=_Cfg(), bogus=1) # --------------------------------------------------------------------------- # validate_vision_grpo_compat # --------------------------------------------------------------------------- @pytest.mark.parametrize("task", ["grpo", "ppo"]) def test_vision_grpo_happy(task): validate_vision_grpo_compat(task=task, modality="vision", backend="transformers") def test_vision_grpo_rejects_sft(): with pytest.raises(ValueError, match="task"): validate_vision_grpo_compat(task="sft", modality="vision", backend="transformers") def test_vision_grpo_rejects_text_modality(): with pytest.raises(ValueError, match="modality"): validate_vision_grpo_compat(task="grpo", modality="text", backend="transformers") def test_vision_grpo_rejects_audio(): with pytest.raises(ValueError, match="modality"): validate_vision_grpo_compat(task="grpo", modality="audio", backend="transformers") def test_vision_grpo_rejects_mlx(): with pytest.raises(ValueError, match="mlx"): validate_vision_grpo_compat(task="grpo", modality="vision", backend="mlx") def test_vision_grpo_empty_task(): with pytest.raises(ValueError, match="task"): validate_vision_grpo_compat(task="", modality="vision", backend="transformers") # --------------------------------------------------------------------------- # Schema field defaults + acceptance # --------------------------------------------------------------------------- def test_vision_grpo_default_false(): assert TrainingConfig().vision_grpo is False def test_vision_grpo_accepts_bool(): tc = TrainingConfig(vision_grpo=True) assert tc.vision_grpo is True # --------------------------------------------------------------------------- # SoupConfig integration # --------------------------------------------------------------------------- def test_prm_task_accepted(): yaml = """ base: test-llama task: prm data: train: ./data.jsonl format: prm output: ./out training: epochs: 1 lr: 1e-4 """ cfg = load_config_from_string(yaml) assert cfg.task == "prm" assert cfg.data.format == "prm" def test_prm_with_wrong_format_rejected(): yaml = """ base: test-llama task: prm data: train: ./data.jsonl format: alpaca output: ./out training: epochs: 1 lr: 1e-4 """ with pytest.raises((ValidationError, ValueError), match="format"): load_config_from_string(yaml) def test_prm_on_mlx_rejected(): yaml = """ base: test-llama task: prm backend: mlx data: train: ./data.jsonl format: prm output: ./out training: epochs: 1 lr: 1e-4 """ with pytest.raises((ValidationError, ValueError), match="mlx"): load_config_from_string(yaml) def test_vision_grpo_soupconfig_happy(): # v0.53.3 #129 — uses a known VLM base. yaml = """ base: Qwen/Qwen2-VL-7B-Instruct task: grpo modality: vision data: train: ./data.jsonl format: chatml output: ./out training: epochs: 1 lr: 1e-4 reward_fn: accuracy vision_grpo: true """ cfg = load_config_from_string(yaml) assert cfg.training.vision_grpo is True def test_vision_grpo_on_text_modality_rejected(): yaml = """ base: test-llama task: grpo data: train: ./data.jsonl format: chatml output: ./out training: epochs: 1 lr: 1e-4 reward_fn: accuracy vision_grpo: true """ with pytest.raises((ValidationError, ValueError), match="modality"): load_config_from_string(yaml) def test_vision_grpo_on_sft_rejected(): yaml = """ base: test-llama task: sft modality: vision data: train: ./data.jsonl format: chatml output: ./out training: epochs: 1 lr: 1e-4 vision_grpo: true """ with pytest.raises((ValidationError, ValueError), match="task"): load_config_from_string(yaml)