"""Tests for v0.50.0 Part A — GRPO objective variants. Covers allowlist validation, metadata immutability, delta-required gates, deferred live-wiring stubs, and schema integration. """ from __future__ import annotations import math import pytest from pydantic import ValidationError from soup_cli.config.loader import load_config_from_string from soup_cli.config.schema import SoupConfig, TrainingConfig from soup_cli.utils import grpo_variants from soup_cli.utils.grpo_variants import ( SUPPORTED_GRPO_VARIANTS, GRPOVariantSpec, apply_variant_loss, get_variant_spec, list_variants, validate_grpo_delta, validate_grpo_variant, variant_is_live_wired, variant_requires_delta, ) # --------------------------------------------------------------------------- # Allowlist surface # --------------------------------------------------------------------------- def test_supported_variants_is_frozenset(): assert isinstance(SUPPORTED_GRPO_VARIANTS, frozenset) with pytest.raises(AttributeError): SUPPORTED_GRPO_VARIANTS.add("evil") # type: ignore[attr-defined] def test_supported_variants_includes_all_v0500(): for name in ("gspo", "dapo", "dr_grpo", "bnpo", "two_sided", "rft"): assert name in SUPPORTED_GRPO_VARIANTS def test_supported_variants_includes_standard(): assert "standard" in SUPPORTED_GRPO_VARIANTS def test_list_variants_sorted_tuple(): result = list_variants() assert isinstance(result, tuple) assert list(result) == sorted(result) # --------------------------------------------------------------------------- # validate_grpo_variant # --------------------------------------------------------------------------- @pytest.mark.parametrize( "name", ["gspo", "dapo", "dr_grpo", "bnpo", "two_sided", "rft", "standard"] ) def test_validate_grpo_variant_happy(name): assert validate_grpo_variant(name) == name def test_validate_grpo_variant_case_insensitive(): assert validate_grpo_variant("GSPO") == "gspo" assert validate_grpo_variant("DAPO") == "dapo" def test_validate_grpo_variant_non_string(): with pytest.raises(ValueError, match="must be a string"): validate_grpo_variant(123) with pytest.raises(ValueError, match="must be a string"): validate_grpo_variant(None) def test_validate_grpo_variant_empty(): with pytest.raises(ValueError, match="non-empty"): validate_grpo_variant("") def test_validate_grpo_variant_null_byte(): with pytest.raises(ValueError, match="null byte"): validate_grpo_variant("gspo\x00") def test_validate_grpo_variant_oversize(): with pytest.raises(ValueError, match="exceeds"): validate_grpo_variant("x" * 100) def test_validate_grpo_variant_unknown(): with pytest.raises(ValueError, match="not supported"): validate_grpo_variant("trpo") def test_validate_grpo_variant_bool_rejected(): """tdd-guide HIGH fix: explicit bool guard test.""" with pytest.raises(ValueError, match="bool"): validate_grpo_variant(True) with pytest.raises(ValueError, match="bool"): validate_grpo_variant(False) def test_deferred_live_invariant(): """tdd-guide LOW fix: catch future drift in allowlist split.""" from soup_cli.utils.grpo_variants import _DEFERRED_LIVE assert len(SUPPORTED_GRPO_VARIANTS) == 7 assert len(_DEFERRED_LIVE) == 6 assert "standard" not in _DEFERRED_LIVE def test_variant_spec_description_and_requires_delta(): """tdd-guide LOW fix: exercise unread spec fields.""" spec = get_variant_spec("two_sided") assert spec.requires_delta is True assert "two-sided" in spec.description.lower() or "two_sided" in spec.description.lower() spec_gspo = get_variant_spec("gspo") assert spec_gspo.requires_delta is False assert "stabilized" in spec_gspo.description.lower() # --------------------------------------------------------------------------- # Variant metadata # --------------------------------------------------------------------------- def test_get_variant_spec_returns_frozen_dataclass(): spec = get_variant_spec("gspo") assert isinstance(spec, GRPOVariantSpec) assert spec.name == "gspo" with pytest.raises(Exception): spec.name = "evil" # type: ignore[misc] def test_get_variant_spec_unknown_raises(): with pytest.raises(ValueError): get_variant_spec("trpo") def test_two_sided_requires_delta(): assert variant_requires_delta("two_sided") is True assert variant_requires_delta("TWO_SIDED") is True @pytest.mark.parametrize("name", ["gspo", "dapo", "dr_grpo", "bnpo", "rft", "standard"]) def test_other_variants_do_not_require_delta(name): assert variant_requires_delta(name) is False def test_variant_requires_delta_non_string(): assert variant_requires_delta(123) is False # type: ignore[arg-type] # --------------------------------------------------------------------------- # Live-wiring flags # --------------------------------------------------------------------------- def test_standard_is_live_wired(): assert variant_is_live_wired("standard") is True @pytest.mark.parametrize("name", ["gspo", "dapo", "dr_grpo", "bnpo", "two_sided", "rft"]) def test_v0500_variants_deferred(name): assert variant_is_live_wired(name) is False def test_variant_is_live_wired_non_string(): assert variant_is_live_wired(None) is False # type: ignore[arg-type] def test_apply_variant_loss_standard_is_noop(): # standard variant returns None (delegates to existing GRPOTrainerWrapper). assert apply_variant_loss("standard") is None @pytest.mark.parametrize("name", ["gspo", "dapo", "dr_grpo", "bnpo", "two_sided", "rft"]) def test_apply_variant_loss_deferred_raises(name): with pytest.raises(NotImplementedError, match="v0.50.1"): apply_variant_loss(name) def test_apply_variant_loss_unknown_raises_validation(): with pytest.raises(ValueError, match="not supported"): apply_variant_loss("trpo") # --------------------------------------------------------------------------- # validate_grpo_delta # --------------------------------------------------------------------------- @pytest.mark.parametrize("v", [0.1, 0.5, 1.0, 0.01]) def test_validate_grpo_delta_happy(v): assert validate_grpo_delta(v) == v def test_validate_grpo_delta_int_coerced(): assert validate_grpo_delta(1) == 1.0 def test_validate_grpo_delta_bool_rejected(): with pytest.raises(ValueError, match="bool"): validate_grpo_delta(True) with pytest.raises(ValueError, match="bool"): validate_grpo_delta(False) def test_validate_grpo_delta_non_number(): with pytest.raises(ValueError, match="must be a number"): validate_grpo_delta("0.5") def test_validate_grpo_delta_nan_inf(): with pytest.raises(ValueError, match="finite"): validate_grpo_delta(float("nan")) with pytest.raises(ValueError, match="finite"): validate_grpo_delta(float("inf")) @pytest.mark.parametrize("v", [0.0, -0.1, 1.5, 2.0]) def test_validate_grpo_delta_out_of_range(v): with pytest.raises(ValueError, match=r"\(0, 1\]"): validate_grpo_delta(v) # --------------------------------------------------------------------------- # Module-level immutability # --------------------------------------------------------------------------- def test_variant_metadata_immutable(): with pytest.raises(TypeError): grpo_variants._VARIANT_METADATA["evil"] = "x" # type: ignore[index] # --------------------------------------------------------------------------- # Schema integration (TrainingConfig.grpo_variant + grpo_delta + grpo_fp16) # --------------------------------------------------------------------------- def test_training_config_default_grpo_variant_is_none(): tc = TrainingConfig() assert tc.grpo_variant is None assert tc.grpo_delta is None assert tc.grpo_fp16 is False @pytest.mark.parametrize("name", ["gspo", "dapo", "dr_grpo", "bnpo", "rft"]) def test_training_config_accepts_v0500_variant(name): tc = TrainingConfig(grpo_variant=name) assert tc.grpo_variant == name def test_training_config_two_sided_requires_delta(): with pytest.raises(ValidationError, match="grpo_delta"): TrainingConfig(grpo_variant="two_sided") def test_training_config_two_sided_with_delta_ok(): tc = TrainingConfig(grpo_variant="two_sided", grpo_delta=0.3) assert tc.grpo_variant == "two_sided" assert tc.grpo_delta == 0.3 def test_training_config_delta_without_two_sided_rejected(): with pytest.raises(ValidationError, match="two_sided"): TrainingConfig(grpo_variant="gspo", grpo_delta=0.3) def test_training_config_unknown_variant_rejected(): with pytest.raises(ValidationError, match="literal_error|Input should be"): TrainingConfig(grpo_variant="trpo") def test_training_config_grpo_delta_nan_rejected(): """Security review fix: NaN rejected at schema layer (either Pydantic bounds or the explicit field_validator catches it).""" with pytest.raises(ValidationError): TrainingConfig(grpo_variant="two_sided", grpo_delta=float("nan")) def test_training_config_grpo_delta_inf_rejected(): """Security review fix: explicit Inf rejection at schema layer.""" with pytest.raises(ValidationError): TrainingConfig(grpo_variant="two_sided", grpo_delta=float("inf")) def test_training_config_grpo_fp16_accepted(): tc = TrainingConfig(grpo_fp16=True) assert tc.grpo_fp16 is True def test_training_config_grpo_fp16_int_coerced_to_bool(): # Pydantic v2 default coerces "yes"-like strings to bool; assert behaviour # is consistent (1/0 -> True/False; arbitrary strings rejected). tc = TrainingConfig(grpo_fp16=1) assert tc.grpo_fp16 is True with pytest.raises(ValidationError): TrainingConfig(grpo_fp16="garbage") # type: ignore[arg-type] # --------------------------------------------------------------------------- # SoupConfig cross-validators # --------------------------------------------------------------------------- def _grpo_yaml(extra: str = "") -> str: return f""" base: test-llama task: grpo data: train: ./data.jsonl format: chatml output: ./out training: epochs: 1 lr: 1e-4 reward_fn: accuracy {extra} """ def test_soupconfig_grpo_variant_happy(): yaml = _grpo_yaml(" grpo_variant: dapo\n") cfg = load_config_from_string(yaml) assert isinstance(cfg, SoupConfig) assert cfg.training.grpo_variant == "dapo" def test_soupconfig_grpo_variant_rejected_on_non_grpo_task(): yaml = """ base: test-llama task: sft data: train: ./data.jsonl format: chatml output: ./out training: epochs: 1 lr: 1e-4 grpo_variant: dapo """ with pytest.raises((ValidationError, ValueError), match="task='grpo'|task=.grpo."): load_config_from_string(yaml) def test_soupconfig_two_sided_with_delta_happy(): yaml = _grpo_yaml(" grpo_variant: two_sided\n grpo_delta: 0.3\n") cfg = load_config_from_string(yaml) assert cfg.training.grpo_variant == "two_sided" assert math.isclose(cfg.training.grpo_delta, 0.3) def test_soupconfig_mlx_backend_rejected(): yaml = """ base: test-llama task: grpo backend: mlx data: train: ./data.jsonl format: chatml output: ./out training: epochs: 1 lr: 1e-4 reward_fn: accuracy grpo_variant: gspo """ with pytest.raises((ValidationError, ValueError), match="mlx"): load_config_from_string(yaml)