forked from MakazhanAlpamys/Soup
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_preference_multi.py
More file actions
177 lines (136 loc) · 7.27 KB
/
Copy pathtest_preference_multi.py
File metadata and controls
177 lines (136 loc) · 7.27 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
"""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"}]})