fix(train): reward-hack mitigation review fixes (v0.71.26)

Fixes from 5 sequential ECC reviews (python/code/security/tdd/verification).

python-review (2 CRITICAL + HIGH/MED/LOW):
- signal/vote coherence: schema now requires the active detector in
  reward_hack_signals + rejects the inactive detector name (was silently
  dropping the primary signal from the vote).
- integral_clamp is its own field (was wrongly hard-wired to beta_ceil).
- task/backend gate runs before controller-config checks; EMA convention
  corrected; type hints; mutable-list default -> tuple + normalised compare.

code-review (4 HIGH + MED/LOW):
- _prune now trims _saved in sync with disk (rollback can't target a deleted
  checkpoint); bang-bang release_count resets after each relaxation (hysteretic
  descent); EMA formula uses standard convention; _escalate no longer burns a
  recovery attempt on a None target; max_recovery_attempts>=1 required with
  rollback; _action_history capped; on_step_end logs errors once; loud warning
  when the mitigation callback can't attach (was a silent safety-off).

security-review (HIGH + MED):
- restore_checkpoint / save_checkpoint refuse a SYMLINKED optimizer.pt
  (torch.load weights_only=False was an RCE via attacker-placed symlink);
  bool-before-int/float guards on all new numeric fields; reward_hack_signals
  max_length=4; empty-signals guard in the callback.

tdd-review: +13 coverage tests (dead-band hold, shim verbatim-on-error,
conservative boundary, read-only-beta dual-write, escalation postconditions,
both-restore, PID D exact, log cap + concurrency, no-top-level-import, fuzz
field-validity).

Test count 152 -> 180 (+2 POSIX-only symlink skips).
This commit is contained in:
Alpamys 2026-07-01 16:38:00 +05:00
parent eb2edb1a51
commit fa992bf381
6 changed files with 631 additions and 54 deletions

View File

@ -15,8 +15,6 @@ Reward-function signature (TRL GRPO/PPO): ``fn(prompts, completions, **kwargs)
lists of ``{"role", "content"}`` message dicts (conversational).
"""
from __future__ import annotations
from typing import Any
# The synthetic "correct answer" the TRUE scorer looks for. Orthogonal to both

View File

@ -1736,9 +1736,11 @@ class TrainingConfig(BaseModel):
)
reward_hack_signals: List[str] = Field(
default_factory=lambda: ["info_rm"],
max_length=4,
description=(
"v0.71.26 — signals combined into the controller's multi-signal "
"vote. Allowlist: info_rm, rm_ensemble, length_trend, repetition."
"vote. Allowlist (max 4): info_rm, rm_ensemble, length_trend, "
"repetition."
),
)
# ---- v0.71.26 Stage 2 — PID-Lagrangian controller + rollback ---------
@ -1769,6 +1771,15 @@ class TrainingConfig(BaseModel):
"(pid_lagrangian mode)."
),
)
reward_hack_integral_clamp: float = Field(
default=1.0,
gt=0.0,
le=1000.0,
description=(
"v0.71.26 — PID anti-windup bound on the integral accumulator "
"(pid_lagrangian mode). Independent of beta_ceil."
),
)
reward_hack_rollback: bool = Field(
default=False,
description=(
@ -1852,6 +1863,36 @@ class TrainingConfig(BaseModel):
f"reward-hack bool flag must be bool, got {type(v).__name__}"
)
@field_validator(
"reward_hack_dwell_steps",
"reward_hack_release_patience",
"reward_hack_rollback_patience",
"reward_hack_max_recovery_attempts",
"reward_hack_smoothing_window",
"reward_hack_beta_floor",
"reward_hack_beta_ceil",
"reward_hack_trip_band",
"reward_hack_release_band",
"reward_hack_kl_gain",
"reward_hack_pid_kp",
"reward_hack_pid_ki",
"reward_hack_pid_kd",
"reward_hack_signal_target",
"reward_hack_integral_clamp",
"reward_hack_shaping_strength",
mode="before",
)
@classmethod
def _reject_bool_on_reward_hack_numerics(cls, v):
"""v0.71.26 — bool-before-int/float policy (security-review MEDIUM): a
YAML ``yes`` must not silently coerce to 1 on a numeric tunable."""
if isinstance(v, bool):
raise ValueError(
"reward-hack numeric tunable must not be bool "
"(YAML on/off/yes/no coerces to a number)"
)
return v
@field_validator("reward_hack_mitigation", mode="before")
@classmethod
def _coerce_reward_hack_mitigation(cls, v):
@ -3260,18 +3301,19 @@ class EvalConfig(BaseModel):
# per stage (Stage 2/3 tunables added with their fields).
# Stage-2 (PID-Lagrangian + rollback) tunables — meaningful only in
# pid_lagrangian mode. Setting one under any other mode is a no-op footgun.
_REWARD_HACK_STAGE2_DEFAULTS: dict = {
_REWARD_HACK_STAGE2_DEFAULTS: dict[str, Any] = {
"reward_hack_pid_kp": 0.5,
"reward_hack_pid_ki": 0.1,
"reward_hack_pid_kd": 0.05,
"reward_hack_signal_target": 0.15,
"reward_hack_integral_clamp": 1.0,
"reward_hack_rollback": False,
"reward_hack_rollback_patience": 3,
"reward_hack_max_recovery_attempts": 2,
}
# Stage-3 (anti-gaming) tunables — meaningful for any non-off mode.
_REWARD_HACK_STAGE3_DEFAULTS: dict = {
_REWARD_HACK_STAGE3_DEFAULTS: dict[str, Any] = {
"reward_hack_signal_smoothing": "none",
"reward_hack_smoothing_window": 8,
"reward_hack_conservative_on_disagreement": False,
@ -3280,7 +3322,7 @@ _REWARD_HACK_STAGE3_DEFAULTS: dict = {
"reward_hack_shaping_strength": 0.0,
}
_REWARD_HACK_TUNABLE_DEFAULTS: dict = {
_REWARD_HACK_TUNABLE_DEFAULTS: dict[str, Any] = {
"reward_hack_beta_floor": 0.02,
"reward_hack_beta_ceil": 1.0,
"reward_hack_trip_band": 0.30,
@ -3288,22 +3330,28 @@ _REWARD_HACK_TUNABLE_DEFAULTS: dict = {
"reward_hack_dwell_steps": 2,
"reward_hack_release_patience": 3,
"reward_hack_kl_gain": 1.5,
"reward_hack_signals": ["info_rm"],
# tuple (not list) so a caller cannot mutate this module-level default.
"reward_hack_signals": ("info_rm",),
**_REWARD_HACK_STAGE2_DEFAULTS,
**_REWARD_HACK_STAGE3_DEFAULTS,
}
def _customized_reward_hack_tunables(tcfg) -> list:
def _customized_reward_hack_tunables(tcfg: Any) -> list[str]:
"""Return the reward-hack control tunables set to a non-default value."""
offenders = []
offenders: list[str] = []
for field_name, default in _REWARD_HACK_TUNABLE_DEFAULTS.items():
if getattr(tcfg, field_name, default) != default:
current = getattr(tcfg, field_name, default)
# Normalise list/tuple so a list value compares equal to a tuple default.
if isinstance(default, tuple) and isinstance(current, (list, tuple)):
if tuple(current) != default:
offenders.append(field_name)
elif current != default:
offenders.append(field_name)
return offenders
def _validate_reward_hack_controller(tcfg) -> None:
def _validate_reward_hack_controller(tcfg: Any) -> None:
"""Validate the mitigation-controller config (only when a mode is active).
Numeric consistency (β floor < ceil, release < trip band), the signal
@ -3325,12 +3373,29 @@ def _validate_reward_hack_controller(tcfg) -> None:
)
from soup_cli.utils.reward_hack_control import SIGNAL_NAMES
for name in tcfg.reward_hack_signals or []:
# The controller votes on the ACTIVE detector's signal plus the auxiliary
# signals. Listing the other detector's name (never produced) or omitting
# the active detector silently drops the primary signal from the vote —
# reject both so the config is coherent (python-review CRITICAL #1).
signals = list(tcfg.reward_hack_signals or [])
allowed = {tcfg.reward_hack_detector, "length_trend", "repetition"}
for name in signals:
if name not in SIGNAL_NAMES:
raise ValueError(
f"reward_hack_signals contains unknown signal {name!r}; "
f"valid: {sorted(SIGNAL_NAMES)}"
)
if name not in allowed:
raise ValueError(
f"reward_hack_signals contains {name!r}, but the active "
f"detector is {tcfg.reward_hack_detector!r}; valid signals "
f"are {sorted(allowed)}"
)
if tcfg.reward_hack_detector is not None and tcfg.reward_hack_detector not in signals:
raise ValueError(
"reward_hack_signals must include the active detector "
f"{tcfg.reward_hack_detector!r} (its signal is the primary vote)"
)
# 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"):
@ -3358,6 +3423,14 @@ def _validate_reward_hack_controller(tcfg) -> None:
"reward_hack_rollback=True requires rl_checkpoint_save_every_steps "
"to be set (a cadence to roll back to)"
)
# max_recovery_attempts=0 with rollback would early-stop on the first HACK
# streak WITHOUT a single rollback — a footgun (code-review MEDIUM).
if tcfg.reward_hack_rollback and tcfg.reward_hack_max_recovery_attempts < 1:
raise ValueError(
"reward_hack_rollback=True requires "
"reward_hack_max_recovery_attempts >= 1 (0 would early-stop "
"before any rollback)"
)
# v0.71.26 Stage 3 — reward shaping MUTATES rewards, so it is only valid
# for a control mode (log_only must stay observe-only).
if tcfg.reward_hack_reward_shaping:
@ -4965,10 +5038,9 @@ 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)
# v0.71.26 — the task / backend gate runs BEFORE the controller-config
# checks so a task mismatch surfaces the actionable error (not a
# numeric-bounds error) — python-review HIGH #3.
if self.task not in ("grpo", "ppo"):
raise ValueError(
"reward_hack_detector / reward_hack_halt / "
@ -4980,6 +5052,10 @@ class SoupConfig(BaseModel):
"reward_hack_detector / reward_hack_mitigation are not "
"supported on backend=mlx (RL detectors are transformers-only)"
)
# Controller config (numeric bounds, signal allowlist, β-schedule
# mutual exclusion) only when a mode is active.
if mitigation != "off":
_validate_reward_hack_controller(tcfg)
return self

View File

@ -269,7 +269,7 @@ def _attach_reward_hack(
signal_target=tcfg.reward_hack_signal_target,
beta_floor=tcfg.reward_hack_beta_floor,
beta_ceil=tcfg.reward_hack_beta_ceil,
integral_clamp=tcfg.reward_hack_beta_ceil,
integral_clamp=tcfg.reward_hack_integral_clamp,
)
callback = RewardHackMitigationCallback(
mode=mitigation,
@ -301,7 +301,15 @@ def _attach_reward_hack(
callback.attach(trainer)
return 1
except (TypeError, ValueError, OSError) as exc:
logger.debug("attach reward-hack mitigation callback rejected: %s", exc)
# A user explicitly enabled mitigation — a silent drop would leave
# them believing a safety controller is active when it is not.
# Warn LOUDLY (e.g. output dir outside cwd fails the log writer).
logger.warning(
"reward-hack mitigation callback NOT attached (%s): %s. "
"Training will proceed WITHOUT mitigation.",
type(exc).__name__,
exc,
)
return 0
if detector is not None:
from soup_cli.utils.reward_hacking import build_reward_hack_callback

View File

@ -29,6 +29,7 @@ Security:
from __future__ import annotations
import json
import logging
import math
import os
import stat
@ -43,6 +44,10 @@ from typing import Any
from soup_cli.monitoring.trace_logger import redact_value
from soup_cli.utils.paths import is_under_cwd
logger = logging.getLogger(__name__)
_MAX_ACTION_HISTORY = 1000 # cap the in-memory action log for long runs
_DEFAULT_CAP_MB = 100
_MIN_CAP_MB = 1
_MAX_CAP_MB = 10_000
@ -57,7 +62,7 @@ SIGNAL_NAMES: frozenset[str] = frozenset(
SMOOTHING_METHODS: frozenset[str] = frozenset({"none", "ema", "median"})
SHAPING_KINDS: frozenset[str] = frozenset({"length", "repetition", "sentinel"})
_EMA_ALPHA = 0.5 # fixed EMA weight on the new sample (documented, Stage 3)
_EMA_ALPHA = 0.5 # EMA smoothing factor: weight on the NEW sample (1 - it on prev)
_CONSERVATIVE_DISAGREE_TOL = 0.2 # detectors differ beyond this → stay cautious
@ -150,9 +155,10 @@ def combine_signals(signals: Mapping[str, Any], names: Sequence[str]) -> float:
def smooth_signal(new: float, window: Sequence[float], *, method: str) -> float:
"""Smooth a scalar signal. ``none`` → new; ``ema`` → 0.5·prev + 0.5·new
(prev = ``window[-1]``, or ``new`` when the window is empty); ``median``
median of ``window + [new]``.
"""Smooth a scalar signal. ``none`` → new; ``ema`` → ``alpha·new +
(1-alpha)·prev`` with alpha = ``_EMA_ALPHA`` = 0.5 (prev = ``window[-1]``,
or ``new`` when the window is empty); ``median`` median of
``window + [new]``.
"""
if method not in SMOOTHING_METHODS:
raise ValueError(
@ -165,7 +171,8 @@ def smooth_signal(new: float, window: Sequence[float], *, method: str) -> float:
if method == "ema":
if not win:
return fnew
return _EMA_ALPHA * win[-1] + (1.0 - _EMA_ALPHA) * fnew
# Standard EMA convention: alpha weights the NEW sample.
return _EMA_ALPHA * fnew + (1.0 - _EMA_ALPHA) * win[-1]
return float(statistics.median(win + [fnew]))
@ -239,7 +246,7 @@ def _completion_text(completion: Any) -> str:
return _completion_to_text(completion)
def _extract_completions(args: tuple, kwargs: dict) -> Any:
def _extract_completions(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
"""Pull the ``completions`` arg from a TRL reward-fn call."""
completions = kwargs.get("completions")
if completions is None:
@ -279,6 +286,10 @@ def shape_reward_fn(
never corrupt training: on any exception the verbatim reward is returned.
``__name__`` is preserved so TRL's per-function logging keys stay correct.
"""
if not callable(inner):
raise TypeError(
f"shape_reward_fn inner must be callable, got {type(inner).__name__}"
)
if kind not in SHAPING_KINDS:
raise ValueError(f"kind must be one of {sorted(SHAPING_KINDS)}, got {kind!r}")
strength_val = _check_finite_float(strength, "strength", nonneg=True)
@ -343,7 +354,7 @@ def explain_giveup(
f"The '{signal_name}' signal stayed elevated (last smoothed drop_pct="
f"{state.last_signal:.3f}).",
]
recent = [str(a) for a in list(action_history)[-5:]]
recent = [str(a) for a in action_history[-5:]]
if recent:
lines.append("Recent actions tried: " + " | ".join(recent))
lines.append(
@ -517,9 +528,13 @@ def bang_bang_step(
if new_beta != beta:
reason = f"relax beta to {new_beta:.4f} (vote={fvote:.3f})"
beta = new_beta
# Reset the patience counter after EACH relaxation so the
# descent is hysteretic — β must see release_patience fresh
# below-band steps before the next geometric relaxation
# (v0.71.26 code-review HIGH: raise fast, relax cautiously).
release = 0
if beta <= policy.beta_floor:
tripped = False
release = 0
else:
release = 0
else:
@ -775,6 +790,10 @@ class RewardHackMitigationCallback(_TrainerCallbackBase): # type: ignore[misc,
self.detector = validate_hack_detector(detector)
self.log_writer = log_writer
self.signals = tuple(signals)
if not self.signals:
# An empty signal set makes the vote always 0.0 → the controller is
# active but permanently inert (security-review LOW #4).
raise ValueError("signals must be non-empty (the controller vote)")
for name in self.signals:
if name not in SIGNAL_NAMES:
raise ValueError(
@ -814,6 +833,13 @@ class RewardHackMitigationCallback(_TrainerCallbackBase): # type: ignore[misc,
self._signal_windows: dict[str, list[float]] = {}
self._action_history: list[str] = []
self._last_drift = False
self._warned_error = False
def _record_action(self, reason: str) -> None:
"""Append an action reason, capping the in-memory history (LOW #9)."""
self._action_history.append(reason)
if len(self._action_history) > _MAX_ACTION_HISTORY:
del self._action_history[:-_MAX_ACTION_HISTORY]
def attach(self, trainer: Any) -> None:
"""Store the trainer reference (the β / kl_coef mutation target)."""
@ -965,7 +991,7 @@ class RewardHackMitigationCallback(_TrainerCallbackBase): # type: ignore[misc,
new_state, action = bang_bang_step(policy, self._state, vote=vote)
self._state = new_state
self._apply_coefficient(action.new_beta)
self._action_history.append(action.reason)
self._record_action(action.reason)
telemetry["vote"] = vote
telemetry["new_beta"] = action.new_beta
telemetry["tripped"] = action.tripped
@ -982,6 +1008,16 @@ class RewardHackMitigationCallback(_TrainerCallbackBase): # type: ignore[misc,
self, model: Any, optimizer: Any, control: Any, telemetry: dict[str, Any]
) -> Any:
"""Escalation ladder rung: rollback to last-good, else early-stop."""
target = self._last_good_step
# No last-good checkpoint yet — do NOT burn a recovery attempt or
# early-stop; keep training until a good checkpoint exists or the real
# rollback budget is spent (v0.71.26 code-review MEDIUM).
if target is None or self.rl_checkpoint_cb is None:
telemetry["escalation"] = (
"no rollback target available yet (no saved checkpoint)"
)
self._hack_streak = 0
return control
if self._state.recovery_attempts >= self.max_recovery_attempts:
telemetry["escalation"] = "early_stop"
telemetry["explanation"] = explain_giveup(
@ -991,17 +1027,19 @@ class RewardHackMitigationCallback(_TrainerCallbackBase): # type: ignore[misc,
)
self._request_stop(control)
return control
target = self._last_good_step
restored = False
if target is not None and self.rl_checkpoint_cb is not None:
try:
restored = bool(
self.rl_checkpoint_cb.restore_checkpoint(
step=target, model=model, optimizer=optimizer
)
try:
restored = bool(
self.rl_checkpoint_cb.restore_checkpoint(
step=target, model=model, optimizer=optimizer
)
except Exception: # noqa: BLE001 — rollback must never crash the run
restored = False
)
except Exception: # noqa: BLE001 — rollback must never crash the run
restored = False
# NOTE (known limitation): the rollback restores the model weights +
# optimizer, but the controller's β / integral state is intentionally
# NOT reset — we keep KL elevated while recovering from hacking. The PID
# continues from its last state, which is the conservative choice.
self._state = replace(
self._state, recovery_attempts=self._state.recovery_attempts + 1
)
@ -1026,7 +1064,7 @@ class RewardHackMitigationCallback(_TrainerCallbackBase): # type: ignore[misc,
new_state, action = pid_step(policy, self._state, signal=vote)
self._state = new_state
self._apply_coefficient(action.new_beta)
self._action_history.append(action.reason)
self._record_action(action.reason)
telemetry["vote"] = vote
telemetry["new_beta"] = action.new_beta
telemetry["tripped"] = action.tripped
@ -1043,7 +1081,7 @@ class RewardHackMitigationCallback(_TrainerCallbackBase): # type: ignore[misc,
control = self._escalate(model, optimizer, control, telemetry)
return control
def on_step_end(self, args, state, control, **kwargs):
def on_step_end(self, args: Any, state: Any, control: Any, **kwargs: Any) -> Any:
"""Per-step hook — read the buffer, compute telemetry, act by mode.
Instrumentation must NEVER crash training: a broad except returns the
@ -1069,5 +1107,16 @@ class RewardHackMitigationCallback(_TrainerCallbackBase): # type: ignore[misc,
)
self.log_writer.record(step=step, snapshot=telemetry)
return control
except Exception: # noqa: BLE001 — instrumentation must never crash
except Exception as exc: # noqa: BLE001 — instrumentation must never crash
# Training must not crash, but a persistent controller bug silently
# disabling the safety loop must be visible — warn ONCE (code-review
# LOW #10), not every step.
if not self._warned_error:
self._warned_error = True
logger.warning(
"reward-hack mitigation callback error (%s): %s. The "
"controller is inactive this step; training continues.",
type(exc).__name__,
exc,
)
return control

View File

@ -28,9 +28,12 @@ Security:
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from typing import Any, Optional
logger = logging.getLogger(__name__)
_MAX_SAVE_EVERY_STEPS = 10_000_000
_MIN_KEEP_LAST = 1
_MAX_KEEP_LAST = 100
@ -285,16 +288,19 @@ class RLCheckpointCallback(_TrainerCallbackBase): # type: ignore[misc, valid-ty
has_optimizer = False
if self.config.include_optimizer_state and optimizer is not None:
try:
import torch
torch.save(
optimizer.state_dict(),
os.path.join(ckpt_dir, "optimizer.pt"),
)
has_optimizer = True
except Exception: # noqa: BLE001 — best-effort, manifest reflects it
opt_out = os.path.join(ckpt_dir, "optimizer.pt")
# Refuse to write THROUGH a pre-placed symlink (write-to-arbitrary
# path in a shared checkpoint dir; security-review LOW #6).
if os.path.islink(opt_out):
has_optimizer = False
else:
try:
import torch
torch.save(optimizer.state_dict(), opt_out)
has_optimizer = True
except Exception: # noqa: BLE001 — best-effort, manifest reflects it
has_optimizer = False
manifest = RLCheckpointState(
step=int(step),
@ -343,12 +349,22 @@ class RLCheckpointCallback(_TrainerCallbackBase): # type: ignore[misc, valid-ty
pass
if optimizer is not None:
opt_path = os.path.join(ckpt_dir, "optimizer.pt")
if os.path.isfile(opt_path):
# SECURITY (review HIGH #1): torch.load(weights_only=False) executes
# arbitrary pickle. Refuse a SYMLINKED optimizer.pt — an attacker
# with write access to a shared checkpoint dir could swap the file
# for a symlink to a malicious pickle between save and restore (RCE).
# opt_path is otherwise contained (output_dir is_under_cwd-verified in
# __init__ + int-cast step); the symlink check closes the TOCTOU.
if os.path.islink(opt_path):
logger.warning(
"refusing to restore optimizer state from a symlinked "
"optimizer.pt (%s) — possible tampering",
opt_path,
)
elif os.path.isfile(opt_path):
try:
import torch
# Trusted file (we wrote it under the cwd-contained run dir);
# weights_only=False loads the full optimizer state_dict.
optimizer.load_state_dict(
torch.load(
opt_path, map_location="cpu", weights_only=False
@ -376,11 +392,16 @@ class RLCheckpointCallback(_TrainerCallbackBase): # type: ignore[misc, valid-ty
continue
entries.append((_step_number(name), full))
entries.sort(key=lambda t: t[0], reverse=True)
for _, path in entries[self.config.keep_last:]:
for step_num, path in entries[self.config.keep_last:]:
try:
shutil.rmtree(path)
except OSError:
pass
continue
# Keep the in-memory ledger in sync with disk so a rollback target
# (max(_saved)) can never point at a deleted checkpoint (v0.71.26
# code-review HIGH — the reward-hack rollback ladder reads _saved).
if step_num in self._saved:
self._saved.remove(step_num)
def on_step_end(self, args, state, control, model=None, **kwargs):
"""Per-step hook — save a checkpoint on the configured cadence."""

View File

@ -1807,12 +1807,27 @@ class TestControllerFuzz:
state = ControllerState(beta=policy.beta_floor)
prev = state.beta
for vote in trace:
state, _ = bang_bang_step(policy, state, vote=vote)
state, action = bang_bang_step(policy, state, vote=vote)
assert policy.beta_floor - 1e-9 <= state.beta <= policy.beta_ceil + 1e-9
assert state.beta > 0.0 and math.isfinite(state.beta)
assert state.beta / prev <= policy.kl_gain + 1e-9 # geometric only
# field-validity invariants (tdd review LOW #8)
assert 0.0 <= state.last_signal <= 1.0
assert state.dwell_count >= 0 and state.release_count >= 0
assert isinstance(state.tripped, bool)
assert action.new_beta == state.beta
prev = state.beta
def test_bang_bang_converges_on_sustained_signal(self):
# convergence property: sustained above-band input eventually trips.
from soup_cli.utils.reward_hack_control import ControllerState, bang_bang_step
policy = _bang_policy(dwell_steps=3)
state = ControllerState(beta=0.02)
for _ in range(5):
state, _ = bang_bang_step(policy, state, vote=0.9)
assert state.tripped and state.beta > 0.02
def test_bang_bang_no_flap_on_alternation(self):
from soup_cli.utils.reward_hack_control import ControllerState, bang_bang_step
@ -1833,3 +1848,413 @@ class TestControllerFuzz:
assert policy.beta_floor - 1e-9 <= state.beta <= policy.beta_ceil + 1e-9
assert state.beta > 0.0 and math.isfinite(state.beta)
assert abs(state.integral) <= policy.integral_clamp + 1e-9
assert math.isfinite(state.integral) and math.isfinite(state.prev_error)
assert 0.0 <= state.last_signal <= 1.0
# =====================================================================
# python-review fixes (v0.71.26)
# =====================================================================
class TestReviewFixesPython:
"""Regression tests for the python-review findings."""
def _cfg(self, extra, *, mitigation="kl_control", detector="info_rm", task="grpo"):
from soup_cli.config.loader import load_config_from_string
return load_config_from_string(
_yaml(task, mitigation=mitigation, detector=detector, extra=extra)
)
def test_signals_must_include_active_detector(self):
# CRITICAL #1 — a signal set that omits the active detector silently
# drops the primary signal from the vote; reject it.
with pytest.raises(ValueError, match="detector"):
self._cfg("reward_hack_signals: [length_trend]")
def test_signals_reject_inactive_detector_name(self):
# detector=info_rm but signals lists rm_ensemble (never produced) → reject.
with pytest.raises(ValueError, match="rm_ensemble|active detector"):
self._cfg("reward_hack_signals: [info_rm, rm_ensemble]")
def test_integral_clamp_is_a_field(self):
# CRITICAL #2 — integral_clamp must be its own tunable, not beta_ceil.
from soup_cli.config.schema import TrainingConfig
assert TrainingConfig().reward_hack_integral_clamp == 1.0
cfg = self._cfg(
"reward_hack_integral_clamp: 5.0", mitigation="pid_lagrangian"
)
assert cfg.training.reward_hack_integral_clamp == 5.0
def test_integral_clamp_wired_into_pid_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="pid_lagrangian",
reward_hack_detector="info_rm",
reward_hack_integral_clamp=7.0,
reward_hack_beta_ceil=3.0,
)
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)][0]
assert mit.pid.integral_clamp == 7.0 # not beta_ceil (3.0)
def test_task_gate_before_controller_checks(self):
# HIGH #3 — a bad beta bound on an sft task should surface the task
# error, not the numeric one.
with pytest.raises(ValueError, match="grpo|ppo|task"):
self._cfg(
"reward_hack_beta_floor: 1.0\nreward_hack_beta_ceil: 0.5",
task="sft",
)
def test_shape_reward_fn_rejects_non_callable(self):
# MEDIUM #10 — non-callable inner must fail fast, not at train time.
from soup_cli.utils.reward_hack_control import shape_reward_fn
with pytest.raises((TypeError, ValueError), match="callable"):
shape_reward_fn("not a fn", kind="length", strength=0.5)
class TestReviewFixesCode:
"""Regression tests for the code-review findings."""
def test_prune_trims_saved_list(self, tmp_path, monkeypatch):
# HIGH #1 — _prune must keep _saved in sync with surviving dirs so the
# rollback target can never point at a deleted checkpoint.
import os
monkeypatch.chdir(tmp_path)
from soup_cli.utils.rl_checkpoint import (
RLCheckpointConfig,
build_rl_checkpoint_callback,
)
cb = build_rl_checkpoint_callback(
RLCheckpointConfig(save_every_steps=1, keep_last=2),
output_dir="run",
task="grpo",
)
for step in (1, 2, 3, 4):
cb.save_checkpoint(step=step, model=_FakeSavableModel(), optimizer=None)
root = os.path.join("run", "rl-checkpoints")
on_disk = sorted(
int(d.split("-")[1]) for d in os.listdir(root) if d.startswith("step-")
)
assert on_disk == [3, 4]
assert sorted(cb._saved) == on_disk # in-memory list matches disk
def test_bang_release_requires_patience_per_relaxation(self):
# HIGH #4 — each geometric relaxation must re-accumulate release_patience.
from soup_cli.utils.reward_hack_control import ControllerState, bang_bang_step
policy = _bang_policy(dwell_steps=1, release_patience=2, kl_gain=1.5, beta_ceil=1.0)
state = ControllerState(beta=0.02)
for _ in range(3): # raise β three times → ~0.0675
state, _ = bang_bang_step(policy, state, vote=0.9)
high = state.beta
state, _ = bang_bang_step(policy, state, vote=0.0) # release=1, no relax
assert state.beta == pytest.approx(high)
state, _ = bang_bang_step(policy, state, vote=0.0) # release=2 → relax
after_first = state.beta
assert after_first < high
state, _ = bang_bang_step(policy, state, vote=0.0) # release reset → 1, no relax
assert state.beta == pytest.approx(after_first)
state, _ = bang_bang_step(policy, state, vote=0.0) # release=2 → relax again
assert state.beta < after_first
def test_rollback_requires_nonzero_recovery_attempts(self):
# MEDIUM #5 — rollback=True with max_recovery_attempts=0 is a footgun.
from soup_cli.config.loader import load_config_from_string
with pytest.raises(ValueError, match="max_recovery_attempts"):
load_config_from_string(
_yaml(
"grpo",
mitigation="pid_lagrangian",
extra=(
"reward_hack_rollback: true\n"
"rl_checkpoint_save_every_steps: 2\n"
"reward_hack_max_recovery_attempts: 0"
),
)
)
def test_escalate_no_target_does_not_waste_attempt(self, tmp_path, monkeypatch):
# MEDIUM #6 — a rollback with no last-good checkpoint must not burn a
# recovery attempt nor early-stop.
monkeypatch.chdir(tmp_path)
ckpt = _FakeCkptCb(saved=[]) # no checkpoints saved yet
cb = _pid_callback(
tmp_path,
_SeqBuffer([_HEALTHY, _HACK, _HACK, _HACK, _HACK]),
rollback=True,
rollback_patience=2,
max_recovery_attempts=1,
ckpt_cb=ckpt,
)
cb.attach(_fake_grpo_trainer(beta=0.02))
control = types.SimpleNamespace(should_training_stop=False)
for step in range(1, 6):
control = cb.on_step_end(
None, types.SimpleNamespace(global_step=step), control,
model=object(), optimizer=object(),
)
assert ckpt.restore_calls == []
assert cb._state.recovery_attempts == 0 # not wasted on a None target
def test_action_history_is_bounded(self, tmp_path, monkeypatch):
# LOW #9 — _action_history must not grow unbounded.
monkeypatch.chdir(tmp_path)
from itertools import repeat
cb = _kl_callback(tmp_path, _SeqBuffer(list(repeat(_HACK, 1))))
cb.attach(_fake_grpo_trainer(beta=0.02))
for step in range(1, 60):
cb.on_step_end(None, types.SimpleNamespace(global_step=step), None)
assert len(cb._action_history) <= 1000
class TestReviewFixesSecurity:
"""Regression tests for the security-review findings."""
@pytest.mark.skipif(os.name == "nt", reason="symlink needs privilege on Windows")
def test_restore_refuses_symlink_optimizer(self, tmp_path, monkeypatch):
# HIGH #1 — torch.load(weights_only=False) on an attacker-symlinked
# optimizer.pt is RCE; restore must refuse a symlinked file.
import torch
monkeypatch.chdir(tmp_path)
from soup_cli.utils.rl_checkpoint import (
RLCheckpointConfig,
build_rl_checkpoint_callback,
)
cb = build_rl_checkpoint_callback(
RLCheckpointConfig(save_every_steps=1), output_dir="run", task="grpo"
)
param = torch.nn.Parameter(torch.zeros(2))
opt = torch.optim.SGD([param], lr=0.1)
cb.save_checkpoint(step=1, model=_FakeSavableModel(), optimizer=opt)
opt_path = os.path.join("run", "rl-checkpoints", "step-1", "optimizer.pt")
evil = tmp_path / "evil.pt"
evil.write_bytes(b"junk")
os.remove(opt_path)
os.symlink(str(evil), opt_path)
# must refuse the symlinked optimizer (return False, never torch.load it)
assert cb.restore_checkpoint(step=1, model=None, optimizer=opt) is False
def test_bool_rejected_on_int_fields(self):
# MEDIUM #2 — bool-before-int policy on the new integer fields.
from soup_cli.config.schema import TrainingConfig
for field in (
"reward_hack_dwell_steps",
"reward_hack_release_patience",
"reward_hack_rollback_patience",
"reward_hack_max_recovery_attempts",
"reward_hack_smoothing_window",
):
with pytest.raises((ValueError, TypeError), match="bool"):
TrainingConfig(**{field: True})
def test_bool_rejected_on_float_fields(self):
from soup_cli.config.schema import TrainingConfig
# pid_kp has ge=0 so True→1.0 would pass the bound without a bool guard.
with pytest.raises((ValueError, TypeError), match="bool"):
TrainingConfig(reward_hack_pid_kp=True)
def test_signals_length_capped(self):
# MEDIUM #3 — unbounded signals list is a per-step DoS.
from soup_cli.config.schema import TrainingConfig
with pytest.raises((ValueError, TypeError)):
TrainingConfig(reward_hack_signals=["info_rm"] * 100)
def test_callback_rejects_empty_signals(self, tmp_path, monkeypatch):
# LOW #4 — an empty signals tuple silently disables the controller.
monkeypatch.chdir(tmp_path)
from soup_cli.utils.reward_hack_control import (
MitigationLogWriter,
RewardHackMitigationCallback,
)
with pytest.raises(ValueError, match="signal"):
RewardHackMitigationCallback(
mode="log_only",
detector="info_rm",
log_writer=MitigationLogWriter("m.jsonl"),
signals=(),
)
class TestReviewFixesTdd:
"""Coverage gaps identified by the tdd review."""
def test_bang_bang_deadband_hold_while_tripped(self):
# GAP 1 (HIGH) — the dead-band 'hold' branch while tripped must keep β
# and reset both counters, without relaxing.
from soup_cli.utils.reward_hack_control import ControllerState, bang_bang_step
policy = _bang_policy(dwell_steps=2, release_patience=2, trip_band=0.3, release_band=0.1)
state = ControllerState(beta=0.02)
state, _ = bang_bang_step(policy, state, vote=0.5)
state, _ = bang_bang_step(policy, state, vote=0.5) # trip → β=0.03
assert state.tripped and state.beta == pytest.approx(0.03)
state, action = bang_bang_step(policy, state, vote=0.2) # dead-band
assert state.beta == pytest.approx(0.03) and state.tripped
assert state.release_count == 0 and state.dwell_count == 0
assert action.reason == "hold"
def test_shape_reward_verbatim_on_shim_error(self, monkeypatch):
# GAP 2 (HIGH) — a shim error must return the verbatim inner reward.
import soup_cli.utils.reward_hack_control as rhc
def boom(*a, **k):
raise RuntimeError("boom")
monkeypatch.setattr(rhc, "_shaping_penalty", boom)
shaped = rhc.shape_reward_fn(_inner_reward, kind="length", strength=0.5)
assert shaped(["p"], ["w " * 40]) == [1.0] # verbatim despite shim error
def test_conservative_disagreement_boundary(self):
# GAP 3 (MEDIUM) — max-min == tol is NOT > tol → mean (not max).
from soup_cli.utils.reward_hack_control import combine_conservative
assert combine_conservative([0.1, 0.3], disagree_tol=0.2) == pytest.approx(0.2)
assert combine_conservative([0.1, 0.301], disagree_tol=0.2) == pytest.approx(0.301)
def test_dual_write_survives_readonly_beta(self, tmp_path, monkeypatch):
# GAP 4 (MEDIUM) — a read-only trainer.beta must not block args.beta.
monkeypatch.chdir(tmp_path)
class _ROTrainer:
def __init__(self):
self.args = types.SimpleNamespace(beta=0.02)
@property
def beta(self):
return 0.02 # read-only property
cb = _kl_callback(tmp_path, _SeqBuffer([_HEALTHY, _HACK]))
trainer = _ROTrainer()
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.beta == pytest.approx(0.04) # args.beta still updated
def test_escalation_postconditions(self, tmp_path, monkeypatch):
# GAP 5 (MEDIUM) — recovery_attempts increments to 1, hack_streak resets.
monkeypatch.chdir(tmp_path)
ckpt = _FakeCkptCb(saved=[10])
cb = _pid_callback(
tmp_path,
_SeqBuffer([_HEALTHY, _HACK, _HACK]),
rollback=True,
rollback_patience=2,
max_recovery_attempts=2,
ckpt_cb=ckpt,
)
cb.attach(_fake_grpo_trainer(beta=0.02))
control = types.SimpleNamespace(should_training_stop=False)
for step in (1, 2, 3):
control = cb.on_step_end(
None, types.SimpleNamespace(global_step=step), control,
model=object(), optimizer=object(),
)
assert ckpt.restore_calls == [10]
assert cb._state.recovery_attempts == 1
assert cb._hack_streak == 0
assert control.should_training_stop is False # max=2 → not stopped yet
def test_pid_derivative_exact_spike(self):
# GAP 2b — pin the exact D-term magnitude, not just > floor.
states = _run_pid(
_pid_policy(kp=0.0, ki=0.0, kd=1.0), [0.65]
)
# error=0.65-0.15=0.5, prev_error=0 → deriv=0.5 → β=floor+0.5=0.52
assert states[0].beta == pytest.approx(0.52)
assert states[0].prev_error == pytest.approx(0.5)
def test_drift_negative_gap(self):
from soup_cli.utils.reward_hack_control import detect_reward_distribution_drift
# gap <= 0 (sorted halves can't reverse, but constant-ish → gap 0) → False
assert detect_reward_distribution_drift([1, 1, 1, 1, 1, 1]) is False
def test_smoothing_window_lower_boundary_ok(self):
from soup_cli.config.loader import load_config_from_string
cfg = load_config_from_string(
_yaml("grpo", mitigation="kl_control", extra="reward_hack_smoothing_window: 2")
)
assert cfg.training.reward_hack_smoothing_window == 2
def test_log_cap_boundaries(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
from soup_cli.utils.reward_hack_control import MitigationLogWriter
assert MitigationLogWriter("a.jsonl", cap_mb=1).cap_bytes == 1024 * 1024
assert MitigationLogWriter("b.jsonl", cap_mb=10_000).cap_bytes == 10_000 * 1024 * 1024
with pytest.raises(ValueError):
MitigationLogWriter("c.jsonl", cap_mb=10_001)
def test_log_concurrent_writes(self, tmp_path, monkeypatch):
import threading
monkeypatch.chdir(tmp_path)
from soup_cli.utils.reward_hack_control import MitigationLogWriter
writer = MitigationLogWriter("cc.jsonl")
def worker(base):
for i in range(50):
writer.record(step=base + i, snapshot={"x": i})
threads = [threading.Thread(target=worker, args=(b,)) for b in (0, 1000, 2000)]
for t in threads:
t.start()
for t in threads:
t.join()
lines = (tmp_path / "cc.jsonl").read_text().strip().splitlines()
assert len(lines) == 150 # no interleaved/corrupt lines
for line in lines:
json.loads(line) # every line is a complete JSON object
def test_record_action_caps_at_max(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
from soup_cli.utils.reward_hack_control import _MAX_ACTION_HISTORY
cb = _kl_callback(tmp_path, None)
for i in range(_MAX_ACTION_HISTORY + 100):
cb._record_action(f"a{i}")
assert len(cb._action_history) == _MAX_ACTION_HISTORY
assert cb._action_history[-1] == f"a{_MAX_ACTION_HISTORY + 99}" # keeps the tail
def test_no_top_level_heavy_import_in_source(self):
import inspect
from soup_cli.utils import reward_hack_control
src = inspect.getsource(reward_hack_control)
for line in src.splitlines():
stripped = line.strip()
# module-scope imports have no indentation
if line and not line[0].isspace():
assert not stripped.startswith(("import torch", "from torch")), line
assert not stripped.startswith(
("import transformers", "from transformers")
), line