DEV Community

Yash Vardhan Shukla
Yash Vardhan Shukla

Posted on

The test suite never knew it was running on Rust

Port Mortem hands you seventy two hours and a dare. Take a real library written in one language, rebuild its guts in another, and prove the new version behaves exactly like the old one. The proof is where it gets interesting. You do not get to write cozy little tests that flatter your own code. You run the original project's own test files against your port, and you keep your hands off those files the entire time. If you touch them, you lose.

I took Track D, Python to Rust, and I picked grantjenks/python-sortedcontainers.

If you have shipped Python for money you have almost certainly used it without thinking twice. It gives you a list that stays sorted as you add to it, a dict whose keys iterate in order, a set with order, and it does all of that in pure Python while keeping pace with things written in C. That last claim is the hook. There is no C extension hiding inside it. The speed comes from a data structure, not from escaping the language. I wanted to see what actually happens when you do escape the language.

The trick the library plays

Most people reach for a balanced tree when they hear "keep it sorted." sortedcontainers does something dumber and faster. It keeps a list of smallish lists. New values get slotted into whichever sublist they belong in, and when a sublist grows past twice a load factor it splits in half. When one shrinks too far it merges with a neighbor. Each sublist stays short enough that a plain insert into a Python list, memory copy and all, beats the pointer chasing you would pay in a tree. This is square root decomposition, and it is the whole engine.

The part that made me want to port it is positional indexing. sl[5000] on a hundred thousand element sorted list has to find the five thousandth element in sorted order, fast. A BTreeSet in Rust's standard library cannot do that. It has no idea how many elements sit to the left of any node. sortedcontainers builds a small binary tree of subtree sizes on the side, flattened into one flat array, and walks it in logarithmic time. That index is clever and finicky and exactly the kind of thing that breaks in quiet ways if you get the arithmetic slightly wrong.

So I rebuilt it. One Rust crate, pure safe code, #![forbid(unsafe_code)] at the top so the compiler physically refuses to let me cheat. The list of lists became a Vec<Vec<T>>. The index tree became a Vec<usize> with the same layout the original flattens into memory. There was one place the original computes an offset using math.log, and floating point at exact powers of two is a coin flip you do not want in your addressing math, so I used integer bit tricks instead and got the same answer without ever touching a float.

Making Python forget

Here is the constraint that shapes everything. The tests do import sortedcontainers. My module is a Rust extension called sortedcontainers_rs. I am not allowed to edit a single test to fix that mismatch.

The answer turned out to be small. A tiny shim directory named sortedcontainers that re-exports my Rust classes, dropped onto the Python path ahead of everything else. When the test suite says import sortedcontainers, Python finds my shim first, and the shim quietly hands back Rust. The test suite spends its entire run believing it is exercising the library it has always known. It never finds out. That is the part I keep grinning about. Two hundred and ninety six tests, written years ago by someone who had never heard of my project, all passing against code they were never meant to see.

The container that ate itself

Every port has one bug that steals an evening. Mine was a test that builds a set, then adds the set to itself.

temp.add(temp)
Enter fullscreen mode Exit fullscreen mode

In Python this is fine. The set now contains a reference to itself, and when you print it, the standard library notices the loop and prints an ellipsis instead of recursing forever. Cute. Harmless.

In my Rust binding it detonated. add had borrowed the set so it could mutate it. Then, to figure out where the new element belonged, it compared the element against the ones already inside, and the element was the set, so the comparison reached back in and tried to borrow the same set a second time. Rust's borrow rules do not care that this is clever. You cannot hand out a second borrow while a mutable one is live. The whole thing panicked with a message about a value already being mutably borrowed.

The fix taught me something about writing bindings. The mistake was holding the borrow across a call back into Python. So I stopped doing that. add now grabs what it needs, lets go of the set completely, lifts the ordered list out into a local variable, does the comparison heavy insertion there where nothing is borrowed, and only then puts everything back. The container can contain itself now, print its little ellipsis, and move on. Same fix pattern showed up three more times before I trusted it.

When a subclass has to arrive from nowhere

The other one that cost me real time was quieter and nastier. There is a constructor detail in the original where SortedList(key=something) does not give you a SortedList at all. It hands back a SortedKeyList, a different class, on the fly. A factory hiding inside a constructor.

Rust bindings cannot do that directly. The moment Python decides which type it is building, that decision is made, and the binding does not get to swap the answer for a different class. I chased pure Rust solutions for a while and every one of them lied to the type system in a way that broke something else. In the end the honest place to put the dispatch was a thin Python layer, with the key list set up to inherit through a diamond so that every identity check the tests make still holds. isinstance is happy, the exact type check is happy, and constructing it the wrong way still raises the same error the original raises. It is not the fix I wanted. It is the fix that is true.

The number I did not want to publish

Everyone loves a benchmark where the new thing wins. I have those. Indexing into the sorted list is about four times faster in Rust, because it is pure integer tree walking with nothing crossing back into Python. Cold import is roughly ten times faster. Inserts are a little faster.

Membership is slower. x in sl runs about thirteen percent behind the pure Python version.

I sat with that for a minute before deciding to lead with it rather than bury it. The reason is not mysterious once you see it. Every comparison during a membership check has to cross from Rust back into Python to ask two objects which one is smaller. Pure Python never leaves the interpreter, so it never pays that toll. My Rust core pays it on every single compare. The crossing is the cost. A tree or a fancier layout would not save me, because the bottleneck is the border, not the algorithm. So the number stays in the README, in bold, next to the wins. A benchmark you can trust is worth more than a benchmark that flatters you.

By the numbers

Tests, the whole point of the exercise:

296 of 296 passing. Zero test files edited. Zero skipped. Zero marked as expected failures. The suite is provably byte for byte identical to the version pinned at kickoff.

Fuzzing, our port against the real library, comparing results after every operation:

1,649,853 randomized operations across list, set and dict. Zero divergences.

Speed, measured on a SortedList of 200,000 elements, CPython 3.13.1, p99 latency:

operation original this port change
indexing sl[i] 1583 ns 375 ns 4.2x faster
insert add 1875 ns 1417 ns 1.3x faster
membership x in sl 833 ns 959 ns 0.87x, slower
cold import 7.38 ms 0.75 ms 9.8x faster

The indexing win is even wider at the median than at the tail. Typical sl[i] drops from around 1166 ns to 125 ns, close to nine times faster, because the whole operation is integer arithmetic in Rust and never once asks Python a question. Membership loses for the exact opposite reason. It cannot take a step without asking Python which of two objects is smaller, and every one of those questions is a trip across the border.

Safety, the part the compiler guarantees rather than the part I promise:

Zero unsafe blocks in the core. Not zero by discipline. Zero by #![forbid(unsafe_code)], which means the crate will not compile if I ever slip.

Hunting for a bug that was not there

One of the bonus objectives is to find a real latent bug in the original library through differential testing. I wanted it badly. I built an oracle, a deliberately stupid and obviously correct model, a plain list kept sorted the slow honest way, and I threw millions of randomized operations at both it and the real library, comparing every result. I aimed at the corners where bugs like to hide. Inverted range bounds. Empty ranges. Negative step slices. Non monotonic keys and how ties order. Pickle round trips.

It found nothing. The library is ten years old and it shows. Every single case came back clean.

I thought about how to spin that into a finding anyway, some tiny documentation nitpick dressed up as a bug. Then I did not. Reporting a clean result honestly is the whole ethic of a project like this. A library surviving tens of millions of adversarial checks without a single divergence is a real result, and pretending otherwise would poison everything else I was claiming. So the write up says plainly: no bug found, and here is exactly how hard I looked.

Proving I did not cheat

The one accusation that could sink this project is "you edited the tests." So I made that accusation impossible to make. At the very start I took a cryptographic hash of every original test file and froze it. The submission carries a single kickoff hash that is bound to those exact files at those exact paths. Anyone can run one script and watch it confirm that the tests are byte for byte what they were on day one, and that they match the real upstream library at the pinned commit. Not similar. Identical.

That hash turned into a small design constraint later, in a good way. When I was tempted to reshuffle folders to match a template more neatly, I realized moving the test directory would change the manifest, change its hash, and break the frozen pin. The provenance proof outranked the tidy folder. So the folder stayed where it was, and I wrote down why. Integrity you can verify beats structure that merely looks right.

What I actually built

Strip away the story and here is the residue. One safe Rust core, zero unsafe code, driving four containers through one shared element type. The original test suite passing in full, two hundred and ninety six out of two hundred and ninety six, with the files provably untouched. Millions of fuzzed operations with zero divergence from the real thing. Honest performance numbers including the one place I lose. And a decision log with seventeen entries explaining every place I chose to differ and why.

The line I keep coming back to is the one Port Mortem opens with. Languages die and code does not. You can take an idea that someone poured years into, written in a language that will eventually fall out of fashion, and carry it forward into new ground without losing what made it correct. The proof that you carried it faithfully is not your word. It is the old code's own tests, running green against something they were never built for, none the wiser.

Built for Port Mortem 2026, the 72 hour porting hackathon run by Hackathon Raptors (@partnerships_raptors ). The full project, the original test suite with its kickoff hash, the differential fuzzer, and the benchmark harness are all in the repo.

Repo: https://github.com/Yash-vs9/Port_Mortem

Top comments (0)