Skip to content

Commit fe1cc0d

Browse files
committed
test(grpo): add test coverage for GRPO utilities
1 parent 4bcd2f9 commit fe1cc0d

6 files changed

Lines changed: 358 additions & 0 deletions

File tree

.coverage

0 Bytes
Binary file not shown.

cspell/project-words.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ styledistance
2828
Wegmann
2929
embs
3030
cdfs
31+
unprimed

tests/voice/comparison/test_comparison.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,20 @@ def test_comparison_group_entry_is_frozen_and_slots():
369369
setattr(e, "avg_percentile", 0.0) # noqa: B010
370370

371371

372+
def test_group_entry_repr_contains_avg_percentile():
373+
e = GroupEntry(
374+
group=MetricGroup.WORD_LENGTH_DISTRIBUTION,
375+
avg_percentile=0.75,
376+
avg_tail=0.25,
377+
)
378+
assert repr(e) == "GroupEntry(avg_percentile=0.7500)"
379+
380+
381+
def test_comparison_results_score_zero_when_no_entries(patch_metrics):
382+
r = ComparisonResults(metrics=())
383+
assert r.score == 0.0
384+
385+
372386
def test_comparison_results_rejects_unknown_metrics(patch_metrics):
373387
with pytest.raises(ValueError, match=r"Unknown metric"):
374388
ComparisonResults(metrics=("len", "nope"))

tests/voice/datasets/test_dataset.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,3 +263,45 @@ def test_voice_dataset_bad_split_attr_raises_attribute_error():
263263
vd = VoiceDataset(datasets={Split.TRAIN: _canonical_hf_ds(1)}, spec=p)
264264
with pytest.raises(AttributeError):
265265
_ = vd.validation
266+
267+
268+
def test_example_seq_slice_returns_subset():
269+
p = _pinned(splits=(Split.TRAIN,))
270+
vd = VoiceDataset(datasets={Split.TRAIN: _canonical_hf_ds(5)}, spec=p)
271+
272+
subset = vd.train[1:3]
273+
274+
assert len(subset) == 2
275+
assert subset[0].answer == "a1"
276+
assert subset[1].answer == "a2"
277+
278+
279+
def test_example_seq_repr():
280+
p = _pinned(splits=(Split.TRAIN,))
281+
vd = VoiceDataset(datasets={Split.TRAIN: _canonical_hf_ds(4)}, spec=p)
282+
283+
r = repr(vd.train)
284+
285+
assert "Examples(" in r
286+
assert "train" in r
287+
assert "4" in r
288+
289+
290+
def test_example_seq_hf_returns_dataset():
291+
from datasets import Dataset
292+
293+
p = _pinned(splits=(Split.TRAIN,))
294+
vd = VoiceDataset(datasets={Split.TRAIN: _canonical_hf_ds(3)}, spec=p)
295+
296+
assert isinstance(vd.train.hf, Dataset)
297+
298+
299+
def test_voice_dataset_repr():
300+
p = _pinned(repo_id="ns/name", revision="a1b2c3d", splits=(Split.TRAIN,))
301+
vd = VoiceDataset(datasets={Split.TRAIN: _canonical_hf_ds(7)}, spec=p)
302+
303+
r = repr(vd)
304+
305+
assert "VoiceDataset(" in r
306+
assert "ns/name" in r
307+
assert "a1b2c3d" in r

tests/voice/rl/test_rewards.py

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
"""
2+
Tests for voice.rl.rewards.
3+
4+
Scope:
5+
- _build_training_cdfs: dataset loading, sorted arrays per metric
6+
- prime_typicality_cdfs: initialisation and no-op on second call
7+
- _typicality: tau formula, group aggregation, eps floor
8+
- typicality_reward: error when unprimed, length, clip, positive signal
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import numpy as np
14+
import pytest
15+
16+
import voice.rl.rewards as rewards_mod
17+
from voice._defaults import SCORING_DEFAULTS, MetricGroup
18+
from voice.rl.rewards import (
19+
_build_training_cdfs,
20+
_typicality,
21+
prime_typicality_cdfs,
22+
typicality_reward,
23+
)
24+
from voice.stylometry.metrics import MetricSpec
25+
26+
# -----------------------------------------------------------------------------
27+
# Helpers
28+
# -----------------------------------------------------------------------------
29+
30+
_CDF = np.array([0.0, 0.25, 0.5, 0.75, 1.0])
31+
32+
_FAKE_DS = [
33+
{
34+
"messages": [
35+
{"role": "user", "content": "q1"},
36+
{"role": "assistant", "content": "ans1"},
37+
]
38+
},
39+
{
40+
"messages": [
41+
{"role": "user", "content": "q2"},
42+
{"role": "assistant", "content": "ans2"},
43+
]
44+
},
45+
]
46+
47+
48+
def _patch_metrics(monkeypatch, fn=None):
49+
"""Replace get_metrics with a single fake metric keyed 'm'."""
50+
if fn is None:
51+
fn = lambda t: 0.5 # noqa: E731
52+
fake = {
53+
"m": MetricSpec(
54+
fn=fn,
55+
group=MetricGroup.WORD_LENGTH_DISTRIBUTION,
56+
description="fake",
57+
)
58+
}
59+
monkeypatch.setattr(rewards_mod, "get_metrics", lambda: fake)
60+
return fake
61+
62+
63+
def _fake_cdfs(fn=None):
64+
"""Return a cdfs dict compatible with _patch_metrics."""
65+
return {"m": _CDF.copy()}
66+
67+
68+
# -----------------------------------------------------------------------------
69+
# _build_training_cdfs
70+
# -----------------------------------------------------------------------------
71+
72+
73+
def test_build_training_cdfs_returns_dict_keyed_by_metric(monkeypatch):
74+
monkeypatch.setattr(rewards_mod, "load_dataset", lambda *a, **kw: _FAKE_DS)
75+
_patch_metrics(monkeypatch)
76+
77+
cdfs = _build_training_cdfs("fake/ds")
78+
79+
assert "m" in cdfs
80+
81+
82+
def test_build_training_cdfs_array_is_sorted(monkeypatch):
83+
values = {"ans1": 0.8, "ans2": 0.2}
84+
monkeypatch.setattr(rewards_mod, "load_dataset", lambda *a, **kw: _FAKE_DS)
85+
_patch_metrics(monkeypatch, fn=lambda t: values.get(t, 0.5))
86+
87+
cdfs = _build_training_cdfs("fake/ds")
88+
89+
arr = cdfs["m"]
90+
assert list(arr) == sorted(arr)
91+
assert pytest.approx(arr[0]) == 0.2
92+
assert pytest.approx(arr[1]) == 0.8
93+
94+
95+
# -----------------------------------------------------------------------------
96+
# prime_typicality_cdfs
97+
# -----------------------------------------------------------------------------
98+
99+
100+
def test_prime_typicality_cdfs_sets_global(monkeypatch):
101+
monkeypatch.setattr(rewards_mod, "_typicality_cdfs", None)
102+
monkeypatch.setattr(
103+
rewards_mod, "_build_training_cdfs", lambda ds: {"m": np.array([0.5])}
104+
)
105+
106+
prime_typicality_cdfs("fake/ds")
107+
108+
assert rewards_mod._typicality_cdfs is not None
109+
assert "m" in rewards_mod._typicality_cdfs
110+
111+
112+
def test_prime_typicality_cdfs_noop_when_already_set(monkeypatch):
113+
sentinel = {"already": np.array([1.0])}
114+
monkeypatch.setattr(rewards_mod, "_typicality_cdfs", sentinel)
115+
116+
called = [0]
117+
118+
def spy(ds: str) -> dict:
119+
called[0] += 1
120+
return {}
121+
122+
monkeypatch.setattr(rewards_mod, "_build_training_cdfs", spy)
123+
124+
prime_typicality_cdfs("any")
125+
126+
assert called[0] == 0
127+
assert rewards_mod._typicality_cdfs is sentinel
128+
129+
130+
# -----------------------------------------------------------------------------
131+
# _typicality
132+
# -----------------------------------------------------------------------------
133+
134+
135+
def test_typicality_returns_float(monkeypatch):
136+
_patch_metrics(monkeypatch)
137+
138+
result = _typicality("text", _fake_cdfs())
139+
140+
assert isinstance(result, float)
141+
142+
143+
def test_typicality_in_unit_interval(monkeypatch):
144+
_patch_metrics(monkeypatch)
145+
146+
result = _typicality("text", _fake_cdfs())
147+
148+
assert 0.0 <= result <= 1.0
149+
150+
151+
def test_typicality_applies_group_eps_floor(monkeypatch):
152+
_patch_metrics(monkeypatch, fn=lambda t: 999.0)
153+
cdfs = {"m": np.array([0.0, 0.5, 0.9])}
154+
155+
result = _typicality("any", cdfs)
156+
157+
assert result == pytest.approx(SCORING_DEFAULTS.group_eps)
158+
159+
160+
def test_typicality_median_value_gives_high_score(monkeypatch):
161+
_patch_metrics(monkeypatch, fn=lambda t: 0.5)
162+
cdfs = {"m": np.array([0.0, 0.25, 0.5, 0.75, 1.0])}
163+
164+
result = _typicality("text", cdfs)
165+
166+
assert result == pytest.approx(0.8)
167+
168+
169+
# -----------------------------------------------------------------------------
170+
# typicality_reward
171+
# -----------------------------------------------------------------------------
172+
173+
174+
def test_typicality_reward_raises_without_primed_cdfs(monkeypatch):
175+
monkeypatch.setattr(rewards_mod, "_typicality_cdfs", None)
176+
177+
with pytest.raises(RuntimeError, match="Typicality CDFs not initialised"):
178+
typicality_reward(
179+
completions=[[{"role": "assistant", "content": "x"}]],
180+
ref_completion=["y"],
181+
)
182+
183+
184+
def test_typicality_reward_returns_list_matching_completions_length(
185+
monkeypatch,
186+
):
187+
_patch_metrics(monkeypatch)
188+
monkeypatch.setattr(rewards_mod, "_typicality_cdfs", _fake_cdfs())
189+
190+
completions = [
191+
[{"role": "assistant", "content": "a"}],
192+
[{"role": "assistant", "content": "b"}],
193+
[{"role": "assistant", "content": "c"}],
194+
]
195+
result = typicality_reward(
196+
completions=completions, ref_completion=["x", "y", "z"]
197+
)
198+
199+
assert len(result) == 3
200+
201+
202+
def test_typicality_reward_clips_negative_to_zero(monkeypatch):
203+
values = {"comp": 0.0, "ref": 0.5}
204+
_patch_metrics(monkeypatch, fn=lambda t: values.get(t, 0.5))
205+
monkeypatch.setattr(rewards_mod, "_typicality_cdfs", _fake_cdfs())
206+
207+
result = typicality_reward(
208+
completions=[[{"role": "assistant", "content": "comp"}]],
209+
ref_completion=["ref"],
210+
)
211+
212+
assert result[0] == 0.0
213+
214+
215+
def test_typicality_reward_positive_when_completion_beats_ref(monkeypatch):
216+
values = {"comp": 0.5, "ref": 0.0}
217+
_patch_metrics(monkeypatch, fn=lambda t: values.get(t, 0.5))
218+
monkeypatch.setattr(rewards_mod, "_typicality_cdfs", _fake_cdfs())
219+
220+
result = typicality_reward(
221+
completions=[[{"role": "assistant", "content": "comp"}]],
222+
ref_completion=["ref"],
223+
)
224+
225+
assert result[0] > 0.0
226+
227+
228+
def test_typicality_reward_passes_kwargs_without_error(monkeypatch):
229+
_patch_metrics(monkeypatch)
230+
monkeypatch.setattr(rewards_mod, "_typicality_cdfs", _fake_cdfs())
231+
232+
result = typicality_reward(
233+
completions=[[{"role": "assistant", "content": "x"}]],
234+
ref_completion=["y"],
235+
extra_kwarg="ignored",
236+
)
237+
238+
assert len(result) == 1

tests/voice/rl/test_utils.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""
2+
Tests for voice.rl._utils.
3+
4+
Scope:
5+
- prompt_transform: returns (callable, {}) tuple
6+
- inner _map function: extracts prompt, true_completion, ref_completion
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from voice.rl._utils import prompt_transform
12+
13+
_EXAMPLE = {
14+
"messages": [
15+
{"role": "system", "content": "You are helpful."},
16+
{"role": "user", "content": "What is 2+2?"},
17+
{"role": "assistant", "content": "It is 4."},
18+
],
19+
"ref_completion": "The answer is 4.",
20+
}
21+
22+
23+
def test_prompt_transform_returns_callable_and_empty_dict():
24+
fn, kwargs = prompt_transform(None)
25+
26+
assert callable(fn)
27+
assert kwargs == {}
28+
29+
30+
def test_prompt_transform_map_extracts_true_completion():
31+
fn, _ = prompt_transform(object())
32+
33+
result = fn(_EXAMPLE)
34+
35+
assert result["true_completion"] == "It is 4."
36+
37+
38+
def test_prompt_transform_map_extracts_ref_completion():
39+
fn, _ = prompt_transform(None)
40+
41+
result = fn(_EXAMPLE)
42+
43+
assert result["ref_completion"] == "The answer is 4."
44+
45+
46+
def test_prompt_transform_map_removes_assistant_from_prompt():
47+
fn, _ = prompt_transform(None)
48+
49+
result = fn(_EXAMPLE)
50+
51+
roles = [m["role"] for m in result["prompt"]]
52+
assert "assistant" not in roles
53+
assert "system" in roles
54+
assert "user" in roles
55+
56+
57+
def test_prompt_transform_map_ignores_tokenizer():
58+
fn, _ = prompt_transform(None)
59+
60+
result_without = fn(_EXAMPLE)
61+
result_with = fn(_EXAMPLE, tokenizer=object())
62+
63+
assert result_without == result_with

0 commit comments

Comments
 (0)