About | GitHub | RSS |

mutable state

Thoughts on software, AI, engineering and the future of work.

A Walking Skeleton for AI Evals

A plastic skeleton in sunglasses and a beanie, sitting at a bar table with a menu and a beer

9 mins read

When I started testing the AI features in one of my apps, my first question was simple: can I get the same answer twice? With normal software, you mostly take that for granted.

AI testing vs traditional software testing

Most traditional software is built to be deterministic: given the same inputs and the same environment, it gives the same output. When it doesn’t, the cause is usually something the test hasn’t pinned down yet, like the clock, the network or the order tests run in, and good testers put a lot of craft into pinning those things down. But AI models vary by design. They’re built to choose their words with a little randomness thrown in for good measure, and for many questions there’s more than one good answer. So the task shifts from removing variation to measuring it, and from checking for the right answer to checking for a right one.

Aviation offers a useful way to think about this. Yep, I’m an aviation nut! No two flights on the same route are identical: the wind, the weight and the pilot’s small corrections all differ, yet a good flight still lands safely at the right airport. An AI model is similar in many respects. Ask it the same thing twice and the answers will land in much the same place, but rarely word for word. There are a few reasons for that:

That variation makes familiar testing problems harder:

With traditional software, repeatability comes built in. With AI features, the model won’t give you that, so you have to build it into how you test. Aviation solved a version of this problem decades ago with the flight recorder, the ‘black box’ that captures what the aircraft did and the conditions it did it in, so investigators can replay a flight on the ground long after it has landed. This post is about how I’m building the same thing for my app: every call to the model is a flight where every call gets recorded, and my scoring code is the investigator reading the data. I’m really stretching this analogy aren’t I? But it works.

The idea, and where it came from

I picked this up from an article by Eferhire on HackerNoon, which describes a dedicated layer for testing and monitoring AI features. It runs as a loop: trace every call to the model, replay past calls on demand, score the results, regression-test after every change, and analyse failures step by step.

You only really understand an architecture once you’ve run it, so I’m building this into Runway, a planner I built for my own work as an Engineering Manager.

The plan

Runway has an AI feature called Quick Add. You type a scrappy note like ‘chase Lukas about the hiring panel by Friday’ and it fills in a task: the type, priority, effort, who’s involved and when it’s due. That’s going to be the first feature to get a flight recorder. Later phases add three more AI features, each bringing a new kind of risk: one that reads data, one that changes data, and one that holds a conversation.

The seven phases of the plan, from Phase 0 (Skeleton) to Phase 5 (Close the loop)
The plan. Phases 0 to 2 cover most of what I want to learn

I’ve also set some ground rules which will shape the whole thing:

My full plan is in the repo: docs/eval-observability.md.

Phase 0: the walking skeleton

A walking skeleton is Alistair Cockburn’s term for the thinnest version of a system that works end to end. I’ll admit my first draft of the plan opened with seven pieces of careful groundwork, which is the planning equivalent of building a hangar before anything has flown. So I threw that away and decided to get one working loop up and running and let this teach me what the groundwork needed to be.

Phase 0 came in three pull requests (spec here):

  1. The flight recorder. Every AI call in Runway now goes through one small piece of code that also writes a trace of each call. Quick Add moved from a paid model to Qwen3-8B running locally.
  2. The test flights. Ten made-up notes, each with the answer I’d expect. (The cases.)
  3. The investigation. A runner with two modes, live and replay.
Diagram comparing live mode, which calls the model, with replay mode, which reads recorded answers instead
Live tests the model. Replay tests the code.

Live is flying the route: the model answers each note, and the answers are scored and saved as recordings. Replay is the investigators on the ground, running the recorded data back through their analysis in seconds while the aircraft stays in the hangar. Netflix used the same principle at much bigger scale. In 2023 their engineers described replay traffic testing: cloning production requests, sending them to the old and new systems, and comparing the responses before switching anyone over.

The first live run scores

FieldAccuracy
item_type4/10
priority8/10
effort_minutes10/10
person10/10
stream8/10
mode8/10
due_date5/10

I then switched the model off and ran replay, and the scores matched. Finally I deleted one recording to test the safety catch, and replay stopped with a clear message naming the missing flight.

A rule of thumb: two modes, two questions

Each mode answers one question, and keeping them separate is what makes the numbers readable.

LiveReplay
Question it answersDid the model’s behaviour change?Did my code or scoring change?
Calls the model?YesNever
Cost and timeSlower, uses the laptop’s memorySeconds, free, runs anywhere
When to run itAfter changing the model, its settings or the promptAfter any other change
Its limitResults vary run to run, so one run is a sampleOnly as fresh as the last live run

That last row is the trade-off to watch: replay trusts old recordings, so a new prompt means a fresh flight.

Here’s what I learned

How much did it cost?

Building Phase 0 with Claude Code used $7.26 of API-equivalent usage, all in one day, measured with ccusage. Remember that running it is free because we’re using a local model.

MeasureValue
Cost$7.26
Modelclaude-opus-5-5 only
Total tokens13.7M
Cache reads13.3M
Cache writes288K
Output115K
Input (uncached)356

What’s next

Phase 1 takes the basic setup from this post and makes it something I can rely on day to day. That means:

Key takeaways

If you take one thing away, I’d suggest this. Save every answer the model gives you, along with exactly what you sent it and which model answered. Test against those saved answers by default, because they don’t change. Only ask the model for fresh answers when that’s what you’re deliberately testing. That way, when a result changes, you can always say why. In my experience, that’s the habit that lets you change things with confidence, whether you’re testing a model or a whole system.

The code, plan and test cases are in the Runway repo, and Phase 0 is tagged eval-phase-0. I’d love to hear how others are approaching this.