feat(train): reversible bang-bang KL controller + hysteresis (v0.71.26 Part B)

Stage 1 of closed-loop reward-hacking mitigation: the first closed loop.

- schema: 8 Stage-1 tunables (beta_floor/ceil, trip/release band, dwell_steps,
  release_patience, kl_gain, signals allowlist) + bounds; extended
  _validate_reward_hack_compat (floor<ceil, release<trip, signal allowlist,
  control-mode XOR ref_model_ema_alpha, footgun-reject tunables while off).
- reward_hack_control: MitigationAction + BangBangPolicy + bang_bang_step (pure
  hysteresis: dwell before trip, release_patience before relax, beta geometric
  x/div kl_gain clamped [floor,ceil], never crosses 0; multi-signal vote).
  Callback kl_control path mutates BOTH trainer.beta and trainer.args.beta
  (GRPO stock + variant) / trainer.args.kl_coef (PPO); seeds beta from trainer;
  length-trend signal.
- peft_wiring: build BangBangPolicy from tcfg for kl_control.
- ppo.py: RLSignalBuffer parity + attach_rl_callbacks(task=ppo) (BETA — GRPO
  gets the on-GPU proof; PPO kl_coef mutation is unit-tested).
- train.py: --reward-hack-mitigation off|log_only|kl_control|pid_lagrangian
  flag + validation + accelerate re-exec passthrough.

+33 tests (test_v07126: 58 -> 91).
This commit is contained in:
Alpamys 2026-07-01 15:05:32 +05:00
parent bcb08ae0ab
commit 925e600702
6 changed files with 850 additions and 10 deletions

View File

@ -167,6 +167,15 @@ def train(
"training.echo_trap_enabled=true on grpo/ppo."
),
),
reward_hack_mitigation: str = typer.Option(
None,
"--reward-hack-mitigation",
help=(
"Closed-loop reward-hacking mitigation mode: off | log_only | "
"kl_control | pid_lagrangian. Requires training.reward_hack_detector "
"on grpo/ppo. Overrides training.reward_hack_mitigation. (v0.71.26)"
),
),
minillm_on_policy: bool = typer.Option(
False,
"--minillm-on-policy",
@ -344,6 +353,29 @@ def train(
cfg.training.echo_trap_tokenizer_aware = True
console.print("[green]Echo-trap tokenizer-aware scoring enabled[/]")
# --- Reward-hack mitigation shortcut (v0.71.26) ---
if reward_hack_mitigation is not None:
valid_modes = ("off", "log_only", "kl_control", "pid_lagrangian")
if reward_hack_mitigation not in valid_modes:
console.print(
"[red]--reward-hack-mitigation must be one of "
f"{', '.join(valid_modes)}[/]"
)
raise typer.Exit(1)
if (
reward_hack_mitigation != "off"
and cfg.training.reward_hack_detector is None
):
console.print(
"[red]--reward-hack-mitigation requires "
"training.reward_hack_detector to be set (the signal source)[/]"
)
raise typer.Exit(1)
cfg.training.reward_hack_mitigation = reward_hack_mitigation
console.print(
f"[green]Reward-hack mitigation:[/] {reward_hack_mitigation}"
)
# --- MiniLLM on-policy rollout shortcut (v0.71.18 #257) ---
if minillm_on_policy:
if not cfg.training.minillm_enabled:
@ -618,6 +650,10 @@ def train(
script_args.append("--tensorboard")
if echo_trap_tokenizer_aware:
script_args.append("--echo-trap-tokenizer-aware")
if reward_hack_mitigation is not None:
script_args.extend(
["--reward-hack-mitigation", reward_hack_mitigation]
)
if yes:
script_args.append("--yes")
argv = build_accelerate_argv(

View File

@ -1672,6 +1672,75 @@ class TrainingConfig(BaseModel):
"{'grpo', 'ppo'} on a non-mlx backend."
),
)
reward_hack_beta_floor: float = Field(
default=0.02,
gt=0.0,
description=(
"v0.71.26 — lower β/kl_coef bound for the mitigation controller. "
"Must be > 0 (β=0 gates off the ref-log-prob path at generation)."
),
)
reward_hack_beta_ceil: float = Field(
default=1.0,
gt=0.0,
le=1000.0,
description=(
"v0.71.26 — upper β/kl_coef bound for the mitigation controller. "
"Must be > reward_hack_beta_floor."
),
)
reward_hack_trip_band: float = Field(
default=0.30,
ge=0.0,
le=1.0,
description=(
"v0.71.26 — hacking drop_pct at/above which the controller wants "
"to RAISE β. Must be > reward_hack_release_band."
),
)
reward_hack_release_band: float = Field(
default=0.10,
ge=0.0,
le=1.0,
description=(
"v0.71.26 — hacking drop_pct at/below which the controller wants "
"to RELAX β. Must be < reward_hack_trip_band."
),
)
reward_hack_dwell_steps: int = Field(
default=2,
ge=1,
le=100_000,
description=(
"v0.71.26 — consecutive trip-band steps required before the "
"controller raises β (hysteresis)."
),
)
reward_hack_release_patience: int = Field(
default=3,
ge=1,
le=100_000,
description=(
"v0.71.26 — consecutive release-band steps required before the "
"controller relaxes β."
),
)
reward_hack_kl_gain: float = Field(
default=1.5,
gt=1.0,
le=100.0,
description=(
"v0.71.26 — multiplicative β step per trip (>1). β is multiplied "
"on trip / divided on release, clamped to [floor, ceil]."
),
)
reward_hack_signals: List[str] = Field(
default_factory=lambda: ["info_rm"],
description=(
"v0.71.26 — signals combined into the controller's multi-signal "
"vote. Allowlist: info_rm, rm_ensemble, length_trend, repetition."
),
)
@field_validator("reward_hack_mitigation", mode="before")
@classmethod
@ -3073,6 +3142,72 @@ class EvalConfig(BaseModel):
)
# --- v0.71.26 reward-hack mitigation validation helpers ---
# Control tunables that are meaningless unless a mitigation mode is set. Setting
# any to a non-default value while reward_hack_mitigation='off' is a silent
# no-op footgun (mirrors the v0.70.0 minillm offenders-list policy). Extended
# per stage (Stage 2/3 tunables added with their fields).
_REWARD_HACK_TUNABLE_DEFAULTS: dict = {
"reward_hack_beta_floor": 0.02,
"reward_hack_beta_ceil": 1.0,
"reward_hack_trip_band": 0.30,
"reward_hack_release_band": 0.10,
"reward_hack_dwell_steps": 2,
"reward_hack_release_patience": 3,
"reward_hack_kl_gain": 1.5,
"reward_hack_signals": ["info_rm"],
}
def _customized_reward_hack_tunables(tcfg) -> list:
"""Return the reward-hack control tunables set to a non-default value."""
offenders = []
for field_name, default in _REWARD_HACK_TUNABLE_DEFAULTS.items():
if getattr(tcfg, field_name, default) != default:
offenders.append(field_name)
return offenders
def _validate_reward_hack_controller(tcfg) -> None:
"""Validate the mitigation-controller config (only when a mode is active).
Numeric consistency (β floor < ceil, release < trip band), the signal
allowlist, and the β-schedule mutual exclusion.
"""
floor = tcfg.reward_hack_beta_floor
ceil = tcfg.reward_hack_beta_ceil
if floor >= ceil:
raise ValueError(
f"reward_hack_beta_floor ({floor}) must be < "
f"reward_hack_beta_ceil ({ceil})"
)
release = tcfg.reward_hack_release_band
trip = tcfg.reward_hack_trip_band
if release >= trip:
raise ValueError(
f"reward_hack_release_band ({release}) must be < "
f"reward_hack_trip_band ({trip})"
)
from soup_cli.utils.reward_hack_control import SIGNAL_NAMES
for name in tcfg.reward_hack_signals or []:
if name not in SIGNAL_NAMES:
raise ValueError(
f"reward_hack_signals contains unknown signal {name!r}; "
f"valid: {sorted(SIGNAL_NAMES)}"
)
# A control mode drives the KL/ref dynamics; a competing β schedule
# (ref_model_ema_alpha regenerates the reference) fights it — reject.
if tcfg.reward_hack_mitigation in ("kl_control", "pid_lagrangian"):
if getattr(tcfg, "ref_model_ema_alpha", None) is not None:
raise ValueError(
"reward_hack_mitigation kl_control/pid_lagrangian is mutually "
"exclusive with ref_model_ema_alpha (both drive the KL/ref "
"dynamics); pick one"
)
class SoupConfig(BaseModel):
"""Root config for soup.yaml."""
@ -4643,6 +4778,14 @@ class SoupConfig(BaseModel):
detector = tcfg.reward_hack_detector
halt = tcfg.reward_hack_halt
mitigation = getattr(tcfg, "reward_hack_mitigation", "off")
# v0.71.26 — footgun: control tunables set while mitigation is off.
if mitigation == "off":
offenders = _customized_reward_hack_tunables(tcfg)
if offenders:
raise ValueError(
f"reward-hack tunables {offenders} require "
"reward_hack_mitigation to be set (not 'off')"
)
if detector is None and not halt and mitigation == "off":
return self
# halt without detector is a silent no-op footgun — reject.
@ -4657,6 +4800,10 @@ class SoupConfig(BaseModel):
f"reward_hack_mitigation={mitigation!r} requires "
"reward_hack_detector to be set (the signal source)"
)
# v0.71.26 — controller config (numeric bounds, signal allowlist,
# β-schedule mutual exclusion) only when a mode is active.
if mitigation != "off":
_validate_reward_hack_controller(tcfg)
if self.task not in ("grpo", "ppo"):
raise ValueError(
"reward_hack_detector / reward_hack_halt / "

View File

@ -191,6 +191,28 @@ class PPOTrainerWrapper:
if self.reward_fn is not None:
reward_funcs.append(self.reward_fn)
# v0.71.26 — reward-hack mitigation buffer parity with GRPO. When a
# detector / mitigation mode is set, capture the callable reward fns'
# rewards + completions into a shared RLSignalBuffer so the mitigation
# callback can observe the step (BETA — the on-GPU proof is GRPO-only).
# nn.Module reward models are skipped (their call shape differs).
self._rl_buffer = None
from soup_cli.utils.peft_wiring import rl_callbacks_need_buffer
if rl_callbacks_need_buffer(tcfg) and reward_funcs:
from soup_cli.utils.rl_signal_buffer import (
RLSignalBuffer,
wrap_reward_funcs,
)
self._rl_buffer = RLSignalBuffer()
reward_funcs = [
wrap_reward_funcs(fn, self._rl_buffer)
if callable(fn) and not hasattr(fn, "forward")
else fn
for fn in reward_funcs
]
# --- Trainer ---
ppo_trainer_params = inspect.signature(ppo_trainer_cls.__init__).parameters
@ -255,6 +277,19 @@ class PPOTrainerWrapper:
self._dataset_in_constructor = True
self.trainer = ppo_trainer_cls(**trainer_kwargs)
# v0.71.26 — reward-hack mitigation / echo-trap / RL-checkpoint callbacks
# (PPO parity with GRPO; kl_coef mutation for the controller).
from soup_cli.utils.peft_wiring import attach_rl_callbacks
attach_rl_callbacks(
self.trainer,
tcfg,
buffer=self._rl_buffer,
tokenizer=self.tokenizer,
output_dir=str(output_dir),
task="ppo",
)
# v0.40.6 #67 — ReLoRA callback (magnitude-prune LoRA every N steps).
from soup_cli.utils.peft_wiring import (
attach_curriculum_callback,

View File

@ -233,6 +233,7 @@ def _attach_reward_hack(
mitigation = getattr(tcfg, "reward_hack_mitigation", "off")
if mitigation != "off" and detector is not None:
from soup_cli.utils.reward_hack_control import (
BangBangPolicy,
MitigationLogWriter,
RewardHackMitigationCallback,
)
@ -244,6 +245,17 @@ def _attach_reward_hack(
signals = tuple(
getattr(tcfg, "reward_hack_signals", None) or ("info_rm",)
)
bang_bang = None
if mitigation == "kl_control":
bang_bang = BangBangPolicy(
beta_floor=tcfg.reward_hack_beta_floor,
beta_ceil=tcfg.reward_hack_beta_ceil,
trip_band=tcfg.reward_hack_trip_band,
release_band=tcfg.reward_hack_release_band,
dwell_steps=tcfg.reward_hack_dwell_steps,
release_patience=tcfg.reward_hack_release_patience,
kl_gain=tcfg.reward_hack_kl_gain,
)
callback = RewardHackMitigationCallback(
mode=mitigation,
detector=detector,
@ -252,6 +264,7 @@ def _attach_reward_hack(
buffer=buffer,
tokenizer=tokenizer,
task=task,
bang_bang=bang_bang,
)
trainer.add_callback(callback)
callback.attach(trainer)

View File

@ -36,7 +36,7 @@ import statistics
import threading
import time
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any
@ -217,6 +217,147 @@ def mean_repetition(completions: Sequence[Any]) -> float:
return 0.0
# --- bang-bang controller (Stage 1) ---
@dataclass(frozen=True)
class MitigationAction:
"""One controller step's decision (frozen).
- ``new_beta``: the β / kl_coef the trainer should apply next step.
- ``tripped``: whether the controller is in the raised band.
- ``verdict``: OK / WARN / HACK from the classifier over the vote.
- ``reason``: human-readable summary for the mitigation log.
"""
new_beta: float
tripped: bool
verdict: str
reason: str
@dataclass(frozen=True)
class BangBangPolicy:
"""Reversible bang-bang + hysteresis controller policy.
Two bands with a dead-zone between them: the controller wants to RAISE β
when the vote is at/above ``trip_band`` and RELAX when at/below
``release_band``. ``dwell_steps`` consecutive want-raise steps are required
before the first trip, and ``release_patience`` consecutive want-relax
steps before relaxing so a signal that flaps across the bands does not
flap β. β moves geometrically by ``kl_gain`` and is clamped to
``[beta_floor, beta_ceil]`` (never crossing 0).
"""
beta_floor: float
beta_ceil: float
trip_band: float
release_band: float
dwell_steps: int
release_patience: int
kl_gain: float
def __post_init__(self) -> None:
_check_finite_float(self.beta_floor, "beta_floor", nonneg=True)
_check_finite_float(self.beta_ceil, "beta_ceil", nonneg=True)
_check_finite_float(self.trip_band, "trip_band", nonneg=True)
_check_finite_float(self.release_band, "release_band", nonneg=True)
_check_finite_float(self.kl_gain, "kl_gain", nonneg=True)
_check_nonneg_int(self.dwell_steps, "dwell_steps")
_check_nonneg_int(self.release_patience, "release_patience")
if self.beta_floor <= 0.0:
raise ValueError(f"beta_floor must be > 0, got {self.beta_floor}")
if self.beta_floor >= self.beta_ceil:
raise ValueError(
f"beta_floor ({self.beta_floor}) must be < "
f"beta_ceil ({self.beta_ceil})"
)
if not 0.0 <= self.release_band < self.trip_band <= 1.0:
raise ValueError(
"require 0 <= release_band < trip_band <= 1, got "
f"release_band={self.release_band}, trip_band={self.trip_band}"
)
if self.dwell_steps < 1:
raise ValueError("dwell_steps must be >= 1")
if self.release_patience < 1:
raise ValueError("release_patience must be >= 1")
if self.kl_gain <= 1.0:
raise ValueError(f"kl_gain must be > 1, got {self.kl_gain}")
def _verdict_for(vote: float) -> str:
from soup_cli.utils.reward_hacking import classify_hack_signal
return classify_hack_signal(min(1.0, max(0.0, float(vote))))
def bang_bang_step(
policy: BangBangPolicy, state: ControllerState, *, vote: float
) -> tuple[ControllerState, MitigationAction]:
"""Advance the bang-bang controller one step given the multi-signal ``vote``.
``vote`` is the combined hacking signal in ``[0, 1]`` (see
:func:`combine_signals`). Returns the next :class:`ControllerState` and the
:class:`MitigationAction` (the β the trainer should apply).
"""
fvote = float(vote)
beta = state.beta if state.beta > 0.0 else policy.beta_floor
tripped = state.tripped
dwell = state.dwell_count
release = state.release_count
reason = "hold"
if fvote >= policy.trip_band:
release = 0
if not tripped:
dwell += 1
if dwell >= policy.dwell_steps:
beta = min(policy.beta_ceil, beta * policy.kl_gain)
tripped = True
dwell = 0
reason = f"trip: raise beta to {beta:.4f} (vote={fvote:.3f})"
else:
new_beta = min(policy.beta_ceil, beta * policy.kl_gain)
if new_beta != beta:
reason = f"raise beta to {new_beta:.4f} (vote={fvote:.3f})"
beta = new_beta
elif fvote <= policy.release_band:
dwell = 0
if tripped:
release += 1
if release >= policy.release_patience:
new_beta = max(policy.beta_floor, beta / policy.kl_gain)
if new_beta != beta:
reason = f"relax beta to {new_beta:.4f} (vote={fvote:.3f})"
beta = new_beta
if beta <= policy.beta_floor:
tripped = False
release = 0
else:
release = 0
else:
# dead-band: hold and decay both counters (hysteresis).
dwell = 0
release = 0
new_state = replace(
state,
step=state.step + 1,
beta=beta,
tripped=tripped,
dwell_count=dwell,
release_count=release,
last_signal=min(1.0, max(0.0, fvote)),
)
action = MitigationAction(
new_beta=beta,
tripped=tripped,
verdict=_verdict_for(fvote),
reason=reason,
)
return new_state, action
class MitigationLogWriter:
"""Thread-safe append-only JSONL log for the reward-hack controller.
@ -341,6 +482,7 @@ class RewardHackMitigationCallback(_TrainerCallbackBase): # type: ignore[misc,
buffer: Any = None,
tokenizer: Any = None,
task: str = "grpo",
bang_bang: BangBangPolicy | None = None,
) -> None:
if mode not in MITIGATION_MODES:
raise ValueError(
@ -363,6 +505,9 @@ class RewardHackMitigationCallback(_TrainerCallbackBase): # type: ignore[misc,
self.buffer = buffer
self.tokenizer = tokenizer
self.task = task
self.bang_bang = bang_bang
if mode == "kl_control" and bang_bang is None:
raise ValueError("kl_control mode requires a BangBangPolicy")
# Compose the v0.70.0 detector callback for the info_rm/rm_ensemble
# baseline + drop_pct logic (DRY — no re-implementation).
self._detector_cb = RewardHackCallback(
@ -370,6 +515,7 @@ class RewardHackMitigationCallback(_TrainerCallbackBase): # type: ignore[misc,
)
self._trainer: Any = None
self._state = ControllerState()
self._length_baseline: float | None = None
def attach(self, trainer: Any) -> None:
"""Store the trainer reference (the β / kl_coef mutation target)."""
@ -386,8 +532,22 @@ class RewardHackMitigationCallback(_TrainerCallbackBase): # type: ignore[misc,
return getattr(args, "kl_coef", None) if args is not None else None
return getattr(trainer, "beta", None)
def _build_telemetry(self, snapshot: Mapping[str, Any], step: int) -> dict[str, Any]:
"""Compute the per-step telemetry entry from a buffer snapshot."""
def _length_trend(self, length_mean: float) -> float:
"""Relative growth of the mean completion length vs its baseline, in
``[0, 1]``. Rising = the policy is padding output (length hacking)."""
if self._length_baseline is None:
if length_mean > 0:
self._length_baseline = length_mean
return 0.0
base = self._length_baseline
if base <= 0:
return 0.0
return min(1.0, max(0.0, (length_mean - base) / base))
def _observe(
self, snapshot: Mapping[str, Any], step: int
) -> tuple[dict[str, Any], dict[str, float]]:
"""Compute the telemetry entry + the per-signal vote inputs."""
raw = self._detector_cb.compute_signal(snapshot)
drop_pct = 0.0
verdict = "OK"
@ -398,7 +558,15 @@ class RewardHackMitigationCallback(_TrainerCallbackBase): # type: ignore[misc,
completions = snapshot.get("completions", []) or []
rewards = snapshot.get("rewards", []) or []
reward_mean, reward_std = reward_mean_std(rewards)
return {
length_mean = mean_token_len(completions)
repetition = mean_repetition(completions)
length_trend = self._length_trend(length_mean)
signals = {
self.detector: drop_pct,
"length_trend": length_trend,
"repetition": repetition,
}
telemetry = {
"mode": self.mode,
"detector": self.detector,
"raw_signal": raw,
@ -407,9 +575,70 @@ class RewardHackMitigationCallback(_TrainerCallbackBase): # type: ignore[misc,
"beta": self._current_coefficient(),
"reward_mean": reward_mean,
"reward_std": reward_std,
"completion_length_mean": mean_token_len(completions),
"repetition": mean_repetition(completions),
"completion_length_mean": length_mean,
"repetition": repetition,
"length_trend": length_trend,
}
return telemetry, signals
def _apply_coefficient(self, value: float) -> None:
"""Write the controller's coefficient to the trainer.
GRPO: β must be dual-written stock ``GRPOTrainer.compute_loss`` reads
``self.beta`` (the instance) while Soup's ``_GRPOTrainerVariant`` reads
``self.args.beta`` (the config). PPO: ``args.kl_coef``.
"""
trainer = self._trainer
if trainer is None:
return
args = getattr(trainer, "args", None)
if self.task == "ppo":
if args is not None:
try:
args.kl_coef = value
except Exception: # noqa: BLE001 — never crash training
pass
return
try:
trainer.beta = value
except Exception: # noqa: BLE001
pass
if args is not None:
try:
args.beta = value
except Exception: # noqa: BLE001
pass
def _seed_coefficient(self, floor: float) -> None:
"""Seed the controller β from the live trainer coefficient on step 1."""
if self._state.beta > 0.0:
return
current = self._current_coefficient()
seed = (
float(current)
if isinstance(current, (int, float))
and not isinstance(current, bool)
and current > 0
else floor
)
self._state = replace(self._state, beta=seed)
def _run_bang_bang(
self, telemetry: dict[str, Any], signals: Mapping[str, float]
) -> None:
"""kl_control: vote → bang-bang step → mutate the trainer coefficient."""
policy = self.bang_bang
if policy is None:
return
self._seed_coefficient(policy.beta_floor)
vote = combine_signals(signals, self.signals)
new_state, action = bang_bang_step(policy, self._state, vote=vote)
self._state = new_state
self._apply_coefficient(action.new_beta)
telemetry["vote"] = vote
telemetry["new_beta"] = action.new_beta
telemetry["tripped"] = action.tripped
telemetry["action"] = action.reason
def on_step_end(self, args, state, control, **kwargs):
"""Per-step hook — read the buffer, compute telemetry, act by mode.
@ -422,10 +651,11 @@ class RewardHackMitigationCallback(_TrainerCallbackBase): # type: ignore[misc,
try:
snapshot = self.buffer.snapshot()
step = int(getattr(state, "global_step", 0) or 0)
telemetry = self._build_telemetry(snapshot, step)
# Stage 0: log_only observes; control modes (kl_control /
# pid_lagrangian) are wired in later stages. In every mode we
# record the telemetry line.
telemetry, signals = self._observe(snapshot, step)
# log_only observes; kl_control drives the bang-bang controller.
# pid_lagrangian is wired in Stage 2.
if self.mode == "kl_control":
self._run_bang_bang(telemetry, signals)
self.log_writer.record(step=step, snapshot=telemetry)
return control
except Exception: # noqa: BLE001 — instrumentation must never crash

View File

@ -49,6 +49,24 @@ def _fake_ppo_trainer(kl_coef=0.2):
args = types.SimpleNamespace(kl_coef=kl_coef)
return types.SimpleNamespace(args=args)
class _SeqBuffer:
"""Fake RLSignalBuffer returning a scripted sequence of snapshots."""
def __init__(self, snapshots):
self._snapshots = snapshots
self._index = 0
def snapshot(self):
snap = self._snapshots[min(self._index, len(self._snapshots) - 1)]
self._index += 1
return snap
# Well-separated rewards (healthy) vs bunched rewards (reward model losing grip).
_HEALTHY = _grpo_snapshot([0.0, 0.0, 1.0, 1.0], ["a", "b", "c", "d"])
_HACK = _grpo_snapshot([0.5, 0.5, 0.5, 0.5], ["a", "b", "c", "d"])
# =====================================================================
# Part A / Stage 0 — schema: reward_hack_mitigation field + gate (Task A1)
# =====================================================================
@ -658,3 +676,364 @@ class TestAttachMitigationWiring:
)
assert any(isinstance(c, RewardHackCallback) for c in added)
assert not any(isinstance(c, RewardHackMitigationCallback) for c in added)
# =====================================================================
# Part B / Stage 1 — schema fields + validators (Task B1)
# =====================================================================
class TestStage1Schema:
"""Bang-bang controller tunables + bounds + mutual-exclusion."""
def _cfg(self, extra: str, *, mitigation: str = "kl_control"):
from soup_cli.config.loader import load_config_from_string
return load_config_from_string(_yaml("grpo", mitigation=mitigation, extra=extra))
def test_kl_control_parses(self):
cfg = self._cfg("reward_hack_beta_floor: 0.05\nreward_hack_beta_ceil: 2.0")
assert cfg.training.reward_hack_mitigation == "kl_control"
assert cfg.training.reward_hack_beta_floor == 0.05
assert cfg.training.reward_hack_beta_ceil == 2.0
def test_defaults(self):
from soup_cli.config.schema import TrainingConfig
t = TrainingConfig()
assert t.reward_hack_beta_floor == 0.02 and t.reward_hack_beta_ceil == 1.0
assert t.reward_hack_trip_band == 0.30 and t.reward_hack_release_band == 0.10
assert t.reward_hack_dwell_steps == 2 and t.reward_hack_release_patience == 3
assert t.reward_hack_kl_gain == 1.5 and t.reward_hack_signals == ["info_rm"]
def test_floor_ge_ceil_rejected(self):
with pytest.raises(ValueError, match="beta_floor"):
self._cfg("reward_hack_beta_floor: 1.0\nreward_hack_beta_ceil: 0.5")
def test_release_ge_trip_rejected(self):
with pytest.raises(ValueError, match="release_band"):
self._cfg("reward_hack_trip_band: 0.2\nreward_hack_release_band: 0.3")
def test_unknown_signal_rejected(self):
with pytest.raises(ValueError, match="signal"):
self._cfg("reward_hack_signals: [info_rm, not_a_signal]")
def test_known_signals_accepted(self):
cfg = self._cfg("reward_hack_signals: [info_rm, length_trend, repetition]")
assert cfg.training.reward_hack_signals == ["info_rm", "length_trend", "repetition"]
def test_kl_control_excludes_ref_ema(self):
with pytest.raises(ValueError, match="ref_model_ema_alpha"):
self._cfg("ref_model_ema_alpha: 0.9")
def test_beta_floor_must_be_positive(self):
with pytest.raises(ValueError):
self._cfg("reward_hack_beta_floor: 0.0")
def test_kl_gain_must_exceed_one(self):
with pytest.raises(ValueError):
self._cfg("reward_hack_kl_gain: 1.0")
def test_dwell_must_be_positive(self):
with pytest.raises(ValueError):
self._cfg("reward_hack_dwell_steps: 0")
def test_tunable_without_mode_rejected(self):
# footgun: setting a control tunable while mitigation is off.
from soup_cli.config.loader import load_config_from_string
with pytest.raises(ValueError, match="reward_hack_mitigation"):
load_config_from_string(
_yaml(
"grpo", detector=None, mitigation="off", extra="reward_hack_kl_gain: 2.0"
)
)
# =====================================================================
# Part B / Stage 1 — BangBangPolicy + bang_bang_step (Task B2)
# =====================================================================
def _bang_policy(**kw):
from soup_cli.utils.reward_hack_control import BangBangPolicy
defaults = dict(
beta_floor=0.02,
beta_ceil=1.0,
trip_band=0.3,
release_band=0.1,
dwell_steps=2,
release_patience=2,
kl_gain=1.5,
)
defaults.update(kw)
return BangBangPolicy(**defaults)
def _run_bang(policy, votes, beta0=None):
from soup_cli.utils.reward_hack_control import ControllerState, bang_bang_step
state = ControllerState(beta=beta0 if beta0 is not None else policy.beta_floor)
actions = []
for vote in votes:
state, action = bang_bang_step(policy, state, vote=vote)
actions.append(action)
return state, actions
class TestBangBang:
"""Reversible bang-bang controller with dwell + release hysteresis."""
def test_policy_floor_lt_ceil(self):
from soup_cli.utils.reward_hack_control import BangBangPolicy
with pytest.raises(ValueError, match="floor"):
BangBangPolicy(
beta_floor=1.0,
beta_ceil=0.5,
trip_band=0.3,
release_band=0.1,
dwell_steps=2,
release_patience=2,
kl_gain=1.5,
)
def test_policy_release_lt_trip(self):
with pytest.raises(ValueError, match="release"):
_bang_policy(trip_band=0.2, release_band=0.3)
def test_policy_kl_gain_gt_one(self):
with pytest.raises(ValueError, match="kl_gain"):
_bang_policy(kl_gain=1.0)
def test_policy_beta_floor_positive(self):
with pytest.raises(ValueError, match="beta_floor"):
_bang_policy(beta_floor=0.0)
def test_below_band_no_trip(self):
state, _ = _run_bang(_bang_policy(), [0.1, 0.1, 0.1, 0.1])
assert not state.tripped and state.beta == pytest.approx(0.02)
def test_dwell_then_trip(self):
state, _ = _run_bang(_bang_policy(), [0.5, 0.5])
assert state.tripped and state.beta == pytest.approx(0.03) # 0.02 * 1.5
def test_keeps_raising_while_hacking(self):
state, _ = _run_bang(_bang_policy(), [0.5, 0.5, 0.5, 0.5])
assert state.tripped and state.beta == pytest.approx(0.02 * 1.5**3)
def test_no_flap_on_alternating(self):
# A naive controller would flap; dwell + release hysteresis prevents it.
state, _ = _run_bang(_bang_policy(), [0.5, 0.05, 0.5, 0.05, 0.5, 0.05])
assert not state.tripped and state.beta == pytest.approx(0.02)
def test_release_reverses(self):
# trip (2 steps) then release_patience (2 steps low) → β back to floor.
state, _ = _run_bang(_bang_policy(), [0.5, 0.5, 0.05, 0.05])
assert state.beta == pytest.approx(0.02) and not state.tripped
def test_beta_clamped_to_ceil(self):
state, _ = _run_bang(_bang_policy(beta_ceil=0.05), [0.5] * 10)
assert state.beta == pytest.approx(0.05)
def test_action_is_frozen(self):
from dataclasses import FrozenInstanceError
from soup_cli.utils.reward_hack_control import ControllerState, bang_bang_step
_, action = bang_bang_step(_bang_policy(), ControllerState(beta=0.02), vote=0.5)
with pytest.raises(FrozenInstanceError):
action.new_beta = 1.0 # type: ignore[misc]
def test_action_verdict_and_reason(self):
from soup_cli.utils.reward_hack_control import ControllerState, bang_bang_step
_, action = bang_bang_step(
_bang_policy(dwell_steps=1), ControllerState(beta=0.02), vote=0.9
)
assert action.verdict == "HACK" and "raise" in action.reason.lower()
# =====================================================================
# Part B / Stage 1 — kl_control callback: β dual-write + kl_coef (Task B3)
# =====================================================================
def _kl_policy(**kw):
from soup_cli.utils.reward_hack_control import BangBangPolicy
defaults = dict(
beta_floor=0.02,
beta_ceil=1.0,
trip_band=0.3,
release_band=0.1,
dwell_steps=1,
release_patience=1,
kl_gain=2.0,
)
defaults.update(kw)
return BangBangPolicy(**defaults)
def _kl_callback(tmp_path, buffer, *, task="grpo", signals=("info_rm",), policy=None):
from soup_cli.utils.reward_hack_control import (
MitigationLogWriter,
RewardHackMitigationCallback,
)
return RewardHackMitigationCallback(
mode="kl_control",
detector="info_rm",
log_writer=MitigationLogWriter(str(tmp_path / "m.jsonl")),
signals=signals,
buffer=buffer,
task=task,
bang_bang=policy or _kl_policy(),
)
class TestKlControlCallback:
"""kl_control mutates β (GRPO, dual-write) / kl_coef (PPO) via the controller."""
def test_kl_control_requires_policy(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
from soup_cli.utils.reward_hack_control import (
MitigationLogWriter,
RewardHackMitigationCallback,
)
with pytest.raises(ValueError, match="kl_control"):
RewardHackMitigationCallback(
mode="kl_control",
detector="info_rm",
log_writer=MitigationLogWriter("m.jsonl"),
)
def test_grpo_dual_write_on_hack(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
cb = _kl_callback(tmp_path, _SeqBuffer([_HEALTHY, _HACK]))
trainer = _fake_grpo_trainer(beta=0.02)
cb.attach(trainer)
cb.on_step_end(None, types.SimpleNamespace(global_step=1), None) # baseline
cb.on_step_end(None, types.SimpleNamespace(global_step=2), None) # hack → raise
assert trainer.beta == pytest.approx(0.04) # 0.02 * 2.0
assert trainer.args.beta == pytest.approx(0.04) # DUAL write (variant path)
def test_ppo_kl_coef_mutated(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
cb = _kl_callback(tmp_path, _SeqBuffer([_HEALTHY, _HACK]), task="ppo")
trainer = _fake_ppo_trainer(kl_coef=0.2)
cb.attach(trainer)
cb.on_step_end(None, types.SimpleNamespace(global_step=1), None)
cb.on_step_end(None, types.SimpleNamespace(global_step=2), None)
assert trainer.args.kl_coef == pytest.approx(0.4) # 0.2 * 2.0
def test_recovery_relaxes_beta(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
cb = _kl_callback(tmp_path, _SeqBuffer([_HEALTHY, _HACK, _HEALTHY]))
trainer = _fake_grpo_trainer(beta=0.02)
cb.attach(trainer)
for step in (1, 2, 3): # baseline, hack (raise), recovery (relax)
cb.on_step_end(None, types.SimpleNamespace(global_step=step), None)
assert trainer.beta == pytest.approx(0.02) # relaxed back to floor
assert not cb._state.tripped
def test_kl_control_logs_vote_and_action(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
cb = _kl_callback(tmp_path, _SeqBuffer([_HEALTHY, _HACK]))
cb.attach(_fake_grpo_trainer(beta=0.02))
cb.on_step_end(None, types.SimpleNamespace(global_step=1), None)
cb.on_step_end(None, types.SimpleNamespace(global_step=2), None)
lines = (tmp_path / "m.jsonl").read_text().strip().splitlines()
last = json.loads(lines[-1])
for key in ("vote", "new_beta", "tripped", "action"):
assert key in last
class TestAttachKlControl:
"""attach_rl_callbacks builds the bang-bang policy from tcfg for kl_control."""
def test_attach_kl_control_builds_policy(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
from soup_cli.config.schema import TrainingConfig
from soup_cli.utils.peft_wiring import attach_rl_callbacks
from soup_cli.utils.reward_hack_control import RewardHackMitigationCallback
tcfg = TrainingConfig(
reward_hack_mitigation="kl_control",
reward_hack_detector="info_rm",
reward_hack_kl_gain=3.0,
reward_hack_signals=["info_rm", "length_trend"],
)
added: list = []
attach_rl_callbacks(
_fake_trainer_recording(added),
tcfg,
buffer=object(),
output_dir=str(tmp_path),
task="grpo",
)
mit = [c for c in added if isinstance(c, RewardHackMitigationCallback)]
assert len(mit) == 1
assert mit[0].mode == "kl_control"
assert mit[0].bang_bang is not None and mit[0].bang_bang.kl_gain == 3.0
assert mit[0].signals == ("info_rm", "length_trend")
class TestPpoBufferParity:
"""PPO wires the RLSignalBuffer + mitigation callback (BETA — GRPO gets the
on-GPU proof; PPO's kl_coef mutation is unit-tested in TestKlControlCallback)."""
def test_ppo_setup_wires_buffer_and_mitigation(self):
import inspect
from soup_cli.trainer import ppo
src = inspect.getsource(ppo)
assert "RLSignalBuffer" in src
assert "rl_callbacks_need_buffer" in src
assert "attach_rl_callbacks" in src
assert 'task="ppo"' in src or "task='ppo'" in src
class TestRewardHackMitigationCli:
"""`soup train --reward-hack-mitigation <mode>` flag + re-exec passthrough."""
def _runner(self):
from typer.testing import CliRunner
from soup_cli.cli import app
return CliRunner(), app
def test_help_shows_flag(self):
runner, app = self._runner()
result = runner.invoke(app, ["train", "--help"])
assert result.exit_code == 0, result.output
assert "--reward-hack-mitigation" in result.output
def test_override_without_detector_rejected(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "cfg.yaml").write_text(
"base: HuggingFaceTB/SmolLM2-135M\n"
"task: grpo\n"
"data:\n train: ./train.jsonl\n format: chatml\n"
"training:\n reward_fn: accuracy\n"
)
runner, app = self._runner()
result = runner.invoke(
app,
["train", "--config", "cfg.yaml", "--reward-hack-mitigation", "log_only", "--yes"],
)
assert result.exit_code == 1
assert "reward_hack_detector" in result.output
def test_reexec_passthrough_present(self):
import inspect
from soup_cli.commands import train as train_mod
src = inspect.getsource(train_mod)
assert "--reward-hack-mitigation" in src
assert "cfg.training.reward_hack_mitigation" in src