From e41bdbc9c886975c4b9e4cfeb745644f77a871eb Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Sat, 5 Sep 2026 10:53:31 -0700 Subject: [PATCH] Add receipt-document gate to classify A DEFINITELY verdict now runs a linear receipt gate on the CLIP-L-ft vector Model 1 already computes: a hit publishes unknown and skips the 124-d provider pass. Trained on CORD-v2 train (800, CC BY 4.0) plus 200 synthetic capture-style receipts against 2,400 ai_train negatives; the threshold is certified on CORD validation and a synthetic holdout with no field-receipt pixels. Field receipts false-AI 19/59 -> 2/59, CORD 89/99 -> 0/99, ai_test DEFINITELY cost 7/1,847. --- docs/ai-generated-image-classifiers.md | 94 +++++++++++++++++ docs/cli.md | 14 +-- docs/known-limitations.md | 12 ++- docs/module-internals.md | 22 ++-- docs/photo-classify-hf/README.md | 37 ++++--- docs/photo-classify-hf/operating-point.json | 11 ++ docs/photo-classify.md | 42 +++++++- .../assets/receipt-gate-2026-09-02.npz | Bin 0 -> 13516 bytes src/remove_ai_watermarks/classify.py | 64 +++++++++++- tests/test_classify.py | 96 ++++++++++++++++++ 10 files changed, 355 insertions(+), 37 deletions(-) create mode 100644 src/remove_ai_watermarks/assets/receipt-gate-2026-09-02.npz diff --git a/docs/ai-generated-image-classifiers.md b/docs/ai-generated-image-classifiers.md index 7fedad6..76f5e20 100644 --- a/docs/ai-generated-image-classifiers.md +++ b/docs/ai-generated-image-classifiers.md @@ -831,6 +831,100 @@ Do not add receipts to CLIP-L train. Next: a layout-first router into an OCR specialist, with thermal CORD still uncovered at a photo-safe cut. Artifacts: `receipt-layout-gate-2026-08-31/report.json`. +In-the-wild confirmation, 2026-09-02: 59 user-donated phone captures of real +receipts (2026-03 to 2026-05, 4032x3024 main-camera JPEG with device EXIF +stripped by the scanning-app export; private eval-only corpus, not for +redistribution) put frozen Model 1 DEFINITELY on **19/59 (32.2%)** and +POSSIBLY on 6/59 (10.2%), with human on 34/59 (57.6%); of the 19 false +verdicts 11 carry a provider, every one `tc260`, the same stray-provider +artifact measured across structured documents. Full-resolution modern phone +captures fail at a third where CORD fails at nine tenths, so the +capture-quality split holds outside curated datasets, and a photographed +receipt stays outside the supported contract. The corpus remains an eval +cell, not CLIP-L negatives, per the 2026-08-27 decision above. Artifacts: +`receipts-phone-eval-2026-09-02/report.json`. + +The 2026-09-02 router pilot then measured every pre-CLIP routing signal +against that field cell (59 phone captures, CORD, FUNSD, a 300-photo +control, 1,847 local ai_test rows). The rebuilt line-count probe +(calibrated to the 2026-08-31 anchors: CORD median 6, photos median 1, +CORD >= 11 lines 16.2%, photos 0.7%) puts field receipts at **median 4 +lines**, on top of the photo distribution: at the photo-safe cut `n_lines +>= 11` it gates **5/59 phone receipts and 2/19 actual false DEFINITELY +verdicts**, and every lower cut trades tens of percent of photo/AI loss +for coverage. An eight-feature morphology battery (line count, ink density, +vertical span, column spread, background statistics) caps at **CV AUC +0.706** for phone receipts versus photos+AI, 41.9% false-gating at 80% +recall: handcrafted layout is structurally dead on field captures. OCR +text density is not: PaddleOCR det+rec (conf >= 0.8) reads a median **926 +characters on a phone receipt versus 0 on the median photo or AI image**, +and a gate at **chars >= 500 covers 57/59 field receipts (96.6%), 19/19 +of the observed false-DEFINITELY failures, at 1/150 photos (0.7%) and +3/100 ai_test (3.0%) routed away**; the two uncovered receipts already +pass as human. FUNSD forms gate at 80.7%. The low-resolution capture cell +stays uncovered by every measured signal: CORD's small photos read a +median 107 characters (0% gated), and the scale-invariant alternative, +text-box area fraction, is rejected because text-heavy AI posters put it +at 19-40% AI false-gating at phone-safe cuts. Artifacts: +`receipt-router-pilot-2026-09-02/report.json`. + +The same day closed the no-OCR paths. A synthetic-receipt generator +(thermal render plus perspective, crumple, sensor, and JPEG degradation) +failed to transfer in both feature spaces: the 124-d residual gate trained +on it covers **0/19** observed false verdicts at 19.8% photo false-gating +(residual sensor statistics are not reproducible by rendering), and in +CLIP space only 24.7% of renders land on the phone-receipt side +(synthetic-only head recall 1.7%). Real receipts, however, separate +perfectly in CLIP 768-d: a linear head on the embedding Model 1 already +computes puts **min(59 phone receipts) above max(1,847 ai_test rows)** +(14.03 versus 13.87), covering 59/59 receipts and 19/19 observed false +verdicts at 0/1,847 AI cost in-sample; leave-one-out holds ~95% at a +conservative threshold. The gate would run only on DEFINITELY, where the +comparison set is AI images alone, and a hit downgrades `ai` to `unknown`: +no OCR, no new dependency, negligible runtime cost. The blocker is data +policy, not modeling: the only real phone-receipt pixels are the 59-file +eval corpus, and training on it would be training on the test set (the +meme-leak lesson). Shipping honestly needs a disjoint train source: a +second user donation as a train corpus (cleanest), or a CORD train split +re-download for the low-res cell. Artifacts: +`receipt-124d-gate-2026-09-02/report.json`. + +The shipped gate resolved that fork with public data. CORD-v2 was +re-fetched from Hugging Face (train 800, validation 100, test 100; CC BY +4.0); the project eval cell was verified to be exactly the test split +(cos 1.0000 on all 99), so the train split is disjoint by construction. A +linear CLIP head trained on CORD train alone covers 100% of CORD but only +73.7% of the field false verdicts; adding 200 synthetic capture-style +receipts as positives bridges the style gap. Final configuration: CORD +train + 200 synthetic + 2,400 ai_train negatives, threshold certified as +the min of the 1st-percentile scores on CORD validation and a 100-image +synthetic holdout (2.0433). Frozen eval: **57/59 field receipts gated, +false `ai` 19/59 -> 2/59, CORD 89/99 -> 0/99, ai_test DEFINITELY cost +7/1,847 (0.38%)**. No field-receipt pixels were used for training or the +threshold; the two misses sit below any certified threshold and stay open. +Shipped 2026-09-02 in `classify.py` as a DEFINITELY-path gate on the same +CLIP vector (asset `receipt-gate-2026-09-02.npz`, operating point updated); +train corpus `cord-train-2026-09-02/`, artifacts +`receipt-gate-shipped-2026-09-02/report.json`. + +Open tails from the same day, all measured. (1) The two field misses sit +at gate scores -0.15 and -0.09 against the certified 2.043 threshold; +covering them would need a threshold at -0.153, raising the ai_test +false-gate cost from 0.38% to 0.65%. The threshold stands: they are the +retrain tail, not a re-cut. (2) The `fw5nxt` train row that reads `human` +at inference is not a pipeline bug: cached-embedding audit of all 4,089 +detector_ai_train rows puts DEFINITELY recall on train at **96.4%** +(61 rows likely_human), and that row sits at the 2nd ridge and 1.3rd MLP +percentiles of train - the AND gate's documented recall contract applies +to train rows too. (3) Renderer drift is now a corpus: +`aigen-drift-2026-09-02/` holds the 34 user generations verified absent +from the freeze catalog. ChatGPT May-June: 5/6 named `openai`; ChatGPT +Aug 9: **5/12 named, 3 `human`, 1 `unknown`**; Gemini: 11/15 named +`google`; grok: `ai` with no provider, correct by design. Eval-only until +the next provider retrain promotes rows. Artifacts: +`receipt-gate-shipped-2026-09-02/report.json` (open_two_misses), +`aigen-drift-2026-09-02/manifest.json`. + Batch-1 then filled four more unmeasured domains under the same sieves (428→421 rows after phantom repair: UI screenshots, 150; Danbooru community digital art, 145; software-rendered matplotlib charts, 82; OpenStreetMap diff --git a/docs/cli.md b/docs/cli.md index ebf5861..265e194 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -122,13 +122,15 @@ remove-ai-watermarks classify image.png --json ``` The command runs the frozen photo detector (CLIP-L-ft ridge AND freeze MLP). -Only a DEFINITELY result is reported as `ai`. On that result only, the 124-d -provider head may return `openai`, `google`, `muse-image`, or `tc260`. POSSIBLY is -`unknown`. Camera-like photographs are `human`. The call does not run cleanup -and does not set `is_ai_generated`. +Only a DEFINITELY result is reported as `ai`. On that result only, the +receipt-document gate abstains receipt photographs to `unknown` before the +124-d provider head, which may return `openai`, `google`, `muse-image`, or +`tc260`. POSSIBLY is `unknown`. Camera-like photographs are `human`. The call +does not run cleanup and does not set `is_ai_generated`. -The contract is AI versus camera. Receipts, UI, and digital art are out of -scope. Microsoft is not a pixel class: a DALL-E Bing image scores as `openai`, +The contract is AI versus camera. Receipts abstain through the gate but are +not a supported verdict; UI and digital art are out of scope. Microsoft is +not a pixel class: a DALL-E Bing image scores as `openai`, an Imagen Designer image as `google`. `tc260` is the China AIGC label standard, not one producer. Weights download on first use from [`wiltodelta/raiw-photo-classify`](https://huggingface.co/wiltodelta/raiw-photo-classify), diff --git a/docs/known-limitations.md b/docs/known-limitations.md index fac0ef4..2790904 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -7,10 +7,14 @@ superseded experiments live in the research archive listed in ## Pixel classification `classify` / `classify_pixels` is AI versus camera, not a universal AI detector. -Receipts, UI screenshots, and digital art are out of contract: a thermal receipt -can score as AI, a photorealistic AI receipt as human. POSSIBLY is reported as -`unknown`. Provider attribution runs only after DEFINITELY and can abstain when -124-d extraction refuses the file. Microsoft is not a pixel class. +UI screenshots and digital art are out of contract. Receipt photographs are +abstained by the 2026-09-02 receipt gate on DEFINITELY: they report `unknown` +with `detector=definitely` instead of a false `ai` (field measurements: +19/59 false verdicts before, 2/59 after; CORD 89/99 before, 0/99 after; the +gate abstains on 7/1,847 ai_test images). A photorealistic AI receipt can +still score as `human`. POSSIBLY is reported as `unknown`. Provider +attribution runs only after DEFINITELY and can abstain when 124-d extraction +refuses the file. Microsoft is not a pixel class. The command is never started by `identify`. A no-signal provenance result stays unknown. This is not a clean verdict and it does not run cleanup. Full guide: diff --git a/docs/module-internals.md b/docs/module-internals.md index 41a16b2..99baec4 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -982,12 +982,22 @@ derived results influence detection. [`classify.py`](../src/remove_ai_watermarks/classify.py) is the 2026-08-31 photo freeze: CLIP-L-ft ridge AND freeze MLP, then 124-d focal heads only on -DEFINITELY. The public label is `ai` / `human` / `unknown`. POSSIBLY is -`unknown`. The named class is `openai` / `google` / `muse-image` / `tc260` / -`None`. `tc260` is the China AIGC label standard, not one producer. The freeze -file keys the Muse Image head `meta_muse_image`. Tests in -`tests/test_classify.py` pin the gate without downloads and pin that -`identify` does not import this module. +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. +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 +eval corpus uses) plus regenerable synthetic receipts, negatives are ai_train +rows. `tests/test_classify.py` pins the asset threshold, the downgrade, and +that the gate skips forensics. The public label is `ai` / `human` / +`unknown`. POSSIBLY is `unknown`. The named class is `openai` / `google` / +`muse-image` / `tc260` / `None`. `tc260` is the China AIGC label standard, +not one producer. The freeze file keys the Muse Image head +`meta_muse_image`. Tests in `tests/test_classify.py` pin the gate without +downloads and pin that `identify` does not import this module. Weights stay out of git. The Hub snapshot is `wiltodelta/raiw-photo-classify`. `RAIW_CLASSIFY_WEIGHTS` overrides it. The extra is `classify`. User guide: diff --git a/docs/photo-classify-hf/README.md b/docs/photo-classify-hf/README.md index 8eedc16..9299af4 100644 --- a/docs/photo-classify-hf/README.md +++ b/docs/photo-classify-hf/README.md @@ -22,7 +22,9 @@ 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 +"AI or not" detector for UI or digital art. Receipt photographs are +detected by the bundled receipt gate and abstain to `unknown` rather than +a false `ai`. 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. @@ -57,10 +59,12 @@ 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. +generated photographs from UI, charts, or illustration; receipts live +inside the AI region too, which is why the shipped receipt gate (below) +abstains them on the DEFINITELY path instead of trying to fix Model 1. 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 @@ -76,14 +80,20 @@ 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] + m1 -->|definitely| rg[Receipt gate on the same CLIP vector] + rg -->|receipt document| rgu[label unknown, detector definitely] + rg -->|not a receipt| m2[Model 2: 124-d one-vs-rest focal] m2 -->|openai google muse-image 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. +CLIP content space is the wrong place to train "UI versus rest" if the +product contract is photographic; that domain remains open. For receipts a +narrower question is solvable in CLIP space: receipts versus AI on the +DEFINITELY path separates perfectly, and the bundled receipt gate (linear +head, CORD-v2 train plus synthetic capture-style positives, threshold +certified on held-out validation) answers it. It abstains to `unknown`; +it is not a receipt-versus-rest specialist and adds no provider class. Provider attribution tracks the **renderer**, not the front-end. Bing Image Creator rows signed Microsoft, OpenAI score as `openai`. Designer rows signed @@ -178,9 +188,12 @@ 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. +- **Photographs only.** UI, scans of forms, maps, and community digital art + sit in the AI region of CLIP-L. Receipt photographs are abstained by the + 2026-09-02 receipt gate: they report `unknown` with `detector=definitely` + instead of a false `ai` (field false-AI fell from 19/59 to 2/59, CORD + 89/99 to 0/99, at a 7/1,847 ai_test cost). A photorealistic AI receipt + can still 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`. diff --git a/docs/photo-classify-hf/operating-point.json b/docs/photo-classify-hf/operating-point.json index 18084e7..2ac8605 100644 --- a/docs/photo-classify-hf/operating-point.json +++ b/docs/photo-classify-hf/operating-point.json @@ -34,6 +34,17 @@ }, "runs_only_after": "definitely" }, + "receipt_gate": { + "added": "2026-09-02", + "asset": "receipt-gate-2026-09-02.npz", + "threshold": 2.0432573877459697, + "runs_only_after": "definitely", + "on_hit": "unknown", + "head": "linear on the CLIP-L-ft 768-d vector Model 1 already computes", + "train": "CORD-v2 train 800 (CC BY 4.0) + 200 synthetic receipts + 2400 ai_train negatives", + "certified": "min of 1st-percentile scores on CORD validation and a synthetic holdout; no field-receipt pixels in training or threshold selection", + "measured": "field receipts false-AI 19/59 -> 2/59; CORD 89/99 -> 0/99; ai_test DEFINITELY cost 7/1847" + }, "files": [ "clip-l-ft.pt", "probe-weights-clip-l-ft.npz", diff --git a/docs/photo-classify.md b/docs/photo-classify.md index a3fa1e0..8e841b3 100644 --- a/docs/photo-classify.md +++ b/docs/photo-classify.md @@ -14,8 +14,11 @@ Campaign notes and rejected variants live in Use `identify` for C2PA, IPTC, visible marks, and other provenance. Use `classify` when those signals are gone and the file is still a photograph. -Do not use `classify` to prove a file is clean. Do not use it on receipts, UI, -or digital art. Do not treat `provider=openai` as a decoded SynthID payload. +Do not use `classify` to prove a file is clean. Do not use it on UI or +digital art. Receipt photographs are detected by the receipt-document gate +and abstain to `unknown` instead of a false `ai`; they are still not a +supported verdict. Do not treat `provider=openai` as a decoded SynthID +payload. ## Install and run @@ -46,14 +49,17 @@ catalog is not published with the package. ## What one call returns -One request runs both heads. Model 2 runs only after Model 1 is DEFINITELY. +One request runs both heads. The receipt gate and Model 2 run only after +Model 1 is DEFINITELY. ```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 focal heads] + m1 -->|definitely| rg[Receipt gate on the same CLIP vector] + rg -->|receipt document| rgu["label unknown, detector definitely, provider none"] + rg -->|not a receipt| m2[Model 2 124-d focal heads] m2 -->|openai google muse-image tc260| named["label ai plus provider"] m2 -->|no_ai or extract fail| aiOnly["label ai, provider none"] ``` @@ -65,6 +71,10 @@ flowchart TD | `detector` | `definitely`, `possibly`, `likely_human` | Raw Model 1 gate | | `provider` | `openai`, `google`, `muse-image`, `tc260`, or `None` | Model 2, only if `label` is `ai` | +`unknown` with `detector=definitely` is the receipt-document gate abstaining: +the file looked AI-generated to Model 1 and like a receipt photograph to the +gate, so no verdict is published and no provider is read. + `human` is camera-like under this contract, not a proof of authorship. `unknown` is POSSIBLY, or a domain this freeze does not support. `provider=None` on an `ai` file is often FLUX or another generator that is not one of the four @@ -108,9 +118,31 @@ Two CPU retrains were byte-identical. DEFINITELY is the shipped cut. The ungated meme row is why Model 2 never runs on `human` or `unknown`. +## Receipt-document gate + +Added 2026-09-02. A linear head on the CLIP-L-ft vector Model 1 already +computes, trained on CORD-v2 train (800, CC BY 4.0) plus 200 synthetic +capture-style receipts against 2,400 ai_train negatives. The threshold is +certified on CORD validation plus a synthetic holdout; no field-receipt +pixels were used for training or the threshold. On DEFINITELY only: a hit +publishes `unknown` and skips the 124-d provider pass. + +| Cell | Result | +| --- | ---: | +| Field phone receipts, false `ai` before | 19/59 -> **2/59** | +| Field phone receipts, gated to `unknown` | 57/59 | +| CORD photographs (99) | **0/99** false `ai` | +| 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). + ## Limits -- Receipts, UI, scans, maps, and community art are out of contract. +- Receipts abstain through the 2026-09-02 gate (below); a photorealistic AI + receipt can still score as `human`. UI, scans, maps, and community art + remain out of contract. - Not SynthID, not C2PA, not `is_ai_generated`. - Images under 256 px cannot yield 124-d features, so provider abstains. - FLUX is a hold-out at 83% DEF, not a named provider class. diff --git a/src/remove_ai_watermarks/assets/receipt-gate-2026-09-02.npz b/src/remove_ai_watermarks/assets/receipt-gate-2026-09-02.npz new file mode 100644 index 0000000000000000000000000000000000000000..4a30b32b0ce86dafe2d7cadbf59f6c7ce06e52ee GIT binary patch literal 13516 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 literal 0 HcmV?d00001 diff --git a/src/remove_ai_watermarks/classify.py b/src/remove_ai_watermarks/classify.py index 469b93e..afbe490 100644 --- a/src/remove_ai_watermarks/classify.py +++ b/src/remove_ai_watermarks/classify.py @@ -29,6 +29,14 @@ PROVIDER_FILE = "provider.pt" OPERATING_POINT_FILE = "operating-point.json" _WEIGHT_FILES = (CLIP_FILE, PROBE_FILE, DETECTOR_FILE, PROVIDER_FILE) +# 2026-09-02 receipt-document gate: linear head on the same CLIP-L-ft +# vector, trained on CORD-v2 train (800, CC BY 4.0) plus 200 synthetic +# 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. +RECEIPT_GATE_FILE = "receipt-gate-2026-09-02.npz" +RECEIPT_GATE_THRESHOLD = 2.0432573877459697 + # 2026-08-31 freeze operating point. Ridge threshold is also in the probe file; # the runtime prefers the file when weights load. MLP_THRESHOLD = 5.9586493237495395 @@ -53,6 +61,34 @@ PUBLIC_PROVIDER: dict[str, PixelProvider] = { _runtime: _Runtime | None = None _runtime_lock = threading.Lock() +_receipt_gate: dict[str, Any] | None = None + + +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 + + 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 + + +def receipt_gate_score(clip_vector: Any) -> float: + """Receipt-document score of a CLIP-L-ft vector; higher is more receipt-like.""" + import numpy as np + + g = _load_receipt_gate() + v = np.asarray(clip_vector, dtype=np.float64).ravel() + v = v / (np.linalg.norm(v) + 1e-12) + return float((((v - g["mu"]) / g["sd"]) @ g["w"]) + g["b"]) @dataclass(frozen=True) @@ -125,8 +161,15 @@ def classify_from_scores( ridge_threshold: float = RIDGE_THRESHOLD, mlp_threshold: float = MLP_THRESHOLD, provider_margin: float = PROVIDER_MARGIN, + receipt_score: float | None = None, + receipt_threshold: float = RECEIPT_GATE_THRESHOLD, ) -> PixelClassification: - """Pure gate: detector AND provider, no file I/O.""" + """Pure gate: detector AND provider, no file I/O. + + A DEFINITELY verdict whose ``receipt_score`` meets ``receipt_threshold`` + downgrades the public label to ``unknown`` (document-domain abstain): + the raw detector level stays ``definitely`` and no provider is read. + """ level = detector_level( ridge_score, mlp_score, @@ -134,6 +177,8 @@ def classify_from_scores( mlp_threshold=mlp_threshold, ) label = label_for(level) + if level == "definitely" and receipt_score is not None and receipt_score >= receipt_threshold: + return PixelClassification(label="unknown", domain="photo", detector=level, provider=None) provider: PixelProvider | None = None if label == "ai" and provider_scores is not None: provider = provider_from_scores(provider_scores, margin=provider_margin) @@ -141,7 +186,11 @@ def classify_from_scores( def classify_pixels(path: Path, *, device: str | None = None) -> PixelClassification: - """Run Model 1 then, on DEFINITELY, Model 2. Never called by ``identify``.""" + """Run Model 1 then, on DEFINITELY, the receipt gate and Model 2. + + Never called by ``identify``. A DEFINITELY verdict that the receipt gate + accepts is published as ``unknown`` before the 124-d provider pass. + """ if not is_available(): raise RuntimeError(f"pixel classification requires the classify extra. Install: pip install {CLASSIFY_EXTRA}") runtime = _get_runtime(device) @@ -158,21 +207,28 @@ def classify_pixels(path: Path, *, device: str | None = None) -> PixelClassifica ridge_threshold=runtime.ridge_threshold, mlp_threshold=runtime.mlp_threshold, ) + gate = _load_receipt_gate() + r_score = receipt_gate_score(clip_vector) if level == "definitely" else None provider_scores = None - if level == "definitely": + if r_score is not None and r_score < gate["threshold"]: forensic = _forensic(rgb) if forensic is not None: provider_scores = _provider_scores(runtime, forensic) else: log.info("124-d features unavailable for %s, provider abstains", path) - return classify_from_scores( + result = classify_from_scores( ridge, mlp, provider_scores, ridge_threshold=runtime.ridge_threshold, mlp_threshold=runtime.mlp_threshold, provider_margin=runtime.provider_margin, + receipt_score=r_score, + receipt_threshold=gate["threshold"], ) + if result.label == "unknown" and r_score is not None: + log.info("receipt-document gate abstained on %s (score %.2f)", path, r_score) + return result @dataclass diff --git a/tests/test_classify.py b/tests/test_classify.py index de3390e..f48ff2c 100644 --- a/tests/test_classify.py +++ b/tests/test_classify.py @@ -15,6 +15,8 @@ from PIL import Image from remove_ai_watermarks.classify import ( MLP_THRESHOLD, PROVIDER_MARGIN, + RECEIPT_GATE_FILE, + RECEIPT_GATE_THRESHOLD, RIDGE_THRESHOLD, WEIGHTS_REPO, WEIGHTS_REVISION, @@ -23,6 +25,7 @@ from remove_ai_watermarks.classify import ( detector_level, label_for, provider_from_scores, + receipt_gate_score, ) from remove_ai_watermarks.cli import main @@ -61,6 +64,10 @@ def test_shipped_operating_point_matches_the_runtime_defaults() -> None: assert payload["model2"]["class_kind"]["tc260"] == "label-standard" assert payload["model2"]["class_kind"]["muse-image"] == "model" assert payload["model2"]["checkpoint_keys"]["muse-image"] == "meta_muse_image" + assert payload["receipt_gate"]["threshold"] == RECEIPT_GATE_THRESHOLD + assert payload["receipt_gate"]["asset"] == RECEIPT_GATE_FILE + assert payload["receipt_gate"]["runs_only_after"] == "definitely" + assert payload["receipt_gate"]["on_hit"] == "unknown" def test_definitely_is_the_and_of_ridge_and_mlp() -> None: @@ -119,6 +126,95 @@ def test_likely_human_does_not_run_provider() -> None: assert result.provider is None +def test_shipped_receipt_gate_asset_matches_the_pinned_threshold() -> None: + payload = np.load(Path("src/remove_ai_watermarks/assets") / RECEIPT_GATE_FILE) + assert float(payload["threshold"]) == RECEIPT_GATE_THRESHOLD + assert payload["w"].shape == (768,) + assert payload["mu"].shape == (768,) + assert payload["sd"].shape == (768,) + + +def test_receipt_gate_downgrades_definitely_to_unknown() -> None: + result = classify_from_scores(1.0, 10.0, _scores(openai=9.0, no_ai=0.0), receipt_score=RECEIPT_GATE_THRESHOLD + 1.0) + assert result.label == "unknown" + assert result.detector == "definitely" + assert result.provider is None + + +def test_receipt_gate_below_threshold_keeps_ai_and_provider() -> None: + result = classify_from_scores( + 1.0, + 10.0, + _scores(openai=1.0, no_ai=0.0), + receipt_score=RECEIPT_GATE_THRESHOLD - 0.01, + ) + assert result.label == "ai" + assert result.provider == "openai" + + +@pytest.mark.parametrize( + ("ridge", "mlp", "expected"), + [(1.0, 0.0, "unknown"), (0.0, 0.0, "human")], +) +def test_receipt_gate_never_touches_non_definitely(ridge: float, mlp: float, expected: str) -> None: + result = classify_from_scores(ridge, mlp, None, receipt_score=99.0) + assert result.label == expected + + +def test_receipt_gate_score_is_deterministic_and_bounded() -> None: + v = np.full(768, 1.0 / np.sqrt(768), dtype=np.float64) + first = receipt_gate_score(v) + assert first == receipt_gate_score(v) + assert -50.0 < first < 50.0 + + +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( + ridge_mean=np.zeros(768), + ridge_scale=np.ones(768), + ridge_weights=np.full(768, 0.01), + ridge_threshold=RIDGE_THRESHOLD, + mlp_threshold=MLP_THRESHOLD, + provider_margin=PROVIDER_MARGIN, + ) + monkeypatch.setattr("remove_ai_watermarks.classify.is_available", lambda: True) + monkeypatch.setattr("remove_ai_watermarks.classify._get_runtime", lambda device: runtime) + monkeypatch.setattr("remove_ai_watermarks.classify._embed", lambda *args, **kwargs: np.full(768, 100.0)) + monkeypatch.setattr("remove_ai_watermarks.classify._mlp_score", lambda *args, **kwargs: 10.0) + monkeypatch.setattr( + "remove_ai_watermarks.classify._forensic", + lambda image: forensic_calls.append(1) or np.zeros(124), + ) + + +def test_receipt_gate_hit_skips_the_124d_forensics(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + calls: list[int] = [] + _patch_definitely_runtime(monkeypatch, forensic_calls=calls) + monkeypatch.setattr( + "remove_ai_watermarks.classify.receipt_gate_score", lambda *args, **kwargs: RECEIPT_GATE_THRESHOLD + 5.0 + ) + result = classify_pixels(_plain_png(tmp_path)) + assert result.label == "unknown" + assert result.detector == "definitely" + assert calls == [] + + +def test_receipt_gate_miss_still_runs_the_124d_forensics(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + calls: list[int] = [] + _patch_definitely_runtime(monkeypatch, forensic_calls=calls) + monkeypatch.setattr( + "remove_ai_watermarks.classify._provider_scores", + lambda runtime, forensic: _scores(), + ) + monkeypatch.setattr( + "remove_ai_watermarks.classify.receipt_gate_score", lambda *args, **kwargs: RECEIPT_GATE_THRESHOLD - 5.0 + ) + result = classify_pixels(_plain_png(tmp_path)) + assert result.label == "ai" + assert calls == [1] + + @pytest.mark.parametrize("caller", ["identify", "has_invisible_target"]) def test_provenance_paths_do_not_import_classify(tmp_path: Path, caller: str) -> None: path = tmp_path / "plain.png"