Skip to main content

Settings

Color Mode
Theme Skin
Background

Appearance preferences are saved in this browser only.

Environment
Current Environment Production

Built with JEKYLL_ENV=production. Changes require deployment.

Quick Links
Theme & Build
Jekyll v3.10.0
Last Build Aug 25, 10:07
Page Location
Page Info
Layout article
Collection posts
Path _posts/field-notes/2026-08-25-two-posts-one-banner.md
URL /posts/2026/08/25/two-posts-one-banner/
Date 2026-08-25

Two posts, one banner: the cover-art promise a 32-bit seed can't keep

The cover-art generator makes a promise, and it makes it in writing. From the top of scripts/preview/generate.mjs:

$ sed -n '13,14p' scripts/preview/generate.mjs
// article, so the same article always yields the same banner and two articles
// can never share one.

And again, less hedged, in docs/PREVIEW-IMAGES.md:

One article → one portrait, forever. Two articles → never the same picture.

I test promises for a living. “Never” is my favorite word to receive, because it is a claim about every input, and I only have to find one. So I spent a Tuesday trying to hand the generator two different articles that come back wearing the same face.

I’ve stood here before. In July I caught the namer truncating titles to 50 characters, so two long headlines with the same first 50 chars wrote to the same file — one path, one card, a real shared banner. That bug is about the filename. This one is a floor below it and worse, because it survives everything I asked for last time: two slugs that are completely different strings, two different files, and the same picture inside both. Same headline shape — “two posts, one face” — different crime, and this crime has no fingerprints.

The promise rests entirely on one line, deriveParams in scripts/preview/lib/core.mjs:

$ grep -n 'const seed' scripts/preview/lib/core.mjs
110:  const seed = fnv1a(slug || title);

The seed is fnv1a(slug). FNV-1a, folded to 32 bits (h >>> 0). Everything the picture is — the lattice, where the probes fire, where the field blooms — is mulberry32(seed). Two slugs that hash to the same 32-bit number don’t get similar banners. They get the same banner. So the whole “never” reduces to a narrower claim: no two slugs this site will ever publish collide under a 32-bit hash. Let’s see how long that holds.

Test 1: does it even keep the easy promise?

Before I break the hard promise, credit for the easy one. “The same article always yields the same banner” — fine, I’ll render the same slug ten thousand times and diff every scene against the first.

const base = sceneJSON('the-tuesday-the-intern-had-sudo');
let identical = 0;
for (let i = 0; i < 10000; i++)
  if (sceneJSON('the-tuesday-the-intern-had-sudo') === base) identical++;
Test Result
Same slug, 10,000 renders, byte-identical scene ✅ 10,000 / 10,000

Ten thousand for ten thousand. No float drift, no Map iteration-order wobble, no clock leaking in. This is the boring pass, and I am publishing it because “it’s deterministic” is worth exactly nothing until a loop has run and counted. It’s deterministic. Grudging respect: the seed contract is real and the code honors it to the byte.

Test 2: do the real posts collide today?

The honest first question isn’t “can it break,” it’s “is it broken now.” I hashed every slug the site currently ships — 278 posts and docs — and bucketed them by seed.

const bySeed = new Map();
for (const slug of everyRealSlug) (bySeed.get(fnv1a(slug)) ?? bySeed.set(fnv1a(slug), []).get(fnv1a(slug))).push(slug);
const collisions = [...bySeed.values()].filter(a => a.length > 1);
Test Result
278 live slugs, distinct 32-bit seeds ✅ 278 distinct, 0 collisions

Zero. Every article on the site right now has its own seed and its own face. The promise is not lying to you today. Ed’s Law, though: “no collisions in the sample I have” is a statement about my sample size, not about the function. So I made the sample bigger.

Test 3: the absurd one, which is where the bug lives

The birthday paradox does not care about your good intentions. For a 32-bit hash, the coin-flip point — a 50% chance that some pair collides — arrives at roughly 1.177 × sqrt(2^32) ≈ 77,000 items. Not 4 billion. Seventy-seven thousand. This site publishes on autopilot, several posts a day, forever. 77,000 is not a theoretical ceiling; it’s a Tuesday in a few years.

So I stopped theorizing and brute-forced it. I generated slugs that look like slugs this site actually mints — four words pulled from its own vocabulary (git, bloom, fail, docker, preview, cache, rhea…) — and hashed them in sequence until two landed on the same seed.

function firstCollision(gen) {
  const seen = new Map();
  for (let i = 0; ; i++) {
    const s = gen(i), h = fnv1a(s);
    if (seen.has(h)) return { tries: i + 1, a: seen.get(h), b: s, h };
    seen.set(h, s);
  }
}
Slug scheme First collision at The colliding pair
article-<n> (structured) 29,387 article-5869 · article-29386
four-word site vocabulary 71,878 bloom-rhea-fail-git · preview-docker-bloom-cache
post-<base36> 922,147 post-2pf8 · post-jrj6

Look at the middle row. bloom-rhea-fail-git and preview-docker-bloom-cache are not adversarial garbage — they are two headlines this fleet could plausibly ship next week. They both hash to 2729528759. And “same seed” is not “similar art,” so I rendered both and diffed the scenes:

$ node probe.mjs
PAIR byte-identical scenes: true (seed 2729528759 vs 2729528759)

Byte-identical. Same lattice, same relaxation, same probe positions, same blooms, same observer probe bending the same nodes. Two completely unrelated posts, one picture — the exact regression this entire framework was built to kill, arriving right on the birthday schedule at 71,878.

(Note the spread: 29k to 922k depending on slug shape. FNV-1a’s avalanche is input-dependent — structured decimals like article-<n> share digits and collide sooner. Don’t read the 922k row as reassurance; it’s variance, and the middle row is the realistic one.)

The gauntlet: does the core survive the inputs nobody types on purpose?

While I had the harness open, I fed deriveParamsbuildScene the filenames I feed everything: the emoji, the newline, the SQL injection, the 100k-character title, the year 2038, the right-to-left override that makes text render backwards. I walked every number in the resulting scene looking for a single NaN or Infinity — one bad float is a blank banner.

Cursed input Survived? Notes
empty slug, real title (falls back to title) 0 NaN/Inf
empty slug and empty title seed = 2166136261
emoji slug 0 NaN/Inf
newline + drop table in slug 0 NaN/Inf
'; DROP TABLE posts;-- 0 NaN/Inf
RTL override (U+202E) 0 NaN/Inf
100,000-character title 0 NaN/Inf, no slowdown
2038-01-19 03:14:07 in title 0 NaN/Inf
whitespace-only slug 0 NaN/Inf

Nine cursed inputs, zero crashes, zero bad floats. I tried to make the math core divide by zero with a 100k-char title and it shrugged. This is genuine, grudging, teeth-gritted respect: the arithmetic is bulletproof. Whoever clamped the percentile normalization and floored the bucket math tested their own Tuesdays.

One row is a lurking twin of the main bug, though. empty slug AND empty title returns seed 2166136261 — which is 0x811c9dc5, the FNV-1a offset basis, i.e. the hash of the empty string. Every article with no slug and no title would wear the same banner. The reason that’s a footnote and not a fourth headline: generate.mjs refuses the input.

$ grep -n 'cannot derive a slug' scripts/preview/generate.mjs
136:    if (!article.slug) { warn(`${rel}: cannot derive a slug from the title`); failed++; continue; }

Grudging respect again: the one input that collides everything is the one input the pipeline hard-rejects. The guard exists. It just guards the empty case, not the birthday case.

Why the lint won’t save you

Here’s the nitpick with a victim attached, which is the only kind I keep. The site already has a check whose entire job is “no two articles share a banner”: shared-preview in scripts/ci/lint_preview.rb. I assumed it would catch a seed collision. It cannot. It groups articles by the stamped path string:

$ grep -n "articles.size" scripts/ci/lint_preview.rb | head -1
116:  next if articles.size < 2

Two colliding slugs produce two different files — bloom-rhea-fail-git.svg and preview-docker-bloom-cache.svg — with byte-identical content. Different filenames, different preview: values, so the lint files them in different buckets and sees two happy singletons. The guard against shared banners checks whether the path is shared, never whether the pixels are. A seed collision is a shared banner that is invisible to the one check built to find shared banners. That is the failure this whole field note is here to name: not “the art is wrong” but “the alarm is deaf to this exact intrusion.”

Verdict

On the “survives a Tuesday” scale:

  • A normal Tuesday: ✅ survives easily. 278 posts, 0 collisions, perfect determinism, a math core that eats SQL injection for breakfast.
  • A bad Tuesday (the site keeps publishing for years): ❌ the promise expires. Around post ~72,000 — 258× today’s archive, but a fixed and arriving date at this cadence — two unrelated posts silently share a face, and the lint waves them through.
  • A Tuesday where the intern has sudo (someone stamps preview: by hand, or crafts a slug to grief another post’s banner): ❌ trivially forced. The collision I found by accident can be chosen on purpose in under a second.

None of this makes the generator bad. It’s the best cover-art system I’ve stress-tested and I said so, four times, through my teeth. The bug is a scope error in one word: “never” is a 4-billion-item claim written on a 32-bit budget. The seed is 32 bits; make it 64 (BigInt FNV-1a is a five-line change) and the birthday point moves from a plausible Tuesday to past the heat death of the content calendar. Or teach shared-preview to bucket by content digest, not path, so the deaf alarm learns to hear the one break it was named after. I’d do both. The math already survived everything I threw at it; the only thing left to widen is the promise.

I ran every number in this post. The loops ran; the tables are counts, not vibes. If you want to watch bloom-rhea-fail-git and preview-docker-bloom-cache come back as the same picture, the four-word scheme above finds the pair in 71,878 hashes on a laptop, in about the time it takes to doubt me.