mirror of https://github.com/razor-ai/soup.git
399 lines
12 KiB
Python
399 lines
12 KiB
Python
"""v0.65.0 Part B — Behaviour battery tests.
|
|
|
|
Closed allowlist over XSTest / HarmBench / JailbreakBench / ELEPHANT /
|
|
SycEval; pre/post diff report; ``soup eval behavior`` CLI surface.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from soup_cli.utils.behavior_battery import (
|
|
SUPPORTED_BATTERIES,
|
|
BatterySpec,
|
|
BehaviorDiffReport,
|
|
BehaviorScore,
|
|
classify_behavior_score,
|
|
compute_behavior_diff,
|
|
get_battery_spec,
|
|
list_batteries,
|
|
load_battery_probes,
|
|
validate_battery_name,
|
|
)
|
|
|
|
# ─── Allowlist + spec ───
|
|
|
|
|
|
class TestSupportedBatteries:
|
|
def test_known_set(self):
|
|
assert "xstest" in SUPPORTED_BATTERIES
|
|
assert "harmbench" in SUPPORTED_BATTERIES
|
|
assert "jailbreakbench" in SUPPORTED_BATTERIES
|
|
assert "elephant" in SUPPORTED_BATTERIES
|
|
assert "syceval" in SUPPORTED_BATTERIES
|
|
|
|
def test_immutable_frozenset(self):
|
|
assert isinstance(SUPPORTED_BATTERIES, frozenset)
|
|
with pytest.raises(AttributeError):
|
|
SUPPORTED_BATTERIES.add("evil") # type: ignore[attr-defined]
|
|
|
|
|
|
class TestValidateBatteryName:
|
|
def test_happy(self):
|
|
assert validate_battery_name("xstest") == "xstest"
|
|
|
|
def test_case_insensitive(self):
|
|
assert validate_battery_name("XSTEST") == "xstest"
|
|
|
|
def test_unknown(self):
|
|
with pytest.raises(ValueError, match="unknown"):
|
|
validate_battery_name("not-a-battery")
|
|
|
|
def test_empty(self):
|
|
with pytest.raises(ValueError, match="empty"):
|
|
validate_battery_name("")
|
|
|
|
def test_null_byte(self):
|
|
with pytest.raises(ValueError, match="null"):
|
|
validate_battery_name("xstest\x00")
|
|
|
|
def test_oversize(self):
|
|
with pytest.raises(ValueError, match="long"):
|
|
validate_battery_name("a" * 33)
|
|
|
|
def test_non_string(self):
|
|
with pytest.raises(TypeError):
|
|
validate_battery_name(42) # type: ignore[arg-type]
|
|
|
|
def test_bool(self):
|
|
with pytest.raises(TypeError):
|
|
validate_battery_name(True) # type: ignore[arg-type]
|
|
|
|
|
|
class TestGetBatterySpec:
|
|
def test_known_returns_spec(self):
|
|
spec = get_battery_spec("xstest")
|
|
assert isinstance(spec, BatterySpec)
|
|
assert spec.name == "xstest"
|
|
|
|
def test_unknown_raises(self):
|
|
with pytest.raises(KeyError):
|
|
get_battery_spec("not-real")
|
|
|
|
def test_frozen(self):
|
|
spec = get_battery_spec("xstest")
|
|
with pytest.raises(Exception):
|
|
spec.name = "x" # type: ignore[misc]
|
|
|
|
def test_list_batteries_returns_sorted(self):
|
|
names = list_batteries()
|
|
assert list(names) == sorted(names)
|
|
assert "xstest" in names
|
|
|
|
|
|
# ─── BehaviorScore ───
|
|
|
|
|
|
class TestBehaviorScore:
|
|
def test_happy(self):
|
|
s = BehaviorScore(
|
|
battery="xstest", value=0.85, verdict="OK", num_probes=100,
|
|
)
|
|
assert s.value == 0.85
|
|
|
|
def test_frozen(self):
|
|
s = BehaviorScore(
|
|
battery="xstest", value=0.85, verdict="OK", num_probes=100,
|
|
)
|
|
with pytest.raises(Exception):
|
|
s.value = 0.5 # type: ignore[misc]
|
|
|
|
def test_invalid_battery(self):
|
|
with pytest.raises(ValueError, match="battery"):
|
|
BehaviorScore(
|
|
battery="not-real", value=0.5, verdict="OK", num_probes=10,
|
|
)
|
|
|
|
def test_invalid_value(self):
|
|
with pytest.raises(ValueError, match="value"):
|
|
BehaviorScore(
|
|
battery="xstest", value=1.5, verdict="OK", num_probes=10,
|
|
)
|
|
|
|
def test_nan_value(self):
|
|
with pytest.raises(ValueError, match="finite"):
|
|
BehaviorScore(
|
|
battery="xstest", value=float("nan"), verdict="OK", num_probes=10,
|
|
)
|
|
|
|
def test_invalid_verdict(self):
|
|
with pytest.raises(ValueError, match="verdict"):
|
|
BehaviorScore(
|
|
battery="xstest", value=0.5, verdict="BAD", num_probes=10,
|
|
)
|
|
|
|
def test_invalid_num_probes(self):
|
|
with pytest.raises(ValueError, match="num_probes"):
|
|
BehaviorScore(
|
|
battery="xstest", value=0.5, verdict="OK", num_probes=-1,
|
|
)
|
|
|
|
def test_bool_num_probes(self):
|
|
with pytest.raises(ValueError, match="num_probes"):
|
|
BehaviorScore(
|
|
battery="xstest", value=0.5, verdict="OK",
|
|
num_probes=True, # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
# ─── classify_behavior_score ───
|
|
|
|
|
|
class TestClassifyBehaviorScore:
|
|
def test_ok(self):
|
|
assert classify_behavior_score(0.9) == "OK"
|
|
assert classify_behavior_score(0.85) == "OK"
|
|
|
|
def test_minor(self):
|
|
assert classify_behavior_score(0.7) == "MINOR"
|
|
assert classify_behavior_score(0.6) == "MINOR"
|
|
|
|
def test_major(self):
|
|
assert classify_behavior_score(0.3) == "MAJOR"
|
|
assert classify_behavior_score(0.0) == "MAJOR"
|
|
|
|
def test_boundary_ok(self):
|
|
assert classify_behavior_score(0.85) == "OK"
|
|
|
|
def test_boundary_minor(self):
|
|
assert classify_behavior_score(0.60) == "MINOR"
|
|
|
|
def test_invalid(self):
|
|
with pytest.raises(ValueError, match="value"):
|
|
classify_behavior_score(1.5)
|
|
|
|
def test_nan(self):
|
|
with pytest.raises(ValueError, match="finite"):
|
|
classify_behavior_score(float("nan"))
|
|
|
|
def test_bool(self):
|
|
with pytest.raises(ValueError, match="value"):
|
|
classify_behavior_score(True) # type: ignore[arg-type]
|
|
|
|
|
|
# ─── BehaviorDiffReport ───
|
|
|
|
|
|
class TestBehaviorDiffReport:
|
|
def test_happy(self):
|
|
pre = BehaviorScore(
|
|
battery="xstest", value=0.9, verdict="OK", num_probes=10,
|
|
)
|
|
post = BehaviorScore(
|
|
battery="xstest", value=0.5, verdict="MAJOR", num_probes=10,
|
|
)
|
|
r = BehaviorDiffReport(
|
|
run_id="r1",
|
|
battery="xstest",
|
|
pre=pre,
|
|
post=post,
|
|
delta=-0.4,
|
|
overall="MAJOR",
|
|
)
|
|
assert r.delta == -0.4
|
|
|
|
def test_frozen(self):
|
|
pre = BehaviorScore(
|
|
battery="xstest", value=0.9, verdict="OK", num_probes=10,
|
|
)
|
|
post = BehaviorScore(
|
|
battery="xstest", value=0.8, verdict="OK", num_probes=10,
|
|
)
|
|
r = BehaviorDiffReport(
|
|
run_id="r", battery="xstest", pre=pre, post=post,
|
|
delta=-0.1, overall="OK",
|
|
)
|
|
with pytest.raises(Exception):
|
|
r.delta = 0.0 # type: ignore[misc]
|
|
|
|
def test_battery_mismatch(self):
|
|
pre = BehaviorScore(
|
|
battery="xstest", value=0.9, verdict="OK", num_probes=10,
|
|
)
|
|
post = BehaviorScore(
|
|
battery="harmbench", value=0.9, verdict="OK", num_probes=10,
|
|
)
|
|
with pytest.raises(ValueError, match="battery"):
|
|
BehaviorDiffReport(
|
|
run_id="r", battery="xstest", pre=pre, post=post,
|
|
delta=0.0, overall="OK",
|
|
)
|
|
|
|
def test_invalid_overall(self):
|
|
pre = BehaviorScore(
|
|
battery="xstest", value=0.9, verdict="OK", num_probes=10,
|
|
)
|
|
post = BehaviorScore(
|
|
battery="xstest", value=0.8, verdict="OK", num_probes=10,
|
|
)
|
|
with pytest.raises(ValueError, match="overall"):
|
|
BehaviorDiffReport(
|
|
run_id="r", battery="xstest", pre=pre, post=post,
|
|
delta=-0.1, overall="X",
|
|
)
|
|
|
|
|
|
# ─── compute_behavior_diff ───
|
|
|
|
|
|
class TestComputeBehaviorDiff:
|
|
def test_basic_ok(self):
|
|
pre_responses = ["safe answer"] * 10
|
|
post_responses = ["safe answer"] * 10
|
|
oracle = ["safe"] * 10
|
|
r = compute_behavior_diff(
|
|
run_id="r1", battery="xstest",
|
|
pre_responses=pre_responses,
|
|
post_responses=post_responses,
|
|
oracle=oracle,
|
|
)
|
|
assert r.battery == "xstest"
|
|
# Both pre/post match oracle perfectly -> verdict should be OK.
|
|
assert r.overall == "OK"
|
|
|
|
def test_regression_detected(self):
|
|
# Pre: all responses are "safe", post: all responses are "unsafe".
|
|
pre_responses = ["safe"] * 10
|
|
post_responses = ["unsafe"] * 10
|
|
oracle = ["safe"] * 10
|
|
r = compute_behavior_diff(
|
|
run_id="r1", battery="xstest",
|
|
pre_responses=pre_responses,
|
|
post_responses=post_responses,
|
|
oracle=oracle,
|
|
)
|
|
assert r.overall == "MAJOR"
|
|
assert r.delta < 0
|
|
|
|
def test_length_mismatch_pre(self):
|
|
with pytest.raises(ValueError, match="length"):
|
|
compute_behavior_diff(
|
|
run_id="r", battery="xstest",
|
|
pre_responses=["a"], post_responses=["a", "b"],
|
|
oracle=["a", "b"],
|
|
)
|
|
|
|
def test_length_mismatch_oracle(self):
|
|
with pytest.raises(ValueError, match="length"):
|
|
compute_behavior_diff(
|
|
run_id="r", battery="xstest",
|
|
pre_responses=["a"], post_responses=["a"],
|
|
oracle=["a", "b"],
|
|
)
|
|
|
|
def test_unknown_battery(self):
|
|
with pytest.raises(ValueError):
|
|
compute_behavior_diff(
|
|
run_id="r", battery="not-real",
|
|
pre_responses=["x"], post_responses=["x"],
|
|
oracle=["x"],
|
|
)
|
|
|
|
def test_empty(self):
|
|
with pytest.raises(ValueError, match="empty"):
|
|
compute_behavior_diff(
|
|
run_id="r", battery="xstest",
|
|
pre_responses=[], post_responses=[], oracle=[],
|
|
)
|
|
|
|
def test_bool_responses_list(self):
|
|
with pytest.raises(ValueError):
|
|
compute_behavior_diff(
|
|
run_id="r", battery="xstest",
|
|
pre_responses=[True], # type: ignore[list-item]
|
|
post_responses=["a"], oracle=["a"],
|
|
)
|
|
|
|
|
|
# ─── load_battery_probes (bundled fixture) ───
|
|
|
|
|
|
class TestLoadBatteryProbes:
|
|
def test_xstest_bundled(self):
|
|
probes = load_battery_probes("xstest")
|
|
assert isinstance(probes, tuple)
|
|
assert len(probes) > 0
|
|
for p in probes:
|
|
assert isinstance(p, dict)
|
|
assert "prompt" in p
|
|
|
|
def test_unknown(self):
|
|
with pytest.raises(ValueError):
|
|
load_battery_probes("not-real")
|
|
|
|
|
|
# ─── CLI smoke ───
|
|
|
|
|
|
class TestBehaviorCli:
|
|
def test_help_listed(self):
|
|
from soup_cli.commands.eval import app
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["--help"])
|
|
assert result.exit_code == 0
|
|
assert "behavior" in result.output.lower()
|
|
|
|
def test_behavior_help(self):
|
|
from soup_cli.commands.eval import app
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["behavior", "--help"])
|
|
assert result.exit_code == 0
|
|
assert "xstest" in result.output.lower() or "battery" in result.output.lower()
|
|
|
|
def test_behavior_unknown_battery(self):
|
|
from soup_cli.commands.eval import app
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"behavior", "test_run",
|
|
"--battery", "evilcorp",
|
|
])
|
|
assert result.exit_code != 0
|
|
|
|
def test_behavior_with_evidence(self, tmp_path, monkeypatch):
|
|
from soup_cli.commands.eval import app
|
|
monkeypatch.chdir(tmp_path)
|
|
ev = tmp_path / "ev.json"
|
|
ev.write_text(json.dumps({
|
|
"pre_responses": ["safe"] * 5,
|
|
"post_responses": ["safe"] * 5,
|
|
"oracle": ["safe"] * 5,
|
|
}))
|
|
out = tmp_path / "out.json"
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"behavior", "test_run",
|
|
"--battery", "xstest",
|
|
"--evidence", str(ev),
|
|
"--output", str(out),
|
|
])
|
|
assert result.exit_code == 0, (result.output, repr(result.exception))
|
|
data = json.loads(out.read_text())
|
|
assert data["battery"] == "xstest"
|
|
|
|
def test_behavior_outside_cwd_evidence(self, tmp_path, monkeypatch):
|
|
from soup_cli.commands.eval import app
|
|
sub = tmp_path / "sub"
|
|
sub.mkdir()
|
|
monkeypatch.chdir(sub)
|
|
outside = tmp_path / "ev.json"
|
|
outside.write_text("{}")
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"behavior", "test_run",
|
|
"--battery", "xstest",
|
|
"--evidence", str(outside),
|
|
])
|
|
assert result.exit_code != 0
|