mirror of https://github.com/razor-ai/soup.git
178 lines
7.3 KiB
Python
178 lines
7.3 KiB
Python
"""Tests for v0.40.0 Part D — multi-objective preference loss.
|
|
|
|
Adds ``training.preference_loss_weights: dict[str, float]`` for blending
|
|
preference losses (e.g. ``{"dpo": 0.7, "bco": 0.3}``). The schema-level
|
|
surface ships in v0.40.0; the live runtime weighted combination is
|
|
deferred to v0.40.1 (mirrors the project's stub-then-live pattern from
|
|
v0.27.0 MII / v0.37.0 multipack / v0.38.0 quant menu / v0.39.0 ReLoRA).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from soup_cli.config.schema import SoupConfig
|
|
|
|
# ─── Schema bounds ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def _base(**training):
|
|
return SoupConfig(
|
|
base="some-model",
|
|
task="preference",
|
|
data={"train": "./data.jsonl", "format": "dpo"},
|
|
training=training,
|
|
)
|
|
|
|
|
|
class TestPreferenceLossWeightsConfig:
|
|
def test_two_loss_blend_accepted(self):
|
|
cfg = _base(preference_loss_weights={"dpo": 0.7, "bco": 0.3})
|
|
assert cfg.training.preference_loss_weights == {"dpo": 0.7, "bco": 0.3}
|
|
|
|
def test_three_loss_blend_accepted(self):
|
|
cfg = _base(
|
|
preference_loss_weights={"dpo": 0.5, "bco": 0.3, "simpo": 0.2},
|
|
)
|
|
assert sum(cfg.training.preference_loss_weights.values()) == pytest.approx(1.0)
|
|
|
|
def test_single_entry_rejected_use_scalar_form_instead(self):
|
|
"""Single-entry blends are equivalent to scalar preference_loss; reject."""
|
|
with pytest.raises(ValidationError, match="2 and 5"):
|
|
_base(preference_loss_weights={"dpo": 1.0})
|
|
|
|
def test_more_than_five_entries_rejected(self):
|
|
with pytest.raises(ValidationError, match="2 and 5"):
|
|
_base(
|
|
preference_loss_weights={
|
|
"dpo": 0.2, "bco": 0.2, "simpo": 0.2,
|
|
"orpo": 0.2, "ipo": 0.1, "extra": 0.1,
|
|
},
|
|
)
|
|
|
|
def test_unknown_key_rejected(self):
|
|
with pytest.raises(ValidationError, match="unknown"):
|
|
_base(preference_loss_weights={"dpo": 0.5, "garbage": 0.5})
|
|
|
|
def test_null_byte_in_key_rejected(self):
|
|
with pytest.raises(ValidationError, match="null byte"):
|
|
_base(preference_loss_weights={"dpo": 0.5, "bco\x00": 0.5})
|
|
|
|
def test_empty_dict_rejected(self):
|
|
with pytest.raises(ValidationError, match="2 and 5"):
|
|
_base(preference_loss_weights={})
|
|
|
|
def test_weights_must_sum_to_one(self):
|
|
with pytest.raises(ValidationError, match="sum"):
|
|
_base(preference_loss_weights={"dpo": 0.5, "bco": 0.4})
|
|
|
|
def test_weight_zero_rejected(self):
|
|
with pytest.raises(ValidationError, match=r"\(0, 1\]"):
|
|
_base(preference_loss_weights={"dpo": 1.0, "bco": 0.0})
|
|
|
|
def test_weight_negative_rejected(self):
|
|
with pytest.raises(ValidationError, match=r"\(0, 1\]"):
|
|
_base(preference_loss_weights={"dpo": 1.5, "bco": -0.5})
|
|
|
|
def test_weight_above_one_rejected(self):
|
|
# Two values both > 1 — the per-value bound (0,1] fires before the
|
|
# sum gate.
|
|
with pytest.raises(ValidationError, match=r"\(0, 1\]"):
|
|
_base(preference_loss_weights={"dpo": 1.1, "bco": 1.1})
|
|
|
|
def test_weight_bool_coerced_to_zero_then_rejected(self):
|
|
"""Pydantic coerces True/False → 1.0/0.0 before model_validator sees
|
|
them, so a False weight is rejected by the (0, 1] gate (not the bool
|
|
guard). Either rejection path is acceptable; this test pins the
|
|
observed behaviour rather than the rejection mechanism."""
|
|
with pytest.raises(ValidationError, match=r"\(0, 1\]|must be a number"):
|
|
_base(preference_loss_weights={"dpo": True, "bco": False})
|
|
|
|
def test_requires_preference_task(self):
|
|
with pytest.raises(ValidationError, match="preference"):
|
|
SoupConfig(
|
|
base="some-model",
|
|
task="dpo",
|
|
data={"train": "./data.jsonl", "format": "dpo"},
|
|
training={"preference_loss_weights": {"dpo": 1.0}},
|
|
)
|
|
|
|
def test_mutually_exclusive_with_scalar_loss(self):
|
|
with pytest.raises(ValidationError, match="mutually exclusive"):
|
|
_base(
|
|
preference_loss="dpo",
|
|
preference_loss_weights={"dpo": 0.7, "bco": 0.3},
|
|
)
|
|
|
|
def test_rejected_on_mlx(self):
|
|
with pytest.raises(ValidationError, match="mlx"):
|
|
SoupConfig(
|
|
base="some-model",
|
|
task="preference",
|
|
backend="mlx",
|
|
data={"train": "./data.jsonl", "format": "dpo"},
|
|
training={"preference_loss_weights": {"dpo": 0.7, "bco": 0.3}},
|
|
)
|
|
|
|
|
|
# ─── Helper API ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestMultiObjectiveHelpers:
|
|
def test_is_multi_objective_true(self):
|
|
from soup_cli.trainer.preference import is_multi_objective_preference
|
|
|
|
cfg = _base(preference_loss_weights={"dpo": 0.7, "bco": 0.3})
|
|
assert is_multi_objective_preference(cfg) is True
|
|
|
|
def test_is_multi_objective_false_scalar(self):
|
|
from soup_cli.trainer.preference import is_multi_objective_preference
|
|
|
|
cfg = _base(preference_loss="dpo")
|
|
assert is_multi_objective_preference(cfg) is False
|
|
|
|
def test_is_multi_objective_false_legacy(self):
|
|
from soup_cli.trainer.preference import is_multi_objective_preference
|
|
|
|
cfg = SoupConfig(
|
|
base="some-model",
|
|
task="dpo",
|
|
data={"train": "./data.jsonl", "format": "dpo"},
|
|
)
|
|
assert is_multi_objective_preference(cfg) is False
|
|
|
|
def test_get_loss_weights_returns_copy(self):
|
|
"""Defensive copy so caller mutation cannot affect cfg."""
|
|
from soup_cli.trainer.preference import get_loss_weights
|
|
|
|
cfg = _base(preference_loss_weights={"dpo": 0.7, "bco": 0.3})
|
|
weights = get_loss_weights(cfg)
|
|
assert weights == {"dpo": 0.7, "bco": 0.3}
|
|
weights["dpo"] = 999.0
|
|
# Re-fetch — original cfg unchanged.
|
|
assert get_loss_weights(cfg) == {"dpo": 0.7, "bco": 0.3}
|
|
|
|
def test_get_loss_weights_none_when_not_set(self):
|
|
from soup_cli.trainer.preference import get_loss_weights
|
|
|
|
cfg = _base(preference_loss="dpo")
|
|
assert get_loss_weights(cfg) is None
|
|
|
|
|
|
# ─── Live wiring stub-then-live ─────────────────────────────────────────────
|
|
|
|
|
|
class TestMultiObjectiveDeferred:
|
|
def test_bco_paired_blend_rejected_at_runtime(self):
|
|
"""v0.40.1 Part B — live runtime ships, but BCO mixed with paired
|
|
losses (DPO/SimPO/ORPO/IPO) is data-format-incompatible and must
|
|
be rejected at setup() with a ValueError naming 'bco'.
|
|
"""
|
|
from soup_cli.trainer.preference import PreferenceTrainerWrapper
|
|
|
|
cfg = _base(preference_loss_weights={"dpo": 0.7, "bco": 0.3})
|
|
wrapper = PreferenceTrainerWrapper(cfg, device="cpu")
|
|
with pytest.raises(ValueError, match="bco"):
|
|
wrapper.setup({"train": [{"prompt": "p", "chosen": "c", "rejected": "r"}]})
|