imagehide / spec / model details

Model details — full reference

locked 2026-04-25 deployed: idea097/d001 (8/light)

Full reference for the deployed carrier (idea097/d001, 8/light backbone) shown on imagehide-demo: payload + ECC layout, architecture, training, eval pipeline, API, diagnostics, constraints. For a one-page overview see spec; for the design rationale (why patch-tile, why α=1, what was rejected) see design. Paper name MWIP, internal A-tolerant.

Highlight any text → 💬 comment. Publishes to GitHub Issues; optional 🤖 Ask Claude to fix tag for action items.

§1Identity

fieldvalue
internal nameA-tolerant (do not use "β-minimal" — obsolete)
paper nameMWIP — Minimal Whole-Image Provenance
versionINN v3 — locked 2026-04-25 after two codex review rounds
payload sizeexactly 896 b in pixels
payload format[ H(128) | sig(512) | pk(256) ]
scopewhole-image, one signature per image. No tile grid, no localization. Crop = tamper.
trust modeloffline content-binding (signature over H from pixels alone) + online registry lookup for device validity
deliverycanonical-resolution / canonical-trim. Out-of-scope: crop, rotate, large platform resize that breaks the 32×32 patch grid.
codesrc/inn_model.py · src/exp0_inn_train.py · src/exp0_inn_eval.py · src/robust_hash.py
full rationaledocs/inn_arch_notes.md (paged-out into design.html eventually)

§2Architecture

This section is the precise spec for the deployed carrier — autohide/.../1024bit_noamp_keepckpt/idea097_design001, the same checkpoint that powers the public imagehide-demo. Variable / module names in this section refer to autohide/.../idea097_design001/code/inn_model.py (verified against train_args.txt: --num_blocks 8 --subnet_type light --secret_len 1024 --no-amp). The simpler "uniform mean-pool" patch-tile description in design § 3 is the v3 reference design; the deployed carrier is the v3 base plus three small extensions (idea031 MoE decode router, idea034 DWT cover predictor, and the 12-permutation balanced-scatter adapter) carried forward from the autohide design line.

§2.1 Notation

symbolshapemeaning
x = host RGB(B, 3, H, W)cover image, x ∈ [-1, 1], with H, W ≡ 0 (mod 64)
m = bits(B, N)ECC codeword fed into the carrier, m ∈ {0, 1}; deployed N = 1024 (BCH(127,99) + RS(128,100) over the 896-b data payload)
xw, w(B, 12, h, w)host DWT and secret DWT respectively, with h = H/2, w = W/2
y = (y1, y2)(B, 24, h, w)INN output split halves; y1 becomes container DWT, y2 is the discarded info channel
x̂ = container RGB(B, 3, H, W)watermarked image, x̂ ∈ [-1, 1]
l = bit logits(B, N)recovered logits; bits = sign(l) > 0
C = 12—DWT channel count (3 RGB × 4 subbands: LL, LH, HL, HH)
S = 1024—grid slot count = 32 × 32 (fixed, not a function of resolution)
host RGB (B,3,H,W) Haar DWT2 fixed · 0 params x_w = host_dwt (B,12,H/2,W/2) bits ∈ {0,1}^1024 ECC codeword PatchBitAdapter 12 balanced perms · scramble w = secret ±1 (B,12,H/2,W/2) DKiS INN — 8 blocks · light coupling · kaiming×0.1 init · ~0.167 M params forward: (x_w, w) → (container_dwt, z) reverse: (c_dwt_aug, 0) → (host_rec, secret_rec) Haar iDWT2 → container RGB (B,3,H,W) CoverPredictor (idea034) — extract-only DecodeRouter (idea031) — extract-only forward (embed) reverse (extract)

§2.2 Embed algorithm

# inputs: x ∈ ℝ^{B×3×H×W}, [-1,1];  m ∈ {0,1}^{B×N}, N=1024.
# preconditions: H % 64 == 0, W % 64 == 0.

x_w   = haar_dwt2(x)                              # (B, 12, H/2, W/2)  — fixed, 0 params
w     = PatchBitAdapter.encode(m, H/2, W/2)       # (B, 12, H/2, W/2)  — see §2.4
y     = INN_forward(concat[x_w, w], keys_1..8)    # (B, 24, H/2, W/2)  — see §2.3
c_dwt = y[:, :12]                                 # first half kept
x̂     = haar_idwt2(c_dwt)                         # (B, 3, H, W)  — fixed, 0 params
# discard:  y[:, 12:] is z; not stored, not transmitted.

§2.3 Extract algorithm

# inputs: x̂ ∈ ℝ^{B×3×H×W}, [-1,1] (possibly attacked).
# outputs: l ∈ ℝ^{B×N} bit logits.

c_dwt          = haar_dwt2(x̂)                                      # (B, 12, H/2, W/2)
host_dwt_est   = CoverPredictor(x̂)                                 # (B, 12, H/2, W/2)  — idea034
c_dwt_aug      = c_dwt + 0.5 · (c_dwt − host_dwt_est)              # residual injection
z_zero         = zeros_like(c_dwt_aug)
y_rev          = INN_reverse(concat[c_dwt_aug, z_zero], keys_1..8) # (B, 24, H/2, W/2)
secret_rec     = y_rev[:, 12:]                                     # the recovered w-slot

# idea031 MoE decode combine:
router_w       = DecodeRouter(x̂)                                   # (B, K=2)  softmax
channel_w      = router_w @ expert_bank                            # (B, 12)  per-batch decode weights

l              = PatchBitAdapter.decode_with_weights(secret_rec, channel_w)   # (B, 1024)  — see §2.5

§2.4 PatchBitAdapter (encode side, paper-exact)

Twelve independent balanced permutations πc : {0, …, S−1} × pc → {0, …, h·w − 1} (one per DWT channel c ∈ {0, …, 11}) scatter each slot value to a disjoint set of pixel positions. Permutations are deterministic from seed 0xD1FF5E ⊕ c ⊕ (h · 1000003 + w), so the verifier can reconstruct them from the resolution alone — no extra state shipped with the image.

# Encode (per autohide PatchBitAdapter.encode):
# 1) ±1 bipolar:
b           = 2·m − 1                                    # (B, N)

# 2) place first N of S=1024 slots; pad rest with 0:
slot_full   = zeros((B, S));  slot_full[:, :N] = b       # (B, 1024)

# 3) fixed ±1 sign-mask scramble (seed 0xB175CA10):
scrambled   = slot_full · sign_mask                       # (B, 1024)

# 4) per-channel balanced scatter to the (h·w) DWT pixels:
for c in range(12):
    perm_c            = balanced_permutation(h·w, S, seed = 0xD1FF5E ^ c ^ ...)  # (S, h·w / S)
    secret[:, c]      = scatter(scrambled, perm_c)        # each slot's bipolar value
                                                            #   replicated to (h·w / S) distinct pixels
secret       = secret.reshape(B, 12, h, w)               # magnitude = 1 by construction

Two things to flag against the simpler "32×32 grid → nearest-neighbor tile → broadcast across 12 channels" description in design § 3: (a) the deployed carrier uses different permutations per channel — bit redundancy is spread across both space and DWT channel, not just across space, and (b) at sizes ≠ 256² the permutations are recomputed for each (h, w) and cached; there is no nearest-neighbor up-sample step. The earlier "broadcast across 12 channels" wording is a v3-base simplification, kept in design.html for design rationale clarity; the deployed adapter generalises it.

§2.5 PatchBitAdapter (decode side, paper-exact)

# Decode (per autohide PatchBitAdapter.decode_with_weights):
# 1) gather each slot's pixels per channel and slot-mean:
for c in range(12):
    gathered_c      = gather(secret_rec[:, c].flatten(), perm_c)     # (B, S, p)
    per_chan[:, c]  = gathered_c.mean(dim=-1)                        # (B, S)

# 2) per-batch MoE-weighted channel combine (idea031):
per_slot        = sum_c ( channel_w[:, c]  ·  per_chan[:, c] )       # (B, 1024)

# 3) un-scramble + slice back to N bits:
unscrambled     = per_slot · sign_mask                                # (B, 1024)
l               = unscrambled[:, :N]                                  # (B, N)

The MoE bank is a (K=2, 12) learnable matrix initialized to a fixed subband-prior of [4, 4, 4, 2, 2, 2, 2, 2, 2, 1, 1, 1] / sum (LL × 4, LH/HL × 2, HH × 1) — biasing the combine toward Haar bands that survive JPEG/WeChat low-pass. The DecodeRouter reads the container RGB and produces a length-2 softmax that picks between the two expert vectors.

§2.6 DKiS coupling block (forward / reverse identities)

Each of the 8 blocks Bi defines an exactly invertible map. Let αi = 0.6i, (k1,i, k2,i) be the per-block fixed sign-mask (channel-wise ±1) and 4×4 channel-tile permutation (seed 0xDB15). Then:

# forward step  B_i : (x1, x2) → (y1, y2)
y1 = x1 + α_i · f_i(x2)
x2 = x2 · k_{1,i}
x2 = shuffle(x2, k_{2,i})
y2 = exp( g_i(y1) ) · x2 + h_i(y1)

# reverse step  B_i⁻¹ : (y1, y2) → (x1, x2)   — exact arithmetic, no learning
x2 = ( y2 − h_i(y1) ) / exp( g_i(y1) )
x2 = unshuffle(x2, k_{2,i})
x2 = x2 · k_{1,i}        # k_{1,i} ∈ {-1,+1}, so · k_{1,i} is its own inverse
x1 = y1 − α_i · f_i(x2)

In the deployed model the three subnets fi, gi, hi are LightConvBlock = Conv2d(in→32, 3×3) · LeakyReLU · Conv2d(32→out, 3×3), the second conv re-initialized as kaiming_normal × 0.1 at construction. This breaks DKiS-upstream's exact-identity init that otherwise leaves f = g = h ≡ 0 at step 0 (see design § 2).

§2.7 Parameter inventory (deployed)

moduleparamsnote
Haar DWT / iDWT0fixed orthogonal basis
DKiS INN — 8 × LightConvBlock (f, g, h triples)~ 0.167 M8 coupling blocks · ~20 k each · 6 conv layers per block (2 per subnet × 3 subnets)
PatchBitAdapter — sign-mask + 12 balanced permutations0 (buffers only)seed-derived, stored as register_buffer
PatchBitAdapter — expert_bank (idea031)2 × 12 = 24two K = 2 per-channel decode-weight rows; learnable
DecodeRouter (idea031)~ 12 k4-conv stack reading container RGB → length-2 softmax; extract-only
CoverPredictor (idea034)~ 35 k3-block residual DWT-domain head; extract-only
Total trained~ 0.21 M0.167 M (INN) + ~0.05 M (idea031 + idea034 heads); fp32 disk < 1 MB

The "0.167 M / 0.7 MB" figure quoted in index, spec and earlier drafts of this page counted the INN coupling backbone only. The idea031 router + expert bank and the idea034 cover predictor together add ~50 k params + ~0.2 MB; the deployed checkpoint ckpt_latest.pt is ~0.9 MB fp32 in total. The router and predictor are extract-side only — embedding latency is governed by the 0.167 M INN backbone, so inference-cost numbers for the camera-side embed pipeline remain valid as quoted.

§2.8 Why this stays cryptographically meaningful

All learnable parameters (INN coupling subnets, expert bank, router, cover predictor) sit between bipolar bit input and bit-logit output. The chain is not a cryptographic primitive — it's a noisy channel. Security comes from the Ed25519 signature carried inside the bit payload (which the INN treats as opaque), not from any property of the network. The INN's job is exactly to deliver p > 1 − 14/128 ≈ 0.986 bit accuracy under the threat model so the outer RS(128, 100) succeeds. See §5 Payload & ECC.

§3Components

componentparamsrole
Haar DWT / iDWT 0 spatial → 4-subband decomposition; reversible by construction
PatchBitAdapter (v3) 0 bits ↔ tensor via 32×32 grid + fixed ±1 sign-mask scramble + nearest-neighbor tile + mean-pool decode. See §6.
DKiS INN backbone (8 / light) 0.167 M 8 coupling blocks · LightConvBlock subnets · conv5 kaiming×0.1 init breaks DKiS default identity init
Total0.167 M0.7 MB on disk fp32 (deployed idea097/d001, sibling autohide tree)

§4Deployed backbone 8 / light

One backbone — the deployed mobile target. (Other (num_blocks, subnet_type) points were ablated during development; not maintained.)

backboneblockssubnetparamsnote
8 / light82-conv flat (Light)0.167 Mpowers idea097/d001 on the public imagehide-demo; survives α-ramp; ~24× fewer FLOPs vs. the early 16/RDB prototype

LightConvBlock: Conv2d(in→32, 3×3) → LeakyReLU → Conv2d(32→out, 3×3) with the second conv kaiming-initialized at scale 0.1 to match the small-residual init invariant that breaks the embed-nothing fixed point at step 0. Regular memory access pattern (no dense concat) pipelines well on Apple Neural Engine / Qualcomm Hexagon.

# 8/light is the default — no flags needed.
python src/exp0_inn_train.py  ...

§5Payload & ECC Slepian-Wolf BCH + RS

896 b headline = data. 1024 b actually embedded after channel coding. The trick: instead of transmitting all 128 H bits, send a 28-b BCH syndrome of H — the receiver computes H' from the attacked image and uses syndrome+H' to recover H. Saves 100 bits for RS parity.

layercodebudgetrole
data H(128) + sig(512) + pk(256) 896 b headline payload — the verifiable claim
inner (perceptual side-info) BCH(127, 99) Slepian-Wolf on H 28 b syndrome transmit a 28-b syndrome instead of all 128 H bits; receiver decodes H = H' XOR BCH_decode(S ⊕ BCH_syn(H')). Corrects ≤ 4 bits of PDQ drift.
compressed message syndrome + sig + pk 796 b = 100 B fits RS(128, 100)
outer (channel) RS(128, 100) over GF(2^8) 28 B parity corrects up to 14 byte errors in the 1024-b codeword
embedded RS codeword 1024 b what the INN actually carries in pixels
Why this design saves bits. Naive transmission of all 128 H bits leaves only 16 RS parity bytes (corrects 8 byte errors). The Slepian-Wolf trick saves 100 bits → ~1.75× the channel-error budget.

Robust hash H — PDQ construction

Standard Meta PDQ pipeline through the 256-bit hash, then keep the 128 lowest-frequency bits in zigzag anti-diagonal order. The global 256-coef median is retained (preserves PDQ's ~50/50 bit balance); the 128 high-freq bits are discarded because they drift much more under low-pass attacks.

Rec.601 luma → Jarosz blur → 64×64 decimate → 64×64 DCT-II
              → top-left 16×16 → threshold against median of all 256 coefs
              → 256-b hash → keep 128 lowest-freq bits (zigzag order)
              → BCH canonicalization 128 → 127 (drop one canonical index)

PDQ drift bound — empirically verified

Reproduced on 100 DIV2K_valid_HR @ 512² with the bit-exact src/pdq_js_port.py, across the baseline 9-chain catalog + 4 adversarial multi-pass JPEG chains. Max drift ≤ 3 bits / 128 over the design's threat model — comfortably inside the BCH t=4 budget. Original verification was 30 COCO val2017 × 11-attack sweep in the JS demo.

attack classmax driftwithin t=4?
single + chained JPEG (Q 30–95)2yes
resize 0.5–0.93yes
9-chain baseline (Insta / X / WhatsApp / WeChat etc.)3yes
screenshot-of-screenshot (2× up-down + gamma + 2-pass JPEG)7~4% over budget — if in scope, bump to t=5 (35-b syndrome, still saves 93 b net)
crop (any size)20–50+out of scope — PDQ has no crop invariance
Implementation note. src/robust_hash.py PHash128 is a simpler 12×12 row-major variant — NOT bit-exact with pdq.js (different drift behavior). The PyPI pdqhash package emits bits in an undocumented order that does not preserve frequency locality (~50% agreement with pdq.js). The canonical reference is the JS demo at ~/yanghangAI.github.io/assets/imagehide/.

§6PatchBitAdapter

Zero learnable parameters. Bits ↔ tensor by construction.

stepoperation
1. bipolarbits ∈ {0,1}^896 → ±1
2. grid padplace 896 of 1024 slots in a 32×32 grid (use 896, ignore 128)
3. scramblefixed sign-mask multiply (PRNG seed locked in code)
4. tilenearest-neighbor up-sample to DWT spatial dims (H/2, W/2)
5. broadcastrepeat across 12 DWT channels → secret (B, 12, H/2, W/2)
← decodemean-pool over patch + sign-mask invert + sign-threshold → bit logits

Adapter round-trip is mathematically perfect (validated on smoke test). The patch grid is fixed at 32×32 regardless of image size, requiring H % 32 == 0 and W % 32 == 0 (so DWT dims divide 32 evenly).

Why no learnable α scalar. Codex 2026-04-25: a learnable amplitude reproduces the "embed nothing" collapse mode with one parameter instead of 5.4 M. Dropped. α = 1 fixed.

§7Training

settingvalue
lossimg_weight · L1(container, cover) + bit_weight · BCE(logits, bits)
optimizerAdamW · lr 2e-4 (baseline) / 4e-4 (mobile, sqrt-scaled) · no weight decay · cosine schedule · grad-clip 5.0 · AMP
resolutionvariable. 18-entry (H, W) pool: squares + 5:3 / 7:5 / 16:9 / 12:5, all /64. Batch derived from a fixed pixel budget (default 1.0 M ≈ 256² × 16). See src/multi_res_dataset.py.
batch (mobile, 256² baseline)16 · lr 4e-4 — smaller activation footprint allows 4× the rdb-baseline batch on 11 GB 2080Ti
steps30,000 (cut from 100 k after run 2 collapsed; the architecture-viability decision is reachable by ~30 k under full α)
threat modelcompression-only — JPEG (single + chained) + down-then-up resize. Geometric attacks (crop, rotate) and other photometric attacks out-of-scope per canonical-delivery design.
resumecheckpoint every 2k to ckpt_latest.pt (model + optim + sched + scaler + RNG). Preempt-safe by design.

Smooth Bernoulli α-ramp curriculum

Replaces the 3-stage step function (clean → low → high) that collapsed runs 1+2 at the discrete A→B boundary.

step rangeαwhat happens
0 → 5,0000clean — attack module skipped entirely
5,000 → 15,000linear 0 → 1each kornia op fires at p_max · α; encoder sees a continuously-increasing fraction of attacked batches
15,000 → 30,0001full target severity

AttackLayer (src/attacks.py) — severity="high", α=1

opQ range / scaleBernoulli p (α=1)
resize (down-then-up)scale ~ U(0.5, 1.0)0.5
jpeg1Q ~ U(40, 95)0.9
jpeg2 (chained)Q ~ U(20, 40)0.3

Forward order: resize first, then JPEG — matches platform pipelines (downsize → encode). Output spatial dims preserved by construction.

§8Eval pipeline (src/exp0_inn_eval.py)

9 sizes

#sizesource
1256×256training-matched
21024×1024Insta square / SDXL
31088×1920vertical phone / Reels
41920×1088Full HD landscape
51280×768HD-ish
62048×15363 MP photo
73008×4032iPhone vertical
84032×3008iPhone landscape
9randomper-image, H, W ∈ {256, 320, …, 2048}

14 attacks · 4 categories

categoryattacks
single JPEGq90 / q70 / q50 / q30
JPEG chain85→60 · 70×3
resize only0.9 / 0.7 / 0.5
platform chainchain_insta 0.5+Q85 · chain_x 0.4+Q75 · chain_whatsapp_std 0.5+Q70 · chain_wechat 0.3+Q50

All spatial-dim preserving (down-then-up). Geometric crop/rotate intentionally absent. Platform chains are the load-bearing rows.

Speedup flags

flageffectverdict
--amp torch.autocast(fp16) around embed/extract; PSNR/SSIM/bit decisions in fp32 recommended ~16% wallclock cut; bit_acc max-diff 0.0001 vs fp32 ref. Output → eval_amp.json to preserve byte-reproducible eval.json.
--compile torch.compile(dynamic=True) not recommended 9 distinct sizes + Python-iterated _INNBackbone + generator graph-break ⇒ recompile-per-shape dwarfs any win

Auto-batching

PIXEL_BUDGET_BY_SUBNET = {"rdb": 1_500_000, "light": 8_000_000}. The 8/light variant tolerates a ~5× larger per-batch pixel cap (24× fewer FLOPs, flat activation profile). Override with --pixel_budget. Measured peak VRAM on 11 GB 2080Ti for 8/light v2 = ~2.0 GB, pinned by 12 MP batch=1.

§9Headline results — deployed model (idea097/d001)

100 DIV2K val · 7 sizes (256² → 2048×1536 + random) · 14 in-scope attacks (clean + 4 JPEG + 2 JPEG-chain + 3 resize + 4 platform). Deployed checkpoint trained at 256² for 30 k steps; secret_len = 1024 (post-ECC); --no-amp; lr 4e-4; bit_weight 20.0.

Per-attack bit_acc

categoryattackavg (7 sizes)@ 1024² onlynote
clean—1.0001.000no-attack baseline
single JPEGq90 / q70 / q50 / q301.000 / 0.999 / 0.999 / 0.9961.000 / 1.000 / 1.000 / 1.000platform re-encode
JPEG chain85→60 · 70×30.999 / 0.9991.000 / 1.000cross-platform re-uploads
resize only0.9 / 0.7 / 0.51.000 / 1.000 / 1.0001.000 / 1.000 / 1.000down-then-up resample
platformchain_insta 0.5+Q850.9981.000Instagram feed
platformchain_x 0.4+Q750.9861.000X (Twitter) post-2022 default
platformchain_whatsapp_std 0.5+Q700.9961.000WhatsApp Std / FB feed
platformchain_wechat 0.3+Q500.9550.992WeChat — worst attack overall; 1024² holds 0.99
aggregate avg 0.995 · min 0.955 avg 0.999 · min 0.992 aggregates over the 13 attack rows (clean excluded)

PSNR / SSIM by size

sizePSNR cleanSSIM clean
256×256 (training resolution)35.00 dB0.945
1024×1024 (canonical target)37.35 dB0.940
1280×76837.42 dB0.941
1088×1920 / 1920×108838.13 / 38.16 dB0.939 / 0.937
2048×153638.35 dB0.935
random (per-image)37.51 dB0.940
Takeaways. (1) At 1024² (the canonical capture target) every in-scope attack lands ≥ 0.99. (2) The 0.955 floor on the 7-size avg is dominated by 256², where the patch grid is tightest and WeChat's 0.3× resize + Q50 leaves the least margin. (3) PSNR is > 35 dB at every size and climbs past 38 dB on the larger captures — more pixels dilute the residual. (4) 0.167 M params is empirically sufficient for the full 14-attack threat model. (5) Noise-augmented platform chains (chain_*_full) and Gaussian-noise rows from eval.json are out of the canonical-delivery threat model — see §13.

Source: autohide/experiments/1024bit_noamp_keepckpt/idea097_design001/eval.json (sibling autohide tree, 2026-05-17, step 30 k). Numbers in this section are computed directly from that file. The historical v1 → v2 ablation that lived here (resize-in-training necessity) has moved to the design & plan pages.

§10Inference cost estimated · pre-quantization

modelparamsdisk fp3212 MP fp16 · ~17 TFLOPS NPU
TrustMark Q21 M85 MB~100 ms (claimed)
MWIP (deployed) · 8/light 0.167 M 0.7 MB ~25 ms (~6–12 ms int8)
MetaSeal~300 M~1.2 GBseconds

The deployed 8/light backbone uses LightConvBlock subnets (no dense concat) plus 8 coupling blocks total — small parameter footprint and a flat memory-access profile that pipelines well on phone NPUs. Single-shot photo capture latency is comfortable; 30 fps video is not. Actual on-device measurement is Exp 4.

§11API

from src.inn_model import INNCodec

model = INNCodec(n_bits=896).cuda()

# embed
container = model.embed(host_rgb, bits)             # (B,3,H,W) ∈ [-1,1]

# extract (bit logits)
logits = model.extract(container)                   # (B, 896)

# extract with diagnostic intermediate (added 2026-04-25)
logits, secret_rec = model.extract_with_secret(container)
# secret_rec: (B,12,H/2,W/2) — recovered before adapter.decode

# convenience
container, logits = model(host_rgb, bits)

Inputs in [-1, 1]; bits in {0, 1}; H, W divisible by 64.

Robust hash H

from robust_hash import PHash128, hamming_distance

hasher = PHash128().to(device)
H = hasher(image_tensor)              # (B, 3, H, W) → (B, 128) ∈ {0, 1}
hamming_distance(H_a, H_b)            # element-wise → (B,)

PHash128 here is the simpler 12×12 row-major variant, not bit-exact pdq.js. For the production payload use the pdq.js port at src/pdq_js_port.py.

§12Diagnostics

Per --log_every step. Both secret_rec_rms and secret_rec_err_rms added 2026-04-25 to catch the embed-nothing collapse the moment it starts.

metrichealthycollapse signal
bit_acc, bit_acc_ema→ 1.0flat at 0.5
psnr(container, cover)30–35 dBrises past ~50 dB = container ≈ cover, secret discarded
l1_container_cover~0.01–0.02drops toward 0 with bit_acc still 0.5
secret_rec_rmsstable through ramp (run 1 saw ~93 in clean phase)drops toward 0 = INN killed the secret pathway
secret_rec_err_rmssmall relative to secret_rec_rmsrises toward secret_rms (~1.0) = recovered tensor uncorrelated
alphamatches curriculum schedulediscontinuities = curriculum bug
img_loss, bit_lossfinite, decreasingNaN
Kill criterion. L1 nosedive while bit_acc returns to 0.5 = deep "embed nothing" collapse. PSNR jumping up under noise is the giveaway. Cancel and root-cause; don't wait for an optimizer recovery (it won't come).

§13Constraints

constraintreason
H, W % 64 == 0DWT halves spatial dims; adapter patch grid 32×32 requires DWT dims % 32 == 0; so input H,W % 64 == 0
Inputs in [-1, 1]training convention; (image / 255) * 2 - 1
Bits are {0, 1} not ±1adapter converts internally; logits returned for BCE
Single canonical center-trim at verifyverifier rejects images that don't fit the trim convention; preserves false-accept surface (codex tightening — replaces multi-shift search)
Crop / rotate = tamperby design; no graceful partial verification under crop
Online registry lookup required for device validityfully-offline device trust dropped in v4 (cert chain pushes payload past Exp 0 stall threshold)

§14Known issues