Dice-coefficient autocorrect for failed SEARCH/REPLACE edits
Agentic edit loops bleed on whitespace drift and one-character mismatches in SEARCH/REPLACE blocks. Replace 'SEARCH text not found' with the top-3 similar candidates scored by Dice's coefficient — the LLM autocorrects on the next iteration at the cost of one O(n*m) scan.
Ilyos Olimov · · 7 min read
Every agentic edit loop in production today — aider, Cline, Cursor's apply mode, Claude Code's surgical edits — runs on a SEARCH/REPLACE protocol. The LLM emits a block of text it expects to find in a file (SEARCH), and a block of text to substitute (REPLACE). The runner does the actual byte-level edit.
It's a beautiful protocol. It's also the source of one of the most common wasted-turn failures in agent loops: the SEARCH text doesn't match, the runner returns SEARCH text not found, and the LLM retries with a guess that's structurally identical to the first one. Architect turn, gone. Tokens, gone. The user watches a spinner.
The fix is embarrassingly cheap, and once you see it you cannot un-see it: when SEARCH fails, don't return not found. Return the top-3 fuzzy matches in the file with a similarity score and line range. The LLM treats those as feedback and almost always corrects on the next iteration. The cost is a single O(n·m) scan — milliseconds — instead of another full architect turn.
The protocol (SEARCH/REPLACE in 30 seconds)
The LLM emits an edit as two fenced blocks:
<<<<<<< SEARCH
const timeout = 5000;
=======
const timeout = 30000;
>>>>>>> REPLACE
The runner reads the target file, finds the SEARCH text by exact substring match, and replaces it with REPLACE. Surgical, no full-file rewrites, no diff parsing, no AST. It works because LLMs are very good at quoting the file they just read.
It works until something between the read and the write perturbs the file by a single character.
The failure mode
The class of failure isn't subtle once you start logging it. In rough order of frequency:
- Whitespace drift. The LLM normalizes indentation in its quote. Two spaces become four, or a tab becomes spaces. Byte mismatch.
- A trailing comma in a tuple or argument list that the LLM helpfully drops because it "looked cleaner."
- An extra blank line between a function and a comment, or a missing one.
- A formatter ran between turns. Prettier or ESLint --fix touched the file on save. The LLM's SEARCH is now stale by 50ms.
- Smart quotes vs straight quotes, or a stray Unicode space pasted from somewhere upstream.
In every one of these cases the LLM's SEARCH is semantically correct. It's pointing at the right region. It's off by one character, or one whitespace run, or one trailing comma. The user knows exactly what edit was meant. The runner does too, if you ask it the right question.
SEARCH text not found is the wrong question. It's a binary answer to a continuous problem.
Dice's coefficient (the math, briefly)
Dice's coefficient measures how similar two strings are by their character bigrams:
dice(A, B) = 2 * |bigrams(A) ∩ bigrams(B)|
─────────────────────────────────
|bigrams(A)| + |bigrams(B)|
Bigrams are overlapping 2-character windows. "hello" produces ["he","el","ll","lo"]. The intersection is multiset intersection — if a bigram appears twice in both strings, it counts twice.
Output is 0.0 (no overlap) to 1.0 (identical). No training, no model call, no embeddings. Five lines of code:
function dice(a: string, b: string): number {
if (a === b) return 1;
if (a.length < 2 || b.length < 2) return 0;
const bigrams = (s: string) => {
const m = new Map<string, number>();
for (let i = 0; i < s.length - 1; i++) {
const g = s.slice(i, i + 2);
m.set(g, (m.get(g) ?? 0) + 1);
}
return m;
};
const A = bigrams(a), B = bigrams(b);
let inter = 0, sizeA = 0, sizeB = 0;
for (const [g, n] of A) { sizeA += n; if (B.has(g)) inter += Math.min(n, B.get(g)!); }
for (const [, n] of B) sizeB += n;
return (2 * inter) / (sizeA + sizeB);
}
You can swap it for Levenshtein, Jaccard, or token-set ratio. Bigram Dice is the sweet spot for code: cheap, locality-aware, robust to small reorderings.
A 3-tier matching strategy
flowchart TD
S["SEARCH block"] --> T1{"Exact match?"}
T1 -->|yes| OK["Apply edit"]
T1 -->|no| T2{"Whitespace-normalized match?"}
T2 -->|yes| OK
T2 -->|no| T3{"Dice similarity ≥ threshold?"}
T3 -->|yes| OK
T3 -->|no| ERR["Return the closest near-miss<br/>in the error → LLM autocorrects"]
style OK fill:#13241a,stroke:#2ecc71,color:#d7f7e3
style ERR fill:#241f13,stroke:#e0a23a,color:#f7e8cf
Fuzzy is the fallback, not the default. Run it only when the cheap paths fail:
function findMatch(file: string, search: string) {
// Tier 1: exact substring. Cheap. ~95% of edits land here.
if (file.includes(search)) return { tier: 'exact', range: locate(file, search) };
// Tier 2: whitespace-normalized. Catches indent drift + formatter runs.
const norm = (s: string) => s.replace(/\s+/g, ' ').trim();
const idx = norm(file).indexOf(norm(search));
if (idx >= 0) return { tier: 'normalized', range: mapBackToOriginal(file, idx) };
// Tier 3: dice over sliding windows of similar length. Final fallback.
const candidates = slidingWindows(file, search.length);
const scored = candidates
.map(c => ({ ...c, score: dice(c.text, search) }))
.sort((a, b) => b.score - a.score)
.slice(0, 3);
return { tier: 'fuzzy', candidates: scored };
}
Tier 1 handles the vast majority. Tier 2 handles formatters. Tier 3 only runs when both fail — which means you pay the O(n·m) scan only on the turns that were going to fail anyway.
The error-message shape that makes the LLM autocorrect
This is the entire trick. The shape of the error becomes inline context for the LLM's next attempt.
Before — useless:
ERROR: SEARCH text not found in src/auth/service.ts
After — useful:
ERROR: SEARCH text not found in src/auth/service.ts.
Closest matches (fuzzy, ranked by similarity):
[0.92] lines 142-148:
if (user.tokenExpiresAt < Date.now()) {
await this.refreshToken(user);
return user;
}
[0.78] lines 201-207:
if (user.tokenExpiresAt <= Date.now() - GRACE_MS) {
await this.refreshToken(user, { force: true });
return user;
}
[0.61] lines 88-93:
if (session.expiresAt < Date.now()) {
await this.invalidate(session);
}
Re-emit SEARCH against the actual file content above.
The LLM reads its own near-miss back, sees the byte that drifted (a < vs <=, a missing space, a renamed variable), and corrects. In practice the score-0.85+ candidate is the right one nearly every time.
This works because LLMs are great at quoting and bad at guessing. Give them the real text and they will quote it.
Cost
Tier 3 on a 500-line file is ~3-5ms in any reasonable runtime. Compare that to one wasted architect turn: a full prompt round-trip on a frontier model, several thousand tokens, two-to-five seconds of latency, real money. The break-even is one prevented retry per thousand calls. The actual hit rate is much higher than that.
Worth considering as variants:
- LLM-supplied line anchors. Have the SEARCH block come with an optional
lineRange: "40-50"hint. Only fuzzy-match within ±10 lines of that band. Faster and more precise. - Token-based similarity for code-heavy SEARCH blocks where identifier overlap matters more than character overlap.
- Levenshtein for short SEARCH blocks (under ~20 chars) where bigrams are too coarse.
Takeaways
SEARCH text not foundis a binary answer to a continuous problem. Don't ship it.- Run tiers in order of cost: exact → whitespace-normalized → fuzzy. Most edits never leave tier 1.
- Dice's coefficient is five lines, no dependencies, no model call. It buys you a tier-3 fallback for free.
- The point isn't the algorithm. The point is the error message shape. Return ranked candidates with scores and line ranges, and the LLM autocorrects.
- One prevented architect retry per thousand calls pays for the entire scan budget. The real rate is much higher.
Keep going
More practical ways to move your app forward.
- How to describe your app to AI: a prompt template
Write a clear AI app-builder prompt with a copyable template, a practical example, acceptance checks, and focused follow-up edits.
- How to turn a client brief into a mobile app prototype
Turn a client brief into a mobile app prototype with a copyable AppX prompt, a practical test checklist, and a focused client-feedback workflow.
- Run Kahn's topological sort on every AI-generated plan
AI architects emit multi-file plans that compile, run, and crash because the dependency graph has a cycle nobody looked at. Validate the plan with Kahn's algorithm before you spend tokens generating. Twenty lines of TypeScript catches a class of runtime bugs at near-zero compute cost.
- SIGKILL is the boss fight: design your sweeper for it
SIGTERM gives you cleanup time. SIGKILL doesn't. Deploys, OOM, and crashes all skip your graceful-shutdown handler. Your sweeper IS the SIGKILL handler — and its query shape should test for invariant violations, not row age.
- Persistent Metro: 1,750x faster than spawning expo export
One-shot bundlers pay cold-start every invocation. Persistent Metro dev server inside each preview container takes file writes to 4ms and warm bundles to 100ms. The pattern generalizes — any incremental build tool with a watch mode wants to be a daemon, not a script.
Ready to try your idea?
Start with a clear description. AppX will help you turn it into an app you can test.