From 5b52782f01536bf90752fd42effc1fb82ad73522 Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Sat, 5 Sep 2026 11:50:09 -0700 Subject: [PATCH] Move the receipt gate to the model side The gate head is fitted on the freeze CLIP-L-ft embedding space, so it belongs with the model: _load_receipt_gate now prefers receipt-gate-2026-09-02.npz from the Hub snapshot or the RAIW_CLASSIFY_WEIGHTS directory, with the package asset kept as the fallback for weights frozen before the gate existed. Card mode of the publish workflow now uploads the gate alongside the README and the operating point, and the snapshot allow-pattern includes it. --- .../workflows/publish-photo-classify-hf.yml | 2 +- docs/module-internals.md | 9 +++- docs/photo-classify-hf/README.md | 5 ++ docs/photo-classify.md | 6 ++- docs/release-and-distribution.md | 8 +-- scripts/publish_photo_classify_hf.py | 12 ++++- src/remove_ai_watermarks/classify.py | 50 ++++++++++++------- tests/test_classify.py | 39 +++++++++++++++ 8 files changed, 105 insertions(+), 26 deletions(-) diff --git a/.github/workflows/publish-photo-classify-hf.yml b/.github/workflows/publish-photo-classify-hf.yml index b1440a3..5a71729 100644 --- a/.github/workflows/publish-photo-classify-hf.yml +++ b/.github/workflows/publish-photo-classify-hf.yml @@ -16,7 +16,7 @@ on: description: "Type publish" required: true mode: - description: "full = card plus weights from the release tag; card = README and operating-point only" + description: "full = card plus weights from the release tag; card = README, operating-point, and receipt gate only" type: choice options: - full diff --git a/docs/module-internals.md b/docs/module-internals.md index f33bffe..5774750 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -1008,8 +1008,13 @@ photo freeze: CLIP-L-ft ridge AND freeze MLP, then 124-d focal heads only on DEFINITELY. Since 2026-09-02 a linear receipt-document gate runs first on that DEFINITELY path, on the same CLIP vector: a hit publishes `unknown` (detector stays `definitely`, provider is not read, 124-d extraction is -skipped). The head ships as `assets/receipt-gate-2026-09-02.npz`; its -threshold is pinned in `classify.py` and in the operating-point sidecar. +skipped). The head ships with the model: `receipt-gate-2026-09-02.npz` in +the Hub snapshot or `RAIW_CLASSIFY_WEIGHTS` directory, with the package +asset `src/remove_ai_watermarks/assets/receipt-gate-2026-09-02.npz` as +the fallback for weights directories frozen before the gate existed (the +head is fitted on the freeze CLIP-L-ft embedding space, so it versions +with the model, not with the code); its threshold is pinned in +`classify.py` and in the operating-point sidecar. Training data and certification live in `receipt-gate-shipped-2026-09-02/report.json` in the research tree; positives are CORD-v2 train (800, CC BY 4.0, disjoint from the CORD test split the diff --git a/docs/photo-classify-hf/README.md b/docs/photo-classify-hf/README.md index 9299af4..41ffd55 100644 --- a/docs/photo-classify-hf/README.md +++ b/docs/photo-classify-hf/README.md @@ -66,6 +66,11 @@ ridge trained on that embedding at a 1% Open Images false-positive cut is the strongest Model 1 we measured. A small MLP on the same vectors, ANDed with the ridge, is the freeze DEFINITELY gate. +The receipt gate (`receipt-gate-2026-09-02.npz`) ships in this repository +alongside the freeze files, so it versions with the embedding space it was +fitted on; the Python package carries a fallback copy for weights +directories frozen before the gate existed. + The 124-d bank is a different feature: patch-local residual ratios (FFT band energy, comb contrast, autocovariance) on 256 px tiles. On that bank, OpenAI versus Gemini is easy (AUC 0.989 in the research sweep) while "AI or not" is diff --git a/docs/photo-classify.md b/docs/photo-classify.md index 8e841b3..1af04db 100644 --- a/docs/photo-classify.md +++ b/docs/photo-classify.md @@ -135,8 +135,10 @@ publishes `unknown` and skips the 124-d provider pass. | ai_test DEFINITELY cost | 7/1,847 (0.38%) | Artifacts: `receipt-gate-shipped-2026-09-02/report.json` (private research -tree). The head ships in the package assets; the operating point records it -in [photo-classify-hf/operating-point.json](photo-classify-hf/operating-point.json). +tree). The head ships with the model (`receipt-gate-2026-09-02.npz` in the +Hub snapshot, package asset as the fallback for pre-gate freezes); the +operating point records it in +[photo-classify-hf/operating-point.json](photo-classify-hf/operating-point.json). ## Limits diff --git a/docs/release-and-distribution.md b/docs/release-and-distribution.md index f982a6e..16ac609 100644 --- a/docs/release-and-distribution.md +++ b/docs/release-and-distribution.md @@ -81,9 +81,11 @@ release fan-out. It holds the photo-classify freeze weights. The library extra type `publish`). Full mode downloads the four weight files from the GitHub Release tag `photo-classify-freeze-2026-08-31` (or the tag you pass) and uploads them with the card in `docs/photo-classify-hf/`. Card mode updates -the README and `operating-point.json` only. A library version bump does not -upload new heads. Pin a Hub revision in production so a later freeze cannot -silently change installed clients. +the README, `operating-point.json`, and the receipt-gate head staged from +the package assets; freeze weights are untouched. A library version bump +alone does not upload new heads -- bump the pinned Hub revision when a new +head must reach installed clients. Pin a Hub revision in production so a +later freeze cannot silently change installed clients. The workflow can also be started manually with an optional version input. diff --git a/scripts/publish_photo_classify_hf.py b/scripts/publish_photo_classify_hf.py index e1e8ae7..e342c57 100644 --- a/scripts/publish_photo_classify_hf.py +++ b/scripts/publish_photo_classify_hf.py @@ -10,6 +10,9 @@ provider.pt either at the root or in run1/. Needs HF_TOKEN with write role. The GitHub Action publish-photo-classify-hf.yml injects secrets.HF_TOKEN. + +Card mode uploads the README, operating-point.json, and the receipt-gate +head (staged from the package assets) without touching the freeze weights. """ from __future__ import annotations @@ -29,7 +32,9 @@ PROBE_FILE = "probe-weights-clip-l-ft.npz" DETECTOR_FILE = "detector.pt" PROVIDER_FILE = "provider.pt" WEIGHT_FILES = (CLIP_FILE, PROBE_FILE, DETECTOR_FILE, PROVIDER_FILE) +GATE_FILE = "receipt-gate-2026-09-02.npz" REPO_ROOT = Path(__file__).resolve().parents[1] +GATE_SOURCE = REPO_ROOT / "src" / "remove_ai_watermarks" / "assets" / GATE_FILE CARD_DIR = REPO_ROOT / "docs" / "photo-classify-hf" @@ -56,9 +61,14 @@ def stage_card(dest: Path) -> None: operating = CARD_DIR / "operating-point.json" if not readme.is_file() or not operating.is_file(): raise SystemExit(f"Hub card missing under {CARD_DIR}") + if not GATE_SOURCE.is_file(): + raise SystemExit(f"receipt-gate asset missing: {GATE_SOURCE}") dest.mkdir(parents=True, exist_ok=True) shutil.copy2(readme, dest / "README.md") shutil.copy2(operating, dest / "operating-point.json") + # The gate head versions with the model: card mode carries it even when + # the four freeze weight files are not being re-uploaded. + shutil.copy2(GATE_SOURCE, dest / GATE_FILE) def _place(src: Path, dest: Path) -> None: @@ -85,7 +95,7 @@ def publish(stage: Path, *, token: str, message: str) -> str: repo_id=HUB_REPO, repo_type="model", commit_message=message, - allow_patterns=["README.md", "operating-point.json", *WEIGHT_FILES], + allow_patterns=["README.md", "operating-point.json", *WEIGHT_FILES, GATE_FILE], ) return getattr(commit, "oid", "") or "" diff --git a/src/remove_ai_watermarks/classify.py b/src/remove_ai_watermarks/classify.py index afbe490..a041fa4 100644 --- a/src/remove_ai_watermarks/classify.py +++ b/src/remove_ai_watermarks/classify.py @@ -34,6 +34,8 @@ _WEIGHT_FILES = (CLIP_FILE, PROBE_FILE, DETECTOR_FILE, PROVIDER_FILE) # capture-style receipts against 2,400 ai_train negatives. Threshold is the # min of the 1st-percentile scores on CORD validation and a synthetic # holdout; no field-receipt pixels were used for training or the threshold. +# The head versions with the model (Hub snapshot or RAIW_CLASSIFY_WEIGHTS +# directory); the package asset is the fallback for pre-gate freezes. RECEIPT_GATE_FILE = "receipt-gate-2026-09-02.npz" RECEIPT_GATE_THRESHOLD = 2.0432573877459697 @@ -61,24 +63,36 @@ PUBLIC_PROVIDER: dict[str, PixelProvider] = { _runtime: _Runtime | None = None _runtime_lock = threading.Lock() -_receipt_gate: dict[str, Any] | None = None +_receipt_gate_cache: dict[str, dict[str, Any]] = {} -def _load_receipt_gate() -> dict[str, Any]: - """Load the bundled receipt-gate head once (numpy-only, no download).""" - global _receipt_gate - if _receipt_gate is None: - import numpy as np +def _load_receipt_gate(folder: Path | None = None) -> dict[str, Any]: + """Load the receipt-gate head, preferring the weights directory. - payload = np.load(Path(__file__).parent / "assets" / RECEIPT_GATE_FILE) - _receipt_gate = { - "w": np.asarray(payload["w"], dtype=np.float64), - "b": float(payload["b"]), - "mu": np.asarray(payload["mu"], dtype=np.float64), - "sd": np.asarray(payload["sd"], dtype=np.float64), - "threshold": float(payload["threshold"]), - } - return _receipt_gate + Model-side first (the Hub snapshot and ``RAIW_CLASSIFY_WEIGHTS`` carry + ``receipt-gate-2026-09-02.npz``), with the bundled package asset as the + fallback for weights directories frozen before the gate existed. The + head is fitted on the freeze CLIP-L-ft embedding space, which is why it + versions with the model, not with the code. + """ + import numpy as np + + key = str(folder) if folder is not None else "" + if key in _receipt_gate_cache: + return _receipt_gate_cache[key] + source: Path | None = _find_weight(folder, RECEIPT_GATE_FILE) if folder is not None else None + if source is None: + source = Path(__file__).parent / "assets" / RECEIPT_GATE_FILE + payload = np.load(source) + gate = { + "w": np.asarray(payload["w"], dtype=np.float64), + "b": float(payload["b"]), + "mu": np.asarray(payload["mu"], dtype=np.float64), + "sd": np.asarray(payload["sd"], dtype=np.float64), + "threshold": float(payload["threshold"]), + } + _receipt_gate_cache[key] = gate + return gate def receipt_gate_score(clip_vector: Any) -> float: @@ -207,7 +221,7 @@ def classify_pixels(path: Path, *, device: str | None = None) -> PixelClassifica ridge_threshold=runtime.ridge_threshold, mlp_threshold=runtime.mlp_threshold, ) - gate = _load_receipt_gate() + gate = _load_receipt_gate(runtime.weights_dir) r_score = receipt_gate_score(clip_vector) if level == "definitely" else None provider_scores = None if r_score is not None and r_score < gate["threshold"]: @@ -244,6 +258,7 @@ class _Runtime: ridge_threshold: float mlp_threshold: float provider_margin: float + weights_dir: Path | None def _resolve_device(device: str | None) -> Any: @@ -274,7 +289,7 @@ def _weights_dir() -> Path: snapshot_download( repo_id=WEIGHTS_REPO, revision=WEIGHTS_REVISION, - allow_patterns=[*_WEIGHT_FILES, OPERATING_POINT_FILE], + allow_patterns=[*_WEIGHT_FILES, OPERATING_POINT_FILE, RECEIPT_GATE_FILE], ) ) except Exception as exc: @@ -379,6 +394,7 @@ def _load_runtime(device: Any) -> _Runtime: ridge_threshold=ridge_threshold, mlp_threshold=mlp_threshold, provider_margin=provider_margin, + weights_dir=folder, ) diff --git a/tests/test_classify.py b/tests/test_classify.py index f48ff2c..ae95aed 100644 --- a/tests/test_classify.py +++ b/tests/test_classify.py @@ -168,6 +168,43 @@ def test_receipt_gate_score_is_deterministic_and_bounded() -> None: assert -50.0 < first < 50.0 +def test_receipt_gate_prefers_the_weights_directory(tmp_path: Path) -> None: + from remove_ai_watermarks.classify import _load_receipt_gate, _receipt_gate_cache + + source = np.load(Path("src/remove_ai_watermarks/assets") / RECEIPT_GATE_FILE) + weights = tmp_path / "weights" + weights.mkdir() + np.savez( + weights / RECEIPT_GATE_FILE, + w=source["w"], + b=source["b"], + mu=source["mu"], + sd=source["sd"], + threshold=99.0, + ) + _receipt_gate_cache.clear() + try: + gate = _load_receipt_gate(weights) + assert gate["threshold"] == 99.0 + mid = (RECEIPT_GATE_THRESHOLD + 99.0) / 2 + result = classify_from_scores(1.0, 10.0, None, receipt_score=mid, receipt_threshold=gate["threshold"]) + assert result.label == "ai" + assert classify_from_scores(1.0, 10.0, None, receipt_score=mid).label == "unknown" + finally: + _receipt_gate_cache.clear() + + +def test_receipt_gate_falls_back_to_the_package_asset() -> None: + from remove_ai_watermarks.classify import _load_receipt_gate, _receipt_gate_cache + + _receipt_gate_cache.clear() + try: + assert _load_receipt_gate(None)["threshold"] == RECEIPT_GATE_THRESHOLD + assert _load_receipt_gate(Path("/nonexistent-weights"))["threshold"] == RECEIPT_GATE_THRESHOLD + finally: + _receipt_gate_cache.clear() + + def _patch_definitely_runtime(monkeypatch: pytest.MonkeyPatch, *, forensic_calls: list[int]) -> None: """Patch classify internals so a plain PNG scores DEFINITELY on Model 1.""" runtime = SimpleNamespace( @@ -177,6 +214,7 @@ def _patch_definitely_runtime(monkeypatch: pytest.MonkeyPatch, *, forensic_calls ridge_threshold=RIDGE_THRESHOLD, mlp_threshold=MLP_THRESHOLD, provider_margin=PROVIDER_MARGIN, + weights_dir=None, ) monkeypatch.setattr("remove_ai_watermarks.classify.is_available", lambda: True) monkeypatch.setattr("remove_ai_watermarks.classify._get_runtime", lambda device: runtime) @@ -246,6 +284,7 @@ def test_124d_is_not_extracted_unless_definitely(monkeypatch: pytest.MonkeyPatch ridge_threshold=RIDGE_THRESHOLD, mlp_threshold=MLP_THRESHOLD, provider_margin=PROVIDER_MARGIN, + weights_dir=None, ) monkeypatch.setattr("remove_ai_watermarks.classify.is_available", lambda: True) monkeypatch.setattr("remove_ai_watermarks.classify._get_runtime", lambda device: runtime)