Commit Graph
304 Commits
Author SHA1 Message Date
Victor Kuznetsov a8d4bce14a Align watermark content with detector coverage 2026-08-27 20:05:03 -07:00
Victor Kuznetsov 17408b958e Reject the uncalibrated text-manifest tiling and verify Content Seal transforms
Tiled diffusion was never provider-oracle calibrated with verified text
restoration: the tiled VAE donor path ran anyway and produced results no
oracle had certified. The combination is now rejected at both the
pipeline and the engine seam (ValueError with the reason), and the CLI
help no longer implies support. The invisible help is generalized and
the metadata container list corrected (MKA/OGA/Opus/AAC).

scripts/contentseal_transforms.py reproduces the deterministic crop,
resize, and JPEG variants of the Content Seal corpus from manifest.csv,
hash-verifying every output; its README gains scripts/README.md context
and new data tests. The corpus README is honest about the one crop the
daily oracle limit left unchecked, and the eval CSVs carry the updated
verdicts. The byte-scan SynthID suppression hoists its soft-binding
lookup so the guard is computed once.

Staged on top of 0.33.1; no version bump in this commit.
2026-08-27 16:53:34 -07:00
Victor Kuznetsov 834342db07 Follow the seal bet in the Likely-source line
A standalone-tag file showed 'platform not specified' while the panel
below priced the Muse Content Seal removal - the platform line lagged
the attribution the seal signal and the strength router already make.
The fallback now attributes 'Meta Muse Image (attributed by the
standalone AI digital-source tag)' when no more specific attribution
applies, so the source line and the priced target agree. Apple Photos
Clean Up keeps its own attribution; the hedge names the attribution
basis, not a detection.
2026-08-27 16:41:28 -07:00
Victor Kuznetsov dcc1bf5e07 Emit the Content Seal as its own signal on standalone-AI-tag files
The strength router already bets that a file whose only provenance is
the standalone AI digital-source tag is Meta Muse output (C2PA issuers
win first), and Muse stamps every output with the invisible Content
Seal. identify now surfaces that same bet as evidence: the additive
content_seal signal (medium confidence - an attribution, not a decode,
since no public decoder exists) plus the watermark string, emitted on
exactly the standalone_iptc condition that routes the cohort. Clients
select pixel removal from the signal list instead of parsing caveats,
the way InvisMark is additive over soft_binding. The API's invisible
gate already runs on ai_from_metadata, so all/invisible behavior is
unchanged; has_invisible_target needs no edit.
2026-08-27 16:25:11 -07:00
Victor Kuznetsov d8fcd0f79b Stop double-counting a named forensic mark as SynthID provenance
A manifest that names its own forensic soft-binding algorithm carries
that vendor's mark; the generic watermark-action vendor-token inference
must not add a second, differently-attributed invisible watermark from
the same bytes. Microsoft Designer manifests triggered exactly that:
signed by Microsoft, watermarked by InvisMark, with the generation
agent named "Azure OpenAI ImageGen" - the OpenAI issuer token inside
that service name plus the InvisMark watermarked action satisfied the
OpenAI SynthID-evidence rule, and identify reported one forensic mark
as two paid pixel watermarks.

Three changes, one rule at every inference site (the verdict-scan
comment's own lesson: a rule that lives in only one copy is a rule the
others silently lack):

- c2pa.py structured path: SynthID evidence now scopes to the
  signer/generator identity strings only (signature issuer, claim
  generator), never the raw chain, and is suppressed entirely when a
  soft-binding algorithm is named.
- c2pa.py byte fallback and metadata.py synthid_source: suppressed when
  the scan names a soft-binding algorithm.
- identify.py verdict scan: same suppression.

Gemini and ChatGPT originals keep their provenance-asserted SynthID
strings; the Designer regression is pinned by
test_designer_synthid_suppression.py (agent name alone is not the
vendor's provenance, and a named soft binding suppresses the
inference).
2026-08-27 15:20:45 -07:00
Victor Kuznetsov f91f9fd9fc Record the public Microsoft Content Provenance check URL
The InvisMark strength ladder was measured against Microsoft's public
Content Provenance page, but no doc or comment carried its URL - the
API how-to link was the only address recorded anywhere, and the page
is what a human can actually check without an Azure account. Record
https://ai.azure.com/nextgen/validate in supported-signals.md, the
watermarking landscape, and the strength derivation comment, with the
honest caveat that its collapsed verdict tops out at Inconclusive
rather than the API's separate watermark-negative result.
2026-08-27 11:21:08 -07:00
Victor KuznetsovandClaude Fable 4.5 c4aecfbbdf Release 0.32.0
Co-Authored-By: Claude Fable 4.5 <noreply@anthropic.com>
2026-08-26 23:03:04 -07:00
Victor KuznetsovandClaude Fable 4.5 ab528ec0e8 Ship the measured Meta Content Seal cohort with auto routing and --vendor override
Full Meta Muse Image support in the invisible-removal path:

- QWEN_ZIMAGE_META_STRENGTH = 0.1: derived by the standard
  worst-boundary-plus-cross-source-spread method over five oracle-bracketed
  generations (data/contentseal/manifest.csv)
- Auto mode: vendor_for_strength routes a file whose only provenance is the
  standalone AI IPTC trainedAlgorithmicMedia tag onto the meta cohort; C2PA
  issuers win first, so Google/OpenAI/Microsoft routing is unchanged. Muse
  WebP outputs place the XMP in a tail chunk, so the scan uses the shared
  chunk-aware metadata.scan_head rather than a plain head read
- Explicit override: --vendor on invisible/all/batch and
  InvisibleOptions.vendor name the cohort on stripped files; naming a cohort
  asserts the watermark is present, so the no-signal gate treats it like
  --force at both the CLI and API seams
- sdxl-zimage has no measured Meta rung: an explicit meta vendor falls to
  the conservative unknown 0.25 rather than inventing one
- identify emits a Content Seal caveat pointing at the removal path
- The legacy visible 'Imagined with AI' mark stays unregistered: a dedicated
  sample hunt (newsroom mockups, community posts, press screenshots, dead
  imagine.meta.com, broken Wayback captures) found no pixel-verifiable
  capture, and the registry rule forbids encoding a corner without one.
  erase --region remains its removal path; outcome recorded in the landscape

Co-Authored-By: Claude Fable 4.5 <noreply@anthropic.com>
2026-08-26 22:59:23 -07:00
Victor KuznetsovandClaude Fable 4.5 a1811b6221 Release 0.31.2
Documentation and corpus release: Meta Muse Image Content Seal support
records (oracle-verified boundaries, wire format, removal verification)
plus the contentseal manifest guard. No runtime behavior change.

Co-Authored-By: Claude Fable 4.5 <noreply@anthropic.com>
2026-08-26 21:11:40 -07:00
Victor Kuznetsov 29f17105de Release 0.31.1 with calibrated provider strengths 2026-08-26 11:22:13 -07:00
Victor KuznetsovandClaude Opus 5 ba5710836b Release 0.31.0
Minor, not patch: the C2PA confidence mapping changed. A cryptographically bound
manifest now reports high confidence where 0.27.0 through 0.30.1 reported medium,
because the previous gate required a trust anchor no installation has. Consumers
that branch on `confidence` will see verdicts move on files whose bytes did not.

The provenance report stays at schema 1. No field was added, removed, renamed or
retyped, and the meaning of `confidence` is unchanged -- the value it carried for a
verified claim was wrong. Bumping the output schema instead would break every
pinned schema-1 consumer on a corrected value rather than a new shape.

pre-commit: 1) maintain.sh - exit 1 on uv-secure, lightning PYSEC-2026-3624 unchanged from 0.30.1, no fixed release exists, vulnerable API unreachable (no load_from_checkpoint in project or trustmark); ruff, ruff format, pyright src/, and 1394 tests passed separately; 2) /simplify - version bump only; 3) docs sync - no version refs outside pyproject, __init__.py, uv.lock; 4) CLAUDE.md - no change

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:11:25 -07:00
Victor KuznetsovandClaude Opus 5 c927560614 Stop gating C2PA confidence on a trust anchor that never ships
High-confidence C2PA attribution required signingCredential.trusted, a status code
the reader emits only when a trust anchor list is loaded. None ships, so from 0.27.0
through 0.30.0 the branch was unreachable in production for every vendor: an intact,
cryptographically bound manifest scored the same medium as a fallback parse that
validated nothing, which collapsed the one distinction the official reader exists to
draw. A hand-built info dict stamping that code kept the branch green in the suite.

Confidence now follows the binding. Signer trust and certificate expiry stay visible
as their own dimensions and as caveats, because a trust list that was never
configured is a missing input, not a finding against the credential. Every committed
provenance fixture with a reader result and an intact binding now reaches high
confidence, and test_no_committed_fixture_reports_a_trusted_signer guards the
reachability itself rather than a synthesized status set.

Revocation joins binding and signature failures as disqualifying. It arrives only on
signer_validity, so a check reading the other two returned a confident AI verdict off
a credential the issuer had disowned, with an empty integrity_clashes -- quieter than
a hash mismatch on the same file. Expiry stays non-disqualifying: it does not imply
the signed bytes changed, and a signature genuinely made outside validity already
arrives as claimSignature.outsideValidity.

The rule now lives in one place. _validation_fields maps status codes to the four
dimensions and names the failures that moved one; c2pa_info_has_invalid_credential
maps dimensions to disqualified. The ingredient-reachability walk and the
user-visible reason both consume that path instead of re-classifying raw codes, so
adding this one rule no longer means editing three layers in lockstep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:04:25 -07:00
Victor Kuznetsov f83da5aee4 Expose Microsoft InvisMark removal signal 2026-08-25 18:52:26 -07:00
Victor Kuznetsov c5764661b4 Release 0.30.0
pre-commit: 1) maintain.sh - exit 1 on uv-secure, lightning PYSEC-2026-3624 unchanged from 0.29.0, no fixed release exists, vulnerable API unreachable (no load_from_checkpoint in project or trustmark); ruff, pyright src/, and 1391 tests passed separately; 2) /simplify - version bump only; 3) docs sync - no version refs outside pyproject, __init__.py, uv.lock; 4) CLAUDE.md - no change
2026-08-25 16:52:27 -07:00
Victor Kuznetsov 97cba11efe Read the InvisMark soft-binding algorithm and its signed watermark id
Reachable c2pa.soft-binding assertions now surface their exact alg and the
bounded printable block value next to the normalized vendor label; a value
without its algorithm stays hidden because it cannot be attributed.
com.microsoft.invismark.1 uses that value as the pixel-watermark identifier
in Microsoft Paint output, so identify names it, metadata carries it, and an
InvisMark soft binding keeps the invisible-removal gate fail-safe even after
C2PA asset binding goes invalid. Content fingerprints still do not trigger
pixel regeneration. Removal is verified against Microsoft's Content
Provenance Detection API, which reports Watermark and C2PA separately; the
protocol and the pixel-identical control requirement are documented.

Implemented in a parallel session; verified, gated, and committed by pi.

pre-commit: 1) maintain.sh - exit 1 on the known uv-secure lightning PYSEC-2026-3624 triage (no fix available, unchanged from 0.29.0); ruff, pyright src/, and 1391 tests passed separately; 2) /simplify - single-pass, clean; 3) docs sync - five docs updated by the author session, no remaining references found; 4) CLAUDE.md - invariants recorded in module-internals, no change needed
2026-08-25 15:54:26 -07:00
Victor Kuznetsov 8871eb3f67 Release 0.29.0
pre-commit: 1) maintain.sh - exit 1 on uv-secure, lightning PYSEC-2026-3624 unchanged from 0.28.1, no fixed release exists, vulnerable API unreachable (no load_from_checkpoint in project or trustmark); ruff, pyright src/, and 1386 tests passed separately; 2) /simplify - version bump only; 3) docs sync - no version refs outside pyproject, __init__.py, uv.lock; 4) CLAUDE.md - no change
2026-08-25 13:25:33 -07:00
Victor Kuznetsov 4beb61463a Say metadata check and strip results are not a clean verdict 2026-08-25 13:22:38 -07:00
Victor Kuznetsov 0d11e4099c Fix adaptive text restoration crop bounds 2026-08-25 12:44:05 -07:00
Victor Kuznetsov fa83c1444f Add geometry-only text manifest schema 2026-08-25 11:33:21 -07:00
Victor Kuznetsov f52e40ae24 Name Copilot, Dreamina, and Qwen in C2PA source labels 2026-08-21 14:43:39 -07:00
Victor Kuznetsov 0aa03c4e89 Release 0.28.1
pre-commit: 1 maintain.sh - uv-secure lightning PYSEC-2026-3624 known, no fix, same as 0.28.0; ruff/pyright/1378 tests passed; 2) /simplify - version bump only; 3) docs sync - no refs; 4) CLAUDE.md - no change
EOF
)
2026-08-19 21:46:14 -07:00
Victor Kuznetsov 91aa49b03a Allow verified-text restoration on tiled images
Tile the Qwen VAE donor the same way as the global pass. Glyph restore already runs on the blended full frame, so the old tile ban was an artificial gate.
2026-08-19 21:33:22 -07:00
Victor Kuznetsov ee865e95e4 Add geometry draft mode and pad silhouette descenders
pre-commit: 1 maintain.sh - not rerun full, ruff + 34 targeted tests passed; 2) /simplify - pad lives in silhouette crop, stable=False reuses probes; 3) docs sync - no README/cli refs to update; 4) CLAUDE.md - no change
EOF
)
2026-08-19 19:53:38 -07:00
Victor Kuznetsov 3ba4ace7e0 Disable mkldnn in the draft engines (linux PIR crash) 2026-08-19 10:22:58 -07:00
Victor Kuznetsov e938b57f8c Package the verified-text draft annotator as text_draft
Move the draft-annotation logic (PaddleOCR line detection, word-box
grouping, three script-chosen recognition engines, crop-jitter
stability gate) from the evaluation-only scripts into the installable
package, with lazy paddle imports and a text-draft extra (CPU, no
torch). draft_text_lines() returns accepted (crop-stable, NEVER
ground-truth-correct - precision on the reference posters was 90.0% /
94.4%) and rejected proposals; source_pixel_sha256 is re-exported for
manifest building. scripts/infer_text_lines.py now dogfoods the
package module instead of loading the eval script by path.
2026-08-19 10:13:22 -07:00
Victor Kuznetsov 3cd1e47935 Add the oracle-measured Google strength floor to qwen-zimage
The resolution curve's 0.154 top left the 4.33 MP CJK-sign fixture
SynthID-detected x3 in Gemini on the full production path (visible
stage -> qwen-zimage seed 0 -> resize-back -> metadata strip), with a
valid pixel-identical stripped control in the same session
(2026-08-18). Google-provenance content now resolves to the flat
QWEN_ZIMAGE_GOOGLE_STRENGTH 0.30 floor instead of the area curve -
anchors at 0.30 measured clean in Gemini on two fixtures (CJK sign +
18-face) at 3/3 checks across two work accounts, and stayed clean
under the vae-glyphs donor layer. openai/unknown content keeps the
curve; an explicit strength still wins.
2026-08-18 22:34:41 -07:00
Victor Kuznetsov 2298282b8b Thread fidelity_anchor through WatermarkRemover
The engine forwards the new kwarg into WatermarkRemover.remove_watermark,
which rejected it with TypeError on the real (non-fake) path - caught by
the deployed Modal smoke test, not by the unit fakes.
2026-08-18 21:32:57 -07:00
Victor Kuznetsov 42d427731f Turn the verified-text fidelity anchor off by default
The whole-frame 15% Qwen-VAE blend returned detector-visible OpenAI
SynthID on poster-scale manifests through the engine text-manifest
path (official Content Provenance API, 2026-08-19: restored detected
x6 with the anchor, clean x6 without it; base outputs clean x6;
pixel-identical stripped controls detected, proving the pixel channel).
Add fidelity_anchor=False to remove_watermark and InvisibleOptions and
--fidelity-anchor on the CLI to reproduce the 0.27.0 research
behavior. Text-box MAE cost of the new default is under one point on
all three fixtures (11.60->11.72, 7.79->7.86, 7.57->8.13).
2026-08-18 21:23:25 -07:00
Victor Kuznetsov 2e1428406e Release 0.27.0 2026-08-18 11:02:18 -07:00
Victor Kuznetsov 21ce1dfadd Cover QuickTime TC260 placements, Luma AI tags, and MiniMax video provenance 2026-08-17 19:38:37 -07:00
Victor Kuznetsov 432b63b6d7 Add verified text restoration 2026-08-15 12:25:35 -07:00
Victor Kuznetsov 2eab24a2e1 Validate C2PA credentials before attribution 2026-08-15 11:31:59 -07:00
Victor KuznetsovandClaude Opus 5 4855586834 Halve decoder memory with strip processing and a raveled Haar pass
Each Haar pass is one flat pywt.downcoef call over a raveled strip instead of
pywt.dwt(..., axis=1)[0], and the plane is walked in strips so no full-plane
float64 intermediate exists. Exact only while the last axis is even, so
_approximation raises on an odd width rather than returning wrong bits, and
TestRaveledHaarPass pins both that raise and the downcoef/dwt equivalence a
pywt upgrade could take away.

Drops the block constructor knob: the fold chains are written for 4, nothing
ever passed another value, and a knob that silently decodes wrong is worse
than no knob.

Peak RSS 111 MB to 21 MB on a 4.3 MP image; the decoder itself 0.011s to
0.007s, which is only 0.4% of identify() now that it is under 2% of the run.
Output bits and detector verdicts over 200 sampled data/ images, two
synthesized carriers and eight degenerate shapes are byte-identical to the
pre-vectorization decoder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:33:34 -07:00
Victor KuznetsovandClaude Opus 5 37789e02f6 Vectorize the DWT-DCT decode path, 15x on the decoder
Output stays bit-identical: decoder bits and detector verdicts recorded over
200 sampled data/ images plus two synthesized carriers before and after, and
the record is byte-identical.

Measured on a 1536x2816 image -- decoder 0.280s to 0.016s, warm identify()
1.757s to 1.365s with both arms timed in one process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 11:59:56 -07:00
Victor Kuznetsov b1319b1d5a Release 0.26.3 2026-08-09 21:15:15 -07:00
Victor Kuznetsov 353bc5f12c Harden TrustMark detection with an official fixture 2026-08-09 21:08:09 -07:00
Victor Kuznetsov 015df40f49 Release 0.26.2 and document distribution surfaces 2026-08-09 00:27:10 -07:00
Victor Kuznetsov 9b656513f1 Improve C2PA attribution and ByteDance metadata handling 2026-08-08 19:34:40 -07:00
Victor Kuznetsov 14b7247e0b Fix SynthID provenance evidence and release 0.26.1 2026-08-06 18:17:40 -07:00
Victor Kuznetsov acd7092477 Merge remote-tracking branch 'origin/main' into research/video-synthid-quality-groundwork 2026-08-05 21:46:19 -07:00
Victor Kuznetsov a83952e375 Add versioned forensic metadata transports 2026-08-05 21:10:55 -07:00
Victor KuznetsovandClaude Opus 5 1124c591be Match the vendor registries against metadata, not coded pixels
The registries are raw substrings and the shortest tokens are four and five bytes
(`Bria`, `Adobe`, `Canva`). Over a megabyte of compressed pixel data such a sequence
turns up by chance: `Bria` matched inside the entropy-coded scan of 4 of 14,707
corpus JPEGs, in none of which the manifest names Bria. The rate is what a four-byte
pattern predicts on that corpus, and the Bria entry asserts AI, so a chance match can
declare an image AI-generated rather than merely mislabel its signer.

`_metadata_region` gives the registry scans the container's metadata: JPEG marker
segments before the coded scan, PNG chunks other than IDAT, both trailers, and
whatever `scan_head` appended past the window. Every other check keeps the full
buffer -- their markers are long and distinctive. A container that does not parse is
returned whole, since dropping real evidence to avoid a chance match is the wrong
trade. `c2pa_marker_in` already refuses a bare `c2pa` substring for this reason;
this is the same defence for the registries.

Verified the way the rules require for a change that MOVES a verdict: over all 48,905
corpus images, exactly one file changed, the one named in advance, from
"C2PA Content Credentials (Bria Artificial Intelligence)" to "(unknown signer)".
Record-path parity is 0 disagreements, down from 75 when this work started.

The audit's own baseline comparison is fixed here too. It compared confidence and
signals only, and so reported "0 changed" for the run whose single intended
correction was a watermark line -- the change it exists to show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:55 -07:00
Victor KuznetsovandClaude Opus 5 9a29dcac8a Keep the pixel forensics in the library, drop the ai-score tooling
`scripts/ai_score.py` and the dataset scanner that fed it are gone: the detector
they trained is not something this project runs, and the corpus lived outside the
repository anyway. Nothing else referenced them.

The scanner's pixel layer was worth keeping, so it moves into the package as
`pixel_evidence.py` -- six families of scale-robust statistics (block-DCT histograms
and Benford deviation, FFT band energies and CFA peaks, high-pass residual, error
level, gradient, colour) measured in a single shared decode. The arithmetic was
verified against the scanner over 60 corpus images, families and artifacts alike,
before the scanner was removed; that comparison is no longer possible, which is why
the tests now pin behavior instead: determinism, empty-not-wrong on images too small
for a family, and one failing family not taking the others with it.

It has no consumer. Nothing in the package reads it, and the module says so.

`artifacts=True` returns the spatial layer -- perceptual hash, 128px thumbnail,
coarse ELA/residual/phase maps. Those identify the source image rather than describe
it, so they are opt-in and separate: everything else is a scalar or a fixed-length
histogram nothing can be reconstructed from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:55 -07:00
Victor KuznetsovandClaude Opus 5 b5da0510c9 Release 0.26.0
Updates the three version sources the release doc names -- `pyproject.toml`,
`__init__.py`, and the root package entry in `uv.lock` -- and carries the marker
simplifications uv produced when it re-resolved the lock.

The release itself is not started here: the tag, push, and GitHub Release are the
remaining steps, and PyPI publishing triggers on the published Release rather than
on a tag push.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:38 -07:00
Victor KuznetsovandClaude Opus 5 bebff368fc Decide the SynthID proxy in the verdict, where both extractors meet
A full-corpus audit of the record path against the file path found 75 of 48,905
images disagreeing, and 74 were one gap: the SynthID byte scan for containers whose
manifest no parser reaches lived in `get_ai_metadata`, an extractor the record path
does not run. The record silently reported no SynthID for images `identify` flagged.

Moving the scan into `identify_from_evidence` fixes it by construction rather than by
copying the rule into a second extractor -- the same shape `soft_binding` already
uses. Its byte checks mirror `metadata.synthid_source` literally instead of reusing
the broader `has_c2pa` / `c2pa_source_kind` derived above, so the file path's answers
do not move: verdicts over a 4,000-image sample are byte-identical.

`scripts/record_parity_audit.py` is the audit itself, now repeatable. It walks a
dataset, judges every image through both seams with the record round-tripped through
JSON, and reports disagreements by field and by signal. The rule in
`.claude/rules/development.md` says to re-run both sides of this seam after changing
either; this is what to run.

Both timing and audit scripts now put the package's OWN `src` on the path. From a
worktree an editable install resolves to the main checkout, so the audit imported a
different tree than the one under test -- the failure the same rules file warns about,
reproduced within an hour of writing it down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:38 -07:00
Victor KuznetsovandClaude Opus 5 2668f1302d Read WebP metadata past the scan window and surface C2PA reader failures
Three gaps found while measuring the record path against the file path, each one
a signal the library could not see:

WebP stores `XMP ` after the pixels, so on any WebP above the scan window a fixed
read stops short of the label. `_riff_late_metadata` steps over the coded image to
reach it, the RIFF analogue of the existing PNG and ISOBMFF readers. Three corpus
files hid an IPTC "Made with AI" tag and a C2PA `trainedAlgorithmicMedia` there.
The decoder-backed fallback now covers only what it is actually for -- metadata the
raw bytes do not spell, such as a compressed PNG `zTXt` packet.

A C2PA reader failure returned the same `None` as a file with no manifest, so a
verdict could fall back to the raw byte scan with no trace anywhere. Failures now
log at warning and only genuine ones do: a file without credentials never reaches
that branch, and an unsupported container is demoted to debug through the reader's
own `C2paError.NotSupported`. The first corpus run with it found a truncated PNG.

`scan_dataset.py` never registered the pillow-heif opener it declares as a
dependency, so every HEIC was scanned as unreadable -- no EXIF, and a pixel layer
that was 397 of 406 features NaN instead of 136.

`_riff_late_metadata` caps its total like `isobmff.scan_c2pa_region` does. Clamping
each chunk to the bytes remaining is not enough on its own: one chunk can declare a
length spanning most of the file, and this runs on the memoized verdict path over
images from arbitrary sources.

Also lands `identify_metadata_record` and `ProvenanceReport.to_dict()`, the
one-call entry point and the versioned JSON contract for the record path.

Record-vs-file equality holds over 3,478 corpus images, and the eight files these
fixes recovered still report AI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:38 -07:00
Victor KuznetsovandClaude Opus 5 0c5961a0ed Add a portable metadata record so collection and verdict can run apart
`collect_metadata_record` returns a JSON-safe record carrying an image's
provenance metadata regions -- never its pixels -- and the existing
`evidence_from_metadata_record` + `identify_from_evidence` build the verdict
from it without opening the file. The contract is equality with
`identify(path, metadata only)`, verified over the tracked fixtures and over a
local corpus of 3,478 images (every file carrying a rare signal, plus a random
slice): zero differences.

Three placements defeated earlier drafts and each is now a rule with a test:
the `scan_head` buffer is the head CONCATENATED with late metadata, so a
structural walk must read the raw head instead; Samsung splits its evidence
between a post-EOI trailer and the coded scan; and PIL's info keys must be
emitted in the file path's candidate order, since the first token match wins.

Also fix a real detection gap found while establishing that equality: a label
the decoder can read but a raw byte scan cannot -- a compressed PNG `zTXt`
packet, or a WebP XMP chunk past the scan window -- was invisible to
`identify`. Eight corpus files carrying a China TC260 AIGC label or an IPTC
"Made with AI" tag were reported as no signal at all.

`scripts/detection_timing.py` and its report script measure the metadata path
per method; they write outside the repository and are read-only over a dataset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:38 -07:00
Victor KuznetsovandClaude Opus 5 8fe0b0110f Make the video SynthID operating point measurable and hard to move silently
The shipped profile was certified by one oracle row, but only noise_std was
pinned: long_side and fps -- two thirds of what the verifier was actually shown
-- could move with a green suite. The test now derives the pin from
data/evaluations/video-synthid-oracle.csv, so a default without a certifying row
fails.

The certified profile is a perturbation-to-signal ratio, not a bare noise_std.
sd-vae-ft-mse publishes no scaling_factor key, so 0.18215 comes from the
AutoencoderKL class default under an upper-unbounded diffusers pin. The loader
now gates that value, carries it on VideoVaeRuntime, and passes it into encode
and decode so the validated value is the applied value. video_synthid_sweep.py
loads through the same function: the harness producing the certified rows was
the one path exempt from the gate it exists to feed.

psnr_db is measured against the already-resized frame and before the encoder, so
it cannot see the downscale, the decimation, or the codec, and no in-loop metric
can. scripts/video_fidelity_probe.py scores the delivered file end to end,
streaming the way the engine does and sharing its frame-selection rule rather
than copying it -- a frame-count check cannot catch a rule that reorders frames
without changing how many.

The manifest gains source geometry, vae, track, verbatim verdict and session
fields. The two 2026-07-31 rows keep them empty: they were never recorded and
are not recoverable. Verdicts now have four states, because the verifier's
unclear reading logged as not_detected is the silent regression the manifest
exists to prevent.

docs/video-synthid-quality-research.md records the research behind this: the
noise axis is worth about 2 dB and is nearly exhausted, resolution is the real
prize but is an uncertified destruction axis rather than a free win, and every
proposed autoencoder swap was refuted. First local measurements included.

Verified: engine output is byte-identical before and after the refactor on a
locally built clip, at noise_std 0.00 and 0.15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:17:38 -07:00
Victor KuznetsovandClaude Opus 5 4a896cd4b5 Make InvisibleOptions engine knobs only and pin the forwarding
`InvisibleOptions` promises in its docstring that every default mirrors
`InvisibleEngine`. Two fields made that promise cost something to keep: `force` is
not an engine parameter at all, and `controlnet_scale` was a third spelling of the
engine's `controlnet_conditioning_scale`. The mirror test carried an exception
table for each. This removes both, so the comparison needs no exceptions -- a field
that needs one is a field that belongs somewhere else.

`force` decides WHETHER the engine runs, which is settled before it is built, so it
joins `backend` and `sensitivity` as a parameter of `remove_all` and `remove_batch`
and is threaded to `_run_invisible` as its own argument. `controlnet_scale` takes
the engine's own name; the click option stays `--controlnet-scale` and is now
translated exactly once instead of at three forwarding sites.

Safe to do today: both symbols landed after 0.25.0 and have never been published.

The forwarding turned out to be the weaker half. A defaults comparison cannot see a
hardcoded literal at the seam, and `_run_invisible` passed the entire suite with
`controlnet_conditioning_scale` pinned to a constant. Each of the two knobs also
reaches the engine through TWO paths -- `remove_all` versus `remove_batch(mode="all")`
for `force`, `_run_invisible` versus `_batch_engine` for the scale -- and guarding one
left the other free to hardcode with a green suite. So:

  * `test_every_field_arrives_at_the_engine_with_the_caller_s_value` drives the real
    seam with all 13 fields set off their defaults; mutating any one of them to its
    default fails it.
  * `test_force_reaches_the_scrub_gate_in_every_scrubbing_mode` and
    `test_batch_controlnet_scale_flows_to_the_cached_engine` are parametrized over
    both modes, so neither path can be pinned alone.

Also fixes an order-dependent test surfaced by the added tests reshuffling the xdist
shards. `test_visible_path_decodes_file_once` counted every `image_io.imread` in the
process, but the Gemini engine loads its own bundled capture assets on first
construction, so the count was 3 on a cold engine and 1 on a warm one and the test
passed only when an earlier test happened to build the engine first. It now counts
decodes of the SOURCE, which is the invariant it exists for, and still fails when the
shared decode is broken. The production path was never wrong: the source bitmap is
decoded exactly once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:33:54 -07:00
Victor KuznetsovandClaude Opus 5 13095fb45c Verify every doc claim against the source and fix what drifted
Every code-referencing claim in the docs, the README and the rules files was
checked against src/, and each finding was re-derived independently before it
was applied. 35 held, 5 were false positives.

Two of them were code, not text. `InvisibleOptions` promises in its docstring to
mirror `InvisibleEngine`, and two defaults had silently stopped:
`max_resolution=None` reached `_target_size`'s `max_resolution > 0` and raised
`TypeError` on every library call that left the options alone, and
`cpu_offload=True` made a library run slower than the identical CLI run. Both are
fixed, and `TestInvisibleOptionsMirrorTheEngine` compares the two signatures
field by field rather than pinning the two values that happen to be known. A
companion assertion in `TestTargetSize` reads the engine's own declared default,
so a drift on the engine side -- which the mirror check alone would accept,
because both sides would still agree -- fails too.

The user-facing docs: README called `invisible` GPU-optional where it raises
without CUDA, and gave the image `metadata` command `video metadata`'s output
rule, promising the source survives a command that overwrites it. Yuanbao was
missing from the supported-mark list. `veo` was listed among the video policies
that require a run anchor, though its row sets no `anchor_iou`.
`known-limitations` called ControlNet the default profile and contradicted
itself ninety lines below. An unescaped pipe truncated the `hailuo` table row.
The `dev` extra, the CI shape, ffmpeg's role, the sdist boundary and the
strength-curve range were corrected, and `remove_all`/`remove_batch`, the pill
gate, `erase --keep-metadata` and `all`'s CUDA failure mode were documented.

Research notes that described removed modules, extras and flags in the present
tense now say so once in the page banner instead of sentence by sentence, which
covers the whole page rather than the lines that happened to be noticed, and one
fixture is referred to by role rather than by name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:38:04 -07:00