Skip to content

fix(photos): re-bake the histograms against the thumbnails actually served - #535

Merged
oddharsh merged 1 commit into
mainfrom
claude/zenc-histogram-drift
Aug 24, 2026
Merged

oddharsh merged 1 commit into
mainfrom
claude/zenc-histogram-drift

Conversation

@oddharsh

Copy link
Copy Markdown
Owner

The last of the three judgment calls from #525. I flagged zenc histogram --check reporting drift on all 158 photos while photos:check passed, guessed a decoder bump, and deliberately did not act. The guess was wrong and the real answer is worse.

The bars describe thumbnails nobody is served

#394 re-encoded 316 JPEG thumbnails on 2026-08-14, minting a new content hash for each, and re-baked zero histograms. images/histograms.json has been stale ever since. The j-tier hash for L1000069_3 went 0e092e5f → 2b30aedc in that commit and the bars stayed put.

All 158 photos move here: 31.2% of 40,448 bins, mean 0.95 of 100, but 65 photos carry at least one bin past 15 and the worst is 59. That is a visible bar, not decoder noise.

The decoder was the obvious suspect and is innocent. image 0.25.10 and zune-jpeg 0.5.15 are pinned identically at every commit involved, and Cargo.lock has not moved since the pipeline was relocated. What changed is the input, which is the one thing a version pin cannot hold still.

Why nothing caught it, which is the more useful half

photos:check cannot see this and never could. It compares histograms.json against images/meta/, and since #441 made meta/ build output that build.mjs derives from histograms.json, the two agree no matter what the JPEGs contain. Gotcha 24's shape: two values from one source, so the check proves that copying works.

zenc histogram --check does read the pixels, and pointing it at a built tree is guaranteed to report drift whether or not any exists. The index packs each bin into one character over 64 levels (HIST_BASE 63, HIST_LEVELS 64) while zenc computes 0..100, so a build-derived meta/ has been through a lossy 101 → 64 → 101 round trip.

Measured: the derived tree reproduces roundTrip(committed) on 100.00% of 40,448 bins, so the packing is faithful and the drift I reported in #525 was an artifact of where I pointed the tool. The real signal was underneath it and only showed up against the pre-#441 committed meta/, which is unquantised:

comparison agreement
build-derived meta/ == roundTrip(pre-#441 committed) 100.00%
build-derived meta/ == pre-#441 raw 70.28% (the quantisation)
pre-#441 committed == fresh zenc bake 60.62% (the real drift)

The fix

Materialise meta/, re-run zenc against the current j-tier, roll it back through build-histogram-index. The result satisfies zenc's own check (histograms match on disk for 158 photos) and round-trips into the built meta/ at 256 of 256 bins for a spot-checked stem. Only histograms.json changes.

The guard

hash-thumbnails.sh now snapshots each stem's j hash and warns when one moves, naming the re-bake. That script is the choke point where new bytes become served bytes. add-photos.sh re-bakes the whole library on every run and was never at risk; the gap is a standalone reencode-thumbnails.sh, whose own header tells you to re-run hash-thumbnails.sh and stops there.

Controlled both ways: a no-op run stays silent, and a stem whose JPEG bytes change prints the warning naming it. That control mutated real files; the tree was restored to 632 tracked / 632 on disk afterwards and re-verified.

Generalises past histograms

Anything derived from a content-addressed file has to be regenerated when the hash moves, and a hash moving is exactly the event that leaves no diff in the derived file to notice. Written up as gotcha 41.

Verification

354 tests, typecheck, lint, build (0 warnings), photos:check and the 142-route oracle green, re-run after rebasing onto #530 and #531.

…erved

#394 re-encoded 316 JPEG thumbnails on 2026-08-14, minting a new content hash
for each, and re-baked no histograms. images/histograms.json has described
thumbnails nobody is served ever since. All 158 photos move here: 31.2% of the
40,448 bins, mean 0.95 of 100, but 65 photos carry at least one bin past 15 and
the worst is 59, so this is a visible bar rather than decoder noise.

The decoder was the obvious suspect and is innocent. image 0.25.10 and
zune-jpeg 0.5.15 are pinned identically at every commit involved, and
Cargo.lock has not moved since the pipeline was relocated. What changed is the
INPUT, which is the one thing a version pin cannot hold still: the j-tier hash
for L1000069_3 went 0e092e5f -> 2b30aedc in #394, and the bars stayed.

WHY NOTHING CAUGHT IT, which is the more useful half.

photos:check compares histograms.json against images/meta/. Since #441 made
meta/ build OUTPUT that build.mjs derives FROM histograms.json, those two agree
no matter what the JPEGs contain. It is gotcha 24's shape: two values from one
source, so the check proves that copying works.

zenc histogram --check does read the pixels, and pointing it at a built tree is
guaranteed to report drift whether or not any exists. The index packs each bin
into ONE character over 64 levels while zenc computes 0..100, so a build-derived
meta/ has been through a lossy 101 -> 64 -> 101 round trip. Measured: the
derived tree reproduces roundTrip(committed) on 100.00% of 40,448 bins, so the
packing is faithful and that drift was an artifact of where it was pointed. The
real signal was underneath it and needed the pre-#441 committed meta to see.

THE FIX AND THE GUARD.

The data: materialise meta/, re-run zenc against the current j-tier, roll it
back through build-histogram-index. The result satisfies zenc's own check
("histograms match on disk for 158 photos") and round-trips into the built
meta/ at 256 of 256 bins for a spot-checked stem.

The guard: hash-thumbnails.sh snapshots each stem's j hash and warns when one
moves, naming the re-bake. That script is the choke point where new bytes become
served bytes. add-photos.sh re-bakes the whole library every run and was never at
risk; the gap is a standalone reencode-thumbnails.sh, whose own header tells you
to re-run hash-thumbnails.sh and stops there.

Controlled both ways: a no-op run stays silent, and a stem whose JPEG bytes
change prints the warning naming it. The control mutated real files and the
tree was restored to 632 tracked / 632 on disk afterwards.

353 tests, typecheck, lint, build, photos:check and the 142-route oracle green.
@github-actions

Copy link
Copy Markdown
Contributor

Wire-size diff vs merge base

e2284df → f0ff7ff

Brotli q11, the quality the edge serves. Every number here is deterministic:
an unchanged file produces no row. Advisory — this check fails on nothing.

Client assets

No change. 22 files, 77.74 KiB Brotli total.

Pages

Total Brotli: 531.29 KiB → 531.30 KiB (+0.01 KiB, +0.0%) across 50 files, 1 changed.

No single file moved by 0.13 KiB or more.

Plus 1 file under the 0.13 KiB floor, net +0.01 KiB — usually content-hash churn in /a/ references, counted in the total above.

Worker bundle

No change. 237.86 KiB gzip.

Dictionary deltas (pd/)

146 → 146 deltas, 492.24 KiB → 494.41 KiB (+2.17 KiB).

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 24, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
aadhar-sh af571b1 Aug 24 2026, 01:41 AM

@oddharsh
oddharsh merged commit 524f74b into main Aug 24, 2026
7 checks passed
@oddharsh
oddharsh deleted the claude/zenc-histogram-drift branch August 24, 2026 02:06
oddharsh added a commit that referenced this pull request Sep 10, 2026
…r the next one (#785)

CLAUDE.md has carried TWO entries numbered 41 since 2026-08-24: #535's
histogram re-encode entry landed on 2026-08-23 and #538 gave the cf-garage
TypeScript-config entry the same number the next day. Twenty-four references
pointed at `gotcha 41` across twelve files. Three meant cf-garage and
twenty-one meant the histograms, and no reader could tell which without
reading both entries.

RENUMBERED THE HISTOGRAMS ENTRY TO 46 rather than shifting 42-45 up by one.
The shift would have rewritten every cross-reference in a 6000-line file to
fix a collision between two of them, over a file whose own layout note warns
that a path rewrite cannot be run blind. So the number moved and the position
did not, and the list now reads 40, 41, 42, 43, 44, 45, 46-where-41-was. It
was already out of order: 22 has sat above 21 since 2026-08-06.

Each reference was resolved from its own sentence, never by pattern. The
reference surface was wider than a grep of CLAUDE.md suggests: eighteen of
the twenty-four live in ci.yml, derivations.json, infra.json, a contract
test, two derive modules and five photo-pipeline files, and two of those
were buried mid-line where a fixed-width context grep missed them on the
first pass.

ONE JUDGMENT CALL, flagged rather than buried. infra.json's token-probe note
("Gotcha 41's lesson through another door") fits both entries: the discriminator
reading suits the histograms entry's self-agreeing check, and the misread-refusal
reading suits cf-garage's "which door it comes through". Git dates settle
nothing, since both entries predate it. It reads as cf-garage, on the refusal
shape and the echoed word, and the reference now NAMES cf-garage so the next
reader inherits a decision instead of the ambiguity.

contract-gotcha-numbers-resolve asserts every number names one entry and every
reference repo-wide resolves to one. UNIQUENESS ONLY, never order: this entry
and the 21/22 pair both break order, and asserting it would invite renumbering
the file to satisfy a test. Both assertions carry a floor, on the a-dict
precedent, since a scanner that has stopped matching reports a clean pass over
an empty list. Three controls, each failing by name: reintroduce the duplicate,
reference a gotcha that does not exist, move the section anchor.

The heading scan is anchored at the section header deliberately. CLAUDE.md
opens with several ordinary numbered lists at column 0, and a whole-document
scan reads those as gotchas 1, 2 and 3 and reports duplicates that are not
there.

No served bytes move: a build here and a build from a detached worktree at
HEAD produce all 1829 files byte-for-byte identical.

Co-authored-by: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant