I used formal verification and agents to make an algorithm 105× faster and prove it behaves the same
Across four deliberately slow Dafny routines, Claude Opus 5.5 produced seven faster implementations, each verified against a frozen specification it could not edit. On a held-out run-length encoding workload it never saw, the accepted version ran 105× faster. Held-out workloads also caught small-input slowdowns the acceptance gate could not see, and failed proofs, not the speed gate, ended three of the four runs.
I let Claude Opus 5.5 edit only the implementation and proof annotations of four naive Dafny routines: integer square root, population count, run-length encoding and sort. A harness accepted a change only if the frozen spec still verified, the compiled Python matched the naive output on every workload, and the search workloads got faster. Seven changes were accepted. On sort and RLE, held-out speedups ran from 3.8× to 105×, with one exception at 1.07×. Every number is Dafny-compiled Python from a single run per routine, and the run recorded about $18.12 in Claude charges.
Setup
Each routine is a pair of Dafny files. Dafny is a programming language with a built-in program verifier: you write code plus statements about what it must guarantee, and a solver checks them for every possible input. Spec.dfy holds the contract and is frozen. Impl.dfy refines it and starts deliberately naive. It is the only file the agent could change.
For sorting, the whole contract is two lines:
ensures Sorted(b)
ensures multiset(b) == multiset(a)
The output must be in order, and it must contain exactly the elements of the input, with the same number of copies of each.
| Routine | Contract (ensures) | Naive start |
|---|---|---|
| ISqrt(n) | r*r <= n < (r+1)*(r+1) | count up from 0 |
| Popcount(n) | c == Pop(n), bits defined recursively | peel one bit at a time |
| RLE Encode(xs) | Decode(rs) == xs, runs canonical | rebuild the result sequence on every element |
| Sort(a) | sorted and same multiset | insertion sort on sequences, O(n²) |
Claude ran through the claude -p CLI with read and edit tools on a scratch copy. It could not run Dafny or the benchmarks. It saw the spec, the current implementation, a description of the search workloads and the latest timings. A harness then applied four checks in order:
- Reject the candidate if
Spec.dfychanged by a single byte, or if the implementation used an escape hatch:assume,{:axiom},{:verify false},{:extern},{:only},expectordecreases *. - Run the Dafny verifier with a 60 s solver limit. On failure, send the errors back, for up to three attempts per round.
- Compile to Python and check that the output matches the naive version on every workload, held-out ones included.
- Time the search workloads with seven interleaved repeats, each in a fresh process. Accept only if the geometric-mean speedup is at least 1.10× and no search workload falls below 0.95×.
Each routine got at most three rounds and stopped at the first rejected one. Each routine also had held-out workloads with different sizes and shapes. Their outputs were checked in step 3, but their timings were only reported afterward, never used for acceptance.
Accepted changes
| Routine | Rounds | Accepted changes | Search | Held-out | Why it stopped | Claude cost |
|---|---|---|---|---|---|---|
| ISqrt | 3 of 3 accepted | binary search → Newton → comparison tree + Newton | 500×, 4190× | 437×, 24856×; tiny inputs 0.97× | three-round cap | $0.66 |
| Popcount | 2 accepted, round 3 failed proof | 8 bits per step → lookup table | 2.55×, 3.19× | 14.4×, 10.4×; small inputs 0.76× | solver time-outs on all 3 attempts | $13.97 |
| RLE | 1 accepted, round 2 failed proof | scan ahead, emit whole runs | 35×, 20× | 105×, 4.4×, 4.3× | 3 failed proof attempts | $1.13 |
| Sort | 1 accepted, round 2 failed proof | insertion sort → merge sort | 11×, 3.5× | 45×, 13.6×, 3.8×; tiny lists 1.07× | proof time-out and termination | $2.36 |
Each speedup is the naive median time over the new median time, from seven repeats. About $12 of the $18.12 total was one failed popcount round.
On sort, the agent replaced insertion sort with a top-down merge sort and wrote the loop invariants Dafny needed to accept it. The first proof attempt passed. On RLE, the naive version rebuilt its output for every element. The accepted version finds where each run ends and appends the whole run at once, and it also verified on the first attempt.
Held-out workloads
Sort and RLE each had one accepted round, so every workload in Table 03 compares that round directly with the naive start.
| Routine | Workload | Shown to agent | Median speedup | Range |
|---|---|---|---|---|
| RLE | 3,000 ints, runs of average length 8 | yes | 35.3× | 23.8–40.7× |
| RLE | 3,000 ints, about 10 runs | yes | 19.6× | 18.8–21.5× |
| RLE | 10,000 ints, runs of average length 4 | no | 104.7× | 74.7–117.2× |
| RLE | 300 sequences of 20 ints | no | 4.39× | 1.91–8.10× |
| RLE | 3,000 ints, no two neighbors equal | no | 4.33× | 1.57–7.11× |
| Sort | 3 lists of 1,000 random ints | yes | 10.95× | 7.02–13.72× |
| Sort | 20 lists of 300 random ints | yes | 3.47× | 1.85–5.37× |
| Sort | 2,000 ints, already sorted | no | 44.5× | 17.4–50.0× |
| Sort | 2,000 ints from {0..4} | no | 13.6× | 10.5–24.5× |
| Sort | 2,000 ints, reverse-sorted | no | 3.81× | 2.48–5.12× |
| Sort | 2,000 lists of 5 random ints | no | 1.07× | 0.73–1.17× |
Range runs from slowest-before over fastest-after to fastest-before over slowest-after. The five-element-list result at 1.07× is not a difference the timing noise can resolve.
The held-out sets also caught what the gate could not. ISqrt round 1, a binary search, ran tiny inputs (0 to 100) at 0.44× and was accepted, because tiny inputs were not a search workload. Later rounds brought that to 0.97×. Popcount rounds 1 and 2 ran inputs below 256 at 0.92× and then 0.83× against their predecessors, which ends at 0.76× against the naive start. A proof fixes behavior. It does not fix speed on every input.
ISqrt round 3 hard-coded a comparison tree sized to the search range, up to about 1.7×10¹⁰. It is still proven correct, but it is shaped around the benchmark.
Interpretation and limits
No accepted change broke its contract: each one verified against the unchanged spec and matched the naive output on every workload. The speed gate never rejected a candidate. Every early stop came from verification. RLE round 2 had real proof errors, but the final attempts for popcount and sort had no errors, only time-outs. Rerun afterward with a 300 s solver limit, sort round 2 verified and popcount round 3 still had one time-out. Sort round 2 stays rejected under the fixed rules and was never timed. So part of where each run stopped reflects the harness's proof budget, not the code.
Limits that affect the estimates above:
- Everything timed is Python compiled from Dafny, which adds runtime overhead. The final versions are still about 5× to 7000× slower than Python's built-ins (
math.isqrt,int.bit_count,sorted) and, for RLE,itertools.groupby. Those run in C and show scale only. - The baselines were deliberately slow, so large speedups against them say little about real code.
- One run per routine, with Claude Opus 5.5, the CLI's default model. The A/A noise floor (naive against itself) was within ±4% for ISqrt, popcount and RLE, but sort reached 0.84× on one workload, and another experiment may have been running on the same Mac.
- Popcount round 2 passed on median speedups of 1.47× and 1.57×, with min–max ranges of 0.78–1.93× and 1.09–1.97×. A rule requiring the whole range above 1.0× would have rejected it.
- Some ISqrt workloads now finish in microseconds, below reliable timing resolution.
- "Correct" means correct against these frozen contracts, nothing broader.
Next experiment
- Give the verifier a longer solver limit, such as the 300 s that let sort round 2 verify, and see where each routine stops.
- Add small and tiny inputs to the search workloads, or a no-regression floor for them to the gate, so slowdowns like ISqrt's 0.44× are rejected rather than reported.
- Require the whole min–max range above 1.0×, not just the median.
- Lengthen the ISqrt workloads before running more rounds.
- Repeat each routine several times, on an otherwise idle machine.
Reproducibility
| Model | Claude Opus 5.5 (the CLI's default model) through the claude -p CLI, limited to read and edit tools (Read, Edit, Write, Glob, Grep). |
|---|---|
| Experiment | Dafny 4.11 verifier with a 60 s solver limit. Up to 3 proof attempts per round and 3 rounds per routine, stopping at the first rejected round. Timing: 7 interleaved repeats, each in a fresh process. Acceptance: geometric-mean speedup of at least 1.10× on search workloads, none below 0.95×. |
| Data | Four routines (ISqrt, popcount, RLE, sort), each with seeded search workloads and held-out workloads. Unit: one workload's median time over 7 repeats. |
| Status | Published 2026-09-24 · Results and code are not published. Open issue: one run per routine on a noisy machine. |