"""Tests for the reward architecture in training/rewards.py. Verifies three properties of the reward architecture: 1. reward_format produces NON-ZERO scores that VARY across rollouts in a group, so ``reward_std > 0`` and GRPO has a gradient. 2. The dynamic schedule actually phases format out and phases environmental in over the planned episode range. 3. The length monitor trips on sustained drift and stays quiet on normal traffic. """ from __future__ import annotations from training.rewards import ( LengthMonitor, RewardPack, RewardSchedule, build_reward_pack, reward_format, weighted_environmental_reward, ) # ───────────────────────────────────────────────────────────────────────────── # reward_format — partial credit + variance across rollouts # ───────────────────────────────────────────────────────────────────────────── def test_format_perfect_short_output_high(): perfect = ( 'reason\n' '\n' '' ) [score] = reward_format([perfect]) # 0.20 + 0.15 + 0.15 + 0.10 + 0.10 + 0.10 + 0.20 (brevity <=400) = 1.00 assert 0.9 <= score <= 1.0 def test_format_empty_floor_is_nonzero_via_brevity(): """Empty string gets only the short-length credit.""" [score] = reward_format([""]) assert 0.15 <= score <= 0.25 def test_format_partial_action_only(): """Action tag present but no reversibility — must earn middle-tier credit.""" partial = '' [score] = reward_format([partial]) # 0.20 (action) + 0.15 (closed) + 0.20 (short) = 0.55 assert 0.45 <= score <= 0.65 def test_format_rambling_is_penalized(): rambling = "x" * 1200 [score] = reward_format([rambling]) # No tags + rambling penalty assert score <= 0.0 def test_format_produces_variance_in_a_group(): """Critical property: a group of diverse rollouts must score differently so reward_std > 0 in GRPO. was a silent-failure mode when rewards return all zeros.""" group = [ "", '', '', 'x', ] scores = reward_format(group) distinct = len(set(round(s, 3) for s in scores)) assert distinct >= 3, f"expected ≥3 distinct rewards, got {distinct}: {scores}" def test_format_length_tiers_are_monotonic(): """400 < 600 < 900 < 1100 < rambling — reward must decline as length grows (holding tag features equal).""" tags = '' scores = reward_format([ tags, # ~45 chars tags + "x" * 400, # ~450 tags + "x" * 700, # ~750 tags + "x" * 1100, # ~1150 — rambling ]) assert scores[0] > scores[1] > scores[2] > scores[3] # ───────────────────────────────────────────────────────────────────────────── # Schedule — format decays, environmental grows # ───────────────────────────────────────────────────────────────────────────── def test_schedule_format_decays_to_zero(): s = RewardSchedule(total_episodes=300) assert s.weight_format(0) == 1.0 assert s.weight_format(30) < 1.0 assert s.weight_format(150) == 0.0 assert s.weight_format(299) == 0.0 def test_schedule_environmental_grows(): s = RewardSchedule(total_episodes=300) assert s.weight_environmental(0) == 0.5 assert s.weight_environmental(60) > s.weight_environmental(0) assert s.weight_environmental(150) == 1.5 assert s.weight_environmental(299) == 1.5 def test_schedule_weights_sum_is_positive_throughout(): """At every point in training, total weight must be > 0 so SOMETHING is being optimized.""" s = RewardSchedule(total_episodes=300) for ep in (0, 50, 100, 150, 200, 299): total = sum(s.weights_at(ep)) assert total > 0.0, f"Zero total weight at episode {ep}" # ───────────────────────────────────────────────────────────────────────────── # LengthMonitor — auto-abort behavior # ───────────────────────────────────────────────────────────────────────────── def test_length_monitor_silent_on_normal_traffic(): m = LengthMonitor(window=5, threshold_chars=1000, trigger_windows=3) for _ in range(30): m.observe("x" * 300) assert m.abort_flag is False def test_length_monitor_trips_on_sustained_drift(): m = LengthMonitor(window=5, threshold_chars=1000, trigger_windows=3) for _ in range(5): m.observe("x" * 200) for _ in range(20): m.observe("x" * 1200) assert m.abort_flag is True def test_length_monitor_tolerates_single_spike(): """One long completion should not trip the monitor — only sustained drift.""" m = LengthMonitor(window=5, threshold_chars=1000, trigger_windows=3) for _ in range(10): m.observe("x" * 200) m.observe("x" * 5000) for _ in range(10): m.observe("x" * 200) assert m.abort_flag is False # ───────────────────────────────────────────────────────────────────────────── # RewardPack composition # ───────────────────────────────────────────────────────────────────────────── def test_build_reward_pack_has_one_text_func(): """The text-only pack contains reward_format only; the env reward is appended separately by stage 3.""" pack = build_reward_pack(total_episodes=100) assert len(pack.funcs) == 1 assert pack.funcs[0].__name__ == "reward_format" def test_reward_pack_dynamic_weighting(): pack = build_reward_pack(total_episodes=300) completion = '' pack.episode_counter[0] = 0 early = pack.funcs[0]([completion])[0] pack.episode_counter[0] = 200 late = pack.funcs[0]([completion])[0] assert early > late assert late == 0.0 def test_reward_pack_updates_length_monitor(): pack = build_reward_pack(total_episodes=100) long_outputs = ["x" * 1500] * 10 for _ in range(3): pack.funcs[0](long_outputs) assert pack.length_monitor.abort_flag is True def test_weighted_environmental_reward_applies_schedule(): """The env reward wrapper must multiply the raw reward by the current environmental weight.""" pack = build_reward_pack(total_episodes=300) def constant_one(completions, **_): return [1.0] * len(completions) wrapped = weighted_environmental_reward(constant_one, pack) pack.episode_counter[0] = 0 early = wrapped(["x"])[0] pack.episode_counter[0] = 200 late = wrapped(["x"])[0] assert early == 0.5 assert late == 1.5 def test_reward_funcs_are_shape_compatible_with_trl(): """TRL requires reward functions to accept (completions, **kwargs) and return list[float] the same length as completions.""" pack = build_reward_pack(total_episodes=100) completions = [ '', "some bad output", ] for fn in pack.funcs: out = fn( completions, actual_r_levels=[1, 4], task_id=["task_x", "task_y"], seed=[1, 2], ) assert isinstance(out, list) assert len(out) == len(completions) assert all(isinstance(x, float) for x in out) def test_wrappers_survive_trl_keyword_calling_convention(): """Regression test for a TRL calling-convention bug. TRL calls reward functions as ``fn(prompts=[...], completions=[...], task_id=[...], seed=[...])``. Both wrappers (text pack funcs and the env wrapper) must handle this without raising "got multiple values for argument 'prompts'".""" pack = build_reward_pack(total_episodes=100) completions = [''] # Text reward — TRL-style keyword call for fn in pack.funcs: scores = fn( prompts=["some prompt"], completions=completions, task_id=["task_log_cleanup"], seed=[0], ) assert len(scores) == 1 # Env wrapper — the function that actually triggered the bug def fake_env_reward(prompts, completions, **_): return [0.5] * len(completions) wrapped = weighted_environmental_reward(fake_env_reward, pack) scores = wrapped( prompts=["some prompt"], completions=completions, task_id=["task_log_cleanup"], seed=[0], ) assert len(scores) == 1 assert scores[0] > 0 # schedule weight * 0.5 > 0 # ───────────────────────────────────────────────────────────────────────────── # Unlikeliness reward shaping (He et al. 2506.02355) # ───────────────────────────────────────────────────────────────────────────── def test_unlikeliness_reward_disabled(): """BETA_RANK is 0.0 (disabled) because unlikeliness shaping INVERTED the gradient signal for our classification-style task. Our continuous partial-credit reward (level_accuracy × calibration) meant top-reward-ranked samples = correct predictions, so the He et al. penalty on top-ranked samples paid more for WRONG predictions. With BETA_RANK=0.0, shaped rewards equal raw rewards (times the schedule weight), so the gradient is clean. """ from training.rewards import BETA_RANK assert BETA_RANK == 0.0, ( f"Expected BETA_RANK=0.0; got {BETA_RANK}. " "If you re-enabled unlikeliness shaping, also re-validate that it " "doesn't invert the gradient for classification-style rewards." ) pack = build_reward_pack(total_episodes=300) pack.episode_counter[0] = 200 # env weight = 1.5 def raw_returning_spread(completions, **_): return [1.0, 0.8, 0.6, 0.4] wrapped = weighted_environmental_reward(raw_returning_spread, pack) scores = wrapped(completions=["a", "b", "c", "d"]) # With BETA_RANK=0.0 and no R-level bonus firing (no training_log exposed # by the raw_fn), the wrapper is just: schedule_weight × raw_reward. # Env weight = 1.5. assert abs(scores[0] - 1.5 * 1.0) < 1e-6, f"top score wrong: {scores[0]}" assert abs(scores[3] - 1.5 * 0.4) < 1e-6, f"bottom score wrong: {scores[3]}" # Ratio of top:bottom preserved (no longer inverted by shaping) assert abs(scores[0] / scores[3] - 1.0 / 0.4) < 1e-6 def test_unlikeliness_reward_passes_negatives_unchanged(): """With BETA_RANK=0.0, negative rewards flow through unchanged too (previously shaping only affected positives; now nothing is shaped).""" pack = build_reward_pack(total_episodes=300) pack.episode_counter[0] = 200 def raw(completions, **_): return [0.8, -0.1, -0.1, -0.1] wrapped = weighted_environmental_reward(raw, pack) scores = wrapped(completions=["a", "b", "c", "d"]) # No penalty on top (BETA_RANK=0.0) assert abs(scores[0] - 1.5 * 0.8) < 1e-6, f"top shouldn't be penalized now: {scores[0]}" # Negatives still flow through for s in scores[1:]: assert abs(s - 1.5 * -0.1) < 1e-6, f"negative reward shaped unexpectedly: {s}" def test_r_level_bonus_applied_for_correct_high_r_predictions(): """When the raw_fn exposes a training_log and the last G entries show correctly-predicted R4 or R5 actions, a bonus is added before the schedule weight multiplies. This directly incentivizes developing the R4/R5 prediction capability on classes the policy underweights.""" pack = build_reward_pack(total_episodes=300) pack.episode_counter[0] = 200 # env weight = 1.5 # Build a fake raw_fn with a training_log attribute (matching # _make_task_reward's contract in stage_3_grpo) training_log = [ {"predicted_r_level": 5, "actual_r_level": 5}, # correct R5 → +0.2 {"predicted_r_level": 4, "actual_r_level": 4}, # correct R4 → +0.1 ] def raw(completions, **_): return [0.5, 0.5] raw.training_log = training_log wrapped = weighted_environmental_reward(raw, pack) scores = wrapped(completions=["a", "b"]) # Without shaping: both are 0.5. With unlikeliness (2 samples, rank 0 and # rank 1 normalized are 1/2=0.5 and 0): sorted descending [0.5, 0.5] — # both same, arbitrary ranking. Since rewards are identical, the rank # order is stable but the penalty is asymmetric. The key test is: the # R-level bonus actually fires and changes the final scores compared # to no-bonus baseline. def raw_no_bonus(completions, **_): return [0.5, 0.5] wrapped_no_bonus = weighted_environmental_reward(raw_no_bonus, pack) baseline = wrapped_no_bonus(completions=["a", "b"]) # Bonus fires for both entries; shaped reward must be > baseline assert scores[0] > baseline[0], f"R5 bonus did not fire: {scores[0]} vs baseline {baseline[0]}" assert scores[1] > baseline[1], f"R4 bonus did not fire: {scores[1]} vs baseline {baseline[1]}" def test_r_level_bonus_skipped_for_wrong_predictions(): """If predicted != actual, no bonus.""" pack = build_reward_pack(total_episodes=300) pack.episode_counter[0] = 200 training_log = [ {"predicted_r_level": 2, "actual_r_level": 5}, # wrong, no bonus ] def raw(completions, **_): return [0.5] raw.training_log = training_log wrapped = weighted_environmental_reward(raw, pack) [score] = wrapped(completions=["a"]) # Only 1 sample — no rank shaping, no bonus. Just schedule weight. expected = 1.5 * 0.5 assert abs(score - expected) < 1e-6, f"wrong prediction got bonus: {score} vs {expected}" def test_r_level_bonus_skipped_for_low_r_predictions(): """R1/R2/R3 predictions get no bonus even when correct — only the rare high-R levels (R4, R5) incentivize the policy to develop them.""" pack = build_reward_pack(total_episodes=300) pack.episode_counter[0] = 200 training_log = [ {"predicted_r_level": 2, "actual_r_level": 2}, # correct R2, no bonus {"predicted_r_level": 1, "actual_r_level": 1}, # correct R1, no bonus ] def raw(completions, **_): return [0.5, 0.5] raw.training_log = training_log wrapped = weighted_environmental_reward(raw, pack) scores = wrapped(completions=["a", "b"]) # No R-level bonus fired. Only schedule weight + unlikeliness (which is # symmetric for identical rewards). The key check: nothing above the # expected shaped value. # With 2 samples and equal raw 0.5, sorted desc: indices could go either # way but rank 0 gets 0.5*(1-0.25*1.0)=0.375 and rank 1 gets # 0.5*(1-0.25*0)=0.5. So after scheduling (×1.5): scores are {0.5625, 0.75}. # Both scores must be bounded above by 1.5*0.5=0.75. for s in scores: assert s <= 1.5 * 0.5 + 1e-6, f"low-R prediction got unexpected bonus: {s}" def test_r_level_bonus_scales_with_r_level(): """The bonus scales R_LEVEL_BONUS_PER_LEVEL × (actual_r_level - 3), so R5 yields 2× the R4 bonus. This rewards the model more for developing the rarest, most valuable prediction capability.""" from training.rewards import R_LEVEL_BONUS_PER_LEVEL pack = build_reward_pack(total_episodes=300) pack.episode_counter[0] = 200 # One-sample groups, so no unlikeliness shaping interferes training_log_r4 = [{"predicted_r_level": 4, "actual_r_level": 4}] def raw_r4(completions, **_): return [0.0] raw_r4.training_log = training_log_r4 wrapped_r4 = weighted_environmental_reward(raw_r4, pack) [r4_score] = wrapped_r4(completions=["a"]) training_log_r5 = [{"predicted_r_level": 5, "actual_r_level": 5}] def raw_r5(completions, **_): return [0.0] raw_r5.training_log = training_log_r5 wrapped_r5 = weighted_environmental_reward(raw_r5, pack) [r5_score] = wrapped_r5(completions=["a"]) # R5 bonus = 0.1 * 2 = 0.2. R4 bonus = 0.1 * 1 = 0.1. Schedule weight 1.5. assert abs(r4_score - 1.5 * R_LEVEL_BONUS_PER_LEVEL) < 1e-6, f"R4 bonus wrong: {r4_score}" assert abs(r5_score - 1.5 * R_LEVEL_BONUS_PER_LEVEL * 2) < 1e-6, f"R5 bonus wrong: {r5_score}" assert r5_score > r4_score, "R5 bonus should exceed R4" def test_wrapper_is_robust_to_missing_training_log(): """If raw_fn doesn't expose training_log (e.g. test fakes), the wrapper must not crash — it just skips the R-level bonus step.""" pack = build_reward_pack(total_episodes=300) pack.episode_counter[0] = 100 def raw_no_log(completions, **_): return [0.5, 0.5] # No training_log attribute at all wrapped = weighted_environmental_reward(raw_no_log, pack) scores = wrapped(completions=["a", "b"]) assert len(scores) == 2 assert all(s > 0 for s in scores)