mirror of https://github.com/razor-ai/soup.git
357 lines
12 KiB
Python
357 lines
12 KiB
Python
"""Live echo-trap detector — v0.70.0 Part F.
|
|
|
|
RAGEN-style detection of trajectory degeneration during multi-turn
|
|
agent RL (Zhu et al. 2025, arXiv:2504.14437). When the policy collapses
|
|
to self-repeating outputs, the reward saturates and the policy drifts
|
|
without learning. This module ships the math kernels + report schema;
|
|
the live HF Trainer callback is deferred to v0.70.1.
|
|
|
|
Composes with v0.53.11 #127 ``GRPOStabilityCallback`` — the live
|
|
echo-trap callback shares the per-step instrumentation hook so both
|
|
detectors can fire in the same training step without duplicating
|
|
trajectory collection.
|
|
|
|
Security:
|
|
- Pure-Python math (no torch import at module top).
|
|
- Bool / NaN / Inf / range rejection on every numeric input.
|
|
- Whitespace-mode tokens must be strings; tokeniser-aware mode accepts
|
|
integer token ids through the dedicated ``*_tokenized`` helpers.
|
|
- ``_MAX_BATCH_TRAJECTORIES = 100_000`` DoS cap (matches v0.55 /
|
|
v0.65 / v0.66 cap policy).
|
|
- ``_MAX_NGRAM_N = 32`` keeps the n-gram counter bounded.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from dataclasses import dataclass
|
|
from typing import Iterable, Sequence
|
|
|
|
VERDICTS: tuple[str, ...] = ("OK", "WARN", "TRAP")
|
|
_VALID_VERDICTS: frozenset[str] = frozenset(VERDICTS)
|
|
|
|
# OK / WARN / TRAP boundaries on the aggregate echo signal. Mirrors v0.26
|
|
# Quant-Lobotomy + v0.56 diagnose three-band taxonomy.
|
|
_ECHO_OK_BAND = 0.30 # signal < 0.30 → OK
|
|
_ECHO_TRAP_BAND = 0.60 # signal >= 0.60 → TRAP; in between → WARN
|
|
|
|
_MAX_NGRAM_N = 32
|
|
_MAX_TRAJECTORY_TOKENS = 1_000_000
|
|
_MAX_BATCH_TRAJECTORIES = 100_000
|
|
|
|
|
|
def _check_ngram_n(value: object) -> int:
|
|
if isinstance(value, bool):
|
|
raise ValueError("ngram_n must not be bool")
|
|
if not isinstance(value, int):
|
|
raise ValueError(f"ngram_n must be int, got {type(value).__name__}")
|
|
if value < 1:
|
|
raise ValueError(f"ngram_n must be >= 1, got {value}")
|
|
if value > _MAX_NGRAM_N:
|
|
raise ValueError(f"ngram_n={value} exceeds {_MAX_NGRAM_N} cap")
|
|
return value
|
|
|
|
|
|
def _check_tokens(tokens: object) -> tuple[str, ...]:
|
|
if isinstance(tokens, (str, bytes)):
|
|
raise TypeError("tokens must be a sequence of strings, not str/bytes")
|
|
try:
|
|
iterator = list(tokens) # type: ignore[arg-type]
|
|
except TypeError as exc:
|
|
raise TypeError(
|
|
f"tokens must be iterable, got {type(tokens).__name__}"
|
|
) from exc
|
|
if len(iterator) > _MAX_TRAJECTORY_TOKENS:
|
|
raise ValueError(
|
|
f"trajectory has {len(iterator)} tokens, exceeds "
|
|
f"{_MAX_TRAJECTORY_TOKENS} cap"
|
|
)
|
|
for idx, t in enumerate(iterator):
|
|
if not isinstance(t, str):
|
|
raise TypeError(
|
|
f"tokens[{idx}] must be str, got {type(t).__name__}"
|
|
)
|
|
return tuple(iterator)
|
|
|
|
|
|
def _check_token_ids(token_ids: object) -> tuple[int, ...]:
|
|
if isinstance(token_ids, (str, bytes)):
|
|
raise TypeError("token_ids must be a sequence of ints, not str/bytes")
|
|
try:
|
|
iterator = list(token_ids) # type: ignore[arg-type]
|
|
except TypeError as exc:
|
|
raise TypeError(
|
|
f"token_ids must be iterable, got {type(token_ids).__name__}"
|
|
) from exc
|
|
if len(iterator) > _MAX_TRAJECTORY_TOKENS:
|
|
raise ValueError(
|
|
f"trajectory has {len(iterator)} token ids, exceeds "
|
|
f"{_MAX_TRAJECTORY_TOKENS} cap"
|
|
)
|
|
for idx, token_id in enumerate(iterator):
|
|
if isinstance(token_id, bool) or not isinstance(token_id, int):
|
|
raise TypeError(
|
|
f"token_ids[{idx}] must be int, got {type(token_id).__name__}"
|
|
)
|
|
return tuple(iterator)
|
|
|
|
|
|
def _score_repetition(units: Sequence[object], *, ngram_n: int) -> float:
|
|
if len(units) < ngram_n:
|
|
return 0.0
|
|
counts: dict[tuple[object, ...], int] = {}
|
|
for i in range(len(units) - ngram_n + 1):
|
|
gram = tuple(units[i : i + ngram_n])
|
|
counts[gram] = counts.get(gram, 0) + 1
|
|
if not counts:
|
|
return 0.0
|
|
repeating = sum(1 for count in counts.values() if count > 1)
|
|
return repeating / len(counts)
|
|
|
|
|
|
def score_trajectory_repetition(tokens: object, *, ngram_n: object = 2) -> float:
|
|
"""Per-trajectory repetition score.
|
|
|
|
Returns the fraction of n-grams whose count exceeds 1 (the
|
|
"repeating n-grams" rate). Range ``[0, 1]``. 0 = every n-gram
|
|
unique; closer to 1 = many n-grams repeat.
|
|
|
|
Edge cases:
|
|
- ``len(tokens) < ngram_n`` returns 0.0 (no n-grams possible).
|
|
- Empty input returns 0.0.
|
|
"""
|
|
n = _check_ngram_n(ngram_n)
|
|
tok = _check_tokens(tokens)
|
|
return _score_repetition(tok, ngram_n=n)
|
|
|
|
|
|
def score_trajectory_repetition_tokenized(
|
|
token_ids: object,
|
|
*,
|
|
ngram_n: object = 2,
|
|
) -> float:
|
|
"""Per-trajectory repetition score over tokenizer ids.
|
|
|
|
This mirrors :func:`score_trajectory_repetition`, but operates on
|
|
integer token ids before decoding/whitespace splitting can hide
|
|
subword repetition. It is intentionally separate so the existing
|
|
string-token API keeps rejecting accidental tensor-id input.
|
|
"""
|
|
n = _check_ngram_n(ngram_n)
|
|
ids = _check_token_ids(token_ids)
|
|
return _score_repetition(ids, ngram_n=n)
|
|
|
|
|
|
def score_echo_signal(
|
|
trajectories: object,
|
|
*,
|
|
ngram_n: object = 2,
|
|
) -> float:
|
|
"""Mean repetition score across a batch of trajectories.
|
|
|
|
Higher = more trajectory degeneration = closer to echo trap.
|
|
Returns 0.0 on empty input (no signal = nothing to flag).
|
|
"""
|
|
n = _check_ngram_n(ngram_n)
|
|
if isinstance(trajectories, (str, bytes)):
|
|
raise TypeError(
|
|
"trajectories must be a sequence of sequences, not str/bytes"
|
|
)
|
|
try:
|
|
batch = list(trajectories) # type: ignore[arg-type]
|
|
except TypeError as exc:
|
|
raise TypeError(
|
|
f"trajectories must be iterable, got "
|
|
f"{type(trajectories).__name__}"
|
|
) from exc
|
|
if len(batch) > _MAX_BATCH_TRAJECTORIES:
|
|
raise ValueError(
|
|
f"batch has {len(batch)} trajectories, exceeds "
|
|
f"{_MAX_BATCH_TRAJECTORIES} cap"
|
|
)
|
|
if not batch:
|
|
return 0.0
|
|
scores: list[float] = []
|
|
for traj in batch:
|
|
scores.append(score_trajectory_repetition(traj, ngram_n=n))
|
|
return sum(scores) / len(scores)
|
|
|
|
|
|
def score_echo_signal_tokenized(
|
|
trajectories: object,
|
|
*,
|
|
ngram_n: object = 2,
|
|
) -> float:
|
|
"""Mean repetition score across token-id trajectories.
|
|
|
|
Use this when the caller has access to the trainer tokenizer and can
|
|
pass ``tokenizer.encode(text)`` output rather than decoded strings.
|
|
"""
|
|
n = _check_ngram_n(ngram_n)
|
|
if isinstance(trajectories, (str, bytes)):
|
|
raise TypeError(
|
|
"trajectories must be a sequence of token-id sequences, not str/bytes"
|
|
)
|
|
try:
|
|
batch = list(trajectories) # type: ignore[arg-type]
|
|
except TypeError as exc:
|
|
raise TypeError(
|
|
f"trajectories must be iterable, got "
|
|
f"{type(trajectories).__name__}"
|
|
) from exc
|
|
if len(batch) > _MAX_BATCH_TRAJECTORIES:
|
|
raise ValueError(
|
|
f"batch has {len(batch)} trajectories, exceeds "
|
|
f"{_MAX_BATCH_TRAJECTORIES} cap"
|
|
)
|
|
if not batch:
|
|
return 0.0
|
|
scores: list[float] = []
|
|
for traj in batch:
|
|
scores.append(score_trajectory_repetition_tokenized(traj, ngram_n=n))
|
|
return sum(scores) / len(scores)
|
|
|
|
|
|
def classify_echo_signal(signal: object) -> str:
|
|
"""Map a signal in ``[0, 1]`` to OK / WARN / TRAP.
|
|
|
|
- signal in [0.0, _ECHO_OK_BAND=0.30): OK
|
|
- signal in [_ECHO_OK_BAND, _ECHO_TRAP_BAND=0.60): WARN
|
|
- signal >= _ECHO_TRAP_BAND: TRAP
|
|
"""
|
|
if isinstance(signal, bool):
|
|
raise ValueError("signal must not be bool")
|
|
if not isinstance(signal, (int, float)):
|
|
raise ValueError(
|
|
f"signal must be a number, got {type(signal).__name__}"
|
|
)
|
|
fv = float(signal)
|
|
if not math.isfinite(fv):
|
|
raise ValueError("signal must be finite (no NaN/Inf)")
|
|
if not (0.0 <= fv <= 1.0):
|
|
raise ValueError(f"signal must be in [0.0, 1.0], got {fv}")
|
|
if fv < _ECHO_OK_BAND:
|
|
return "OK"
|
|
if fv < _ECHO_TRAP_BAND:
|
|
return "WARN"
|
|
return "TRAP"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EchoTrapReport:
|
|
"""Frozen result of an echo-trap probe.
|
|
|
|
- ``signal``: aggregate echo signal in ``[0, 1]``.
|
|
- ``verdict``: OK / WARN / TRAP per :func:`classify_echo_signal`.
|
|
- ``step``: training step at which the probe fired. Non-negative
|
|
int (bool rejected per project policy).
|
|
- ``trajectories_seen``: count of trajectories that contributed to
|
|
the signal. Non-negative.
|
|
- ``details``: tuple of human-readable lines for the report panel.
|
|
"""
|
|
|
|
signal: float
|
|
verdict: str
|
|
step: int
|
|
trajectories_seen: int
|
|
details: tuple[str, ...]
|
|
|
|
def __post_init__(self) -> None:
|
|
if isinstance(self.signal, bool):
|
|
raise ValueError("signal must not be bool")
|
|
if not isinstance(self.signal, (int, float)):
|
|
raise TypeError(
|
|
f"signal must be a number, got {type(self.signal).__name__}"
|
|
)
|
|
fv = float(self.signal)
|
|
if not math.isfinite(fv) or not (0.0 <= fv <= 1.0):
|
|
raise ValueError(f"signal must be in [0.0, 1.0], got {self.signal}")
|
|
if self.verdict not in _VALID_VERDICTS:
|
|
raise ValueError(
|
|
f"verdict={self.verdict!r} must be one of {sorted(_VALID_VERDICTS)}"
|
|
)
|
|
if isinstance(self.step, bool):
|
|
raise ValueError("step must not be bool")
|
|
if not isinstance(self.step, int):
|
|
raise TypeError(f"step must be int, got {type(self.step).__name__}")
|
|
if self.step < 0:
|
|
raise ValueError(f"step must be non-negative, got {self.step}")
|
|
if isinstance(self.trajectories_seen, bool):
|
|
raise ValueError("trajectories_seen must not be bool")
|
|
if not isinstance(self.trajectories_seen, int):
|
|
raise TypeError(
|
|
"trajectories_seen must be int, got "
|
|
f"{type(self.trajectories_seen).__name__}"
|
|
)
|
|
if self.trajectories_seen < 0:
|
|
raise ValueError(
|
|
f"trajectories_seen must be non-negative, got "
|
|
f"{self.trajectories_seen}"
|
|
)
|
|
if not isinstance(self.details, tuple):
|
|
raise TypeError(
|
|
f"details must be a tuple, got {type(self.details).__name__}"
|
|
)
|
|
|
|
|
|
def build_echo_trap_callback(
|
|
*,
|
|
threshold: float,
|
|
halt_on_trap: bool = True,
|
|
ngram_n: int = 2,
|
|
tokenizer_aware: bool = False,
|
|
):
|
|
"""Live HF Trainer callback for echo-trap detection.
|
|
|
|
Deferred to v0.70.1. Validates inputs at the public boundary so
|
|
misconfigured callers fail fast (mirrors v0.50.0 / v0.62.0 /
|
|
v0.67.0 / v0.69.0 / Part A/B/C/D/E deferred-live policy).
|
|
"""
|
|
# Threshold validation runs FIRST so a bad numeric value fires the
|
|
# actionable error before we check the bool flags.
|
|
if isinstance(threshold, bool):
|
|
raise ValueError("threshold must not be bool")
|
|
if not isinstance(threshold, (int, float)):
|
|
raise ValueError(
|
|
f"threshold must be a number, got {type(threshold).__name__}"
|
|
)
|
|
fv = float(threshold)
|
|
if not math.isfinite(fv) or not (0.0 <= fv <= 1.0):
|
|
raise ValueError(f"threshold must be in [0.0, 1.0], got {threshold}")
|
|
if not isinstance(halt_on_trap, bool):
|
|
raise TypeError(
|
|
f"halt_on_trap must be bool, got {type(halt_on_trap).__name__}"
|
|
)
|
|
if not isinstance(tokenizer_aware, bool):
|
|
raise TypeError(
|
|
"tokenizer_aware must be bool, got "
|
|
f"{type(tokenizer_aware).__name__}"
|
|
)
|
|
_check_ngram_n(ngram_n)
|
|
raise NotImplementedError(
|
|
f"Live echo-trap HF Trainer callback (threshold={fv}) is deferred "
|
|
"to v0.70.1. v0.70.0 ships the schema + math kernels only."
|
|
)
|
|
|
|
|
|
# Public re-exports — type hints for the v0.70.1 callback signature so
|
|
# external consumers (e.g. the GRPO stability callback) can import them
|
|
# without circular dependencies.
|
|
__all__ = [
|
|
"VERDICTS",
|
|
"EchoTrapReport",
|
|
"build_echo_trap_callback",
|
|
"classify_echo_signal",
|
|
"score_echo_signal",
|
|
"score_echo_signal_tokenized",
|
|
"score_trajectory_repetition",
|
|
"score_trajectory_repetition_tokenized",
|
|
]
|
|
|
|
|
|
# Type aliases retained for the v0.70.1 wiring.
|
|
TrajectoryTokens = Sequence[str]
|
|
TrajectoryBatch = Iterable[TrajectoryTokens]
|
|
TokenIdTrajectory = Sequence[int]
|
|
TokenIdTrajectoryBatch = Iterable[TokenIdTrajectory]
|