"""Tests for FP8 recipe support (v0.28.1). Covers: - Schema: fp8_recipe field accepts valid literals, rejects invalid strings - Schema: fp8_recipe requires quantization_aware='fp8' when non-default - Schema: default recipe is 'tensorwise' (backward compat with v0.28.0) - Dispatch: apply_fp8_training passes correct recipe to Float8LinearConfig - Integration: fp8_recipe with non-SFT tasks rejected (via quantization_aware gate) """ from __future__ import annotations from unittest.mock import MagicMock, patch import pytest from pydantic import ValidationError from soup_cli.config.schema import SoupConfig, TrainingConfig # ─── Schema: fp8_recipe field ────────────────────────────────────────────── class TestFP8RecipeSchema: """fp8_recipe accepts 'tensorwise', 'rowwise', 'rowwise_with_gw_hp'.""" def test_fp8_recipe_default_tensorwise(self): cfg = SoupConfig(base="test/model", data={"train": "./data.jsonl"}) assert cfg.training.fp8_recipe == "tensorwise" def test_fp8_recipe_tensorwise_explicit(self): cfg = SoupConfig( base="test/model", data={"train": "./data.jsonl"}, training={"quantization_aware": "fp8", "fp8_recipe": "tensorwise"}, ) assert cfg.training.fp8_recipe == "tensorwise" def test_fp8_recipe_rowwise(self): cfg = SoupConfig( base="test/model", data={"train": "./data.jsonl"}, training={"quantization_aware": "fp8", "fp8_recipe": "rowwise"}, ) assert cfg.training.fp8_recipe == "rowwise" def test_fp8_recipe_rowwise_with_gw_hp(self): cfg = SoupConfig( base="test/model", data={"train": "./data.jsonl"}, training={ "quantization_aware": "fp8", "fp8_recipe": "rowwise_with_gw_hp", }, ) assert cfg.training.fp8_recipe == "rowwise_with_gw_hp" def test_fp8_recipe_invalid_string_rejected(self): """Only the three literal values are accepted.""" with pytest.raises(ValidationError) as exc: SoupConfig( base="test/model", data={"train": "./data.jsonl"}, training={ "quantization_aware": "fp8", "fp8_recipe": "delayed", }, ) assert "fp8_recipe" in str(exc.value) def test_fp8_recipe_invalid_empty_string_rejected(self): with pytest.raises(ValidationError): SoupConfig( base="test/model", data={"train": "./data.jsonl"}, training={"quantization_aware": "fp8", "fp8_recipe": ""}, ) def test_fp8_recipe_invalid_int_rejected(self): with pytest.raises(ValidationError): SoupConfig( base="test/model", data={"train": "./data.jsonl"}, training={"quantization_aware": "fp8", "fp8_recipe": 42}, ) # ─── Schema: fp8_recipe requires quantization_aware='fp8' ───────────────── class TestFP8RecipeRequiresFP8: """Non-default fp8_recipe without quantization_aware='fp8' is rejected.""" def test_default_recipe_allowed_without_fp8(self): """tensorwise (default) is fine even without fp8 — it's the default.""" cfg = SoupConfig( base="test/model", data={"train": "./data.jsonl"}, training={"fp8_recipe": "tensorwise"}, ) assert cfg.training.fp8_recipe == "tensorwise" assert cfg.training.quantization_aware is False def test_rowwise_without_fp8_rejected(self): with pytest.raises(ValidationError) as exc: SoupConfig( base="test/model", data={"train": "./data.jsonl"}, training={"fp8_recipe": "rowwise"}, ) assert "quantization_aware" in str(exc.value) def test_rowwise_with_gw_hp_without_fp8_rejected(self): with pytest.raises(ValidationError) as exc: SoupConfig( base="test/model", data={"train": "./data.jsonl"}, training={"fp8_recipe": "rowwise_with_gw_hp"}, ) assert "quantization_aware" in str(exc.value) def test_rowwise_with_bool_true_qat_rejected(self): """Bool True = int8 QAT, not FP8 — recipe should be rejected.""" with pytest.raises(ValidationError) as exc: SoupConfig( base="test/model", data={"train": "./data.jsonl"}, training={ "quantization_aware": True, "fp8_recipe": "rowwise", }, ) assert "quantization_aware" in str(exc.value) # ─── Dispatch: apply_fp8_training recipe parameter ──────────────────────── class TestFP8RecipeDispatch: """apply_fp8_training passes recipe to Float8LinearConfig.from_recipe_name.""" def test_apply_fp8_dispatches_tensorwise(self): """Default recipe passes 'tensorwise' to from_recipe_name.""" mock_config = MagicMock() mock_from_recipe = MagicMock(return_value=mock_config) mock_convert = MagicMock() fake_float8 = MagicMock() fake_float8.convert_to_float8_training = mock_convert fake_config_mod = MagicMock() fake_config_mod.Float8LinearConfig.from_recipe_name = mock_from_recipe with patch.dict( "sys.modules", { "torchao": MagicMock(), "torchao.float8": fake_float8, "torchao.float8.config": fake_config_mod, }, ): # Need to reimport to pick up the mocked modules import importlib import soup_cli.utils.fp8 as fp8_mod importlib.reload(fp8_mod) # Mock is_fp8_available to return True with patch.object(fp8_mod, "is_fp8_available", return_value=True): model = MagicMock() result = fp8_mod.apply_fp8_training(model, recipe="tensorwise") mock_from_recipe.assert_called_once_with("tensorwise") mock_convert.assert_called_once_with(model, config=mock_config) assert result is True def test_apply_fp8_dispatches_rowwise(self): """Rowwise recipe passes 'rowwise' to from_recipe_name.""" mock_config = MagicMock() mock_from_recipe = MagicMock(return_value=mock_config) mock_convert = MagicMock() fake_float8 = MagicMock() fake_float8.convert_to_float8_training = mock_convert fake_config_mod = MagicMock() fake_config_mod.Float8LinearConfig.from_recipe_name = mock_from_recipe with patch.dict( "sys.modules", { "torchao": MagicMock(), "torchao.float8": fake_float8, "torchao.float8.config": fake_config_mod, }, ): import importlib import soup_cli.utils.fp8 as fp8_mod importlib.reload(fp8_mod) with patch.object(fp8_mod, "is_fp8_available", return_value=True): model = MagicMock() result = fp8_mod.apply_fp8_training(model, recipe="rowwise") mock_from_recipe.assert_called_once_with("rowwise") assert result is True def test_apply_fp8_dispatches_rowwise_with_gw_hp(self): """rowwise_with_gw_hp recipe passes through correctly.""" mock_config = MagicMock() mock_from_recipe = MagicMock(return_value=mock_config) mock_convert = MagicMock() fake_float8 = MagicMock() fake_float8.convert_to_float8_training = mock_convert fake_config_mod = MagicMock() fake_config_mod.Float8LinearConfig.from_recipe_name = mock_from_recipe with patch.dict( "sys.modules", { "torchao": MagicMock(), "torchao.float8": fake_float8, "torchao.float8.config": fake_config_mod, }, ): import importlib import soup_cli.utils.fp8 as fp8_mod importlib.reload(fp8_mod) with patch.object(fp8_mod, "is_fp8_available", return_value=True): model = MagicMock() result = fp8_mod.apply_fp8_training( model, recipe="rowwise_with_gw_hp" ) mock_from_recipe.assert_called_once_with("rowwise_with_gw_hp") assert result is True def test_apply_fp8_returns_false_when_unavailable(self): """When FP8 deps are missing, apply_fp8_training returns False.""" from soup_cli.utils.fp8 import apply_fp8_training with patch("soup_cli.utils.fp8.is_fp8_available", return_value=False): model = MagicMock() assert apply_fp8_training(model, recipe="rowwise") is False def test_apply_fp8_default_recipe_is_tensorwise(self): """Calling without recipe= uses 'tensorwise' (v0.28.0 compat).""" mock_config = MagicMock() mock_from_recipe = MagicMock(return_value=mock_config) mock_convert = MagicMock() fake_float8 = MagicMock() fake_float8.convert_to_float8_training = mock_convert fake_config_mod = MagicMock() fake_config_mod.Float8LinearConfig.from_recipe_name = mock_from_recipe with patch.dict( "sys.modules", { "torchao": MagicMock(), "torchao.float8": fake_float8, "torchao.float8.config": fake_config_mod, }, ): import importlib import soup_cli.utils.fp8 as fp8_mod importlib.reload(fp8_mod) with patch.object(fp8_mod, "is_fp8_available", return_value=True): model = MagicMock() fp8_mod.apply_fp8_training(model) # Default should be tensorwise mock_from_recipe.assert_called_once_with("tensorwise") # ─── Integration: fp8_recipe + non-SFT tasks ────────────────────────────── class TestFP8RecipeNonSFT: """FP8 recipe on non-SFT tasks: accepted on transformer backends (v0.35.0).""" def test_fp8_recipe_on_dpo_accepted(self): """DPO + fp8 + recipe is valid (all transformer trainers wired in v0.35.0).""" cfg = SoupConfig( base="m", task="dpo", data={"train": "./d.jsonl", "format": "dpo"}, training={ "quantization_aware": "fp8", "fp8_recipe": "rowwise", }, ) assert cfg.training.fp8_recipe == "rowwise" def test_fp8_recipe_on_grpo_accepted(self): cfg = SoupConfig( base="m", task="grpo", data={"train": "./d.jsonl"}, training={ "quantization_aware": "fp8", "fp8_recipe": "rowwise_with_gw_hp", }, ) assert cfg.training.fp8_recipe == "rowwise_with_gw_hp" def test_fp8_recipe_on_mlx_rejected(self): """MLX backend does not support FP8 — rejected at config level.""" with pytest.raises(ValidationError): SoupConfig( base="m", task="sft", backend="mlx", data={"train": "./d.jsonl"}, training={ "quantization_aware": "fp8", "fp8_recipe": "rowwise", }, ) def test_fp8_recipe_on_sft_accepted(self): """SFT + fp8 + recipe is valid.""" cfg = SoupConfig( base="m", task="sft", data={"train": "./d.jsonl"}, training={ "quantization_aware": "fp8", "fp8_recipe": "rowwise", }, ) assert cfg.training.fp8_recipe == "rowwise" assert cfg.training.quantization_aware == "fp8" def test_all_recipes_accepted_on_sft(self): """All three recipes are valid on SFT with fp8.""" for recipe in ("tensorwise", "rowwise", "rowwise_with_gw_hp"): cfg = SoupConfig( base="m", task="sft", data={"train": "./d.jsonl"}, training={ "quantization_aware": "fp8", "fp8_recipe": recipe, }, ) assert cfg.training.fp8_recipe == recipe # ─── Backward compatibility ─────────────────────────────────────────────── class TestFP8RecipeBackwardCompat: """v0.28.0 configs without fp8_recipe still work (defaults to tensorwise).""" def test_v028_config_no_recipe_field(self): """Config with quantization_aware='fp8' but no fp8_recipe is valid.""" cfg = SoupConfig( base="test/model", data={"train": "./data.jsonl"}, training={"quantization_aware": "fp8"}, ) assert cfg.training.quantization_aware == "fp8" assert cfg.training.fp8_recipe == "tensorwise" def test_v028_bool_true_unaffected(self): """Bool True (int8 QAT) is unaffected by fp8_recipe field.""" cfg = SoupConfig( base="test/model", data={"train": "./data.jsonl"}, training={"quantization_aware": True}, ) assert cfg.training.quantization_aware is True assert cfg.training.fp8_recipe == "tensorwise" # default, unused def test_training_config_fp8_recipe_default(self): """TrainingConfig alone defaults fp8_recipe to tensorwise.""" tcfg = TrainingConfig() assert tcfg.fp8_recipe == "tensorwise" # ─── Dispatch through apply_v028_speed_memory (covers 10 non-SFT trainers) ── class TestFP8RecipeViaV028Features: """All non-SFT trainers go through apply_v028_speed_memory; verify it passes the user-configured recipe through to apply_fp8_training instead of always defaulting to tensorwise (silent no-op bug fix).""" def _run_dispatch(self, recipe: str) -> MagicMock: """Invoke apply_v028_speed_memory with quantization_aware=fp8 and the given recipe, returning the patched apply_fp8_training mock.""" tcfg = MagicMock() tcfg.quantization_aware = "fp8" tcfg.fp8_recipe = recipe tcfg.use_cut_ce = False tcfg.kernel_auto_compose = False with patch("soup_cli.utils.fp8.apply_fp8_training", return_value=True) as m: from soup_cli.utils.v028_features import apply_v028_speed_memory apply_v028_speed_memory( model=MagicMock(), tcfg=tcfg, base_model="test/model", console=None, device="cuda", backend="transformers", ) return m def test_v028_dispatch_tensorwise(self): m = self._run_dispatch("tensorwise") m.assert_called_once() assert m.call_args.kwargs.get("recipe") == "tensorwise" def test_v028_dispatch_rowwise(self): m = self._run_dispatch("rowwise") m.assert_called_once() assert m.call_args.kwargs.get("recipe") == "rowwise" def test_v028_dispatch_rowwise_with_gw_hp(self): m = self._run_dispatch("rowwise_with_gw_hp") m.assert_called_once() assert m.call_args.kwargs.get("recipe") == "rowwise_with_gw_hp" def test_v028_skips_apply_when_quant_not_fp8(self): """If quantization_aware != 'fp8', apply_fp8_training is not called.""" tcfg = MagicMock() tcfg.quantization_aware = True # int8 QAT, not fp8 tcfg.fp8_recipe = "rowwise" tcfg.use_cut_ce = False tcfg.kernel_auto_compose = False with patch("soup_cli.utils.fp8.apply_fp8_training") as m: from soup_cli.utils.v028_features import apply_v028_speed_memory apply_v028_speed_memory( model=MagicMock(), tcfg=tcfg, base_model="test/model", console=None, ) m.assert_not_called()