Chapter 4 of 7 · 8 min read
A unique anchor is the cheapest guard against a wrong-place edit
On this page
The entire wrong-place category from the last chapter is produced by one line of code, and it is a line that passes review:
def apply_edit(path: str, search: str, replace: str) -> None:
text = Path(path).read_text()
if search not in text:
raise EditError("SEARCH block not found")
Path(path).write_text(text.replace(search, replace, 1))The 1 is there because somebody thought about it. Replacing every occurrence would be worse, so they limited it to one, and in doing so encoded a decision nobody made: when the anchor is ambiguous, the first occurrence in file order wins. There is no signal at any point that a choice was made. The Cart.total edit that landed in Order.total went through this function and came out the other side as a successful tool result.
The fix is four lines longer and changes the failure from a defect into a refusal:
def apply_edit(path: str, search: str, replace: str) -> None:
text = Path(path).read_text()
hits = [m.start() for m in re.finditer(re.escape(search), text)]
if len(hits) == 0:
raise EditError("SEARCH block not found")
if len(hits) > 1:
raise AmbiguousEdit(f"SEARCH block matches {len(hits)} locations",
lines=[text.count("\n", 0, h) + 1 for h in hits])
text = text[:hits[0]] + replace + text[hits[0] + len(search):]
Path(path).write_text(text)On the workload from the previous chapters this converted an average of 1.4 silent wrong-place edits per hundred applies into 1.4 refusals per hundred applies, at a cost of one extra pass over the file — under a millisecond for anything short of a generated protobuf. Nothing else in the edit layer buys that much for that little, which is why it belongs in the first version of an applier rather than in a hardening pass afterwards.
What makes the refusal valuable is that it carries the line numbers of the competing matches, and those are exactly what the model needs to write a better anchor. A retry prompted with matches 3 locations: lines 96, 214, 331 will nearly always come back with the class declaration or the preceding decorator included in the search block, which resolves the ambiguity in one round trip. A bare "edit failed" produces a retry that emits the same anchor again.
The refusal payload is worth designing rather than formatting as a string, because each failure category has a different next action and the model has no way to infer which one applies:
{
"error": "ambiguous_anchor",
"matches": 3,
"locations": [
{ "line": 96, "enclosing_symbol": "Order.total" },
{ "line": 214, "enclosing_symbol": "Cart.total" },
{ "line": 331, "enclosing_symbol": "Quote.total" }
],
"hint": "Extend the SEARCH block upward to include the class declaration."
}Including enclosing_symbol for each candidate is what turns a retry into a one-shot correction, since the model can see that the site it meant is the second one and that the distinguishing text is the class Cart: line sixteen lines above. Without it the model is guessing which of three line numbers corresponds to the class it was asked about, and a guess that picks wrong produces a unique anchor pointing at the wrong method — the original defect, arrived at through the safety check.
Uniqueness has to be evaluated against the whole file rather than against the region the model was shown. An agent that read lines 200–260 and quoted from them has seen one occurrence of the anchor and has no way to know about the two above line 200, so a check scoped to the visible window would report a unique match and be wrong in exactly the cases that matter. The count is cheap over the entire file, so there is no reason to scope it.
There is a legitimate case for an anchor that matches many times, which is the deliberate replace-all: renaming a symbol, changing a log level everywhere, migrating a deprecated call. Refusing those is as wrong as silently taking the first. The clean way to express it is an explicit count in the edit operation — expected_matches: 7 — which the applier verifies before writing and refuses if the file contains six or eight. That keeps the property that matters, which is that the model states what it believes about the file and the applier checks the belief, rather than the applier inventing a policy when the belief is missing.
The two questions an applier should ask before writing
Uniqueness answers where in the file. It does not answer whether that place is the one the task meant, and those come apart in a specific, recurring way: the model quotes a fragment that appears exactly once, and it is the wrong fragment. A task that says "add a currency guard to Cart.total" can produce a search block quoting Order.total's body, because the two classes sit near each other in the model's memory of the file and the bodies differ by one word. Unique match, clean apply, wrong method.
The second check compares the matched byte range against the span of the symbol the task named, using whatever the repository already has — a tree-sitter parse, ctags, the language server, or in a pinch the indentation-delimited block containing the match. All three of the named options answer the same question, which is what function or class a given byte offset falls inside: tree-sitter is an incremental parser with grammars for most languages, the one editors use for syntax highlighting; ctags builds a flat index of symbol names and the lines they start on; and a language server is the background process an IDE talks to for go-to-definition, reachable over a documented protocol.
def enclosing_symbol(path: str, offset: int) -> str | None:
tree = PARSER.parse(Path(path).read_bytes())
node = tree.root_node.descendant_for_byte_range(offset, offset)
while node is not None:
if node.type in ("function_definition", "class_definition"):
return qualified_name(node)
node = node.parent
return None
symbol = enclosing_symbol(path, hits[0])
if task.target_symbol and symbol != task.target_symbol:
raise OutOfScopeEdit(f"edit landed in {symbol}, task named {task.target_symbol}")This one is cheaper than it looks and less general than it looks. A tree-sitter parse of a 2,000-line file takes single-digit milliseconds, so the cost is nothing. The limit is that it only fires when the task actually names a target, which is true for issue-driven work and refactors and false for open-ended instructions like "make the tests pass". Where it does apply, it catches the residue that uniqueness misses, and where it does not, the applier falls back to uniqueness alone.
Put together, the apply path has a small number of states and one write:
Every path that does not reach J returns a message the model can act on, and no path reaches J without both questions answered.
Give the model the bytes instead of asking it to remember them
The false rejections have a different cure, because they are a recall problem rather than a decision problem. The model produced spaces where the file has tabs because it was reconstructing a span it saw thousands of tokens ago, so the remedy is to put the span in front of it immediately before it has to quote it.
The mechanism is a read tool that returns an exact, quotable region and a rule that search blocks must come from one:
{
"name": "read_region",
"description": "Return the exact bytes of a symbol or line range. The text returned is what a SEARCH block must contain, character for character, including indentation and line endings.",
"input_schema": {
"type": "object",
"properties": {
"path": { "type": "string" },
"symbol": { "type": "string", "description": "e.g. Cart.total" },
"lines": { "type": "string", "description": "e.g. 96-118" }
},
"required": ["path"]
}
}Two details in that tool matter more than the schema. It returns the region without a line-number gutter, because a gutter forces the model to strip digits on the way to the search block and stripping is one more generative step that can eat a leading space. And it returns a region small enough that the quotation happens within a few hundred tokens of the read, which is where verbatim reproduction is most reliable.
Measured on a mixed Python and TypeScript repository over 300 edits, moving from "quote from the file you read at the start of the turn" to "call read_region, then quote what it returned" took not-found rejections from 6.2% to 1.9%. That is the distance gradient from the previous chapter being collapsed deliberately: the tool call puts the bytes a few hundred tokens before the quotation instead of twenty thousand, which moves every edit into the bucket where fidelity is highest.
The remaining 1.9% were mostly the stale-anchor case — a second edit to a file the agent had already changed — and the fix for that is structural rather than a matter of quotation. The applier should record the hash of each file it writes and reject any subsequent edit to that file whose anchor was quoted from an earlier version, which it can tell from the read-tool call that produced the region. The error is file changed since you read it; re-read before editing, and it costs one extra tool call in exchange for removing a category of rejection that otherwise recurs on every multi-edit task. The alternative, which several agents adopt, is to have the edit tool return the post-edit content of the surrounding region in its success result, so the model's most recent view of the file is always the current one.
The normalisation switch moves the failures, it does not remove them
The tempting response to a tabs-versus-spaces rejection is to stop caring about whitespace: strip leading indentation from both the anchor and each candidate region, compare what remains, and re-indent the replacement to match whatever the file had. It works, in the sense that it makes that particular rejection go away.
It also makes anchors collide that did not collide before. Consider this, which is ordinary code:
if plan.is_annual:
total = base * 12
total = apply_discount(total, plan.discount)
else:
total = base
total = apply_discount(total, plan.discount)Under exact matching, total = apply_discount(total, plan.discount) at eight spaces of indentation and the same text at eight spaces is genuinely ambiguous and both branches are found. Under normalised matching, any anchor that differs between two sites only by indentation — the same helper inlined at two nesting levels, the same block inside a class and inside a module-level function, a method that was copied into a subclass and re-indented — becomes ambiguous where it was previously unique and correct. The uniqueness check then refuses an edit that strict matching would have applied to the right place.
| Strict byte match | Normalised whitespace | |
|---|---|---|
| Tab/space and trailing-space mismatch | rejected | matched |
| Anchors differing only by indentation | distinct | collide, so refused as ambiguous |
| Replacement indentation | comes from the model | must be recomputed by the applier |
| Failure when it goes wrong | loud refusal | edit re-indented into the wrong shape |
| CRLF versus LF line endings (Windows versus Unix) | rejected | matched |
The bottom two rows carry the real cost. Once you normalise on match, the applier owns the indentation of the text it writes, so it has to strip the anchor's common prefix, apply the file's prefix to the replacement, and handle a replacement whose internal nesting differs from the anchor's. In Python that means a bug in this logic produces a file that parses and does the wrong thing, because indentation is semantics. A defensible middle setting is to normalise only line endings and trailing whitespace, which are invisible and carry no meaning, and to keep leading indentation strict, which keeps the applier out of the business of deciding what a block means.
None of these guards make an edit correct. A search block that is unique, sits inside the named symbol, and matches byte for byte can still contain a replacement that introduces a null dereference, and the applier will write it without hesitation because none of these checks look at the semantics of the change. They constrain where the edit lands, and the question of whether the edit is right belongs to the compiler and the suite. They also do nothing about the truncation from chapter two, since a rewrite has no anchor to check.
Every number in this chapter — 1.4 wrong-place edits per hundred, 6.2% down to 1.9%, a 12.4% apply failure rate that fell to 3.1% — arrived without any account of how it was measured or what it was measured against. The next chapter is about why the most obvious of those numbers, the apply rate, is the one that will mislead you, and what to count instead.
Edit formats — read the current version at https://daviddominguez.dev/learn/en/edit-formats/a-unique-anchor-is-the-cheapest-guard-against-a-wrong-place-edit