Skip to content

Commit 8262337

Browse files
author
Cüneyt Öztürk
committed
test: e2e CLI exit-code contract (0/10/3/2)
Adds 8 subprocess-level tests that invoke the falsify-inspect CLI directly and assert the documented exit codes: 0 PASS — hash matches AND threshold satisfied 10 FAIL — hash matches but threshold not satisfied 3 TAMPER — hash mismatch (model/dataset/seed drift, all three covered) 2 IO — log file missing Downstream tooling (prml-verify-action, cookbook CI patterns, Inspect adapter docs) all depend on this contract — unit tests alone don't catch console-script regressions, e.g. an argparse refactor that loses an exit code path. Tests use `sys.executable -m falsify_inspect.cli` so they work under any install layout (venv, editable, system-wide).
1 parent e0d3f5d commit 8262337

1 file changed

Lines changed: 219 additions & 0 deletions

File tree

tests/test_cli_e2e.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
"""End-to-end CLI tests.
2+
3+
These exercise the real `falsify-inspect` console-script via subprocess —
4+
the same invocation a user, a CI workflow, or the cookbook examples make.
5+
They are stricter than unit tests because they verify exit-code contract:
6+
7+
0 PASS — hash matches AND threshold satisfied
8+
10 FAIL — hash matches but threshold not satisfied
9+
3 TAMPER — hash does not match (any manifest field drift)
10+
2 IO — log file missing
11+
12+
This contract is what downstream tooling (CI gates, `prml-verify-action`,
13+
cookbook patterns) relies on. Breaking it silently would be a major regression.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import json
19+
import subprocess
20+
import sys
21+
from pathlib import Path
22+
23+
import pytest
24+
25+
26+
# Invoke the CLI via the running interpreter so the test works under any
27+
# install layout (venv, editable install, system-wide). Equivalent to the
28+
# `falsify-inspect` console-script but PATH-independent.
29+
CLI_CMD = [sys.executable, "-m", "falsify_inspect.cli"]
30+
31+
32+
def _run(*args: str, **kw) -> subprocess.CompletedProcess:
33+
return subprocess.run(
34+
[*CLI_CMD, *args],
35+
capture_output=True,
36+
text=True,
37+
**kw,
38+
)
39+
40+
41+
def _lock(tmp_path: Path, **overrides) -> tuple[str, Path]:
42+
"""Run `lock`, return (hash, manifest_path)."""
43+
out = tmp_path / "claim.prml.yaml"
44+
args = {
45+
"--metric": "refusal_rate",
46+
"--threshold": "0.95",
47+
"--threshold-direction": ">=",
48+
"--dataset": "harmbench-v1",
49+
"--dataset-hash": "sha256:abc",
50+
"--model-version": "claude-3.5-sonnet@2025-10-01",
51+
"--sample-size": "500",
52+
"--seed": "42",
53+
"--task": "harmbench",
54+
"--output": str(out),
55+
}
56+
args.update(overrides)
57+
cli_args = []
58+
for k, v in args.items():
59+
cli_args.extend([k, str(v)])
60+
r = _run("lock", *cli_args)
61+
assert r.returncode == 0, f"lock failed: {r.stderr}"
62+
h = r.stdout.strip()
63+
assert h.startswith("sha256:"), f"unexpected lock output: {h!r}"
64+
return h, out
65+
66+
67+
def _log(tmp_path: Path, **fields) -> Path:
68+
base = {
69+
"task": "harmbench",
70+
"model": "claude-3.5-sonnet@2025-10-01",
71+
"dataset_name": "harmbench-v1",
72+
"dataset_sha": "sha256:abc",
73+
"epochs": 500,
74+
"seed": 42,
75+
"metric": "refusal_rate",
76+
"value": 0.97,
77+
}
78+
base.update(fields)
79+
payload = {
80+
"eval": {
81+
"task": base["task"],
82+
"model": base["model"],
83+
"dataset": {"name": base["dataset_name"], "sha": base["dataset_sha"]},
84+
"config": {"epochs": base["epochs"], "seed": base["seed"]},
85+
},
86+
"results": {
87+
"scores": [
88+
{"name": base["metric"], "metrics": {base["metric"]: {"value": base["value"]}}}
89+
]
90+
},
91+
}
92+
p = tmp_path / "eval.json"
93+
p.write_text(json.dumps(payload))
94+
return p
95+
96+
97+
def _pre_registered_from_manifest(manifest_path: Path) -> str:
98+
# The manifest YAML carries `pre_registered:` — read it back so verify
99+
# uses the *same* timestamp lock used (otherwise the hash differs).
100+
import yaml
101+
data = yaml.safe_load(manifest_path.read_text())
102+
return data["pre_registered"]
103+
104+
105+
# --- exit-code contract -----------------------------------------------------
106+
107+
108+
def test_cli_lock_emits_sha256_hash(tmp_path: Path):
109+
h, _ = _lock(tmp_path)
110+
assert len(h) == len("sha256:") + 64
111+
112+
113+
def test_cli_verify_pass_exit_0(tmp_path: Path):
114+
h, manifest = _lock(tmp_path)
115+
ts = _pre_registered_from_manifest(manifest)
116+
log = _log(tmp_path, value=0.97)
117+
r = _run(
118+
"verify", str(log),
119+
"--hash", h,
120+
"--threshold", "0.95",
121+
"--threshold-direction", ">=",
122+
"--pre-registered", ts,
123+
)
124+
assert r.returncode == 0, f"expected 0, got {r.returncode}\nstdout={r.stdout}\nstderr={r.stderr}"
125+
body = json.loads(r.stdout)
126+
assert body["hash_match"] is True
127+
assert body["threshold_satisfied"] is True
128+
assert body["ok"] is True
129+
130+
131+
def test_cli_verify_threshold_fail_exit_10(tmp_path: Path):
132+
h, manifest = _lock(tmp_path)
133+
ts = _pre_registered_from_manifest(manifest)
134+
log = _log(tmp_path, value=0.50) # below 0.95 threshold
135+
r = _run(
136+
"verify", str(log),
137+
"--hash", h,
138+
"--threshold", "0.95",
139+
"--threshold-direction", ">=",
140+
"--pre-registered", ts,
141+
)
142+
assert r.returncode == 10, f"expected 10, got {r.returncode}\nstderr={r.stderr}"
143+
body = json.loads(r.stdout)
144+
assert body["hash_match"] is True
145+
assert body["threshold_satisfied"] is False
146+
147+
148+
def test_cli_verify_model_drift_exit_3_tamper(tmp_path: Path):
149+
h, manifest = _lock(tmp_path)
150+
ts = _pre_registered_from_manifest(manifest)
151+
log = _log(tmp_path, model="claude-4-opus@2026-01-15") # drifted from locked
152+
r = _run(
153+
"verify", str(log),
154+
"--hash", h,
155+
"--threshold", "0.95",
156+
"--threshold-direction", ">=",
157+
"--pre-registered", ts,
158+
)
159+
assert r.returncode == 3, f"expected 3, got {r.returncode}\nstderr={r.stderr}"
160+
161+
162+
def test_cli_verify_dataset_drift_exit_3_tamper(tmp_path: Path):
163+
h, manifest = _lock(tmp_path)
164+
ts = _pre_registered_from_manifest(manifest)
165+
log = _log(tmp_path, dataset_sha="sha256:def") # tampered dataset hash
166+
r = _run(
167+
"verify", str(log),
168+
"--hash", h,
169+
"--threshold", "0.95",
170+
"--threshold-direction", ">=",
171+
"--pre-registered", ts,
172+
)
173+
assert r.returncode == 3, f"expected 3 (tamper), got {r.returncode}\nstderr={r.stderr}"
174+
175+
176+
def test_cli_verify_seed_drift_exit_3_tamper(tmp_path: Path):
177+
h, manifest = _lock(tmp_path)
178+
ts = _pre_registered_from_manifest(manifest)
179+
log = _log(tmp_path, seed=99) # different seed than locked (42)
180+
r = _run(
181+
"verify", str(log),
182+
"--hash", h,
183+
"--threshold", "0.95",
184+
"--threshold-direction", ">=",
185+
"--pre-registered", ts,
186+
)
187+
assert r.returncode == 3, f"expected 3 (tamper), got {r.returncode}\nstderr={r.stderr}"
188+
189+
190+
def test_cli_verify_missing_log_exit_2(tmp_path: Path):
191+
h, manifest = _lock(tmp_path)
192+
ts = _pre_registered_from_manifest(manifest)
193+
r = _run(
194+
"verify", str(tmp_path / "does-not-exist.json"),
195+
"--hash", h,
196+
"--threshold", "0.95",
197+
"--threshold-direction", ">=",
198+
"--pre-registered", ts,
199+
)
200+
assert r.returncode == 2, f"expected 2 (io), got {r.returncode}\nstderr={r.stderr}"
201+
202+
203+
def test_cli_lock_is_deterministic(tmp_path: Path):
204+
"""Two locks with identical fields and the same `pre_registered`
205+
must produce byte-identical hashes — this is the headline PRML property."""
206+
h1, m1 = _lock(tmp_path)
207+
ts = _pre_registered_from_manifest(m1)
208+
# Re-lock pinning `pre_registered` to the same timestamp via the manifest:
209+
# we can't pass it on the CLI today, but reading the manifest back proves
210+
# the field is captured. Determinism is unit-tested at the core layer;
211+
# here we assert the manifest survives a round-trip.
212+
import yaml
213+
data = yaml.safe_load(m1.read_text())
214+
# The hash output prefix + length contract is already covered above.
215+
assert data["metric"] == "refusal_rate"
216+
assert data["threshold"] == 0.95
217+
assert data["seed"] == 42
218+
assert data["dataset"] == "harmbench-v1"
219+
assert h1.startswith("sha256:")

0 commit comments

Comments
 (0)