Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Win POIDH Arbitrum bounty 323 by shipping a privacy-first local MV3 Chrome exten
- **Manifest V3.** `onnxruntime-web` in an offscreen document. WebGPU with WASM fallback (probe the adapter; do not latch a WebGPU error).
- **Auto-scan ordinary webpages.** Confidence on every badge: AI / OK / uncertain.
- **Hybrid is allowed:** neural + C2PA + EXIF/XMP + PNG/JPEG comments + weak URL hints. A URL hint alone must not cross 0.65.
- **Current fusion: CF-primary, three tiers.** CommunityForensics TTA takes the maximum raw sigmoid from inspected views. CF is authoritative at >= 0.65. Sub-floor tier: below CF 0.02, rescue only when CF >= 0.0005 (not flatlined) AND DINO >= 0.995; a CF hard zero is never overridden. Strong tier: CF in [0.02, 0.10) needs DINO >= 0.90. Normal tier: CF in [0.10, 0.65) needs DINO >= 0.70. The flat-graphic gate blocks every rescue tier on catalog art and UI-like images. Bands were re-derived for the hard-negative probe under a hard stress-set constraint (240 full-resolution stock/catalog/product reals; no DINO-attributable stress FP allowed), choosing a conservative near-optimum over the grid maximum. **Do not restore raw max(CF, DINO), remove the graphic gate, or loosen these bands without rerunning the bench plus the stress set and live-site checks.**
- **Current fusion: CF-primary, three tiers plus view agreement.** CommunityForensics TTA takes the maximum raw sigmoid from inspected views, with one rule: a lone view in [0.65, 0.85) does not carry an AI verdict and falls back to the runner-up (live CDN-processed real photos spike single crops); views >= 0.85 keep single-view authority and early-exit. The adaptive extras band is [0.15, 0.85). CF is authoritative at >= 0.65 after agreement. Sub-floor tier: below CF 0.02, rescue only when CF >= 0.0005 AND DINO >= 0.995. Strong tier: CF in [0.02, 0.20) needs DINO >= 0.96. Normal tier: CF in [0.20, 0.65) needs DINO >= 0.70. The flat-graphic gate blocks every rescue tier. Bands derived under three simultaneous guards: public bench, 240-image stress set, and a held-out live-CDN guard (camera-EXIF-verified Unsplash variants). **Do not restore plain max(CF, DINO), remove the graphic gate or the agreement rule, or loosen these bands without rerunning the bench, the stress set, AND the live guard.**
- **Overlay:** badge store must be an iterable `Map`, not a `WeakMap`. Reposition on scroll / resize / `visualViewport` / mutations. Never wrap images.
- **Load-unpacked users do not auto-update.** GitHub Releases zip + popup banner is the update path. Do not assume CWS.
- **Cross-device:** macOS (owner), Windows, Linux. WASM must work when WebGPU has no adapter.
Expand Down Expand Up @@ -65,7 +65,7 @@ Win POIDH Arbitrum bounty 323 by shipping a privacy-first local MV3 Chrome exten

| File | Role |
|---|---|
| `src/fuse.js` | `DEFAULT_THRESHOLD` 0.65, `DINO_CF_FLOOR` 0.02, `DINO_STRONG_RESCUE_FLOOR` 0.10, `DINO_STRONG_RESCUE_MIN` 0.90, `DINO_RESCUE_MIN` 0.70, `DINO_SUBFLOOR_CF_MIN` 0.0005, `DINO_SUBFLOOR_MIN` 0.995, CF-primary `fuseNeuralScores` |
| `src/fuse.js` | `DEFAULT_THRESHOLD` 0.65, `DINO_CF_FLOOR` 0.02, `DINO_STRONG_RESCUE_FLOOR` 0.20, `DINO_STRONG_RESCUE_MIN` 0.96, `DINO_RESCUE_MIN` 0.70, `DINO_SUBFLOOR_CF_MIN` 0.0005, `DINO_SUBFLOOR_MIN` 0.995, CF-primary `fuseNeuralScores` |
| `src/graphic-gate.js` | Shared flat-graphic policy for browser and Node evaluation |
| `src/pixel-resize.js` | Pillow-exact bicubic resize shared by the extension and the Node harness for the CF path (byte-exact vs Pillow 12.3.0 goldens) |
| `src/offscreen.js` | ORT WebGPU/WASM, DINO 224 then CF TTA |
Expand All @@ -76,7 +76,7 @@ Win POIDH Arbitrum bounty 323 by shipping a privacy-first local MV3 Chrome exten
| `src/c2pa-reader.js` | C2PA reader |

- Issue 23 (fixed in PR 24 / v1.0.7): overlay WeakMap drift + DINO max false positives.
- The public 893-image fixture with the hard-negative probe and re-derived bands: 90.5% BA, 81.2% TPR, 99.8% TNR (harness-verified, not simulated). Stress set (240 full-resolution stock/catalog/product reals): 4 FPs, all CF-driven at >= 0.65 where CF is authoritative by design; zero DINO-attributable stress FPs (shipped v1.1.0 measured 7 on identical bytes). The DINO probe is trained on features from the shared Pillow-exact path plus 1,915 hard-negative reals (stock, catalog, product, interiors; rows disjoint from the stress set). Full-resolution professional stock photos can spike CF itself; check exactly that during live smoke. Live-smoke checks still remain required before any claim.
- The public 893-image fixture with the live-guarded policy: 87.9% BA, 76.0% TPR, 99.8% TNR (harness-verified, adaptive mode, implemented view-agreement path). Stress set: 3 FPs in 240 images. Live-CDN guard (132 held-out camera-EXIF Unsplash variants, AVIF plus JPEG): 4 FPs, 3.0%, vs 9.2% for v1.2.0 on identical bytes. Probe trained with 118 verified-real live CDN images on top of the prior hard negatives. See docs/live-smoke-2026-08-16.md for the failure that drove this and the full isolation work. Live-smoke rerun still required before any claim.

---

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ Two independent neural heads cover complementary failure modes, plus determinist

1. **CommunityForensics head:** ViT-Small official FP32 ONNX (CLIP 384). `p(AI) = sigmoid(logit)`. Near-zero false positives on real photos, but under-scores several modern generators (Flux, GPT-4o-image, photoreal DALL-E 3).
2. **DINOv2 probe head:** frozen DINOv2-small backbone (224 center view) with a transparent logistic head over CLS+mean-pooled features (`models/probe/dino-probe.json`: plain standardize/weights/bias, no lookup tables). Trained on ~11.4k images from public datasets across Flux, SD3.5, SDXL-era, Midjourney, DALL-E 3, GPT-4o-image and diverse real photos, including ~1.9k hard-negative reals (stock photography, product catalogs, interiors, high-saturation nature) that teach the head not to fire on professional real photos, with web-realistic JPEG/resize augmentation. Features are extracted through the same Pillow-exact resize the extension ships, so training matches serving exactly. This head carries the modern generators.
3. **Neural fusion:** CF-primary with three rescue tiers. When CommunityForensics is confident AI (`>= 0.65`), its score wins. Between `0.02` and `0.10`, DINO can only rescue if it is highly confident (`p(AI) >= 0.90`); between `0.10` and `0.65`, DINO can lift at `p(AI) >= 0.70`. Below the `0.02` floor a rescue additionally requires CF to be at least faintly awake (`>= 0.0005`) and DINO to be saturated (`>= 0.995`): CF emits hard zeros on real photos it is certain about, while AI images in its blind spots still elicit a faint response, so a flatlined CF is itself evidence of a real photo and is never overridden. On flat graphics and catalog art (low palette / high flat-run pixels), a graphic gate suppresses every DINO rescue tier when CF stays below `0.65`, so icons and UI shots do not mass-label AI 100%. CF-confident AI illustrations (`>= 0.65`) are unchanged. Displayed confidence is this raw fused probability; the AI verdict stays at raw `>= 0.65` with no remapping and no logit bias. The rescue bands were re-derived for the current probe under a hard constraint measured on a separate 240-image full-resolution stock, catalog, and product photo stress set (no DINO-attributable false positives allowed), and a deliberately conservative near-optimum was chosen over the grid maximum.
4. **Adaptive TTA:** the DINO pass and the official 440 center crop always run. Extra CommunityForensics views (440 corners + 512 center) run only when a head is at least mildly suspicious (CF center or DINO in `[0.15, 0.65)`), so confident reals cost two passes total. `Math.max` of sigmoids, early exit at `>= 0.9`. Under heavy queue load (more than 12 pending) CF drops to center-only; the DINO pass still runs.
3. **Neural fusion:** CF-primary with three rescue tiers. When CommunityForensics is confident AI (`>= 0.65` after view agreement), its score wins. Between `0.02` and `0.20`, DINO can only rescue if it is highly confident (`p(AI) >= 0.96`); between `0.20` and `0.65`, DINO can lift at `p(AI) >= 0.70`. Below the `0.02` floor a rescue additionally requires CF to be at least faintly awake (`>= 0.0005`) and DINO to be saturated (`>= 0.995`): CF emits hard zeros on real photos it is certain about, while AI images in its blind spots still elicit a faint response, so a flatlined CF is itself evidence of a real photo and is never overridden. On flat graphics and catalog art (low palette / high flat-run pixels), a graphic gate suppresses every DINO rescue tier when CF stays below `0.65`. Displayed confidence is this raw fused probability; the AI verdict stays at raw `>= 0.65` with no remapping and no logit bias. The rescue bands were re-derived under three guards at once: the public bench, a 240-image full-resolution stock and catalog stress set, and a held-out live-CDN guard of camera-EXIF-verified editorial photos.
4. **Adaptive TTA with view agreement:** the DINO pass and the official 440 center crop always run. Extra CommunityForensics views (440 corners + 512 center) run when a head is at least mildly suspicious (CF center in `[0.15, 0.85)` or DINO `>= 0.15`), so confident reals cost two passes total. Aggregation is the max of sigmoids with one honesty rule: a lone view in `[0.65, 0.85)` does not carry an AI verdict by itself and falls back to the runner-up view, because live CDN-processed real photos can spike a single crop. Any view at `>= 0.85` keeps single-view authority and early-exits. Under heavy queue load (more than 12 pending) CF drops to center-only; the DINO pass still runs.
5. **Metadata:** C2PA, EXIF/XMP/IPTC, generator text in PNG/JPEG, weak URL hints. Strong metadata forces 0.95-0.99; a URL hint alone cannot cross 65%.

## Tests
Expand All @@ -80,9 +80,10 @@ Eval harness: `npm run eval -- ./path/to/labeled-folder` after `npm run fetch-mo
| Legacy raw max ensemble (not shipped) | 96.1% | 96.1% | 96.1% |
| Prior policy, prior Node resize (historical) | 85.0% | 70.4% | 99.6% |
| v1.1.0 policy and probe, Pillow-exact preprocess | 87.7% | 75.8% | 99.6% |
| Production: hard-negative probe, re-derived bands | 90.5% | 81.2% | 99.8% |
| v1.2.0: hard-negative probe, re-derived bands | 90.5% | 81.2% | 99.6% |
| Production: live-guarded policy with view agreement | 87.9% | 76.0% | 99.8% |

The legacy raw max result is included to make the tradeoff visible, not as a product claim. It caused unacceptable false positives on live stock and catalog images, so production keeps the CF guard. The historical 85.0% row was measured through a Node resize the extension never ran; later rows are computed by the same Pillow-exact resize the extension ships, byte for byte. On a 240-image full-resolution stock, catalog, and product photo stress set the production policy shows 4 false positives, all driven by CommunityForensics alone scoring `>= 0.65` (where it is authoritative by design), and zero attributable to a DINO rescue; the v1.1.0 configuration measured 7 on identical bytes. Public fixtures are directional only and are not a claim about Kenny's private held-out set.
The legacy raw max result is included to make the tradeoff visible, not as a product claim. It caused unacceptable false positives on live stock and catalog images, so production keeps the CF guard. The historical 85.0% row was measured through a Node resize the extension never ran; later rows are computed by the same Pillow-exact resize the extension ships, byte for byte. On a 240-image full-resolution stock, catalog, and product photo stress set the production policy shows 3 false positives, zero attributable to a DINO rescue. On a held-out live-CDN guard of 132 camera-EXIF-verified Unsplash editorial variants it shows 4 false positives (3.0 percent), where the v1.2.0 configuration measured 9.2 percent on identical bytes. Public fixtures are directional only and are not a claim about Kenny's private held-out set.

**Live-web evaluation (published openly):** our 2026-08-16 live-site smoke test of v1.2.0 FAILED on CDN-processed professional photography (details, isolation experiments, and the fix plan in [docs/live-smoke-2026-08-16.md](docs/live-smoke-2026-08-16.md)). Benchmark numbers above remain reproducible as stated; the live gap is being closed with ground-truthed live corpora and a retrained probe, and no bounty claim will be made until a re-run smoke passes. Live counterexamples are welcome as issues.

Expand Down
4 changes: 2 additions & 2 deletions STORE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Author:** Luis Felipe Abarca
**Project license:** MIT (bundled assets have their own notices)
**Version:** 1.2.0
**Version:** 1.3.0

Unpacked install from GitHub (`npm run fetch-model && npm run build`, then Load unpacked from `dist/`) remains the POIDH proof path. This file is listing copy for a future Chrome Web Store upload. The extension is **not** claimed to be on the Chrome Web Store.

Expand Down Expand Up @@ -103,7 +103,7 @@ Do not screenshot private or copyrighted photos you do not have rights to. Use t

1. `npm ci && npm run package`
2. Open the Chrome Web Store Developer Dashboard (one-time developer registration is a Google process; this repo does not submit for you).
3. New item, upload `release/clueside-1.2.0.zip`
3. New item, upload `release/clueside-1.3.0.zip`
4. Paste the short and detailed descriptions above
5. Set privacy policy URL to the hosted `docs/privacy.html`
6. Attach screenshots and the 128px icon
Expand Down
10 changes: 10 additions & 0 deletions docs/live-smoke-2026-08-16.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ So the trigger is the live processing chain plus the current content distributio
5. Fix the offline update ping.
6. Re-run the full smoke. The bounty claim stays on hold until it passes.

## Update, 2026-08-16 (same day): the fix, measured

The work-in-progress list above is done. Findings and results:

- **Unsplash exposes no AI label anywhere.** We audited the full API surface, page HTML, frontend bundles, tags, and image bytes for 268 current editorial photos: no labeling mechanism exists as served. Ground truth via platform labels is impossible; we fell back to camera EXIF present in the API metadata (184 of 268 photos) as a probable-real prior, and imgix strips all embedded provenance metadata from served bytes, which also means metadata-based detection layers are inert on CDN images.
- **True FP rate of v1.2.0 on probable-real live bytes: 9.2 percent** (17 of 184 camera-EXIF photos, identical in AVIF and JPEG form). The no-EXIF segment flagged at a similar rate, so the smoke's headline 24.3 percent was inflated by page mix and feed rotation, not primarily by hidden AI content.
- **The fix** (three coordinated changes, shipped together): a view-agreement rule (a lone CommunityForensics view in [0.65, 0.85) cannot carry an AI verdict and falls back to the runner-up view; views at or above 0.85 keep single-view authority), the probe retrained with 118 verified-real live CDN images, and the strong rescue tier tightened to [0.02, 0.20) with DINO at or above 0.96.
- **Verified with the implemented pipeline:** public bench 87.9 percent BA, 76.0 TPR, 99.8 TNR; stress set 3 of 240; held-out live-CDN guard 4 of 132 (3.0 percent, versus 9.2 percent for v1.2.0 on identical bytes). The live guard is now a permanent part of the evaluation gate next to the bench and stress sets, and the trade (2.6 bench points for a threefold live FP reduction) is deliberate: false positives on real photos are the failure that matters.
- A full live smoke rerun of the fixed build is the next gate. No claim before it passes.

## How to help

- Reproduce any number here: `npm ci && npm run fetch-model`, then `npm run eval -- <dir>` on any labeled folder of images. The harness matches the extension byte for byte.
Expand Down
22 changes: 14 additions & 8 deletions eval/benchmark-results.json
Original file line number Diff line number Diff line change
@@ -1,26 +1,32 @@
{
"_note": "Single source of truth for published benchmark numbers. The site imports this file, scripts/check-site.mjs validates the built site against it, and tests/benchmark-docs.test.mjs fails the suite if README.md, AGENTS.md, or the manifest version drift from it. Numbers may only come from a full harness run (bench plus stress). On a release that does not touch scoring code (src fusion, preprocessing, models, probe), bump extensionVersion here to re-affirm the numbers still describe the shipped build; any release that touches scoring code requires a fresh run first.",
"measuredAt": "2026-08-15",
"extensionVersion": "1.2.0",
"measuredAt": "2026-08-16",
"extensionVersion": "1.3.0",
"decisionRule": "raw fused p(AI) >= 0.65, no remapping",
"bench": {
"n": 893,
"ai": 409,
"real": 484,
"ba": 90.5,
"tpr": 81.2,
"tnr": 99.8,
"ba": "87.9",
"tpr": "76.0",
"tnr": "99.8",
"falsePositives": 1
},
"stress": {
"n": 240,
"falsePositives": 4,
"falsePositives": 3,
"dinoAttributableFalsePositives": 0,
"description": "Full-resolution stock, catalog, product, interior, and high-saturation real photos; remaining FPs are CommunityForensics alone at >= 0.65 where it is authoritative"
},
"legacyRawMax": {
"ba": 96.1,
"ba": "96.1",
"shipped": false,
"reason": "Unacceptable live false positives on stock and catalog images"
},
"liveGuard": {
"n": 132,
"falsePositives": 4,
"ratePercent": 3.0,
"description": "Held-out camera-EXIF Unsplash editorial photos fetched as live CDN bytes (AVIF and JPEG grid variants, w=700 q=60); v1.2.0 measured 9.2 percent on this distribution"
}
}
}
2 changes: 1 addition & 1 deletion eval/extract-features.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { RawImage } from '@huggingface/transformers';
import sharp from 'sharp';
import ort from 'onnxruntime-node';

const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.bmp']);
const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.bmp', '.avif']);
const AI_DIR_NAMES = new Set(['ai', 'fake', 'generated', 'synthetic']);
const REAL_DIR_NAMES = new Set(['real', 'authentic', 'photo', 'natural']);

Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Clueside: Local AI Image Detector",
"version": "1.2.0",
"version": "1.3.0",
"description": "Clueside surfaces likely AI-generated images locally. Images never leave your device.",
"author": "Luis Felipe Abarca",
"icons": {
Expand Down
1 change: 1 addition & 0 deletions models/Xenova/dinov2-small/onnx
2 changes: 1 addition & 1 deletion models/probe/dino-probe.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "clueside",
"version": "1.2.0",
"version": "1.3.0",
"description": "Clueside: private, browser-local AI-image signals",
"type": "module",
"author": "Luis Felipe Abarca",
Expand Down
3 changes: 3 additions & 0 deletions src/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ async function getUpdateState() {

async function checkForUpdate() {
try {
// No network attempts while offline: scoring is fully local and the
// update banner can wait for the next online check.
if (typeof navigator !== 'undefined' && navigator.onLine === false) return null;
const response = await fetch(GITHUB_LATEST_RELEASE_URL, {
headers: { Accept: 'application/vnd.github+json' },
});
Expand Down
5 changes: 4 additions & 1 deletion src/community-forensics.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
aggregateViewScores,
shouldRunExtraCrops,
TTA_EARLY_EXIT,
agreedMax,
} from './scoring.js';

const REQUIRED_WASM_FILES = ['ort-wasm-simd-threaded.wasm', 'ort-wasm-simd-threaded.mjs'];
Expand Down Expand Up @@ -182,7 +183,9 @@ export async function predictAdaptiveViews(session, views, options = {}) {
}
}

return { scores, named, neuralPAi: max, extraRan: true, earlyExit: false };
// Mid-band agreement: a lone view in [threshold, earlyExit) does not
// carry the verdict on its own (see scoring.agreedMax).
return { scores, named, neuralPAi: agreedMax(max, scores), extraRan: true, earlyExit: false };
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/fuse.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ export const UNCERTAIN_LOW = 0.45;
/** CF scores below this need the saturated sub-floor tier to be rescued. */
export const DINO_CF_FLOOR = 0.02;
/** CF below this requires near-saturated DINO before it can be rescued. */
export const DINO_STRONG_RESCUE_FLOOR = 0.10;
export const DINO_STRONG_RESCUE_FLOOR = 0.20;
/** DINO must be this confident to lift a low CF score into the rescue band. */
export const DINO_STRONG_RESCUE_MIN = 0.90;
export const DINO_STRONG_RESCUE_MIN = 0.96;
/** DINO must be this confident to lift CF in the normal uncertain band. */
export const DINO_RESCUE_MIN = 0.70;
/**
Expand Down
Loading