mirror of https://github.com/razor-ai/soup.git
424 lines
17 KiB
Python
424 lines
17 KiB
Python
"""v0.71.41 — `soup reward stress` adversarial verifier probe + telemetry doc-fix.
|
|
|
|
Turns the v0.71.26 reward-hacking expertise on a reward VERIFIER itself: does it
|
|
pay out for degenerate completions (empty / length-padded / repetition /
|
|
sentinel-spam)? Pure, offline, CPU-only.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from soup_cli.cli import app as soup_app
|
|
from soup_cli.utils import reward_stress as rst
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Task 1 — attack generation
|
|
# ---------------------------------------------------------------------------
|
|
class TestGenerateAttacks:
|
|
def test_all_kinds_present(self):
|
|
attacks = rst.generate_attacks()
|
|
kinds = {k for k, _ in attacks}
|
|
assert kinds == set(rst.ATTACKS)
|
|
|
|
def test_deterministic(self):
|
|
assert rst.generate_attacks() == rst.generate_attacks()
|
|
|
|
def test_empty_is_empty_string(self):
|
|
text = dict(rst.generate_attacks())["empty"]
|
|
assert text == ""
|
|
|
|
def test_length_attack_exceeds_saturation(self):
|
|
text = dict(rst.generate_attacks())["length"]
|
|
assert len(text.split()) > 32 # _SHAPING_LENGTH_SAT
|
|
|
|
def test_sentinel_attack_contains_custom_sentinel(self):
|
|
text = dict(rst.generate_attacks(sentinel="ZZZTOKEN"))["sentinel"]
|
|
assert "ZZZTOKEN" in text
|
|
assert "GOLD" not in text
|
|
|
|
def test_default_sentinel_is_gold(self):
|
|
text = dict(rst.generate_attacks())["sentinel"]
|
|
assert "GOLD" in text
|
|
|
|
def test_repetition_attack_repeats(self):
|
|
text = dict(rst.generate_attacks())["repetition"]
|
|
words = text.split()
|
|
assert len(words) > 5 and len(set(words)) < len(words)
|
|
|
|
def test_kinds_subset_respected(self):
|
|
attacks = rst.generate_attacks(kinds=("empty", "sentinel"))
|
|
assert {k for k, _ in attacks} == {"empty", "sentinel"}
|
|
|
|
def test_unknown_kind_raises(self):
|
|
with pytest.raises(ValueError, match="bogus"):
|
|
rst.generate_attacks(kinds=("bogus",))
|
|
|
|
def test_bare_str_kinds_rejected(self):
|
|
# A bare string is a Sequence[char] — must fail with a type error, not
|
|
# silently probe per-letter.
|
|
with pytest.raises(TypeError, match="sequence"):
|
|
rst.generate_attacks(kinds="sentinel")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Task 2 — run_stress scoring + verdict
|
|
# ---------------------------------------------------------------------------
|
|
class TestRunStress:
|
|
def _numeric_verifier(self):
|
|
import re
|
|
|
|
num = re.compile(r"[+-]?\d+(?:\.\d+)?")
|
|
|
|
def reward_fn(completions, **kwargs):
|
|
answers = kwargs.get("answer") or []
|
|
out = []
|
|
for i, comp in enumerate(completions):
|
|
text = comp[0]["content"] if isinstance(comp, list) else str(comp)
|
|
gold = str(answers[i]) if i < len(answers) else ""
|
|
got = num.findall(text)
|
|
out.append(1.0 if got and got[-1] == gold else 0.0)
|
|
return out
|
|
|
|
return reward_fn
|
|
|
|
def test_degenerate_always_one_is_gameable(self):
|
|
def reward_fn(completions, **kwargs):
|
|
return [1.0] * len(completions)
|
|
|
|
rep = rst.run_stress(reward_fn, ["42", "7"])
|
|
assert rep.gameable is True
|
|
assert rep.gameability == 1.0
|
|
assert rep.reference_accept == 1.0
|
|
|
|
def test_strict_numeric_is_robust(self):
|
|
rep = rst.run_stress(self._numeric_verifier(), ["42", "7", "100"])
|
|
assert rep.gameable is False
|
|
assert rep.gameability == 0.0
|
|
assert rep.reference_accept == 1.0
|
|
|
|
def test_per_attack_breakdown_distinguishes(self):
|
|
# A length-based verifier accepts the two long attacks (length=60 words,
|
|
# repetition=40 words) but rejects the two short ones — the per-attack
|
|
# breakdown must separate exactly which junk slipped through.
|
|
def reward_fn(completions, **kwargs):
|
|
out = []
|
|
for comp in completions:
|
|
text = comp[0]["content"] if isinstance(comp, list) else str(comp)
|
|
out.append(1.0 if len(text.split()) >= 32 else 0.0)
|
|
return out
|
|
|
|
rep = rst.run_stress(reward_fn, ["42"])
|
|
per = {a.kind: a.accept_rate for a in rep.attacks}
|
|
assert per["length"] == 1.0
|
|
assert per["repetition"] == 1.0
|
|
assert per["empty"] == 0.0
|
|
assert per["sentinel"] == 0.0 # 20 words < 32 -> rejected
|
|
assert rep.gameable is True
|
|
|
|
def test_no_gold_fallback(self):
|
|
def reward_fn(completions, **kwargs):
|
|
return [0.0] * len(completions)
|
|
|
|
rep = rst.run_stress(reward_fn, [])
|
|
assert rep.reference_accept is None
|
|
assert rep.gameable is False
|
|
|
|
def test_max_gameable_boundary_inclusive(self):
|
|
# Accepts exactly the 'sentinel' attack -> accept-rate = 1/4 across 4 kinds.
|
|
def reward_fn(completions, **kwargs):
|
|
out = []
|
|
for comp in completions:
|
|
text = comp[0]["content"] if isinstance(comp, list) else str(comp)
|
|
out.append(1.0 if "GOLD" in text else 0.0)
|
|
return out
|
|
|
|
rep_at = rst.run_stress(reward_fn, ["x"], max_gameable=0.25)
|
|
assert rep_at.gameability == 0.25
|
|
assert rep_at.gameable is False # 0.25 > 0.25 is False -> inclusive
|
|
rep_below = rst.run_stress(reward_fn, ["x"], max_gameable=0.2)
|
|
assert rep_below.gameable is True
|
|
|
|
def test_threshold_applied(self):
|
|
# Verifier returns 0.4 everywhere: accepted at threshold 0.3, rejected at 0.5.
|
|
def reward_fn(completions, **kwargs):
|
|
return [0.4] * len(completions)
|
|
|
|
assert rst.run_stress(reward_fn, ["x"], threshold=0.3).gameable is True
|
|
assert rst.run_stress(reward_fn, ["x"], threshold=0.5).gameable is False
|
|
# Exactly on the boundary: 0.4 >= 0.4 accepts (proves >= is inclusive,
|
|
# mutation-kills a `>` implementation).
|
|
assert rst.run_stress(reward_fn, ["x"], threshold=0.4).gameable is True
|
|
|
|
def test_golds_capped(self):
|
|
seen = {"n": 0}
|
|
|
|
def reward_fn(completions, **kwargs):
|
|
seen["n"] = max(seen["n"], len(completions))
|
|
return [0.0] * len(completions)
|
|
|
|
rst.run_stress(reward_fn, [str(i) for i in range(500)])
|
|
assert seen["n"] == rst._MAX_STRESS_GOLDS == 200 # pins the exact cap value
|
|
|
|
def test_per_attack_counts(self):
|
|
rep = rst.run_stress(self._numeric_verifier(), ["42", "7"])
|
|
assert {a.kind for a in rep.attacks} == set(rst.ATTACKS)
|
|
for a in rep.attacks:
|
|
assert a.n == 2 and a.accepted == 0
|
|
|
|
def test_short_return_raises_not_false_robust(self):
|
|
# A gold-requiring builtin scored with no answer returns [] (its
|
|
# zip short-circuits) — must be a hard error, never a silent 0/1=robust.
|
|
def gold_requiring(completions, **kwargs):
|
|
answers = kwargs.get("answer", [])
|
|
return [1.0 for _c, _a in zip(completions, answers)]
|
|
|
|
with pytest.raises(ValueError, match="--references"):
|
|
rst.run_stress(gold_requiring, [])
|
|
|
|
def test_non_finite_score_raises(self):
|
|
def nan_fn(completions, **kwargs):
|
|
return [float("nan")] * len(completions)
|
|
|
|
with pytest.raises(ValueError, match="non-finite"):
|
|
rst.run_stress(nan_fn, ["42"])
|
|
|
|
def test_real_accuracy_builtin_gold_path_robust(self):
|
|
from soup_cli.trainer.rewards import load_reward_fn
|
|
|
|
rep = rst.run_stress(load_reward_fn("accuracy"), ["42", "7"])
|
|
# accuracy compares the completion tail against the gold; junk never matches,
|
|
# and a gold scored as its own completion is a perfect match.
|
|
assert rep.gameable is False
|
|
assert rep.reference_accept == 1.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Task 3 — CLI
|
|
# ---------------------------------------------------------------------------
|
|
_ROBUST_VERIFIER = '''
|
|
import re
|
|
_NUM = re.compile(r"[+-]?\\d+(?:\\.\\d+)?")
|
|
def reward_fn(completions, **kwargs):
|
|
answers = kwargs.get("answer") or []
|
|
out = []
|
|
for i, c in enumerate(completions):
|
|
text = c[0]["content"] if isinstance(c, list) else str(c)
|
|
gold = str(answers[i]) if i < len(answers) else ""
|
|
got = _NUM.findall(text)
|
|
out.append(1.0 if got and got[-1] == gold else 0.0)
|
|
return out
|
|
'''
|
|
|
|
_DEGENERATE_VERIFIER = '''
|
|
def reward_fn(completions, **kwargs):
|
|
return [1.0] * len(completions)
|
|
'''
|
|
|
|
|
|
def _write(tmp_path, name, text):
|
|
p = tmp_path / name
|
|
p.write_text(text, encoding="utf-8")
|
|
return p
|
|
|
|
|
|
def _refs(tmp_path):
|
|
p = tmp_path / "refs.jsonl"
|
|
p.write_text('{"answer": "42"}\n{"answer": "7"}\n', encoding="utf-8")
|
|
return p
|
|
|
|
|
|
class TestStressCli:
|
|
def test_help(self):
|
|
r = runner.invoke(soup_app, ["reward", "stress", "--help"])
|
|
assert r.exit_code == 0, (r.output, repr(r.exception))
|
|
|
|
def test_robust_verifier_exit_0(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
v = _write(tmp_path, "v.py", _ROBUST_VERIFIER)
|
|
r = runner.invoke(
|
|
soup_app,
|
|
["reward", "stress", v.name, "--references", _refs(tmp_path).name],
|
|
)
|
|
assert r.exit_code == 0, (r.output, repr(r.exception))
|
|
assert "robust" in r.output.lower() or "not gameable" in r.output.lower()
|
|
|
|
def test_degenerate_verifier_exit_2(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
v = _write(tmp_path, "d.py", _DEGENERATE_VERIFIER)
|
|
r = runner.invoke(
|
|
soup_app,
|
|
["reward", "stress", v.name, "--references", _refs(tmp_path).name],
|
|
)
|
|
assert r.exit_code == 2, (r.output, repr(r.exception))
|
|
# Discriminate the verdict text — "gameable" also occurs in "not gameable".
|
|
assert "GAMEABLE" in r.output
|
|
assert "robust" not in r.output.lower()
|
|
|
|
def test_bad_attacks_exit_1(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
v = _write(tmp_path, "v.py", _ROBUST_VERIFIER)
|
|
r = runner.invoke(soup_app, ["reward", "stress", v.name, "--attacks", "bogus"])
|
|
assert r.exit_code == 1, (r.output, repr(r.exception))
|
|
assert "bogus" in r.output
|
|
|
|
def test_bad_threshold_exit_1(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
v = _write(tmp_path, "v.py", _ROBUST_VERIFIER)
|
|
r = runner.invoke(soup_app, ["reward", "stress", v.name, "--threshold", "9"])
|
|
assert r.exit_code == 1, (r.output, repr(r.exception))
|
|
assert "threshold" in r.output.lower()
|
|
|
|
def test_bad_max_gameable_exit_1(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
v = _write(tmp_path, "v.py", _ROBUST_VERIFIER)
|
|
r = runner.invoke(soup_app, ["reward", "stress", v.name, "--max-gameable", "2"])
|
|
assert r.exit_code == 1, (r.output, repr(r.exception))
|
|
assert "max-gameable" in r.output.lower()
|
|
|
|
def test_empty_attacks_list_exit_1(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
v = _write(tmp_path, "v.py", _ROBUST_VERIFIER)
|
|
r = runner.invoke(soup_app, ["reward", "stress", v.name, "--attacks", ","])
|
|
assert r.exit_code == 1, (r.output, repr(r.exception))
|
|
assert "at least one" in r.output.lower()
|
|
|
|
def test_verifiable_target_with_references(self, tmp_path, monkeypatch):
|
|
# The 'verifiable' builtin path + --verifiable-domain must load and probe.
|
|
monkeypatch.chdir(tmp_path)
|
|
r = runner.invoke(
|
|
soup_app,
|
|
["reward", "stress", "verifiable", "--verifiable-domain", "math",
|
|
"--references", _refs(tmp_path).name],
|
|
)
|
|
assert r.exit_code == 0, (r.output, repr(r.exception))
|
|
assert "robust" in r.output.lower()
|
|
|
|
def test_report_path_validated_before_code_runs(self, tmp_path, monkeypatch):
|
|
# A target that writes a marker on import + a bad (outside-cwd) report path:
|
|
# the report-path check must fire FIRST, so the marker is never written.
|
|
monkeypatch.chdir(tmp_path)
|
|
marker = tmp_path / "IMPORTED"
|
|
target = _write(
|
|
tmp_path, "marks.py",
|
|
f"open(r{str(marker)!r}, 'w').close()\n"
|
|
"def reward_fn(completions, **kwargs):\n return [0.0]*len(completions)\n",
|
|
)
|
|
r = runner.invoke(
|
|
soup_app,
|
|
["reward", "stress", target.name, "--output-report", "../escape.json"],
|
|
)
|
|
assert r.exit_code == 1, (r.output, repr(r.exception))
|
|
assert not marker.exists(), "target code ran before the report path was rejected"
|
|
|
|
def test_target_raises_at_import_exit_1(self, tmp_path, monkeypatch):
|
|
# load_reward_fn exec's the target's module code; a raise-at-import must
|
|
# be caught and mapped to exit 1 with a clear message (not leak the
|
|
# traceback / rely on the top-level cli.py safety net).
|
|
monkeypatch.chdir(tmp_path)
|
|
v = _write(tmp_path, "boom.py", 'raise RuntimeError("boom-at-import")\n')
|
|
r = runner.invoke(soup_app, ["reward", "stress", v.name])
|
|
assert r.exit_code == 1, (r.output, repr(r.exception))
|
|
assert "could not load reward target" in r.output
|
|
|
|
def test_oversized_sentinel_exit_1(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
v = _write(tmp_path, "v.py", _ROBUST_VERIFIER)
|
|
r = runner.invoke(
|
|
soup_app,
|
|
["reward", "stress", v.name, "--sentinel", "z" * 300],
|
|
)
|
|
assert r.exit_code == 1, (r.output, repr(r.exception))
|
|
assert "sentinel" in r.output.lower()
|
|
|
|
def test_builtin_no_references_exit_1(self, tmp_path, monkeypatch):
|
|
# `soup reward stress accuracy` with no --references cannot probe a
|
|
# gold-requiring builtin — must exit 1 with a helpful message, NOT a
|
|
# false "robust" exit 0.
|
|
monkeypatch.chdir(tmp_path)
|
|
r = runner.invoke(soup_app, ["reward", "stress", "accuracy"])
|
|
assert r.exit_code == 1, (r.output, repr(r.exception))
|
|
assert "--references" in r.output
|
|
|
|
def test_duplicate_attacks_deduped(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
v = _write(tmp_path, "v.py", _ROBUST_VERIFIER)
|
|
r = runner.invoke(
|
|
soup_app,
|
|
["reward", "stress", v.name, "--references", _refs(tmp_path).name,
|
|
"--attacks", "empty,empty,sentinel", "--output-report", "rep.json"],
|
|
)
|
|
assert r.exit_code == 0, (r.output, repr(r.exception))
|
|
data = json.loads((tmp_path / "rep.json").read_text(encoding="utf-8"))
|
|
kinds = [a["kind"] for a in data["attacks"]]
|
|
assert kinds == ["empty", "sentinel"]
|
|
|
|
def test_output_report_written(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
v = _write(tmp_path, "d.py", _DEGENERATE_VERIFIER)
|
|
r = runner.invoke(
|
|
soup_app,
|
|
[
|
|
"reward", "stress", v.name,
|
|
"--references", _refs(tmp_path).name,
|
|
"--output-report", "rep.json",
|
|
],
|
|
)
|
|
assert r.exit_code == 2, (r.output, repr(r.exception))
|
|
data = json.loads((tmp_path / "rep.json").read_text(encoding="utf-8"))
|
|
assert data["gameable"] is True
|
|
assert "attacks" in data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Task 4 — hardening + registration
|
|
# ---------------------------------------------------------------------------
|
|
class TestHardening:
|
|
def test_no_top_level_torch(self):
|
|
src = Path(rst.__file__).read_text(encoding="utf-8")
|
|
tree = ast.parse(src)
|
|
for node in tree.body: # module-level only
|
|
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
mod = getattr(node, "module", "") or ""
|
|
names = mod + " " + " ".join(a.name for a in getattr(node, "names", []))
|
|
assert "torch" not in names and "transformers" not in names, names
|
|
|
|
def test_stress_registered(self):
|
|
r = runner.invoke(soup_app, ["reward", "--help"])
|
|
assert r.exit_code == 0, (r.output, repr(r.exception))
|
|
assert "stress" in r.output
|
|
|
|
def test_target_outside_cwd_rejected(self, tmp_path, monkeypatch):
|
|
# Use a REAL file outside cwd so the ONLY possible failure is the
|
|
# containment check (a nonexistent path would exit 1 via "not found" even
|
|
# if containment were deleted — a vacuous security test).
|
|
sub = tmp_path / "work"
|
|
sub.mkdir()
|
|
(tmp_path / "evil.py").write_text(_ROBUST_VERIFIER, encoding="utf-8")
|
|
monkeypatch.chdir(sub)
|
|
r = runner.invoke(soup_app, ["reward", "stress", "../evil.py"])
|
|
assert r.exit_code == 1, (r.output, repr(r.exception))
|
|
assert "under cwd" in r.output.lower()
|
|
|
|
def test_symlinked_target_rejected(self, tmp_path, monkeypatch):
|
|
# A symlinked .py target must be refused by enforce_under_cwd_and_no_symlink
|
|
# (a symlink could point outside cwd). POSIX-only — Windows symlink creation
|
|
# needs privilege.
|
|
monkeypatch.chdir(tmp_path)
|
|
real = _write(tmp_path, "real.py", _ROBUST_VERIFIER)
|
|
link = tmp_path / "link.py"
|
|
try:
|
|
link.symlink_to(real)
|
|
except (OSError, NotImplementedError):
|
|
pytest.skip("symlink creation not permitted on this platform")
|
|
r = runner.invoke(soup_app, ["reward", "stress", link.name])
|
|
assert r.exit_code == 1, (r.output, repr(r.exception))
|