Olivier Couthaud

proof

A small TypeScript library that checks whether an AI system's choices about what to raise carry any information, and refuses to print a number when they don't.

proof is a tool I wrote to answer one question about an AI assistant of mine: is it actually any good at deciding what to tell me? The code isn't public, and the assistant it came from runs on my personal data, so what I can talk about here is the tool.

Think of a weather forecaster who says it will rain every single day. He catches every rainy day, and he is useless. The only way to see that is to look at the days he did not say rain, and ask whether it rained less on those. Most ways of testing an AI never look at those days.

My assistant chooses what to bring up with me. So I kept a record of everything it considered, including what it decided not to bring up, then checked what really happened and compared the two piles. If the things it flagged go wrong no more often than the things it let pass, it was never telling me anything.

While I was building the assistant, I fooled myself five times in about a week, each time with a clean, believable result about nothing. In one, the part of the assistant that decides what is worth raising (I call it the judge) flagged 19 of 21 items. In another, a "no signal" really meant "we never looked". In a third, an apparent 18 percent improvement was seven things counted over and over. Each became a rule in the tool. The most useful one: when there are too few cases, or it couldn't see what really happened, it prints NOT ENOUGH EVIDENCE instead of a percentage.

how often a ticket was still open two weeks later Predictor that reads ticket age flagged 93% let through 50% Coin flip flagged 58% let through 68% Flags everything flagged 65% let through: nothing, so nothing to compare with
Diagram. Three guessers (I call them predictors) from the first example I wrote for it, run on 60 made-up support tickets. Only the first one flagged tickets that really did stay open more often.

The example is 60 pretend support tickets. Some never get closed, the rest get closed a few days to a few weeks after they are opened. The question is whether a ticket will still be open two weeks after the predictor looks. Three predictors all look reasonable. One reads how long a ticket has been sitting, one flips a coin, and one flags everything, so it has the best catch count. A test that only checks whether answers were right would have trouble telling them apart, because all three look defensible from the inside. Comparing the piles, only the first is worth anything.

I also pointed it at real issues from a big public software project: I pulled 400 of them, and 206 were usable, because most of the rest had already been closed within a few days of opening. The elaborate predictor found something real, but no more than a one-line rule about an issue's age, so the tool said it wasn't earning its cost. When I pulled the tool out of my assistant in July, it was telling me NOT ENOUGH EVIDENCE about the assistant's main claim, which was the right answer at that point. It makes cheating on the future hard to do by accident, not impossible, and my examples use hand-written scoring rules, not a language model.

What's next

The real-issues example only uses facts that were fixed when each issue was opened. Rebuilding things that change later, like comment counts, from the issue history is the honest next step.

What I wanted

I was building an assistant that decides for itself what to bring up with me, and I wanted to know if it was any good. The usual way to test an AI is to ask whether its answers were right. That doesn't fit here. A system that chooses what to raise doesn't fail by being wrong, it fails by raising everything, or nothing, and looking fine either way. Only a denominator shows that, meaning the cases it considered and did not raise, and almost nobody keeps those.

In about a week of building that assistant I caught myself believing results that were about nothing, five times. Every one was clean and well formed and looked publishable, and every one became a rule in this library. The assistant works on my personal data, so I'm leaving it out. What's here is the harness I pulled out of it in July 2026, version 0.1.0. It's TypeScript with no runtime dependencies, and the code isn't public.

Five rules, each from a mistake I made first

  1. Keep the control group. Record every candidate the judge considered, flagged or not, and measure whether the flagged set goes wrong more often than the unflagged set. In the library this is ClaimLedger, and the headline number is discrimination, not accuracy.

    What went wrong first: a judge that flagged 19 of 21 items. Its accuracy looked fine. It was a mood, not a prediction: ask "is this at risk?" about something that's already open and the answer is always yes. I replaced the yes or no with a probability, and made the flag line a threshold I own.

  2. Unknown is an answer. Whatever checks what really happened is allowed to say it can't tell, and that is never scored as a failure. In the library this is the unknown outcome and coverage.

    What went wrong first: a run reported no signal, and then coverage showed that 11 of its 13 failure labels really meant "we never looked there". A promise settled in person scored the same as one dropped on the floor. The negative result was a statement about the observer, not about the judge.

  3. Put the leak guard in the API. The judge never receives the world, only what evidenceAt(subject, asOf) hands it for a past date. Leaking the future then has to be deliberate.

    What went wrong first: a candidate query that, given a past date, only ever returned subjects who never came back. That population has a 0% event rate by construction. It reported 0 of 60 across every configuration, so the answer was fixed before the judge said anything.

  4. Refuse to certify. Below the evidence bar the report says NOT ENOUGH EVIDENCE instead of giving a percentage, and it counts distinct subjects, not rows. In the library this is sufficiency.

    What went wrong first: a +18 point lift over 19 judgments, which was really 7 subjects. And a sign that flipped across three runs on overlapping data (-0.03, -0.06, +0.07), which is what noise looks like.

  5. The evidence must not shrink. Stores merge per row instead of rewriting the whole file. In the library this is mergeWriteJsonl.

    What went wrong first: a daily cycle wrote ten predictions, deleted them, and reported success. Three stores were open at once, each rewriting the whole file, and the last one won. Nothing crashed, and every metric afterwards stayed well formed. NOT ENOUGH EVIDENCE had become impossible to tell apart from "the evidence was deleted".

How it works

Here is the whole path a claim takes, from what the judge is shown to the verdict at the end.

What counts as a leak?

A backtest puts the judge at some earlier date and asks it to predict what happens next, when I already know what did happen. A leak is any detail in what the judge sees that wasn't available on that date, such as a comment count fetched today. It doesn't cause an error. It just makes the judge look better than it was.

Every candidate the judge considers goes into a ledger, flagged or not, because the unflagged rows are the control group. A claim can't be written without a falsifier, which is the observation that would prove it wrong, and a check date after the as-of date. Otherwise the write throws.

Reality then answers each claim with confirmed (the predicted bad thing happened), refuted or unknown. The thing that answers is an oracle I supply. Unknown, and anything still pending, stays out of every rate.

What is the oracle?

It's the part that answers "what actually happened?" for a claim and its check date. Sometimes that's a plain lookup, like whether a ticket's closed date comes before the check date. Sometimes it has to dig through messy records and honestly can't see. For those cases it can answer unknown, and it can mark a verdict as resting on having seen nothing.

Subjectswith history evidenceAt(s, asOf) judgescore 0..1 flagWhenthreshold you own ClaimLedgeror claims[] the leak guard: the only world the judge sees oraclestate by checkDate confirmed refuted unknown unknown is excluded, never a failure coverage1 oracle ok? separation2 any signal? discrimination3 helps? sufficiency4 enough? a lift, no signal, orNOT ENOUGH EVIDENCE, never a bare score Subjectswith history evidenceAt(s, asOf)the leak guard:the only world the judge sees judgescore 0..1 flagWhenthreshold you own ClaimLedgeror claims[] oraclestate by checkDate confirmed refuted unknown unknown is excluded,never a failure coverage1 oracle ok? separation2 any signal? discrimination3 helps? sufficiency4 enough? a lift, no signal, orNOT ENOUGH EVIDENCE,never a bare score
Diagram. How a claim moves through it. Backtests return the claims as an array. ClaimLedger is the live path, where I call resolve() later. There is a fifth, optional check, versusBaseline, that compares the judge with a cheap heuristic. It isn't drawn.

The report asks four questions, in a fixed order. Is the oracle actually observing things? Do the raw scores separate the outcomes at all? Does the flagged set fail more than the unflagged set? Are there enough distinct subjects to say so? I put them in that order because asking them the other way round is how a broken oracle gets reported as a null result about the judge. That happened to me.

What do separation and lift measure?

Separation ignores the flag line completely. It's the average score on things that turned out fine minus the average score on things that went wrong, so if it's near zero then no flag line anywhere can help. Lift comes after you draw a line: how much more often the flagged items went wrong than the ones you let pass, in percentage points.

There are two ways in. The live way is ClaimLedger: I write claims as they happen and call resolve() once reality can answer. The other is backtest, which stands the judge at past dates across history I already have and grades it today. Before spending a model call on that, baseRate checks that the outcome actually varies, because if it happens 100% of the time every predictor scores the same. The backtest never grades a claim whose check date hasn't arrived, skips subjects that were already settled, and counts everything it excludes, because a silent filter is how a sample gets rigged.

Fixed when the issue is openedcreated_at title bodyauthor association Changes afterwardscomments labelsreactions assignees evidenceAt(issue, asOf)creation-time fields only judgesees this only never passed in. Reading them improves thelift, throws no error, and is cheating. Fixed when the issue is openedcreated_at title bodyauthor association evidenceAt(issue, asOf)creation-time fields only judgesees this only Changes afterwardscomments labelsreactions assignees never passed in. Reading them improves thelift, throws no error, and is cheating.
Diagram. The leak guard in my GitHub example. An issue's comment count and labels are today's values, so handing them to a judge standing weeks in the past would quietly improve its score.

One more piece is a small check on the claim "no single input caused this". isRealJoin wants at least two strands of at least two different kinds, more than a day apart, each dated and sourced. A candidate that fails is dropped, not downgraded, and if that leaves nothing today then nothing is the honest output. The precedent is a derived-state signal I'd shipped as "unfakeable" that turned out to be produced by a string-truncation bug. I deleted it instead of patching it.

Results

There are two example scripts. The first is a world of 60 made-up support tickets. 14 of them never get closed; the rest are closed 5 to 24 days after they are opened. Every prediction asks the same thing: will this ticket still be open 14 days later? Predictions are made every 21 days along a ticket's life, at most three per ticket, which gives 88 per predictor. Three predictors take part. The alarmist flags everything. The coin gives a random score. The judge reads how long the ticket has been sitting, with score = 1 minus days open divided by 60, and flags anything below 0.35.

I put the 88 predictions per predictor into the slider below. If you have scripts off, the table shows the setting I used, a flag line of 0.35.

PredictorFlaggedOpenNot flaggedOpen
Alarmist885700
Coin31185739
Judge30285829

The judge holds a lift of between +34 and +57 points at every flag line the harness will certify, from about 0.06 up to 0.88. Push the line further right and the unflagged side drops below 8, and it refuses. The coin's lift is zero or negative at almost every setting. At 0.16 and 0.17 it shows +2 points, which is pure noise, and the separation line still reads backwards. The sufficiency check is a count and not a significance test, so it can't catch that by itself. The separation line is what does. The alarmist, wherever you put the line, has nothing on one side, and the harness says NOT ENOUGH EVIDENCE.

outcome happened, % of resolved items solid = flagged, outline = unflagged control alarmist (flags everything) flagged 65% 57/88 no unflagged items: 0/0 coin (flags at random) flagged 58% 18/31 unflagged 68% 39/57 judge (reads the signal) flagged 93% 28/30 unflagged 50% 29/58 GitHub issues, vercel/next.js flagged 97% 160/165 unflagged 88% 320/362
Diagram. The same results at the flag line I used. The first three are the 60 made-up tickets. The last is 206 real issues from the Next.js repository, 527 predictions in all.

The point of the first example is that the alarmist has the highest raw catch count and is worth nothing. With nothing unflagged it has no control group, so the harness refuses to score it. The coin is backwards, with separation -0.08. The judge shows a +43 point lift, but I built this world so that ticket age predicts stalling, and then gave the judge ticket age. It shows the harness can tell the three apart. It doesn't show that any real judge works.

verdict     NOT ENOUGH EVIDENCE — 88 flagged / 0 unflagged resolved. Any lift here is anecdote.verdict     NO SIGNAL — flagged 58% vs unflagged 68%. The selection carries no information; subtract it.verdict     LIFT +43pp — flagged 93% vs unflagged 50% (flat baseline 65%), over 60 subjects.

The second example uses real data. I pulled 400 issues from the Next.js repository, opened between January and April 2026, and 206 of them were usable: most of the rest had already been closed within three days of opening, before the first prediction date, so there was nothing left to predict. That gave 527 predictions, with the question "will this issue still be open in a fortnight?" The oracle is a lookup, whether closed_at comes before the check date, so a negative verdict is much more likely to be about the judge than about the instrument. The judge is a hand-weighted function. Age dominates, a reproduction in the issue helps, a very short report helps, and an outside reporter makes it linger. It's up against an incumbent, a one-line heuristic that only looks at age, and the comparison flags the same number of issues for both so neither wins by being trigger-happy.

coverage    527/527 observedsignal      separation +0.15, right direction: the estimate carries informationselection   flagged 160/165 · unflagged 320/362 (control) · 206 subjectsverdict     LIFT +9pp — flagged 97% vs unflagged 88% (flat baseline 91%), over 206 subjects.baseline    too close to call (9pp vs 9pp) — a model that only matches a cheap heuristic is not earning its cost

So the judge has a real signal and a real lift, and it still isn't earning its cost, because the heuristic gets the same lift for free. The lift is also small against a high base rate: both groups stay open about nine times in ten. I find that more useful than a result that flatters the judge.

The first run of this example was refused. I'd pulled the newest issues, which spanned twelve days, and the horizon is fourteen, so no check date had passed and the harness wouldn't score an empty set. Reaching for older issues isn't a workaround. You can't grade the future.

Why count distinct subjects, not rows?

If I ask about the same ticket every three weeks, the answers aren't independent, since a ticket that's stuck now is probably stuck next time too. A hundred rows about seven tickets is closer to seven experiments than a hundred. So the harness bounds what it will say by the number of distinct subjects.

What didn't work, and the limits

The leak guard is an API shape, not a guarantee. A determined caller can reach around evidenceAt, and all the library does is make leaking deliberate rather than accidental. The merge-on-write storage can't express a real delete, so it's safe for stores that mark rows closed or superseded and wrong for one that needs to remove them. The sufficiency check is a count, 8 per side and 8 distinct subjects by default, and those two defaults are rules of thumb you can change. It is also not an accuracy benchmark. There's no golden dataset, and ground truth is whatever actually happened. If your system produces four gradeable items a month, it will tell you that, every time you ask.

Both examples score with hand-written functions, so I haven't shown the harness grading a language model. When I pulled it out in July 2026, it reported NOT ENOUGH EVIDENCE about the central claim of the system it came from. That's the intended behaviour, and it's why I trust it.

What is unfinished

The GitHub example only uses fields fixed at creation time. Comment counts and labels change after an issue is opened, so using today's values would be cheating. Rebuilding them as of each date from the issue timeline is the honest way, and it costs an API call per issue. I haven't done it. The tests cover the write-time guard, the unknown handling, the subject count, the storage merge, the leak guard and the join check. It isn't published as a package either.

Stack
TypeScript, no runtime dependencies; tests run with tsx
Modules
ledger, backtest, metrics, join (cross-reference check), jsonl (merge-on-write store)
Status
Prototype, version 0.1.0
Code
Not public

← All work