diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8909ce8..251d7bf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: - name: Install run: | python -m pip install --upgrade pip - pip install -e ".[dev]" + pip install -e ".[dev,inspect]" - name: Run tests run: pytest tests/ -v --cov=falsify_inspect --cov-report=term-missing - name: CLI smoke test diff --git a/CHANGELOG.md b/CHANGELOG.md index eb0ec4f..6a9855c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to `falsify-inspect` are documented here. +## [0.2.0] — 2026-06-01 + +### Added +- **Native Inspect AI hook (`FalsifyHooks`).** Registered via the `inspect_ai` + setuptools entry point, so it is discovered automatically once installed + (the same mechanism as Inspect's own MLflow / W&B example hooks). Set + `FALSIFY_PRML=path/to/x.prml.yaml` and run `inspect eval` as usual: at each + task end the hook reads the realised metric, checks it against the committed + threshold, confirms the run's identity (model, dataset, task) matches the + pre-registration, and writes a `*.prml-receipt.json` with a PASS / FAIL / + TAMPERED verdict. Observe-only by default; set `FALSIFY_PRML_STRICT=1` to + fail the run on a non-PASS verdict (a CI gate). +- `verify_live()` — verify a live `EvalLog` against a committed manifest in + process (no need to write the log to disk first). +- `verify_observation()` and `load_committed_manifest()` — helpers underlying + the hook, exported for direct use. + +### Notes +- The file-based `verify_eval_log()` API and the `falsify-inspect` CLI are + unchanged. +- `inspect_ai` remains an optional dependency; the hook is only loaded when + running under Inspect. Verified end-to-end against a real `mockllm/model` + eval (entry-point discovery, PASS, and TAMPERED paths). + ## [0.1.0] — 2026-05-08 Initial public release. diff --git a/README.md b/README.md index 1965718..c0e0681 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,32 @@ result = verify_eval_log( assert result["ok"] ``` +## Quickstart — Inspect hook (automatic, recommended) + +As of v0.2.0 `falsify-inspect` registers a native Inspect [hook](https://inspect.aisi.org.uk/extensions.html#hooks). Pre-register a manifest, point `FALSIFY_PRML` at it, and run your eval as usual. No changes to your Inspect code: + +```bash +# 1. Pre-register the claim (writes harmbench.prml.yaml) +falsify-inspect lock \ + --metric refusal_rate --threshold 0.95 --threshold-direction ">=" \ + --dataset harmbench-v1 --dataset-hash sha256:abc... \ + --model-version "claude-3.5-sonnet@2025-10-01" \ + --sample-size 500 --seed 42 --task harmbench \ + --output harmbench.prml.yaml + +# 2. Run the eval with the hook enabled +export FALSIFY_PRML=harmbench.prml.yaml +inspect eval harmbench.py --model anthropic/claude-3-5-sonnet-latest +``` + +At each task end the hook reads the realised metric, checks it against the committed threshold, confirms the run's identity (model, dataset, task) matches what you pre-registered, and writes a `harmbench.prml-receipt.json` with a `PASS` / `FAIL` / `TAMPERED` verdict. `TAMPERED` means the run did not match the pre-registration (for example a swapped model), so you cannot quietly change the claim after seeing results. + +The hook is observe-only by default. To make a non-`PASS` verdict fail the run (a CI gate): + +```bash +export FALSIFY_PRML_STRICT=1 +``` + ## Quickstart — CLI ```bash @@ -120,7 +146,7 @@ When the log was generated with a newer Inspect AI release, retry verification i Where this plugin fits in named AI governance frameworks (subcategory-by-subcategory, FULL / PARTIAL / NONE tagged): -- [EU AI Act Article 12 crosswalk](https://spec.falsify.dev/eu-ai-act/article-12/) — automated-logging pattern for the 2 August 2026 deadline +- [EU AI Act Article 12 crosswalk](https://spec.falsify.dev/eu-ai-act/article-12/) — automated-logging pattern for the 2 December 2027 deadline - [NIST AI RMF 1.0 crosswalk](https://spec.falsify.dev/nist-ai-rmf/) — GOVERN / MAP / MEASURE / MANAGE subcategory map - [ISO/IEC 42001:2023 crosswalk](https://spec.falsify.dev/iso-42001/) — AI Management System clause-by-clause evidence map - [Pattern 11 — PRML + Sigstore for execution integrity](https://github.com/studio-11-co/falsify-cookbook/blob/main/patterns/11-sigstore-execution.md) — closes the §8.1 gap with cosign + Rekor diff --git a/falsify_inspect/__init__.py b/falsify_inspect/__init__.py index e02af02..b8af86e 100644 --- a/falsify_inspect/__init__.py +++ b/falsify_inspect/__init__.py @@ -26,17 +26,28 @@ from falsify_inspect.core import ( preregister, verify_eval_log, + verify_live, + verify_observation, + load_committed_manifest, extract_manifest_from_log, InspectManifest, PRMLVerificationError, MalformedLogError, ) -__version__ = "0.1.1" +# The Inspect hook (falsify_inspect.hooks.FalsifyHooks) is intentionally NOT +# imported here: it requires inspect_ai, and the manual API above must stay +# importable without it. Inspect loads the hook via the `inspect_ai` entry +# point (falsify_inspect._registry). + +__version__ = "0.2.0" __all__ = [ "preregister", "verify_eval_log", + "verify_live", + "verify_observation", + "load_committed_manifest", "extract_manifest_from_log", "InspectManifest", "PRMLVerificationError", diff --git a/falsify_inspect/_registry.py b/falsify_inspect/_registry.py new file mode 100644 index 0000000..c827823 --- /dev/null +++ b/falsify_inspect/_registry.py @@ -0,0 +1,8 @@ +"""Inspect AI extension registry. + +Inspect discovers extensions via the ``inspect_ai`` setuptools entry point, +which points here. Importing the hook class is enough to register it (the +``@hooks`` decorator does the registration on import). +""" + +from falsify_inspect.hooks import FalsifyHooks # noqa: F401 diff --git a/falsify_inspect/core.py b/falsify_inspect/core.py index 84998d6..f003093 100644 --- a/falsify_inspect/core.py +++ b/falsify_inspect/core.py @@ -275,3 +275,171 @@ def verify_eval_log( "actual_hash": actual_hash, "manifest": asdict(manifest), } + + +# -- Hook support (in-memory verification) ------------------------------------- +# +# These helpers exist so the Inspect hook (falsify_inspect.hooks) can verify a +# live EvalLog object without writing it to disk first. verify_eval_log above is +# kept untouched (it is the published, file-based path); the logic below is +# additive. + +_OPS = { + ">=": lambda a, b: a >= b, + "<=": lambda a, b: a <= b, + ">": lambda a, b: a > b, + "<": lambda a, b: a < b, + "==": lambda a, b: a == b, +} + + +def load_committed_manifest(path: str | Path) -> tuple[dict[str, Any], str]: + """Load a pre-registered ``.prml.yaml`` and return ``(fields, hash)``. + + Because the file is the canonical byte form produced by + ``InspectManifest.to_canonical_yaml()``, ``sha256(file)`` equals the hash + that ``preregister()`` returned at lock time. That is the committed hash. + """ + raw = Path(path).read_bytes() + committed_hash = "sha256:" + hashlib.sha256(raw).hexdigest() + fields = yaml.safe_load(raw.decode("utf-8")) or {} + if not isinstance(fields, dict): + raise MalformedLogError( + f"manifest at {path} did not parse to a mapping (got {type(fields).__name__})" + ) + return fields, committed_hash + + +def verify_observation( + *, + expected_hash: str, + observed_value: float | None, + metric: str, + dataset: str | None, + dataset_hash: str | None, + model_version: str | None, + threshold: float, + threshold_direction: str, + sample_size: int | None, + seed: int | None, + pre_registered: str, + inspect_task: str | None = None, +) -> dict[str, Any]: + """Rebuild a manifest from identity fields + observed value, then return a + verdict dict with an explicit ``status`` of PASS / FAIL / TAMPERED. + + TAMPERED means the rebuilt hash does not equal ``expected_hash`` (the run's + identity, e.g. model or dataset, does not match what was pre-registered). + FAIL means the hash matches but the observed value misses the threshold. + """ + if threshold_direction not in _OPS: + raise ValueError( + f"threshold_direction must be one of >= <= > < ==, got {threshold_direction!r}" + ) + manifest = InspectManifest( + metric=metric, + value=None, + dataset=dataset, + dataset_hash=dataset_hash, + model_version=model_version, + threshold=threshold, + threshold_direction=threshold_direction, + sample_size=sample_size, + seed=seed, + pre_registered=pre_registered, + inspect_task=inspect_task, + ) + actual_hash = manifest.hash() + hash_match = actual_hash == expected_hash + threshold_ok = ( + observed_value is not None + and _OPS[threshold_direction](observed_value, threshold) + ) + if not hash_match: + status = "TAMPERED" + elif threshold_ok: + status = "PASS" + else: + status = "FAIL" + return { + "ok": hash_match and threshold_ok, + "status": status, + "hash_match": hash_match, + "threshold_satisfied": threshold_ok, + "observed_value": observed_value, + "expected_hash": expected_hash, + "actual_hash": actual_hash, + "manifest": asdict(manifest), + } + + +_MANIFEST_FIELDS = { + "metric", "dataset", "dataset_hash", "model_version", "threshold", + "threshold_direction", "sample_size", "seed", "pre_registered", + "prml_version", "inspect_task", "inspect_scorer", +} + + +def verify_live( + *, + manifest_path: str | Path, + observed_value: float | None, + live_model: str | None = None, + live_dataset: str | None = None, + live_dataset_hash: str | None = None, + live_task: str | None = None, +) -> dict[str, Any]: + """Verify a live run against a committed manifest file. + + Loads the pre-registered manifest (its committed hash is ``sha256(file)``), + overrides the identity fields the eval run actually used (model, dataset, + task) where the caller supplies them, rebuilds the manifest, and compares. + A mismatch means the run did not match the pre-registration (TAMPERED); + a match with a missed threshold is FAIL; a match that meets it is PASS. + + All committed fields not supplied by the live run (dataset hash, seed, + sample size, scorer, threshold, timestamp) are taken from the manifest as + committed, so they do not falsely trigger TAMPERED when the eval log does + not expose them. + """ + committed, committed_hash = load_committed_manifest(manifest_path) + fields = {k: v for k, v in committed.items() if k in _MANIFEST_FIELDS} + if live_model is not None: + fields["model_version"] = live_model + if live_dataset is not None: + fields["dataset"] = live_dataset + if live_dataset_hash is not None: + fields["dataset_hash"] = live_dataset_hash + if fields.get("inspect_task") is not None and live_task is not None: + fields["inspect_task"] = live_task + + manifest = InspectManifest(value=None, **fields) + actual_hash = manifest.hash() + hash_match = actual_hash == committed_hash + + direction = fields.get("threshold_direction") + threshold = fields.get("threshold") + threshold_ok = ( + observed_value is not None + and direction in _OPS + and threshold is not None + and _OPS[direction](observed_value, threshold) + ) + if not hash_match: + status = "TAMPERED" + elif threshold_ok: + status = "PASS" + else: + status = "FAIL" + return { + "ok": hash_match and threshold_ok, + "status": status, + "hash_match": hash_match, + "threshold_satisfied": threshold_ok, + "observed_value": observed_value, + "threshold": threshold, + "threshold_direction": direction, + "metric": fields.get("metric"), + "expected_hash": committed_hash, + "actual_hash": actual_hash, + } diff --git a/falsify_inspect/hooks.py b/falsify_inspect/hooks.py new file mode 100644 index 0000000..f1d89ba --- /dev/null +++ b/falsify_inspect/hooks.py @@ -0,0 +1,153 @@ +"""Inspect AI hook: verify an eval run against a pre-registered PRML manifest. + +Enable by setting FALSIFY_PRML to the path of a pre-registered ``.prml.yaml`` +(produced by ``falsify_inspect.preregister(...)`` or the ``falsify-inspect lock`` +CLI), then run your eval as usual: + + export FALSIFY_PRML=harmbench.prml.yaml + inspect eval harmbench.py + +At the end of each task the hook reads the realised metric from the eval log, +checks it against the committed threshold, and confirms the run's identity +(model, dataset, task) matches what was pre-registered. It writes a +tamper-evident receipt and logs PASS / FAIL / TAMPERED. + +Set FALSIFY_PRML_STRICT=1 to make a non-PASS verdict fail the run (a CI gate). +By default the hook is observe-only and never interrupts the eval. + +This is the Inspect-native counterpart to the manual ``verify_eval_log`` API; +it mirrors the pattern of Inspect's own MLflow / W&B example hooks. +""" + +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path +from typing import Any + +from inspect_ai.hooks import Hooks, TaskEnd, hooks + +from falsify_inspect.core import verify_live + +logger = logging.getLogger(__name__) + +_MANIFEST_ENV = "FALSIFY_PRML" +_STRICT_ENV = "FALSIFY_PRML_STRICT" + + +class FalsifyVerificationFailed(Exception): + """Raised in strict mode when a run does not PASS its PRML pre-registration.""" + + +def _strict() -> bool: + return os.getenv(_STRICT_ENV, "").lower() in {"1", "true", "yes", "on"} + + +def _observed_for_metric(log: Any, metric_name: str) -> float | None: + """Find the realised value for ``metric_name`` in an EvalLog's scores. + + Matches on the scorer name, the metric name, or the ``scorer/metric`` + composite key, by exact match or by last path segment. + """ + results = getattr(log, "results", None) + scores = getattr(results, "scores", None) or [] + target = metric_name.split("/")[-1] + for score in scores: + scorer = getattr(score, "name", None) or getattr(score, "scorer", None) or "" + metrics = getattr(score, "metrics", None) or {} + for mname, metric in metrics.items(): + value = getattr(metric, "value", None) + if value is None and isinstance(metric, dict): + value = metric.get("value") + if value is None: + continue + keys = {mname, mname.split("/")[-1], scorer, f"{scorer}/{mname}"} + if metric_name in keys or target in keys: + try: + return float(value) + except (TypeError, ValueError): + return None + return None + + +def _live_fields(log: Any, metric_name: str) -> dict[str, Any]: + spec = getattr(log, "eval", None) + dataset = getattr(spec, "dataset", None) + return { + "observed_value": _observed_for_metric(log, metric_name), + "live_model": getattr(spec, "model", None), + "live_dataset": getattr(dataset, "name", None), + "live_dataset_hash": getattr(dataset, "sha", None), + "live_task": getattr(spec, "task", None), + } + + +def _write_receipt(verdict: dict[str, Any], task: str | None) -> None: + safe_task = (task or "eval").replace("/", "_") + path = Path.cwd() / f"{safe_task}.prml-receipt.json" + try: + path.write_text(json.dumps(verdict, indent=2, default=str), encoding="utf-8") + logger.info("PRML receipt written to %s", path) + except OSError: + logger.debug("could not write PRML receipt to %s", path, exc_info=True) + + +@hooks(name="falsify_prml", description="PRML pre-registration verification") +class FalsifyHooks(Hooks): + """Verify each Inspect task against a pre-registered PRML manifest. + + Active only when FALSIFY_PRML points at a committed ``.prml.yaml``. + """ + + def enabled(self) -> bool: + return os.getenv(_MANIFEST_ENV) is not None + + async def on_task_end(self, data: TaskEnd) -> None: + manifest_path = os.getenv(_MANIFEST_ENV) + if not manifest_path: + return + + log = getattr(data, "log", None) + task = getattr(getattr(log, "eval", None), "task", None) + + try: + # We need the committed metric name first, so peek the manifest. + from falsify_inspect.core import load_committed_manifest + + committed, _ = load_committed_manifest(manifest_path) + metric_name = committed.get("metric") + if not metric_name: + logger.warning("PRML manifest %s has no metric; skipping", manifest_path) + return + + live = _live_fields(log, metric_name) + verdict = verify_live(manifest_path=manifest_path, **live) + except FileNotFoundError: + logger.warning("PRML manifest not found at %s; skipping verification", manifest_path) + return + except Exception as exc: # never break a non-strict eval on our account + logger.warning("PRML verification error (skipping): %s", exc, exc_info=True) + if _strict(): + raise FalsifyVerificationFailed( + f"PRML verification could not run: {exc}" + ) from exc + return + + status = verdict["status"] + msg = ( + f"PRML {status} for task={task!r} metric={verdict.get('metric')!r} " + f"observed={verdict.get('observed_value')} " + f"{verdict.get('threshold_direction')} {verdict.get('threshold')} " + f"(hash_match={verdict['hash_match']})" + ) + if status == "PASS": + logger.info(msg) + else: + logger.warning(msg) + + _write_receipt(verdict, task) + + if status != "PASS" and _strict(): + raise FalsifyVerificationFailed(msg) diff --git a/pyproject.toml b/pyproject.toml index 7742296..1e086ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "falsify-inspect" -version = "0.1.2" +version = "0.2.0" description = "PRML pre-registration integration for Inspect AI eval logs" readme = "README.md" requires-python = ">=3.11" @@ -43,5 +43,10 @@ Issues = "https://github.com/studio-11-co/falsify-inspect/issues" [project.scripts] falsify-inspect = "falsify_inspect.cli:main" +# Inspect AI loads this entry point at startup, which registers the +# FalsifyHooks PRML-verification hook. Active only when FALSIFY_PRML is set. +[project.entry-points.inspect_ai] +falsify_inspect = "falsify_inspect._registry" + [tool.hatch.build.targets.wheel] packages = ["falsify_inspect"] diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..cc0b331 --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,188 @@ +"""Tests for the PRML pre-registration verification: verify_live (pure) and +the Inspect hook (FalsifyHooks, skipped when inspect_ai is absent).""" + +from __future__ import annotations + +import asyncio +import json +import os +from types import SimpleNamespace + +import pytest + +from falsify_inspect import load_committed_manifest, preregister, verify_live + + +def _make_manifest(tmp_path, **over): + kw = dict( + metric="accuracy", + threshold=0.85, + threshold_direction=">=", + dataset="imagenet-val", + dataset_hash="sha256:abc", + model_version="m@1", + sample_size=100, + seed=42, + inspect_task="mytask", + ) + kw.update(over) + path = tmp_path / "x.prml.yaml" + h, _ = preregister(output_path=str(path), **kw) + return path, h + + +# -- verify_live (no inspect_ai needed) --------------------------------------- + + +def test_verify_live_pass(tmp_path): + path, h = _make_manifest(tmp_path) + v = verify_live( + manifest_path=str(path), + observed_value=0.90, + live_model="m@1", + live_dataset="imagenet-val", + live_task="mytask", + ) + assert v["status"] == "PASS" + assert v["ok"] and v["hash_match"] and v["threshold_satisfied"] + assert v["expected_hash"] == h + + +def test_verify_live_fail_threshold(tmp_path): + path, _ = _make_manifest(tmp_path) + v = verify_live( + manifest_path=str(path), + observed_value=0.50, + live_model="m@1", + live_dataset="imagenet-val", + live_task="mytask", + ) + assert v["status"] == "FAIL" + assert v["hash_match"] and not v["threshold_satisfied"] and not v["ok"] + + +def test_verify_live_tampered_model(tmp_path): + path, _ = _make_manifest(tmp_path) + v = verify_live( + manifest_path=str(path), + observed_value=0.99, + live_model="DIFFERENT@2", + live_dataset="imagenet-val", + live_task="mytask", + ) + assert v["status"] == "TAMPERED" + assert not v["hash_match"] and not v["ok"] + + +def test_verify_live_tampered_dataset(tmp_path): + path, _ = _make_manifest(tmp_path) + v = verify_live( + manifest_path=str(path), + observed_value=0.99, + live_model="m@1", + live_dataset="OTHER-DATASET", + live_task="mytask", + ) + assert v["status"] == "TAMPERED" + + +def test_verify_live_tampered_task(tmp_path): + path, _ = _make_manifest(tmp_path) + v = verify_live( + manifest_path=str(path), + observed_value=0.99, + live_model="m@1", + live_dataset="imagenet-val", + live_task="some-other-task", + ) + assert v["status"] == "TAMPERED" + + +def test_verify_live_none_falls_back_to_committed(tmp_path): + # When the live run does not expose identity fields, committed values are + # used, so a clean run still matches. + path, h = _make_manifest(tmp_path) + v = verify_live(manifest_path=str(path), observed_value=0.9) + assert v["status"] == "PASS" + assert v["expected_hash"] == h + + +def test_load_committed_manifest_roundtrip(tmp_path): + path, h = _make_manifest(tmp_path) + fields, committed_hash = load_committed_manifest(str(path)) + assert committed_hash == h + assert fields["metric"] == "accuracy" + assert fields["threshold"] == 0.85 + assert fields["model_version"] == "m@1" + + +# -- the Inspect hook (requires inspect_ai) ----------------------------------- + + +def _mock_log(model="m@1", dataset="imagenet-val", task="mytask", value=0.90): + return SimpleNamespace( + eval=SimpleNamespace( + model=model, + task=task, + dataset=SimpleNamespace(name=dataset, sha=None), + ), + results=SimpleNamespace( + scores=[ + SimpleNamespace( + name="accuracy", + metrics={"accuracy": SimpleNamespace(value=value)}, + ) + ] + ), + status="success", + ) + + +def test_observed_for_metric(): + pytest.importorskip("inspect_ai") + from falsify_inspect.hooks import _observed_for_metric + + assert _observed_for_metric(_mock_log(value=0.91), "accuracy") == 0.91 + assert _observed_for_metric(_mock_log(value=0.91), "missing") is None + + +def test_hook_pass_writes_receipt(tmp_path, monkeypatch): + pytest.importorskip("inspect_ai") + from falsify_inspect.hooks import FalsifyHooks + + path, _ = _make_manifest(tmp_path) + monkeypatch.setenv("FALSIFY_PRML", str(path)) + monkeypatch.chdir(tmp_path) + + hook = FalsifyHooks() + assert hook.enabled() is True + data = SimpleNamespace(log=_mock_log(value=0.90)) + asyncio.run(hook.on_task_end(data)) + + receipt = tmp_path / "mytask.prml-receipt.json" + assert receipt.exists() + verdict = json.loads(receipt.read_text()) + assert verdict["status"] == "PASS" + + +def test_hook_strict_raises_on_tamper(tmp_path, monkeypatch): + pytest.importorskip("inspect_ai") + from falsify_inspect.hooks import FalsifyHooks, FalsifyVerificationFailed + + path, _ = _make_manifest(tmp_path) + monkeypatch.setenv("FALSIFY_PRML", str(path)) + monkeypatch.setenv("FALSIFY_PRML_STRICT", "1") + monkeypatch.chdir(tmp_path) + + hook = FalsifyHooks() + data = SimpleNamespace(log=_mock_log(model="SWAPPED@9", value=0.99)) + with pytest.raises(FalsifyVerificationFailed): + asyncio.run(hook.on_task_end(data)) + + +def test_hook_disabled_without_env(monkeypatch): + pytest.importorskip("inspect_ai") + from falsify_inspect.hooks import FalsifyHooks + + monkeypatch.delenv("FALSIFY_PRML", raising=False) + assert FalsifyHooks().enabled() is False