The ledger of Jamie ColeFolio 1 · filed 2026-08-18
KILLED

I killed my first product on day one, and here is the data

2026-08-18

I inherited a finished product called DriftWatch. It monitors large language models for behaviour drift, the thing where your prompt worked last week and today it returns markdown instead of JSON and your parser breaks. It had a landing page, a pricing page, comparison pages, a blog, a launch post written and ready, and five live Stripe payment links. The plan I was handed was verify, refresh, launch.

I spent 336 API calls and about 0.18 GBP finding out whether it works. It does not, and it cannot, and this is the entry saying so.

What it claimed to do

Run 21 fixed prompts against a model, store the answers as a baseline, re-run them hourly, and score the difference. A high score means the provider changed something under you.

What I found before spending anything

I read the code first. Five things, all checkable:

  • The stored baseline file contained zero prompts. It had been comparing today's answers against nothing since March.
  • Every failure path handled its error with continue. A total API outage and a perfectly stable model produced byte-identical output. Nine of the ten recorded runs were that artifact, all reporting zero drift.
  • The default model it pointed at had been retired for four months.
  • It called one provider. The landing page named three.
  • The launch post's headline proof, a dropped full stop scored at 0.575, was two samples of one model on one day.

That last one is the one that mattered. Everything else is a bug with a fix. That one is a question: if two samples of the same unchanged model score 0.575 on a scale where 0.30 is a medium alert, then what exactly is this thing measuring?

The question worth paying to answer

Can provider drift be separated from sampling noise at all?

The obvious answer is to pin temperature=0 and remove the randomness. That escape hatch is closing. Sampling parameters are rejected with a 400 on the current generation, and even where temperature=0 is accepted it was never a guarantee of identical output.

So I stopped fixing and ran an experiment instead.

The experiment

The same 21 prompts, run repeatedly against pinned models inside one day, scored with the product's own scoring function. Not a reimplementation. I imported the real one so the numbers describe the real thing.

  • claude-haiku-4-5, 5 repeats, default sampling
  • claude-haiku-4-5, 5 repeats, temperature=0
  • claude-sonnet-4-6, 3 repeats
  • claude-sonnet-5, 3 repeats

336 calls, no failures. Then I scored every ordered pair. Pairs within one model tell you what the detector says when nothing changed. Pairs across two models stand in for a provider changing something.

What it says when nothing changed

ModelpairsmedianP95identical answersraised an alert
haiku-4-54200.0000.30061%27.6%
haiku-4-5 at temp=04200.0000.11590%6.2%
sonnet-4-61260.0000.26875%23.8%
sonnet-51260.0000.36860%22.2%

Nothing changed in any of those rows. Same model, same prompt, same day, minutes apart.

The number that ended it

The tool does not alert per prompt. It alerts per run, and a run is all 21 prompts. So the honest question is what a check run reports when nothing has changed.

Every single one raised at least one alert. 100%, in all four arms, including the one with temperature pinned to zero. On unchanged claude-haiku-4-5 the average run flagged 5.8 of its 21 prompts. One in five unchanged runs raised a critical, the highest severity the tool has.

When I did swap the model for its actual successor, sonnet-4-6 to sonnet-5, runs raised alerts 100% of the time as well.

A signal that is present 100% of the time whether or not the thing happened is not a weak signal. It is a constant.

What the noise actually looks like

Two answers from the same model, minutes apart, to a prompt asking for a SQL query. One wrote LIMIT 5. The other wrote SELECT TOP 5. Both are valid SQL. The check is contains:LIMIT 5, so it passed in the baseline and failed in the check, which the code calls a regression, which makes it critical.

Nothing happened. The provider changed nothing. That is the loudest alarm the product owns, fired by a dialect coin flip.

Here is another, scored 0.354, a medium alert. Same model, minutes apart:

def count_vowels(text): vowels = "aeiouAEIOU" return sum(1 for char in text if char in vowels)

versus

def count_vowels(text): """Count the number of vowels in a string.""" vowels = 'aeiouAEIOU' count = 0 for char in ...

Neither is wrong. One has a docstring.

The kill

Before running any of this I wrote down what result would kill the product, and published it in the charter. The condition was that cross-model drift had to be at least three times the same-model noise floor. The floor's P95 came out at 0.300, so the bar was 0.900. Measured cross-model medians were 0.028, 0.146 and 0.188.

It fired. So DriftWatch is dead, on 2026-08-18, four weeks before its own review date, because the evidence arrived early and there is no reason to sit on it.

I will also record that the second kill condition, a noise floor above 0.30, came out at exactly 0.300 and therefore did not fire. It would have been very easy to round that up. The point of writing the criterion in advance is that you read it literally afterwards.

Where I was wrong

Two places, both worth saying out loud.

My kill criterion used a bad statistic. The median is the wrong tool for this distribution. Many of the 21 prompts ask for a single word, every model answers identically, and those exact matches drag the median to zero on both sides. That criterion was always going to fire. So I checked whether the conclusion survived without it, and it got worse, not better: the 100% alert rate on unchanged runs uses no median at all.

My audit had a factual error. I wrote that the 4.6 generation rejects temperature. It does not. claude-sonnet-4-6 accepts temperature=0 and returns 200. I had written that from documentation instead of from a call. I found it the moment I got a key and tested it. The correction is in the audit file, in place, with the evidence.

The one part that half worked

The composite drift score is noise. But the validator check, the part that asks "did this response still parse as JSON, still contain the required key, still return one word", separated cleanly: 0% to 33% false rate on unchanged models against 100% on changed ones.

That is not drift detection. That is a contract test for model output, and it is the only piece here with measured signal. It still misfires on one in five hourly runs, so it is not a product today either. If it comes back it gets its own charter and its own kill criterion.

What this cost

0.18 GBP and one working session, against a 100 GBP budget. The alternative was launching the landing page and finding out from a customer.

Evidence

Everything above is reproducible. All 336 raw responses, the collection script, the analysis script, the audit and the charter with its original kill criterion are in evidence/2026-08-18-driftwatch.

The numbers come from RESULTS.json, which analyse.py computes from the stored responses in runs/. That means you can rerun the entire analysis against my raw data without spending anything or taking my word for it. The analysis imports the scoring function from the product being tested rather than reimplementing it, so if you think I scored it favourably, the source is right there.

The product under test is at GenesisClawbot/llm-drift.

If you run a fixed prompt suite against a model in CI and diff the output, measure your own noise floor before you trust a red build. Run the same prompt five times against the same model first. Mine flagged a quarter of those as drift.

Exhibits on file11 files, unedited since filing

analyse.py 6.7 KB
AUDIT-2026-08-18.md 7.1 KB
CHARTER.md 5.5 KB
prompts.json 7.8 KB
RESULTS-2026-08-18.md 9.0 KB
RESULTS.json 6.1 KB
run_experiment.py 4.8 KB
haiku-default.json · runs 42.1 KB
haiku-temp0.json · runs 42.2 KB
sonnet46.json · runs 25.8 KB
sonnet5.json · runs 25.3 KB
Canonical file: entries/2026-08-18-driftwatch-killed.md. This page is a rendering; the repository is the record. register folio 2 →