Random Isn’t Fair

We ran a photo wall at our synagogue’s Selichot service this year. Photographers scanned a QR code, uploaded pictures of newly-created artworks with their phones, and the newly-captured imagery drifted across a hundred-foot projection of a starfield. By the end of the evening we had 94 photos in the folder.

And someone said, “I saw that photo way too many times”

Yes, I saw the same – a certain couple of photos seemed to be shown far more than others. People waited to see the photo of their own art, but kept seeing some photos several times before even seeing theirs once. That’s the complaint, and it’s a real one, and it turned out I’d caused it.

The code picked the next photo at random. Which is fair. Ask anyone.

Here’s the problem with that

Random is fair, in the sense that every photo has exactly the same chance on every draw. What it isn’t is even. Those are different words and I had been using them as if they were the same one.

Over a 90-minute evening, ten photos on screen at a time, the app makes about 4,350 draws. If you pick uniformly at random every time, the counts follow a bell curve. Here’s what came out of the simulation:

94 photos, 4,350 draws
least-shown photo:  32 times
most-shown photo:   63 times

Two to one. Nobody’s thumb on the scale, no bug, no bias — that spread is just what independent random sampling does. The math even tells you to expect it: standard deviation of √(n·p·(1−p)), about 6.8 around a mean of 46. My code did exactly what I asked. I asked for the wrong thing.

Why didn’t I catch it sooner? Two reasons, and both are the kind that hide a problem instead of announcing it.

First, small folders self-correct. With ten photos and ten slots on screen, everything is up nearly all the time. The selection policy never gets exercised. It took a folder big enough that most photos are off screen before randomness had room to clump.

Second — and this one annoys me — the numbers get better while the experience gets worse. After 1,000 draws the most-shown photo has appeared 4.8× as often as the least-shown. By 4,350 that’s down to 2.2×. Converging nicely! Except the ratio isn’t what anyone feels. What they feel is the raw difference: 4-vs-19 became 12-vs-33 became 29-vs-64. The gap went from 15 showings to 35 and it keeps climbing all evening, because the average grows linearly while the spread only grows as the square root. The statistic improves. The grievance grows.

The obvious fix, which is also wrong

So: track when each photo was last shown, and always pick the one that’s been waiting longest. LRU – Least Recently Used. It’s a solved problem, it’s in every cache textbook, and it’s obviously correct.

It is obviously correct. It’s also, and I did not see this coming – a fixed loop.

Think about it for a second. You show the oldest photo. Now it’s the newest. It goes to the back of the queue. Do that over and over and you’re not choosing anything — you’re rotating a ring. After the first pass through all 94 photos, the order is locked. Forever. Same sequence, every time, until you quit the app.

Now, does that matter? Ten photos are in flight at once at different speeds, so the ring gets smeared around and you’d never spot it consciously. But the same photo always follows the same photo. Two minutes apart, all evening. The person who’s been standing there watching for their own picture is exactly the person primed to notice that it always shows up right after the one of the twins.

I traded a fairness problem for a predictability problem.

As random as possible, but no randomer

The fix is one line and it’s a knob, not a rewrite: don’t pick the oldest photo — pick at random from the three oldest.

That’s it. Here’s the sweep, where k is how many of the oldest you’re choosing among:

  k    count spread    worst wait    can you predict the next one?
  1        1 show          134s              100%   ← strict LRU
  2        1 show          140s               13%
  3        2 shows         142s                9%
  5        2 shows         165s                6%
 12        7 shows         232s                5%
 94       42 shows        1117s                4%   ← where I started

Look at what happens between k=1 and k=2. Predictability falls off a cliff — 100% to 13% — and it costs you six seconds of worst-case wait. Six seconds. The fixed ring turns out to be extremely cheap to break.

I went with k=3. Every photo shows within one of every other photo, nobody waits more than about two and a half minutes, and the odds of guessing what’s next are about one in eleven. Technically I call this is a k-weighted random LRU. I’m sure someone else invented it already.

There’s a bonus I didn’t expect. The old code had a special rule — “don’t pick a photo that’s already on screen” — which I’d bolted on to stop obvious duplicates. Under the new scheme that rule is redundant. A photo that’s on screen was just shown, so it’s the most recently used, so it’s automatically last in line. The special case dissolved into the ordering.

Which, in hindsight, is the tell. When your software needs a guard clause to avoid an outcome it shouldn’t have produced, the algorithm is usually wrong. I’d written the guard clause months earlier and never asked why I needed it. I call these clever kludges – cleverness is a handy attribute but they create technical debt and often are masking a more fundamental problem that with some wisdom (and a bigger budget) you can fix in a more elegant manner.

Boring, in the Shannon sense

Here’s the part I actually like.

I added a little histogram to the control panel — one bar per photo, filling in as each gets its turn. I expected to use it for debugging. Instead I just watched it.

It’s completely boring, and it’s boring in a specific, technical way. I can’t predict which bar will light up next — that’s about 1.6 bits of surprise per draw, which is exactly the randomness we deliberately added. But I’m entirely confident all of them will fill, and that none will run ahead of the others. Zero bits in the aggregate. Nothing to learn from the shape.

That’s the design target, and I didn’t have language for it until I saw the histogram: unpredictable step to step, uninformative in aggregate.

Pure random gets the first part right and fails the second — the shape of that histogram is readable, and what it tells you is that the thing is unfair. Strict LRU gets the second part right and fails the first. The answer wasn’t a compromise between them. It was noticing they were two different requirements that I’d been treating as one.

Which is the whole trick, most of the time. There’s usually an elegant solution in there. It just takes a while to find out what problem you’re actually solving. Now on to shuffling my music library better…


The app is a two-projector simple projection-mapping tool. The simulation work and the sweep above were done with Claude, who also measured the wrong thing first and got a reassuring answer, which is its own lesson. Claude is an interesting programming partner for me as the product designer but also an ex-programmer. Claude can read a lot of documentation much faster than I can and generate working code that often does the right thing, knowing when it’s wrong and showing it how to correct it is the charm.

Loading