I'm mid-port on two things right now: a Pascal solver called YASS, going to Rust, for the Sokoban work on tilebasedworlds.com, and BurntSushi's fst crate, going from Rust to Nim, for the search layer behind nlpcitations.com. Different source languages, different target languages, different domains — but the same failure mode kept showing up when I tried to hand either of them to a model in one shot. Paste in a few thousand lines, ask for a port, get back something that compiles and is subtly wrong in ways that take longer to find than writing it yourself would have. The fix wasn't a better prompt. It was splitting the work into stages that don't all deserve the same model.

Porting Is a Different Problem to Writing

New code has an underspecified target — you’re deciding what “correct” means as you go. A port has a fully specified target: the reference implementation already encodes every edge case, every off-by-one, every weird interaction between two flags nobody remembers the reason for. The job isn’t invention, it’s translation without loss. That reframes the whole task. You’re not asking a model to design something. You’re asking it to preserve behaviour across a language boundary where the idioms, memory model, and type system are all different — and a model that treats this as a creative exercise will “improve” things you didn’t ask it to improve, usually the exact parts that mattered.

Dumping the whole source file in and asking for a Rust or Nim equivalent invites exactly that. The model has no scaffold to fill in, so it invents one — its own module boundaries, its own idea of what’s shared state versus local, its own naming. None of that is wrong on its own terms. It’s wrong because it isn’t the reference’s terms, and now every bug you find later has to be traced through a structure you didn’t design and don’t recognise.


Four Passes, Not One

The workflow that’s worked for both ports has four stages, and the stages don’t all deserve the same model budget.

1. Architecture pass. No code. Just a written map: what are the distinct data structures, what’s pure computation versus stateful, what are the dependency edges between functions. This is where I spend my best model’s time — Opus or Fable, not Sonnet — because getting this decision wrong poisons everything after it. For YASS, this is where Pascal’s units and records had to get mapped onto Rust’s ownership model, and that mapping is not mechanical. Pascal doesn’t have a borrow checker’s opinion about who owns what; Rust demands one. Deciding that upfront, deliberately, is worth more than any amount of careful function-by-function translation done against the wrong ownership shape.

2. Skeleton pass. Port just the structure from the map — structs, enums, function signatures — with todo!() bodies, and get it compiling. No logic yet. This does two things: it forces the architecture decisions to actually typecheck before you’ve committed to filling anything in, and it gives the next stage something concrete to fill rather than something to invent.

3. Grind pass. Now it’s mechanical, and mechanical is where Sonnet is both good and cheap. One function or module at a time, fed with its immediate dependencies in context — not the whole file, not the whole codebase. Translating a bounded, well-specified chunk against an established pattern is a narrow task, and narrow tasks are exactly what you want handing to the cheaper model in a loop.

4. Verification, before any of it is trusted. Covered on its own below, because it’s the part that actually makes stage three safe to run unsupervised.

The split matters because architecture mistakes are expensive to unwind and grind mistakes are cheap to unwind — so you want your most expensive model attention spent where mistakes are expensive, not spread evenly across a task where most of the work is narrow and repetitive.


When the Data Structure Carries State

The FST port surfaces a failure mode the Sokoban port doesn’t. A plain finite state automaton is accept/reject — you’re either in an accepting state at the end or you’re not. A finite state transducer carries output along with it: each transition can emit a value, and those values combine along a path — for fst, by summation — to produce the final output for a key. That combining behaviour is not an incidental detail bolted onto a DFA. It’s the entire reason the structure exists.

The risk is that a model treats it as exactly that — a DFA with some extra bookkeeping — and the output-combination logic ends up scattered across the traversal code instead of living as a first-class concept. That flattening is easy to miss on a skim, because the port will still compile, still walk the automaton correctly, still return values that look plausible for simple cases. It’s only when you check a key whose output depends on summing across several edges that the flattening shows up as a wrong number.

The fix is to make sure the skeleton pass treats output-carrying as a named thing from the start — a type, not an afterthought — rather than letting it emerge implicitly during the grind pass. If the architecture map from stage one says “this edge carries an output value that combines via addition along the path,” that sentence needs to survive into the skeleton as an actual field on an actual struct, before a single function body gets filled in.


Port the Tests Before the Implementation

For anything with an existing test suite in the source language — fst has a thorough one — port the tests to the target language first, before touching the implementation. This flips the usual order and it’s worth the friction. A ported test suite gives you a red/green oracle per function, immediately, rather than a growing pile of Nim code you’re eyeballing for correctness against your memory of what the Rust version does. You find out a function is wrong the moment you write it, not three modules later when something downstream produces a strange result and you have to work backward to find which layer introduced it.


Oracle Testing Is the Part That Actually Matters

Everything above is about not introducing bugs. This is about catching the ones that get through anyway, and it’s the single highest-leverage thing across both ports: build a large, fixed corpus of inputs and a trusted baseline of outputs, generated before changing anything, and diff against it on every edit.

For the Sokoban solver, that corpus is a set of competition-grade levels — thousands of them, deliberately varied across deadlock-heavy and open-space designs, because a regression confined to one class of level hides behind an aggregate pass rate if your corpus is too uniform. Run YASS across the full set once, store solution lengths and move sequences rather than a bare solved/unsolved flag, and treat that as ground truth. Every change to the Rust port gets diffed against it — regressions and improvements both surface per-level, immediately, instead of as a vague sense that things seem to work now.

The FST port uses the same principle against a different surface: generate baseline outputs from the Rust implementation across a representative set of keys, lookups, and range queries — whatever the public API actually exposes — and diff the Nim port’s outputs against that baseline as changes land, not only against the hand-ported unit tests.

Unit tests check that specific behaviours are correct. Baseline diffing over a large corpus checks that nothing else broke.

You want both. The unit tests catch the cases you thought to write. The corpus catches the ones you didn’t — and it’s what makes it safe to let the grind pass run through modules quickly without manually reasoning through every edge case yourself.


Do This in an Agent, Not a Chat Window

The stage that makes this practical rather than theoretical is running it through something that reads and edits files directly and can execute cargo check / cargo test or the Nim equivalent in the loop. That’s the difference between copy-pasting three thousand lines back and forth across a chat window and actually iterating module by module against the real repository. The verification harness only pays off if it’s cheap to run constantly — and it’s only cheap to run constantly if the model doing the grind pass can run it itself, see the failure, and fix it without a human relaying error messages by hand.


When Not to Bother With Any of This

This is ceremony, and ceremony has a cost. For a small, self-contained port — a couple hundred lines, no exotic state, no ambiguous ownership questions — one pass with a capable model and a careful read-through is faster and just as safe. The four-stage split earns its keep specifically when the source has structure worth getting wrong: nontrivial ownership questions, state that needs first-class modelling, or enough surface area that a single pass can’t hold the whole thing in view at once.

It also depends entirely on having something to diff against. If there’s no reference implementation left to run — the original binary is lost, the source language has no working interpreter anymore, the “port” is really a reconstruction from a spec or from memory — the baseline-diffing step degrades to hand-written test cases, and a lot of the safety this workflow provides goes with it. In that situation you’re back to reading carefully and reasoning about edge cases by hand, which is slower and worth knowing upfront rather than discovering three modules in.

And if what you actually want is a rewrite rather than a port — different algorithm, different tradeoffs, not just a different language — this workflow will fight you. It’s built around preserving behaviour exactly. Redesign is a different task with a different process, and pretending it’s a port because the source happens to be in another language will just mean the oracle keeps flagging “regressions” that are actually the point.


The thing both ports have taught me is that the model choice matters less than where you spend it. Sonnet grinding through mechanical translation, module by module, against a scaffold and a test oracle, is cheap and reliable. The same model inventing architecture from a raw Pascal file, with no verification loop, is neither. The workflow isn’t really about porting code faster. It’s about deciding in advance where a wrong guess is expensive and making sure that’s where the expensive model — and the human paying attention — actually is.