Skip to content

Do You Still Need to Read AI-Generated Code?

23 min read

Do You Still Need to Read AI-Generated Code?

·23 min read
An editorial photograph split into two halves. On the left, a messy heap of hundreds of slightly different printed cards spills across dark slate. On the right, one short neat stack of identical cream cards sits under a brass paperweight on pale paper.

I asked three coding agents for the same small function, 45 times between them, from the same one-line ticket: format a millisecond duration for the UI, given number | null | undefined. Then I called each of the 45 implementations with null.

34 runs   "\u2014"   em dash
 9 runs   "\u2013"   en dash
 2 runs   "-"        hyphen

The function this ticket was written from returns the en dash, so 36 of 45 runs differed from the original, and Claude Sonnet 5.5 produced all three dashes on its own, across 15 runs of the same prompt. A compiler given the same source twice gives you the same program twice. That property is the whole reason nobody reviews the assembly their compiler emits, and it is the property this experiment set out to measure for agents.

The question I started with was the one every team using agents argues about: do you still need to read every line of generated code? The usual way to settle it is an analogy. Compilers raised the level of abstraction and we stopped reading their output, so agents raise it again and we will stop reading theirs. Or the opposite: compilers are deterministic and formally specified, agents are neither, so the analogy is broken. Both camps argue from the definition of a compiler. Neither had measured how far an agent is from one, or what moves it closer.

So I did. Five functions, four levels of spec, three agents, 15 fresh sessions per cell: 900 runs, and $23.84 of Claude usage.

Key Takeaways

  • From a one-line ticket, the agents produced a median of 26 distinct behaviours per function in 45 runs. Not one run, out of 225, matched the behaviour the ticket was written from.
  • Adding example tests cut that to a median of 10. Adding a precise prose spec on top cut it to 2, and 31% of runs then matched the reference exactly. Adding property tests on top of that left the median at 2 and lifted exact matches to 60%.
  • At the top level, two functions behaved like a compiler: one behaviour from all 45 runs. Those 45 runs still contained 11 and 28 different source texts. Code converged too, but much less than behaviour.
  • Where the spec was wrong and nothing contradicted it, every agent copied the mistake. Given a spec that never says to clean the file extension, all 45 runs of safeFilename returned a “safe” filename containing a slash. Where the spec clashed with a convention the agents knew, they followed the convention instead.
  • What still diverged at the top was mostly where the spec was silent, wrong about its own reference, or at odds with a convention. That residue is what is left to read.

Is an LLM a compiler?

The analogy is old. In February 2023, Vivek Haldar argued that LLMs should be understood as compilers from natural language, and that once they reach compiler-level reliability programmers will no longer need to debug their output, just as nobody debugs the assembly a modern compiler emits.

The rebuttals all go after the definition. John Crickett points out that people do read compiler output (Godbolt exists), and that a compiler takes a formal language “with grammars and semantics defined precisely enough that ‘what does this code mean’ has only one answer”. Federico Tomassetti calls agent coding an unreliable compiler. The Renegade Coder argues that “compilers are predictable”, that you can guarantee a compiler’s behaviour “by pinning its version”, and that compilers are a true abstraction because what we commit is the code, not the prompt. Dijkstra made the general version of that point in the late 1970s, in On the foolishness of “natural language programming”.

Meanwhile the practice moved without waiting for the argument. In May, Simon Willison wrote that as agents got more reliable, “I’m not reviewing every line of code that they write anymore, even for my production level stuff”, and then: “I’m not reviewing that code. And now I’ve got that feeling of guilt.” Addy Osmani named the cost comprehension debt, the gap between how much code a system has and how much of it anyone understands. And spec-driven development, where a precise spec is the source of truth and code is a generated artifact, became a mainstream practice; GitHub’s Spec Kit alone passed 111,000 stars by June.

The measurements that exist are about the model, not the request. Ouyang et al. ran ChatGPT on 829 benchmark problems, found high degrees of non-determinism in the code it returned for the same request, and found that setting the temperature to zero “does not guarantee determinism”. That establishes that an LLM is not a deterministic compiler. It does not say whether a more precise request makes it behave more like one, which is the part that matters for what you should read. If precision collapses the output to one behaviour, then the request is the source and reading the request is enough. If it does not, the code is still where decisions get made.

The experiment

Everything below is in github.com/iceinvein/specdial, MIT licensed.

Five functions. Small, pure TypeScript functions from my own repositories, five of the eighteen I used in the tests-lock-in-bugs experiment, so each already had a real reference implementation and a precise written spec: a .env parser, a CSV serialiser, a glob-to-regex converter, a safe-filename builder and a duration formatter. Each leaves real decisions open: how to treat export in an .env line, whether **/ matches at the root, what null formats as.

Four levels. Each level adds one artifact to the one below:

LevelThe agent is given
L0a one-to-three-sentence ticket, written the way a teammate files one, plus the function signature
L1+ 5 or 6 example-based tests
L2+ the precise plain-English spec
L3+ property-based tests (fast-check) stating the invariants

The tickets were written by a separate agent that never saw the specs or the references, and committed before any run, so they were not tuned to be vague. The formatDuration ticket, whole: “Job durations show up as raw millisecond counts in the UI. Add formatDuration(ms: number | null | undefined): string that formats a duration as a short human-readable string for display. Example: formatDuration(90000) could show 1m 30s.”

Three agents. Claude Sonnet 5.5 and Claude Opus 5.5 through Claude Code, and Codex (gpt-6.1-sol, effort high), each in a fresh Docker container per run, holding only a stub that throws and the level’s files. No web tools, none of my own instructions or hooks, and a probe run before every batch to check that. The prompt is one sentence naming the function and the files. 15 runs per agent per cell, so 45 per function per level.

Counting behaviours. Every implementation is called on a corpus of about 560 inputs per function that the agent never sees (four of them happen to match inputs in the visible examples): 40 to 80 written by hand, each tagged with the spec clause or open decision it probes, plus 500 generated ones. The outputs form a behaviour signature, and two runs count as the same behaviour exactly when their signatures match on every input. The headline number, k, is how many distinct behaviours a cell produced. A compiler scores 1.

Five runs were excluded because they fetched a tool from npm (a fetch is a fetch, whatever it fetches); keeping them moves the L1 median from 10 to 11 and changes no conclusion. Six runs were rerun for infrastructure failures.

Precision collapses behaviour: 26, 10, 2, 2

From a ticket, the median function came back in 26 different behaviours out of 45 runs. Not one of the 225 L0 runs matched the reference, and that says more about tickets than about agents. A one-line ticket does not say what null formats as, or whether export FOO=1 keeps the export. Every one of those behaviours is a defensible reading, and that is the problem: 26 defensible readings means 26 programs, and only reading the code tells you which one you got.

Per function, pooled over the three agents:

FunctionTicket+ examples+ prose spec+ properties
parseEnv29523
toCsv6221
globToRegex261045
safeFilename322411
formatDuration211122

Example tests did a lot of work for some functions and little for others, and that tracks what the examples happened to pin. The parseEnv examples settle comments, quoting and trimming, and k fell from 29 to 5. The safeFilename examples pin the headline rules (whitespace, forbidden characters, the fallback name, the extension dot) but not how those rules combine at the edges, and k only fell from 32 to 24. Examples are a spec with holes in it, and the holes are exactly as wide as you left them.

Added on top of the examples, the prose spec cut the remaining behaviours by 80%, where the examples had cut 62%. Median k went from 10 to 2, and safeFilename went to a single behaviour, the reference’s, for every agent. I expected executable examples to matter more than prose, because prose is ambiguous. But these specs were written to be precise, clause by clause, and a precise sentence covers a whole class of inputs where an example covers one. The comparison has a limit: the spec was only ever added on top of the examples, never tested alone, and it was written from the reference (more on that below).

Property tests barely moved it. Median k stayed at 2 and in two functions it went up: parseEnv from 2 to 3 and globToRegex from 4 to 5. They did move runs onto the reference: exact matches went from 31% to 60%. So the properties mostly settled which of the remaining readings was right, without reducing how many readings there were.

Does the code converge when the behaviour does?

If behaviour converges, does code? A compiler is deterministic in its output text too. These agents converge on text as well, but much more slowly: safeFilename went from 45 distinct source texts at L0 to 8 at L2, while its behaviours went from 32 to 1. At the top level the gap is wide everywhere.

At the top level, toCsv produced one behaviour from 45 runs and 28 different source texts. formatDuration produced two behaviours from 28 texts. Source texts here are counted after stripping types, comments and whitespace, so these are real differences in structure, helpers or names, with the same outputs on every one of the roughly 560 inputs.

Here the data answers “should you read every line?” most directly. In a cell like toCsv at L3, a reviewer reading 45 diffs line by line would find 28 different programs and no behavioural difference between them. The variation is real and, for behaviour, it is noise. The thing that determined the behaviour was the spec, and the spec was the same in every run.

Do agents copy the spec’s mistakes?

The convergence has a catch.

safeFilename builds a download filename from user text and an extension. Here is what the agents returned for the extension "p/n g", at L0 and at L2:

Ticket only (L0)                 Ticket + prose spec (L2)
30 runs   "img.png"              45 runs   "img.p/n g"
 8 runs   "img.p_n g"
 3 runs   "img.p_n_g"
 2 runs   "img.p_ng"
 2 runs   THROW

From the ticket alone, every run either cleaned the extension or refused it. With the example tests added, 19 of 45 already passed the slash through, all 15 Codex runs among them. Given the spec, every run, all three agents, returned a “safe” filename with a slash in it. The spec says an extension gains a leading dot if it lacks one, and says nothing about cleaning it, because that is what the reference does. The reference can get away with it because its only caller, in the app it came from, passes a hard-coded "pdf". The spec was accurate. The behaviour it accurately describes is a path-traversal hole the moment a caller passes user input as the extension.

The agents did what a compiler does: implement the source faithfully, including the parts that are bugs. At L0 their own judgement filled the gap, and on this one point it was better than the written spec. At L2 the written spec won, everywhere.

The fallback name shows the same mechanism working in your favour. Given "***???", nothing usable is left, so the function falls back to a default. From the ticket, 35 runs produced "______.txt", 9 produced "download.txt" and one "_.txt". The original lives in a resume builder and falls back to "resume.txt", which no ticket reader could have guessed. One example test showing it was enough: from L1 on, all 45 runs returned "resume.txt".

That changes what reviewing means. When behaviour is determined by the spec, the review that matters is of the spec. A reviewer who skips the spec and reads the diff is reviewing 28 ways of writing it down.

What do agents still disagree on with a precise spec?

Two of five functions reached one behaviour. The other three did not, and what is left over is mostly not random. Nearly every disagreement at L3, between runs or with the reference, sits on a spot where the spec was silent, wrong about its own reference, or at odds with a convention.

Where the spec misdescribes its own reference. The formatDuration spec says values under ten seconds get one decimal by “standard rounding”. The reference uses toFixed(1), which on a binary float rounds 9.95 down. So for 9950 ms:

24 runs   "10.0s"   what the spec says
21 runs   "9.9s"    what the reference does

With the spec alone, 41 of 44 runs implemented the spec’s rounding. The L3 property tests pin 10.0s from 9951 ms up and leave 9950 out, an indirect hint towards the reference’s reading, and 21 runs moved onto it. The split was then almost even overall but lopsided by model, in opposite directions: Sonnet 13 to 2 for 9.9s, Opus 13 to 2 for 10.0s, Codex 9 to 6 for 10.0s. The spec and the reference disagree, and the only signal pointing at the reference was a gap in a test.

Where the agents know a convention the spec breaks. The globToRegex spec says the / after ** is absorbed into the **. Combined literally with its other clauses, that makes **/Dockerfile match xDockerfile, which is what the reference does, though the spec never says so in as many words and its own examples fit either reading. It is not what glob tools do: in gitignore and in common glob libraries such as picomatch, **/ means zero or more whole directories.

At L2 and L3, 89 of 90 runs resolved it towards the convention. No globToRegex run matched the reference at any level. The agents had the spec in front of them and read it the way the rest of the world reads globs, which is probably the right call for users and the wrong call for a compiler. The splits between runs at L3 are smaller: seven Opus runs match the reference on whether ** crosses a newline, where the spec again misdescribes its reference, plus single runs on rarer inputs.

Where the spec says nothing. parseEnv still splits on __proto__=polluted. Assigning that key on a plain object sets the prototype instead of adding an entry, so the reference returns {}. 25 runs match it; 20, including all 15 Codex runs, return an object with a __proto__ entry. The spec does not mention the case, and both readings are defensible. One of those 20, an Opus run, also kept export as the key in export =spaced, where the spec plainly says the export prefix is removed. It accounts for the third behaviour, and it is the one residual case here where an agent misread an explicit clause.

So the residue at the top of the dial is contradictions, conventions, silences and the occasional misread clause. None of them look unusual in a diff. All but the last are visible in the spec, if you read it for what it does not say.

At the bottom, every run is a new program

One more pattern from L0. In four of five functions, the pooled k at L0 equals the three agents’ k added together: parseEnv 7 + 13 + 9 = 29, globToRegex 26, safeFilename 32, formatDuration 21. No complete behaviour was produced by two different agents. That says less about agents than about how much each run varies: Opus alone produced 13 behaviours from 15 parseEnv runs. With that much variance, two runs, from the same agent or from different ones, rarely land on exactly the same set of decisions.

Single decisions are another matter. Where a convention exists, the agents agree. Asked to format one hour, all 45 runs said "1h". The reference says "60m 0s". The agents agreed with each other completely and with the original not at all. A ticket is underspecified in the particular places where your codebase made a choice the world does not share, and those are the places where agents will agree, confidently, on something other than what you meant.

So a test suite written from one run’s output pins one run’s set of decisions, and the next run will make a different set, one more way the lock-in from the last experiment bites.

So, do you still need to read every line?

From this data, the answer depends on where you are on the dial.

At ticket level, yes, but read the decisions, not the lines. With a median of 26 behaviours per 45 runs, the code is the only place your behaviour is written down. Every unstated decision was made by the agent, and many of them came out differently from run to run. Reading the diff is how you find out which decisions you got. Read it like you would read a new hire’s first PR: not for style, for what it decided on your behalf.

At spec level, read the spec, and read the residue. Once the spec is precise, behaviour stops depending on which run you got, and line-by-line reading turns into reading 28 equivalent programs. The reading moves up. You read the spec for silences, contradictions with the code it describes, and places where it fights a convention, because that is where the agents split. And you read the diff only where the spec could not decide.

Either way, what you are now comprehending is the spec. This is the part the compiler analogy gets right. Compilers did not make comprehension unnecessary. They moved it up a layer, to a source language precise enough that reading it told you what the program did. We still read C. We drop to the assembly when something below the abstraction leaks: performance, a compiler bug, a security property the source cannot express. Agents can move comprehension up a layer too, but only as far as the spec is precise. Below that level, the code is still the source, and you have 26 of them.

There is a cost to moving up that the analogy hides. When nobody writes the code, the people who read specs need to be able to tell what a sentence will do when an agent implements it. The apprenticeship used to teach that skill, and the July 2026 (Im)Paired Programming study found coding agents erode it: of 54 students, those using a coding agent finished faster than those using a chatbot and understood their code less well. The safeFilename slash is the example to keep in mind. The spec was correct, precise and implemented perfectly 45 times, and only someone who understood what the code would do with it could have seen the hole.

What I would do in a real workflow

Write the spec you would want the compiler to compile. On top of example tests, a clause-by-clause spec cut the remaining behaviours by 80% in this data. Not a design document: one sentence per decision, including the boring ones like what null returns. If you can’t write that sentence, you do not know what you are asking for, and the agent will pick for you.

Review the spec before the code, and review it for what it does not say. The spec is the artifact that determines behaviour now, so it deserves the review effort the diff used to get. The questions that found nearly every residual split here are short: where is it silent, where does it disagree with the existing code, where does it contradict a convention a reader would assume.

Use property tests to choose between readings, not to cut their number. They took exact matches from 31% to 60% and did nothing to the median count. If two runs from the same spec behave differently, you have found an ambiguity. A property test is a good way to settle it once you have decided, and a poor way to discover it.

Measure your own dial. Run the same request through your agent five times in fresh sessions and diff the behaviour, not the code. The number of distinct behaviours you get back is a direct measure of how much your request leaves to chance, and the inputs where they differ are the review checklist. It costs a few cents a run, and it is the evaluate-it-yourself argument aimed at the request instead of the model.

Limitations

  • The specs were written from the code. Each L2 spec describes its reference, clause by clause, so convergence onto the reference at L2 is partly by construction. A spec written before the code would be less complete, and I would expect more residue at the top, not less.
  • k is a lower bound. Two implementations can differ on an input the corpus does not contain. Every spec clause has hand-written inputs, but agreement on 560 inputs is not equivalence.
  • Small pure functions. Five of them, single-file and stateless. That gives the cleanest possible behaviour signature and says little about multi-file or stateful work, where I would expect the dial to need turning much further.
  • Author-written tickets and examples. The ticket sets how far L0 diverges and the examples set L1. Three of the five example files pin more than “the common case plus two edge cases” I intended, which makes L1 look better than a typical first test file would.
  • Public source repositories. An agent reproducing a reference from memory would inflate convergence. No run at L0 or L1 matched any reference, which argues against it, but it cannot be ruled out higher up.
  • Repeats are not independent of the tool. Every agent sometimes returned identical code in more than one run of a cell, so part of a low k is each tool’s sampling, not only the request. Neither CLI exposes temperature; what is measured is what a user gets.

Frequently Asked Questions

Are coding agents deterministic like compilers?

Not from a loose request. From a one-line ticket, Claude Sonnet 5.5, Claude Opus 5.5 and Codex produced a median of 26 behaviourally distinct implementations per function across 45 fresh sessions. From a precise spec the median fell to 2, and two of five functions produced a single behaviour from all 45 runs, though from 11 and 28 different source texts.

Do you need to review every line of AI-generated code?

Line-by-line review is the right tool when the request was loose, because the code is the only place the agent’s decisions are recorded. When the spec is precise, behaviour mostly stops depending on the run, and reviewing the spec for silences and contradictions catches more than reading the diff. In this experiment, all but one of the residual disagreements at the top spec level traced back to a silence, a contradiction or a convention clash in the spec.

Are example tests or a written spec better for coding agents?

Both helped, and they were only tested in sequence. Adding example tests cut the median from 26 behaviours to 10; adding a clause-by-clause spec on top cut it to 2, removing 80% of what remained where the examples had removed 62%. Examples only settle the cases they show. A precise sentence settles a whole class of inputs. Property tests added afterwards raised exact matches from 31% to 60% without reducing the number of behaviours.

Will agents fix mistakes in my spec?

Sometimes, and you cannot rely on which. Given a spec that never says to clean the file extension, all 45 runs built filenames containing a slash. Given a glob spec whose literal reading contradicts common convention, 89 of 90 runs followed the convention. Agents follow the spec except where they recognise a convention it breaks, and you will not know which case you are in without reading the spec yourself.

The source moved

The compiler analogy was never wrong. It was answering a different question. “Is an LLM a compiler?” has an easy answer: no, not deterministic, no formal semantics. The useful question is how far your request is from a source a compiler could compile, because that distance is exactly how much of the code you still have to read.

At one end of the dial, the ticket is not a source and the code is: 26 programs, each a different set of decisions you did not make. At the other end the spec is the source, the code is 28 renderings of it, and the review that matters is of a document most teams do not write. We are heading where compilers took us, and we will stop reading the generated code for the reason we stopped reading assembly: something above it got precise enough to read instead. That something is a spec, and somebody has to be able to read it.

The harness, tasks, all 900 runs and the analysis are at github.com/iceinvein/specdial, MIT licensed. Point it at a function you care about and see how many programs your ticket is.

Sources

All retrieved 2026-09-30.

Share this post

If it was useful, pass it along.

What the link looks like when shared.
XLinkedInBluesky

Search posts, projects, resume, and site pages.

Jump to

  1. HomeEngineering notes from the agent era
  2. ResumeWork history, skills, and contact
  3. ProjectsSelected work and experiments
  4. AboutWho I am and how I work
  5. ContactEmail, LinkedIn, and GitHub