"""Tests for v0.12.0 Advanced PEFT — DoRA, LoRA+, GaLore.""" import pytest from pydantic import ValidationError from soup_cli.config.schema import SoupConfig # ─── DoRA Config Tests ────────────────────────────────────────────────────── class TestDoRAConfig: """Test DoRA (use_dora) config validation.""" def test_use_dora_default_false(self): cfg = SoupConfig( base="some-model", data={"train": "./data.jsonl"}, ) assert cfg.training.lora.use_dora is False def test_use_dora_enabled(self): cfg = SoupConfig( base="some-model", data={"train": "./data.jsonl"}, training={"lora": {"use_dora": True}}, ) assert cfg.training.lora.use_dora is True def test_dora_with_all_tasks(self): """DoRA should be valid for all task types.""" for task in ["sft", "dpo", "kto", "orpo", "simpo", "ipo", "grpo"]: cfg = SoupConfig( base="some-model", task=task, data={"train": "./data.jsonl"}, training={"lora": {"use_dora": True}}, ) assert cfg.training.lora.use_dora is True def test_dora_with_custom_lora_params(self): cfg = SoupConfig( base="some-model", data={"train": "./data.jsonl"}, training={ "lora": { "r": 128, "alpha": 32, "dropout": 0.1, "use_dora": True, } }, ) assert cfg.training.lora.use_dora is True assert cfg.training.lora.r == 128 assert cfg.training.lora.alpha == 32 def test_dora_yaml_round_trip(self): from soup_cli.config.loader import load_config_from_string yaml_str = """ base: some-model task: sft data: train: ./data.jsonl training: lora: r: 64 alpha: 16 use_dora: true """ cfg = load_config_from_string(yaml_str) assert cfg.training.lora.use_dora is True # ─── LoRA+ Config Tests ───────────────────────────────────────────────────── class TestLoraPlusConfig: """Test LoRA+ (loraplus_lr_ratio) config validation.""" def test_loraplus_default_none(self): cfg = SoupConfig( base="some-model", data={"train": "./data.jsonl"}, ) assert cfg.training.loraplus_lr_ratio is None def test_loraplus_custom_ratio(self): cfg = SoupConfig( base="some-model", data={"train": "./data.jsonl"}, training={"loraplus_lr_ratio": 16.0}, ) assert cfg.training.loraplus_lr_ratio == pytest.approx(16.0) def test_loraplus_must_be_positive(self): with pytest.raises(ValidationError): SoupConfig( base="some-model", data={"train": "./data.jsonl"}, training={"loraplus_lr_ratio": 0}, ) def test_loraplus_negative_rejected(self): with pytest.raises(ValidationError): SoupConfig( base="some-model", data={"train": "./data.jsonl"}, training={"loraplus_lr_ratio": -1.0}, ) def test_loraplus_yaml_round_trip(self): from soup_cli.config.loader import load_config_from_string yaml_str = """ base: some-model task: sft data: train: ./data.jsonl training: loraplus_lr_ratio: 16.0 """ cfg = load_config_from_string(yaml_str) assert cfg.training.loraplus_lr_ratio == pytest.approx(16.0) def test_loraplus_with_dora_combined(self): """LoRA+ and DoRA can be used together.""" cfg = SoupConfig( base="some-model", data={"train": "./data.jsonl"}, training={ "loraplus_lr_ratio": 8.0, "lora": {"use_dora": True}, }, ) assert cfg.training.loraplus_lr_ratio == pytest.approx(8.0) assert cfg.training.lora.use_dora is True # ─── GaLore Config Tests ──────────────────────────────────────────────────── class TestGaLoreConfig: """Test GaLore config validation.""" def test_galore_default_disabled(self): cfg = SoupConfig( base="some-model", data={"train": "./data.jsonl"}, ) assert cfg.training.use_galore is False def test_galore_enabled(self): cfg = SoupConfig( base="some-model", data={"train": "./data.jsonl"}, training={"use_galore": True, "quantization": "none"}, ) assert cfg.training.use_galore is True def test_galore_rank_default(self): cfg = SoupConfig( base="some-model", data={"train": "./data.jsonl"}, ) assert cfg.training.galore_rank == 128 def test_galore_rank_custom(self): cfg = SoupConfig( base="some-model", data={"train": "./data.jsonl"}, training={"galore_rank": 256}, ) assert cfg.training.galore_rank == 256 def test_galore_rank_must_be_positive(self): with pytest.raises(ValidationError): SoupConfig( base="some-model", data={"train": "./data.jsonl"}, training={"galore_rank": 0}, ) def test_galore_update_proj_gap_default(self): cfg = SoupConfig( base="some-model", data={"train": "./data.jsonl"}, ) assert cfg.training.galore_update_proj_gap == 200 def test_galore_scale_default(self): cfg = SoupConfig( base="some-model", data={"train": "./data.jsonl"}, ) assert cfg.training.galore_scale == pytest.approx(0.25) def test_galore_scale_must_be_positive(self): with pytest.raises(ValidationError): SoupConfig( base="some-model", data={"train": "./data.jsonl"}, training={"galore_scale": 0}, ) def test_galore_yaml_round_trip(self): from soup_cli.config.loader import load_config_from_string yaml_str = """ base: some-model task: sft data: train: ./data.jsonl training: use_galore: true galore_rank: 64 galore_update_proj_gap: 100 galore_scale: 0.5 quantization: none """ cfg = load_config_from_string(yaml_str) assert cfg.training.use_galore is True assert cfg.training.galore_rank == 64 assert cfg.training.galore_update_proj_gap == 100 assert cfg.training.galore_scale == pytest.approx(0.5) # ─── GaLore Validation Tests ──────────────────────────────────────────────── class TestGaLoreValidation: """Test GaLore config validation helper.""" def test_galore_incompatible_with_4bit(self): from soup_cli.utils.galore import validate_galore_config errors = validate_galore_config( use_galore=True, quantization="4bit", backend="transformers", ) assert len(errors) == 1 assert "quantization" in errors[0].lower() def test_galore_incompatible_with_8bit(self): from soup_cli.utils.galore import validate_galore_config errors = validate_galore_config( use_galore=True, quantization="8bit", backend="transformers", ) assert len(errors) == 1 def test_galore_incompatible_with_unsloth(self): from soup_cli.utils.galore import validate_galore_config errors = validate_galore_config( use_galore=True, quantization="none", backend="unsloth", ) assert len(errors) == 1 assert "unsloth" in errors[0].lower() def test_galore_valid_config_no_errors(self): from soup_cli.utils.galore import validate_galore_config errors = validate_galore_config( use_galore=True, quantization="none", backend="transformers", ) assert len(errors) == 0 def test_galore_disabled_no_errors(self): from soup_cli.utils.galore import validate_galore_config errors = validate_galore_config( use_galore=False, quantization="4bit", backend="unsloth", ) assert len(errors) == 0 def test_galore_multiple_errors(self): """Both quantization and unsloth should be flagged.""" from soup_cli.utils.galore import validate_galore_config errors = validate_galore_config( use_galore=True, quantization="4bit", backend="unsloth", ) assert len(errors) == 2 # ─── GaLore Optimizer Helper Tests ────────────────────────────────────────── class TestGaLoreOptimizerHelper: """Test the get_galore_optimizer_and_params helper.""" def test_returns_galore_optimizer_name(self): from soup_cli.utils.galore import get_galore_optimizer_and_params result = get_galore_optimizer_and_params( ) assert result["optim"] == "galore_adamw" def test_returns_target_modules(self): from soup_cli.utils.galore import get_galore_optimizer_and_params result = get_galore_optimizer_and_params( ) assert "optim_target_modules" in result assert isinstance(result["optim_target_modules"], list) def test_returns_optim_args_with_rank(self): from soup_cli.utils.galore import get_galore_optimizer_and_params result = get_galore_optimizer_and_params(galore_rank=64) assert "rank=64" in result["optim_args"] def test_returns_optim_args_with_update_gap(self): from soup_cli.utils.galore import get_galore_optimizer_and_params result = get_galore_optimizer_and_params(galore_update_proj_gap=100) assert "update_proj_gap=100" in result["optim_args"] def test_returns_optim_args_with_scale(self): from soup_cli.utils.galore import get_galore_optimizer_and_params result = get_galore_optimizer_and_params(galore_scale=0.5) assert "scale=0.5" in result["optim_args"] def test_invalid_rank_raises_value_error(self): from soup_cli.utils.galore import get_galore_optimizer_and_params with pytest.raises(ValueError, match="Invalid GaLore"): get_galore_optimizer_and_params(galore_rank=0) def test_invalid_scale_raises_value_error(self): from soup_cli.utils.galore import get_galore_optimizer_and_params with pytest.raises(ValueError, match="Invalid GaLore"): get_galore_optimizer_and_params(galore_scale=0) # ─── Sweep Shortcut Tests ─────────────────────────────────────────────────── class TestAdvancedPEFTSweepParams: """Test sweep shortcuts for DoRA, LoRA+, GaLore.""" def test_use_dora_shortcut(self): from soup_cli.commands.sweep import _set_nested_param config = {} _set_nested_param(config, "use_dora", True) assert config["training"]["lora"]["use_dora"] is True def test_loraplus_lr_ratio_shortcut(self): from soup_cli.commands.sweep import _set_nested_param config = {} _set_nested_param(config, "loraplus_lr_ratio", 16.0) assert config["training"]["loraplus_lr_ratio"] == pytest.approx(16.0) def test_use_galore_shortcut(self): from soup_cli.commands.sweep import _set_nested_param config = {} _set_nested_param(config, "use_galore", True) assert config["training"]["use_galore"] is True def test_galore_rank_shortcut(self): from soup_cli.commands.sweep import _set_nested_param config = {} _set_nested_param(config, "galore_rank", 64) assert config["training"]["galore_rank"] == 64 # ─── Experiment Name Validation Tests ──────────────────────────────────────── class TestExperimentNameValidation: """Test experiment_name path traversal protection.""" def test_normal_name_accepted(self): cfg = SoupConfig( base="some-model", data={"train": "./data.jsonl"}, experiment_name="my_experiment_1", ) assert cfg.experiment_name == "my_experiment_1" def test_none_name_accepted(self): cfg = SoupConfig( base="some-model", data={"train": "./data.jsonl"}, experiment_name=None, ) assert cfg.experiment_name is None def test_forward_slash_rejected(self): with pytest.raises(ValidationError): SoupConfig( base="some-model", data={"train": "./data.jsonl"}, experiment_name="../../etc/passwd", ) def test_backslash_rejected(self): with pytest.raises(ValidationError): SoupConfig( base="some-model", data={"train": "./data.jsonl"}, experiment_name="..\\..\\secret", ) def test_colon_rejected(self): with pytest.raises(ValidationError): SoupConfig( base="some-model", data={"train": "./data.jsonl"}, experiment_name="C:\\evil", ) def test_null_byte_rejected(self): with pytest.raises(ValidationError): SoupConfig( base="some-model", data={"train": "./data.jsonl"}, experiment_name="name\x00evil", )