From 55e7c53699a22fde8cac27c3f137c53fef38fa1f Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Mon, 31 Aug 2026 21:41:09 -0700 Subject: [PATCH] Add a manual Hugging Face publish path for photo-classify weights. (#89) workflow_dispatch uploads wiltodelta/raiw-models using secrets.HF_TOKEN. Weights stay out of git and come from a GitHub Release tag. Library releases do not push new heads. --- .../workflows/publish-photo-classify-hf.yml | 72 ++++++ docs/index.md | 3 +- docs/photo-classify-hf/README.md | 237 ++++++++++++++++++ docs/photo-classify-hf/operating-point.json | 36 +++ docs/release-and-distribution.md | 10 + scripts/README.md | 1 + scripts/publish_photo_classify_hf.py | 119 +++++++++ tests/test_publish_photo_classify_hf.py | 50 ++++ 8 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish-photo-classify-hf.yml create mode 100644 docs/photo-classify-hf/README.md create mode 100644 docs/photo-classify-hf/operating-point.json create mode 100644 scripts/publish_photo_classify_hf.py create mode 100644 tests/test_publish_photo_classify_hf.py diff --git a/.github/workflows/publish-photo-classify-hf.yml b/.github/workflows/publish-photo-classify-hf.yml new file mode 100644 index 0000000..89ec6f4 --- /dev/null +++ b/.github/workflows/publish-photo-classify-hf.yml @@ -0,0 +1,72 @@ +name: Publish photo classifier to Hugging Face + +# Manual only. Not part of a library release. The Space demo +# (wiltodelta/remove-ai-watermarks) stays on distribute.yml / raiw-hf-space. +# +# Hub model: wiltodelta/raiw-models +# Secret: HF_TOKEN, write-role Hugging Face token (same secret Space sync uses). +# +# Weights are not in git. Full mode downloads them from a GitHub Release +# of this repository, then uploads with the card from docs/photo-classify-hf/. + +on: + workflow_dispatch: + inputs: + confirm: + description: "Type publish" + required: true + mode: + description: "full = card plus weights from the release tag; card = README and operating-point only" + type: choice + options: + - full + - card + default: full + weights_tag: + description: "GitHub Release tag that holds clip-l-ft.pt, probe, detector.pt, provider.pt" + required: false + default: photo-classify-freeze-2026-08-31 + +jobs: + publish: + if: ${{ github.event.inputs.confirm == 'publish' }} + runs-on: ubuntu-latest + timeout-minutes: 180 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + + - uses: astral-sh/setup-uv@v7 + with: + python-version: "3.12" + + - name: Download freeze weights from the GitHub Release + if: ${{ github.event.inputs.mode == 'full' }} + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.inputs.weights_tag }} + run: | + set -euo pipefail + mkdir -p weights + gh release download "$TAG" --repo "${{ github.repository }}" --dir weights + ls -l weights + + - name: Publish to Hugging Face + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + MODE: ${{ github.event.inputs.mode }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + if [ -z "${HF_TOKEN:-}" ]; then + echo "HF_TOKEN secret is not set" >&2 + exit 1 + fi + extra=(--message "Photo-classify freeze (GitHub ${SHA:0:7})") + if [ "$MODE" = "card" ]; then + extra+=(--card-only) + else + extra+=(--src weights) + fi + uv run --no-project --with huggingface-hub python scripts/publish_photo_classify_hf.py "${extra[@]}" diff --git a/docs/index.md b/docs/index.md index 0eb9127..46df068 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,7 +22,7 @@ to run the tool. Use the maintainer references only when changing the code. | [Development](development.md) | Environment setup, dependency recovery, CI behavior, and fixture policy. | | [Code provenance](code-provenance.md) | Required notices for licensed derivative work. | | [Verification plan](verification-plan.md) | Verification methods, completed measurements, and remaining validation gaps. | -| [Release and distribution](release-and-distribution.md) | PyPI, Homebrew, Hugging Face Space, ComfyUI Registry, and the release workflow. | +| [Release and distribution](release-and-distribution.md) | PyPI, Homebrew, Hugging Face Space, photo-classify Hub model, ComfyUI Registry, and the release workflow. | | [Watermarking landscape](watermarking-landscape.md) | Vendor signals and detection approaches. | | [SynthID technical reference](synthid.md) | Mechanism, detector access, robustness, and implications for this project. | @@ -36,6 +36,7 @@ The current behavior is defined by the code, tests, README, and user guides. - [Qwen improvement research](qwen-improvement-research.md) - [Doubao reverse-alpha research](research-doubao-distillation.md) - [AI-generated image classifiers](ai-generated-image-classifiers.md) (photo AI-versus-camera freeze, not a product verdict) +- [Photo classifier Hugging Face card](photo-classify-hf/README.md) (Hub publication text of the 2026-08-31 freeze) - [SynthID source classifiers](synthid-classifiers.md) - [SynthID local detector research](synthid-detector-research.md) - [SynthID mark removal research](synthid-removal-research.md) diff --git a/docs/photo-classify-hf/README.md b/docs/photo-classify-hf/README.md new file mode 100644 index 0000000..3e7c7ee --- /dev/null +++ b/docs/photo-classify-hf/README.md @@ -0,0 +1,237 @@ +--- +language: + - en +license: apache-2.0 +library_name: transformers +pipeline_tag: image-classification +base_model: openai/clip-vit-large-patch14 +inference: false +tags: + - clip + - image-classification + - ai-generated-content + - photo + - provider-attribution +datasets: + - hugging-face/open-images + - detection-datasets/coco +--- + +# Photo AI-versus-camera classifier (freeze 2026-08-31) + +Two heads, one call. Model 1 decides whether a **photograph** looks generated +or camera-like. Model 2 names a provider only after Model 1 is DEFINITELY AI. + +This is not a SynthID decoder. It is not provenance. It is not a universal +"AI or not" detector for receipts, UI, or digital art. The Python package +[`remove-ai-watermarks`](https://github.com/wiltodelta/remove-ai-watermarks) +loads these files through an explicit `classify` extra. `identify` never +starts this model, including after a no-signal metadata scan. + +Library guide: +[photo pixel classification](https://github.com/wiltodelta/remove-ai-watermarks/blob/main/docs/photo-classify.md). +Research archive: +[AI-generated image classifiers](https://github.com/wiltodelta/remove-ai-watermarks/blob/main/docs/ai-generated-image-classifiers.md). +The demo Space +[`wiltodelta/remove-ai-watermarks`](https://huggingface.co/spaces/wiltodelta/remove-ai-watermarks) +is a watermark-removal UI, not this classifier. + +The training catalog is not in this Hub repository and is not on GitHub. + +## Intended use + +Use it when you have a photographic still and you want a pixel opinion after +metadata is gone: + +- `label=ai` plus `provider=openai|google|meta|tc260` on a stripped ChatGPT, + Gemini, Meta Muse, or TC260 photograph +- `label=human` on camera photographs at a low false-positive rate +- `label=unknown` when the detector is only POSSIBLY AI, or when 124-d + features cannot be extracted + +Do not use it to assert that a file is clean. Do not use it as a legal +authorship test. Do not run Model 2 on every image. + +## Approach + +Content embeddings and forensic residuals answer different questions. + +CLIP-L, after a light vision-block fine-tune, separates **generated +photographs** from **camera photographs**. It does not reliably separate +generated photographs from receipts, UI, charts, or illustration. A 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 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 +the wrong job. Firefly and PixelBin sit near Google on those residuals, which +is why a provider finder must keep foreign generators in the test and must +abstain. + +The two heads therefore stay specialized: + +```mermaid +flowchart TD + image[Photograph] --> m1[Model 1: CLIP-L-ft ridge AND MLP] + m1 -->|likely_human| human[label human, provider none] + m1 -->|possibly| unk[label unknown, provider none] + m1 -->|definitely| m2[Model 2: 124-d one-vs-rest focal] + m2 -->|openai google meta tc260| named[label ai plus provider] + m2 -->|no_ai or extract fail| aiOnly[label ai, provider none] +``` + +CLIP content space is the wrong place to train "receipt versus rest" or +"UI versus rest" if the product contract is photographic. Those domains +remain open. The shipped heads do not include a receipt specialist. + +Provider attribution tracks the **renderer**, not the front-end. Bing Image +Creator rows signed Microsoft, OpenAI score as `openai`. Designer rows signed +Microsoft, Google LLC score as `google`. Native Designer is mixed. There is +no Microsoft pixel class. Instagram `made_with_ai` is not a Meta pixel class. +Meta requires known-provenance Muse Image output. + +## Architecture + +### Model 1 + +- Backbone: `openai/clip-vit-large-patch14` +- Fine-tune: last two vision blocks, visual projection, post-layernorm +- Preprocess: 224 letterbox, pad RGB `(123, 117, 104)`, BICUBIC, L2-normalized + `get_image_features` (`pooler_output` when present) +- Ridge: linear probe on 768-d vectors, threshold `thr_oi_1pct` = 0.3056 + from the locked `photo_dev_oi` 1% cut +- MLP: `768-512-128-1` with dropout 0.3 / 0.1, threshold 5.9586, 1.67% + photo-dev quantile +- DEFINITELY = ridge AND MLP. POSSIBLY = either. The public `label` is `ai` + only on DEFINITELY. + +### Model 2 + +- Input: 124-d residual vector, or abstain if the image is smaller than + 256 px or the extractor refuses +- Heads: one-vs-rest focal MLP `124-64-1` per class + (`openai`, `google`, `tc260`, `meta_muse_image`, `no_ai`) +- Decision: a class wins only if it beats `no_ai` by margin 0.30, then argmax + among those that passed +- Public name `meta` is the Muse Image class. `no_ai` is not reported as + `human`; it becomes `provider=None` on an `ai` label (for example FLUX) + +## Training + +CPU. Two consecutive freeze runs from the same catalog and caches were +byte-identical. + +| Item | Value | +| --- | --- | +| Catalog identity | sha256 of file bytes, 32,690 listed files | +| CLIP-L embeddings keyed | 26,573 | +| Detector seed | 20260940 | +| Provider seed | 20260956 | +| Sample seed for provider OpenAI/Google draws | 20260821 | +| MLP epochs / width | 40 / 512 | +| Provider focal gamma / epochs | 2.0 / 40 | +| Private UI in train | 0. Replaced with 150 CC Flickr screenshots via Openverse, host `live.staticflickr.com` only | + +Flickr licenses in that replacement cell: CC BY 2.0 42, CC BY-NC 2.0 42, +CC BY-SA 2.0 29, CC BY-NC-SA 2.0 28, CC BY-NC-ND 2.0 6, CC BY-ND 2.0 2, +CC0 1. + +The pixel catalog is **not published on the Hub**. Head retrain uses +sha256-keyed CLIP-L and 124-d caches plus `catalog.json` roles. The original +files remain stored privately so embeddings and 124-d features can be rebuilt. +Public named sources in those roles include COCO, Open Images, Kodak, Picsum, +Hugging Face FLUX, Openverse CC Flickr, and Meta Muse API hold-out v3. +In-house labeled OpenAI, Gemini, and TC260 photographs stay private. See +[photo-classify-training.md](https://github.com/wiltodelta/remove-ai-watermarks/blob/main/docs/photo-classify-training.md). + +## Evaluation (run 1 = run 2) + +DEFINITELY is the shipped operating point. + +| Head | Cell | Result | +| --- | --- | ---: | +| Detector DEFINITELY | AI-test | 92.4% (n=1,847) | +| Detector DEFINITELY | Open Images fresh | 1.3% (n=3,000) | +| Detector DEFINITELY | Kodak | 0/24 | +| Detector DEFINITELY | FLUX hold | 83.0% (n=300) | +| Provider | OpenAI test | 90.8% (345/380 of 381) | +| Provider | Google test | 90.9% (339/373 of 377) | +| Provider | TC260 test | 78.6% (298/379 of 384) | +| Provider | Meta hold-out v2 | 85.7% (66/77 listed 79) | +| Provider | Meta hold-out v3 | 89.4% (177/198) | +| Provider | Meta hold-out pooled | 88.4% (243/275 listed 277) | +| Provider | meme templates, ungated | 29.1% leak (86 of 97) | + +The ungated meme leak is why Model 2 must not run on every file. Gated on +DEFINITELY, that leak is not a provider attribution. + +Ridge-only Model 1 at the earlier 1% Open Images cut (before the AND with +the freeze MLP) is documented in the research page: Kodak 0/24, fresh 1.7%, +AI-test 93.0%, FLUX 92.7%, Firefly 94.0%. Nobody in that sweep hit both +≤1% fresh FPR and ≥90% TPR. The freeze AND is stricter on photos (1.3% +DEF) at a small recall cost. + +## Limitations + +- **Photographs only.** Receipts, UI, scans of forms, maps, and community + digital art sit in the AI region of CLIP-L. A real thermal receipt can + score as AI. A photorealistic AI receipt can score as human. +- **Not SynthID.** A `provider=openai` result means the pixels look like an + OpenAI-rendered photograph, not that a payload was decoded. +- **Not provenance.** C2PA, IPTC, and visible marks stay on `identify`. +- **124-d can refuse.** Some sizes and some images yield no vector. The + detector can still return DEFINITELY; provider is then `None`. +- **Microsoft has no head.** Route by renderer, or use C2PA for the + front-end name. +- **Open-world generators.** FLUX is a hold-out at 83% DEF, not 100%. + Unknown generators may land on `ai` with `provider=None`, or miss + DEFINITELY. + +## How to use + +Install the extra. First call downloads this repository into the Hugging +Face cache, or reads `RAIW_CLASSIFY_WEIGHTS` if you already have the files. + +```bash +uv tool install --force "remove-ai-watermarks[classify]" +remove-ai-watermarks classify image.png +``` + +```python +from pathlib import Path +from remove_ai_watermarks.classify import classify_pixels + +result = classify_pixels(Path("image.png")) +# result.label, result.detector, result.provider +``` + +`device` is a library parameter (`None` / `"auto"` / `"cpu"` / `"cuda"`), +not a CLI option. Pin a Hub revision in production so a later upload cannot +silently change the heads. + +## Files in this repository + +| File | Role | +| --- | --- | +| `clip-l-ft.pt` | HeadedCLIP state dict (CLIP-L plus unused linear head) | +| `probe-weights-clip-l-ft.npz` | Ridge mean, scale, weights, `thr_oi_1pct` | +| `detector.pt` | Freeze MLP | +| `provider.pt` | Focal heads keyed `openai`, `google`, `tc260`, `meta_muse_image`, `no_ai` | +| `operating-point.json` | Seeds, thresholds, margin, freeze date | + +The image catalog is not in this repository. + +## License + +Apache-2.0 for the trained heads and this card. CLIP-L inherits its upstream +license from [`openai/clip-vit-large-patch14`](https://huggingface.co/openai/clip-vit-large-patch14). +Flickr replacement images are CC-licensed per file; they are not redistributed +here. + +## Citation + +Freeze date 2026-08-31, package [`remove-ai-watermarks`](https://github.com/wiltodelta/remove-ai-watermarks). +Protocol and rejected variants: [docs/ai-generated-image-classifiers.md](https://github.com/wiltodelta/remove-ai-watermarks/blob/main/docs/ai-generated-image-classifiers.md). +Provider renderer study: [docs/synthid-classifiers.md](https://github.com/wiltodelta/remove-ai-watermarks/blob/main/docs/synthid-classifiers.md). diff --git a/docs/photo-classify-hf/operating-point.json b/docs/photo-classify-hf/operating-point.json new file mode 100644 index 0000000..c350586 --- /dev/null +++ b/docs/photo-classify-hf/operating-point.json @@ -0,0 +1,36 @@ +{ + "freeze": "2026-08-31", + "task": "photo_ai_versus_camera", + "not": ["synthid_decoder", "provenance", "universal_ai_detector"], + "seeds": { + "sample": 20260821, + "detector": 20260940, + "provider": 20260956 + }, + "model1": { + "backbone": "openai/clip-vit-large-patch14", + "letterbox": 224, + "pad_rgb": [123, 117, 104], + "ridge_threshold_key": "thr_oi_1pct", + "ridge_threshold": 0.3056212276800537, + "mlp_threshold": 5.9586493237495395, + "mlp_width": 512, + "definitely": "ridge_and_mlp", + "public_ai_label": "definitely_only" + }, + "model2": { + "feature_width": 124, + "margin": 0.3, + "classes": ["openai", "google", "tc260", "meta_muse_image", "no_ai"], + "public_names": { + "meta_muse_image": "meta" + }, + "runs_only_after": "definitely" + }, + "files": [ + "clip-l-ft.pt", + "probe-weights-clip-l-ft.npz", + "detector.pt", + "provider.pt" + ] +} diff --git a/docs/release-and-distribution.md b/docs/release-and-distribution.md index d2af94b..e96e033 100644 --- a/docs/release-and-distribution.md +++ b/docs/release-and-distribution.md @@ -73,6 +73,16 @@ Upgrading the Hugging Face Space is a separate, manual step: bump pin. Its callback smoke tests run in that repository's CI on every push; they are the scenario check for the demo surface. +The Hugging Face **model** `wiltodelta/raiw-models` is not part of that +release fan-out. It holds the photo-classify freeze weights. Update it only +with `.github/workflows/publish-photo-classify-hf.yml` (`workflow_dispatch`, +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 workflow can also be started manually with an optional version input. If a distribution job fails because a repository or Hugging Face credential is diff --git a/scripts/README.md b/scripts/README.md index 58f2a8b..26764d0 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -43,6 +43,7 @@ explicitly names a tracked canonical result. | `visible_positives.py` | List corpus images carrying a registered visible mark. | | `visible_recall_sample.py` | Build an unbiased blinded sample for recall measurement. | | `visible_sheets.py` | Build blinded contact sheets for relaxation candidates. | +| `publish_photo_classify_hf.py` | Upload the photo-classify card and freeze weights to Hugging Face `wiltodelta/raiw-models`. Manual; the Action `publish-photo-classify-hf.yml` is the write-token path. | ## Research and diagnostic prototypes diff --git a/scripts/publish_photo_classify_hf.py b/scripts/publish_photo_classify_hf.py new file mode 100644 index 0000000..ad9db51 --- /dev/null +++ b/scripts/publish_photo_classify_hf.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Publish the photo-classify freeze to Hugging Face wiltodelta/raiw-models. + +Manual. Not a library release step. Weights are not in git: pass --src, a +directory that holds clip-l-ft.pt and the probe, with detector.pt and +provider.pt either at the root or in run1/. + + uv run python scripts/publish_photo_classify_hf.py --src DIR + uv run python scripts/publish_photo_classify_hf.py --card-only + +Needs HF_TOKEN with write role. The GitHub Action +publish-photo-classify-hf.yml injects secrets.HF_TOKEN. +""" + +from __future__ import annotations + +import argparse +import logging +import os +import shutil +import tempfile +from pathlib import Path + +log = logging.getLogger(__name__) + +HUB_REPO = "wiltodelta/raiw-models" +CLIP_FILE = "clip-l-ft.pt" +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) +REPO_ROOT = Path(__file__).resolve().parents[1] +CARD_DIR = REPO_ROOT / "docs" / "photo-classify-hf" + + +def weight_paths(src: Path) -> dict[str, Path]: + """Map Hub filenames to files under ``src`` or ``src/run1``.""" + src = src.expanduser().resolve() + if not src.is_dir(): + raise SystemExit(f"missing weights directory: {src}") + found: dict[str, Path] = {} + for name in WEIGHT_FILES: + direct = src / name + nested = src / "run1" / name + if direct.is_file(): + found[name] = direct + elif nested.is_file(): + found[name] = nested + else: + raise SystemExit(f"missing {name} in {src} or {src / 'run1'}") + return found + + +def stage_card(dest: Path) -> None: + readme = CARD_DIR / "README.md" + 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}") + dest.mkdir(parents=True, exist_ok=True) + shutil.copy2(readme, dest / "README.md") + shutil.copy2(operating, dest / "operating-point.json") + + +def _place(src: Path, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + try: + os.link(src, dest) + except OSError: + shutil.copy2(src, dest) + + +def stage_weights(dest: Path, src: Path) -> None: + for name, path in weight_paths(src).items(): + _place(path, dest / name) + log.info("stage %s (%s bytes)", name, path.stat().st_size) + + +def publish(stage: Path, *, token: str, message: str) -> str: + from huggingface_hub import HfApi, create_repo + + create_repo(HUB_REPO, repo_type="model", exist_ok=True, private=False, token=token) + api = HfApi(token=token) + commit = api.upload_folder( + folder_path=str(stage), + repo_id=HUB_REPO, + repo_type="model", + commit_message=message, + allow_patterns=["README.md", "operating-point.json", *WEIGHT_FILES], + ) + return getattr(commit, "oid", "") or "" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--src", type=Path, help="Freeze or weights directory") + parser.add_argument("--card-only", action="store_true") + parser.add_argument("--message", default="") + args = parser.parse_args() + logging.basicConfig(level=logging.INFO, format="%(message)s") + token = os.environ.get("HF_TOKEN", "").strip() + if not token: + raise SystemExit("HF_TOKEN is not set (write role required)") + if args.card_only == bool(args.src): + raise SystemExit("pass exactly one of --src or --card-only") + message = args.message.strip() or ( + "Update photo-classify card" if args.card_only else "Publish photo-classify freeze" + ) + with tempfile.TemporaryDirectory(prefix="raiw-hf-") as tmp: + stage = Path(tmp) + stage_card(stage) + if args.src is not None: + stage_weights(stage, args.src) + oid = publish(stage, token=token, message=message) + log.info("published %s revision=%s", HUB_REPO, oid or "(unknown)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_publish_photo_classify_hf.py b/tests/test_publish_photo_classify_hf.py new file mode 100644 index 0000000..6f51ee6 --- /dev/null +++ b/tests/test_publish_photo_classify_hf.py @@ -0,0 +1,50 @@ +"""Path resolution for Hub photo-classify publish, no network.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from typing import Any + +import pytest + + +def _publish() -> Any: + path = Path(__file__).resolve().parents[1] / "scripts" / "publish_photo_classify_hf.py" + spec = importlib.util.spec_from_file_location("publish_photo_classify_hf", 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_hub_id_is_raiw_models() -> None: + assert _publish().HUB_REPO == "wiltodelta/raiw-models" + + +def test_weight_paths_accepts_run1_layout(tmp_path: Path) -> None: + (tmp_path / "clip-l-ft.pt").write_bytes(b"clip") + (tmp_path / "probe-weights-clip-l-ft.npz").write_bytes(b"probe") + run1 = tmp_path / "run1" + run1.mkdir() + (run1 / "detector.pt").write_bytes(b"det") + (run1 / "provider.pt").write_bytes(b"prov") + found = _publish().weight_paths(tmp_path) + assert found["detector.pt"] == run1 / "detector.pt" + assert found["clip-l-ft.pt"] == tmp_path / "clip-l-ft.pt" + + +def test_weight_paths_requires_every_file(tmp_path: Path) -> None: + (tmp_path / "clip-l-ft.pt").write_bytes(b"clip") + with pytest.raises(SystemExit, match=r"missing probe-weights-clip-l-ft\.npz"): + _publish().weight_paths(tmp_path) + + +def test_stage_card_copies_tracked_files(tmp_path: Path) -> None: + dest = tmp_path / "stage" + _publish().stage_card(dest) + assert (dest / "README.md").is_file() + assert (dest / "operating-point.json").is_file() + text = (dest / "operating-point.json").read_text() + assert "clip-l-ft.pt" in text