Chapter 3 of 7 · 9 min read
A search block is an anchor that must match byte for byte
On this page
The switch paid off on the headline number immediately. Apply failures across the same workload fell from 12.4% to 3.1% in the first week, multi-hunk edits stopped being special, and the tickets about mysterious rejected patches dried up. Then two new tickets appeared, filed by different engineers on the same afternoon, and neither of them looked like a patch problem.
The first said the agent kept insisting a block of code was not in a file. The engineer had the file open and the code was on screen, ten lines of a method the agent had itself read forty seconds earlier, and the agent's tool result said SEARCH block not found in payments/reconcile.py. She retried twice, watched the model produce what looked like the same ten lines, and got the same rejection.
The second said the agent had changed the wrong function. The ticket asked for a null guard in Cart.total(), the pull request contained a null guard, and the guard was in Order.total() two hundred lines further down. Nothing errored, nothing retried, and the tests passed because neither method had coverage for that path.
Both are the same mechanism seen from two sides. A search-and-replace block is an edit expressed as two pieces of literal text, the existing lines to be replaced and the lines to put in their place, wrapped in delimiters the applier can parse. It replaces the coordinate that chapter one showed the model computing badly with a string the model has to reproduce exactly, and the applier finds the edit site by looking for that string in the file. The existing-text half is the anchor, so called because it is the only thing tying the edit to a position.
payments/reconcile.py
<<<<<<< SEARCH
def total(self):
return sum(line.amount for line in self.lines)
=======
def total(self):
if not self.lines:
return Money.zero(self.currency)
return sum(line.amount for line in self.lines)
>>>>>>> REPLACEThere are no line numbers in that block and nothing for the model to count. The applier reads the SEARCH text, looks for it in the file, and writes the REPLACE text over the region it found. Everything the format can get wrong is now a question about that string: whether it exists in the file, and whether it exists in exactly one place.
The bytes the model did not copy
Take the rejected search block from the first ticket and the file it was rejected against, and pipe both through cat -A so the invisible characters show up. ^I is a tab and $ is end of line.
$ sed -n '412,414p' payments/reconcile.py | cat -A
^I^Idef _settlement_key(self, row):$
^I^I return (row.batch_id, row.currency)$
$
$ sed -n '2,4p' /tmp/search_block.txt | cat -A
def _settlement_key(self, row):$
return (row.batch_id, row.currency)$
$The file is indented with tabs. The model emitted spaces. On screen, rendered at a tab width of four, the two are pixel-identical, which is why the engineer looking at her editor could see the code plainly and the applier could not find it. "\t\tdef" == " def" is false in every language, and string equality is the only thing the applier is doing.
The cause is that the model is not copying, it is regenerating. The file entered the context perhaps 15,000 tokens ago, and reproducing a span of it is a generative act shaped by everything the model believes about how Python is written. Indentation is exactly the kind of low-information detail that generation smooths toward the prior — the prior here being what the model expects by default from having seen millions of files, which is what it falls back on wherever the evidence in front of it is faint — and the prior for Python is four spaces. The same pressure produces a family of near-misses that all look identical on a screen:
| What the file has | What the model emits | Why |
|---|---|---|
| Tab indentation | Four spaces | The dominant convention in training data |
foo(a, b, c) on one 140-char line | The same call wrapped over three lines | Formatter-shaped prior toward 88 or 100 columns |
items = [a, b] | items = [a, b,] or the reverse | Trailing-comma habits differ by codebase and formatter |
\r\n line endings | \n | The file came from a Windows contributor; the model normalises |
'user' | "user" | Quote-style prior, especially after seeing code formatted by Black, Python's dominant auto-formatter |
| Two blank lines between methods | One | Blank-line runs carry almost no signal to reproduce |
| Trailing space at end of a line | No trailing space | Invisible in the prompt rendering, so invisible to the model |
# TODO: fix this | # TODO: Fix this | Casual normalisation of prose inside comments |
Two of these are worth singling out because they defeat careful engineers. Trailing whitespace is not visible anywhere in the pipeline — not in the prompt, not in the model's output, not in the tool result, and not in the terminal — so a search block that fails on it produces a rejection nobody can explain by reading the two strings side by side. And if the harness renders the file into the prompt with line numbers, as many do to help the model reason about locations, the model must strip those numbers to produce a search block, and the stripping is one more generative step that can leave a stray digit or eat a leading space.
There is a second-order version of this that hurts more. When the agent has already made one edit to a file in the current run, the model's memory of that file is the version it read at the start, and its search block for the second edit may quote text the first edit already replaced. The anchor is a faithful copy of a file that no longer exists.
Fidelity falls off with distance from the read
The normalisation is not uniform across a run, and its shape is measurable. Bucketing 1,100 search blocks from one agent by how many tokens separated the tool result that returned the file from the assistant turn that quoted it gives a clean gradient:
| Tokens since the file was read | Search blocks | Byte-mismatch rejections |
|---|---|---|
| under 1,000 | 402 | 1.5% |
| 1,000 – 5,000 | 391 | 4.1% |
| 5,000 – 20,000 | 218 | 9.2% |
| over 20,000 | 89 | 17.9% |
The first bucket is what happens when the model reads a region and immediately quotes it, which is close to copying: the bytes are a few hundred tokens back and attention can retrieve them directly. The last bucket is what happens after the model has read four other files, run the test suite, and reasoned about a stack trace, at which point the file is a memory rather than a recent observation and the prior does more of the work. Nothing about the format changed between buckets, and the failure rate moved by a factor of twelve.
The gradient explains why the same agent seems reliable in a short session and flaky in a long one, and why reproducing a reported failure by hand often succeeds — the reproduction has the file in immediate context. It also says where the fix has to live, which is in the distance rather than in the prompt wording.
The opposite failure, from anchors that are too small
The second ticket is the mirror image. The model produced a search block that matched the file perfectly, and matched it in three places:
<<<<<<< SEARCH
def total(self):
return sum(line.amount for line in self.lines)
=======$ grep -c "return sum(line.amount for line in self.lines)" checkout/models.py
3Cart, Order and Quote all have a total() method with the same one-line body, which is what you would expect from three classes over the same line item type. The applier searched from the top of the file and replaced the first occurrence, which was Order at line 96, because the classes are ordered alphabetically. The task named Cart. There was no error to report; the applier did precisely what a string-replace does when handed an ambiguous needle, and str.replace(old, new, 1) is a perfectly reasonable-looking line of code that quietly encodes "the first one will do".
This is not chapter one's fuzz failure wearing a different hat. There, the model stated a coordinate, the coordinate drifted, and the applier searched near the stated location and settled somewhere plausible; the stated location was at least evidence about intent, even when it was wrong. Here there is no coordinate at all, so the applier has nothing to disambiguate with and no notion of "near". Position information was removed from the format on purpose, which is what made the format robust to the arithmetic problem, and the cost of removing it is that identical text anywhere in the file is an equally good match.
The two failure modes pull the anchor in opposite directions, and that tension is the shape of the whole format.
| Anchor length | Chance of a byte mismatch | Chance of multiple matches | Output tokens |
|---|---|---|---|
| 1 line | low | high — common idioms repeat | ~15 |
| 3–6 lines | moderate | low in most files | ~60 |
| 15–40 lines (a whole method) | high — more bytes to get wrong | essentially zero | ~350 |
| The whole file | certain to fail on something | zero | thousands |
Every byte added to the anchor is another byte the model can normalise, and every byte removed is another chance the text appears twice. There is no anchor length that is safe in both directions, which means the format cannot be made correct by instruction alone. Telling the model "include enough context to be unique" pushes toward long anchors and more false rejections; telling it "keep the search block minimal" pushes toward short anchors and more wrong-place edits. Prompt guidance moves the failures between the two categories rather than removing them.
What a search block does not fix
It does not make the model's memory of the file accurate. The format's entire premise is that the model can reproduce a span of the file verbatim, which is a demand on recall under exactly the conditions — long context, many tokens of distance, low-salience detail — where recall is weakest. A rewrite asks the model to reproduce the whole file and fails at the cap; a search block asks it to reproduce a fragment and fails at the fragment's fidelity. Both are asking for reproduction, in different sizes.
It also does not remove the wrong-place category that made unified diffs dangerous, which is what most teams miss when they switch. Apply failures fall sharply, which shows up immediately in the dashboards and feels like a large win, while the silent wrong-place edits fall much less and are counted by nobody. The visible number improved more than the underlying reliability did, and a team that stops measuring at the apply rate will conclude the problem is solved.
And it does not eliminate the need for the model to have read the file. A search block quoting a method the model saw summarised in a symbol index — a listing of the names and signatures a file defines, with the bodies left out to save context — rather than in full is a guess about what the bytes are, and guesses fail the match. Formats interact with the retrieval strategy above them.
Both failure modes have short mitigations, and neither requires a better model. Counting the matches before applying turns a silent wrong-place edit into a refusal the loop can act on, and giving the model the exact bytes to quote back — rather than asking it to recall them — removes most of the normalisation. There is also a switch that looks like it solves everything, whitespace-insensitive matching, which trades one failure mode for the other rather than removing either. The next chapter works through all three.
Edit formats — read the current version at https://daviddominguez.dev/learn/en/edit-formats/a-search-block-is-an-anchor-that-must-match-byte-for-byte