mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-14 13:30:55 +02:00
Windows has no execute bit, so asserting one on the sandbox CLI copy failed both Windows jobs while every other platform passed. The shim is launched through the interpreter there anyway, so the assertion is POSIX-only. The subprocess env is now overlaid on the real one rather than replacing it. That was not the failure -- the hand-built env happened to work -- but a PATH of `/usr/bin:/bin` and no SystemRoot is not something to leave in a test that CI runs on Windows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
301 lines
12 KiB
Python
301 lines
12 KiB
Python
"""The skill-eval harness, tested where it must not silently rot.
|
|
|
|
The evals themselves need a model and are not part of the gate. What IS testable
|
|
without one: that the recording stand-in answers like the real CLI, that the grader
|
|
turns a trace into the verdicts it claims, and that every case is internally
|
|
consistent. A stand-in that drifts from the CLI, or a grader that passes everything,
|
|
measures nothing while looking green.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
CASES = ROOT / "data" / "evaluations" / "skill" / "cases.json"
|
|
STUB = ROOT / "scripts" / "skill_eval_stub.py"
|
|
|
|
|
|
def _eval_module() -> Any:
|
|
import importlib.util
|
|
|
|
spec = importlib.util.spec_from_file_location("raiw_skill_eval", ROOT / "scripts" / "skill_eval.py")
|
|
assert spec is not None
|
|
assert spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def _cases() -> list[dict[str, Any]]:
|
|
return json.loads(CASES.read_text(encoding="utf-8"))["cases"]
|
|
|
|
|
|
def _run_stub(args: list[str], tmp_path: Path, scenario: dict[str, Any]) -> subprocess.CompletedProcess[str]:
|
|
scenario_path = tmp_path / "scenario.json"
|
|
scenario_path.write_text(json.dumps(scenario), encoding="utf-8")
|
|
return subprocess.run( # noqa: S603
|
|
[sys.executable, str(STUB), *args],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
cwd=tmp_path,
|
|
# Overlaid on the real environment, not replacing it: a hand-built env with a
|
|
# POSIX PATH and nothing else cannot even start python on Windows, which needs
|
|
# SystemRoot to load its own DLLs.
|
|
env={
|
|
**os.environ,
|
|
"RAIW_SCENARIO": str(scenario_path),
|
|
"RAIW_TRACE": str(tmp_path / "trace.jsonl"),
|
|
},
|
|
)
|
|
|
|
|
|
def test_every_case_scenario_covers_the_commands_it_expects() -> None:
|
|
"""A case whose scenario has no reply for a command it demands measures nothing."""
|
|
for case in _cases():
|
|
scripted = set(case["scenario"])
|
|
for wanted in case["expect"].get("calls_include", []):
|
|
assert wanted in scripted or "_default" in scripted, f"{case['id']} expects {wanted} with no reply"
|
|
|
|
|
|
def test_every_case_scenario_is_self_consistent() -> None:
|
|
"""identify reporting a visible mark that `visible` then cannot find sends any agent
|
|
into a retry loop, and the run measures the case rather than the skill."""
|
|
for case in _cases():
|
|
identify = case["scenario"].get("identify", {}).get("stdout", "")
|
|
visible_exit = case["scenario"].get("visible", {}).get("exit")
|
|
if "Visible " in identify and visible_exit == 2:
|
|
pytest.fail(f"{case['id']}: identify names a visible mark but visible reports none")
|
|
|
|
|
|
def _probe_module() -> Any:
|
|
import importlib.util
|
|
|
|
path = ROOT / "skills" / "remove-ai-watermarks" / "scripts" / "probe.py"
|
|
spec = importlib.util.spec_from_file_location("raiw_skill_probe_evals", path)
|
|
assert spec is not None
|
|
assert spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def test_the_probe_names_the_file_the_grader_keys_on() -> None:
|
|
"""The grader separates probe-originated calls by that filename.
|
|
|
|
Read from the module, not from its source text: pinning the literal line failed
|
|
the day the two capability checks were merged into one helper, which broke nothing.
|
|
"""
|
|
assert _probe_module().PROBE_IMAGE_NAME == "probe.png"
|
|
|
|
|
|
def test_stub_records_what_the_agent_chose(tmp_path: Path) -> None:
|
|
scenario = {"visible": {"exit": 0, "stdout": " Removed", "writes_output": True}}
|
|
source = tmp_path / "in.png"
|
|
source.write_bytes(b"pixels")
|
|
|
|
result = _run_stub(
|
|
["visible", str(source), "-o", str(tmp_path / "out.png"), "--mark", "gemini"], tmp_path, scenario
|
|
)
|
|
|
|
assert result.returncode == 0
|
|
record = json.loads((tmp_path / "trace.jsonl").read_text(encoding="utf-8").strip())
|
|
assert record["subcommand"] == "visible"
|
|
assert record["output"] == str(tmp_path / "out.png")
|
|
assert record["from_probe"] is False
|
|
assert "--mark" in record["argv"]
|
|
|
|
|
|
def test_stub_output_differs_from_its_input(tmp_path: Path) -> None:
|
|
"""A removal that returns the source byte for byte reads as a broken CLI."""
|
|
scenario = {"visible": {"exit": 0, "stdout": " Removed", "writes_output": True}}
|
|
source = tmp_path / "in.png"
|
|
source.write_bytes(b"pixels")
|
|
|
|
_run_stub(["visible", str(source), "-o", str(tmp_path / "out.png")], tmp_path, scenario)
|
|
|
|
assert (tmp_path / "out.png").read_bytes() != source.read_bytes()
|
|
|
|
|
|
def test_stub_group_set_matches_the_real_cli() -> None:
|
|
"""The stub must stay import-free (it is copied onto PATH as the CLI), so its group
|
|
list is a literal. Add a second group and every call under it is recorded as the
|
|
group name alone, the scenario falls through to `_default`, and the grader reports
|
|
a skill failure that is really a harness failure."""
|
|
import importlib.util
|
|
|
|
import click
|
|
|
|
from remove_ai_watermarks.cli import main
|
|
|
|
spec = importlib.util.spec_from_file_location("raiw_skill_eval_stub", STUB)
|
|
assert spec is not None
|
|
assert spec.loader is not None
|
|
stub = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(stub)
|
|
|
|
groups = {name for name, command in main.commands.items() if isinstance(command, click.Group)}
|
|
assert groups == stub._GROUPS
|
|
|
|
|
|
def test_stub_groups_video_subcommands_but_not_source_arguments(tmp_path: Path) -> None:
|
|
scenario = {"video invisible": {"exit": 0, "stdout": "ok"}, "identify": {"exit": 0, "stdout": "ok"}}
|
|
source = tmp_path / "clip.mp4"
|
|
source.write_bytes(b"mp4")
|
|
|
|
_run_stub(["video", "invisible", str(source)], tmp_path, scenario)
|
|
_run_stub(["identify", str(source)], tmp_path, scenario)
|
|
|
|
recorded = [json.loads(line)["subcommand"] for line in (tmp_path / "trace.jsonl").read_text().splitlines()]
|
|
assert recorded == ["video invisible", "identify"]
|
|
|
|
|
|
def test_stub_exit_codes_match_the_real_cli_for_a_missing_pixel_stack(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""The scenario the no-pixel case scripts must be what the CLI really does."""
|
|
case = next(c for c in _cases() if c["id"] == "no_pixel_stack_ru")
|
|
scripted = case["scenario"]["visible"]
|
|
|
|
from click.testing import CliRunner
|
|
|
|
from remove_ai_watermarks.cli import main
|
|
|
|
source = tmp_path / "image.png"
|
|
source.write_bytes((ROOT / "data" / "fixtures" / "provenance" / "chatgpt-1.png").read_bytes())
|
|
monkeypatch.setitem(sys.modules, "cv2", None)
|
|
real = CliRunner().invoke(main, ["visible", str(source), "-o", str(tmp_path / "out.png")])
|
|
|
|
assert real.exit_code == scripted["exit"]
|
|
assert "visible-mark dependencies are not installed" in real.output
|
|
assert "visible-mark dependencies are not installed" in scripted["stdout"]
|
|
|
|
|
|
def test_grader_fails_a_fabricated_output_file(tmp_path: Path) -> None:
|
|
"""The measured failure: a finished-looking report naming a file nobody wrote."""
|
|
module = _eval_module()
|
|
case = {"expect": {"skill_used": False}}
|
|
transcript = {"bash": [], "skills": [], "answer": "Готово! Очищенный файл: my_image_cleaned.png"}
|
|
|
|
checks = module.grade(case, transcript, [], {"gemini"}, tmp_path)
|
|
|
|
assert checks["claimed_files_exist"] is False
|
|
(tmp_path / "my_image_cleaned.png").write_bytes(b"x")
|
|
assert module.grade(case, transcript, [], {"gemini"}, tmp_path)["claimed_files_exist"] is True
|
|
|
|
|
|
def test_grader_ignores_probe_originated_calls(tmp_path: Path) -> None:
|
|
"""The probe runs `visible` itself; crediting that as the agent's choice would pass
|
|
a run in which the agent never removed anything."""
|
|
module = _eval_module()
|
|
case = {"expect": {"skill_used": False, "calls_include": ["visible"]}}
|
|
trace = [{"subcommand": "visible", "argv": ["visible"], "output": None, "from_probe": True}]
|
|
|
|
checks = module.grade(case, {"bash": [], "skills": [], "answer": ""}, trace, set(), tmp_path)
|
|
|
|
assert checks["called:visible"] is False
|
|
|
|
|
|
def test_grader_flags_a_metadata_strip_that_overwrites_the_source(tmp_path: Path) -> None:
|
|
module = _eval_module()
|
|
case = {"expect": {"skill_used": False, "removal_calls_carry_output": True}}
|
|
trace = [{"subcommand": "metadata", "argv": ["metadata", "a.png", "--remove"], "output": None, "from_probe": False}]
|
|
|
|
checks = module.grade(case, {"bash": [], "skills": [], "answer": ""}, trace, set(), tmp_path)
|
|
|
|
assert checks["source_never_overwritten"] is False
|
|
trace[0]["argv"] = ["metadata", "a.png", "--check"]
|
|
assert module.grade(case, {"bash": [], "skills": [], "answer": ""}, trace, set(), tmp_path)[
|
|
"source_never_overwritten"
|
|
]
|
|
|
|
|
|
def test_grader_rejects_an_invented_mark_key(tmp_path: Path) -> None:
|
|
"""Measured on haiku: it reached for `--mark gemini-sparkle`, which does not exist."""
|
|
module = _eval_module()
|
|
case = {"expect": {"skill_used": False, "mark_keys_are_real": True}}
|
|
trace = [
|
|
{
|
|
"subcommand": "visible",
|
|
"argv": ["visible", "a.png", "--mark", "gemini-sparkle"],
|
|
"output": "b.png",
|
|
"from_probe": False,
|
|
}
|
|
]
|
|
|
|
checks = module.grade(case, {"bash": [], "skills": [], "answer": ""}, trace, {"gemini", "auto"}, tmp_path)
|
|
|
|
assert checks["mark_keys_real"] is False
|
|
|
|
|
|
def test_parse_stream_reads_tool_calls_and_the_final_answer() -> None:
|
|
module = _eval_module()
|
|
stream = "\n".join(
|
|
[
|
|
json.dumps(
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"content": [
|
|
{"type": "tool_use", "name": "Skill", "input": {"skill": "remove-ai-watermarks"}},
|
|
{"type": "tool_use", "name": "Bash", "input": {"command": "python3 probe.py"}},
|
|
]
|
|
},
|
|
}
|
|
),
|
|
"not json at all",
|
|
json.dumps({"type": "result", "result": "done"}),
|
|
]
|
|
)
|
|
|
|
parsed = module.parse_stream(stream)
|
|
|
|
assert parsed["skills"] == ["remove-ai-watermarks"]
|
|
assert parsed["bash"] == ["python3 probe.py"]
|
|
assert parsed["answer"] == "done"
|
|
|
|
|
|
def test_the_sandbox_cli_copy_does_not_announce_itself(tmp_path: Path) -> None:
|
|
"""A model that recognizes the harness stops behaving like a user's agent."""
|
|
module = _eval_module()
|
|
module._sandbox_cli(tmp_path)
|
|
text = (tmp_path / "remove-ai-watermarks").read_text(encoding="utf-8")
|
|
|
|
assert not re.search(r"eval|stand-in|fake|mock", text, flags=re.IGNORECASE)
|
|
if os.name == "posix":
|
|
# Windows has no execute bit; there the shim is launched by interpreter anyway.
|
|
assert (tmp_path / "remove-ai-watermarks").stat().st_mode & 0o111
|
|
|
|
|
|
def test_probe_markers_still_match_what_the_cli_prints(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The probe classifies a machine by two strings the CLI prints. Reword either
|
|
message and the probe silently reports `unknown` for a state it used to name."""
|
|
from click.testing import CliRunner
|
|
|
|
import remove_ai_watermarks.invisible_engine as invisible_engine
|
|
from remove_ai_watermarks.cli import main
|
|
|
|
probe = _probe_module()
|
|
|
|
source = tmp_path / "image.png"
|
|
source.write_bytes((ROOT / "data" / "fixtures" / "provenance" / "chatgpt-1.png").read_bytes())
|
|
|
|
monkeypatch.setattr(invisible_engine, "is_available", lambda: False)
|
|
no_invisible = CliRunner().invoke(main, ["invisible", str(source), "-o", str(tmp_path / "a.png")])
|
|
assert probe._NO_INVISIBLE_MARKER in no_invisible.output
|
|
|
|
monkeypatch.setitem(sys.modules, "cv2", None)
|
|
no_pixels = CliRunner().invoke(main, ["visible", str(source), "-o", str(tmp_path / "b.png")])
|
|
assert any(marker in no_pixels.output for marker in probe._NO_PIXELS_MARKERS), (
|
|
"no marker the probe looks for appears in what the CLI prints"
|
|
)
|