2026-08-25 · 7 min read
Two implementations, one contract
Porting a library to a second language is easy. Proving the two agree — and keeping them agreeing — is the actual work.
phantom-core fakes AI responses. Give it a provider, a model and a seed and it returns something shaped exactly like the real API — deterministically, offline, for free. It was written in TypeScript because the lab is TypeScript. Then it needed to exist in Python too.
The tempting version of that job is a transliteration: read the TypeScript, write the equivalent Python, run both, eyeball the output, ship it. That produces two libraries that agree today and drift quietly forever after. The interesting version is to make agreement a property you can test.
Start at the bottom
Everything phantom-core does is downstream of one seeded PRNG. If the random streams match, the word choices match; if the word choices match, the sentences match; if the sentences match, the response bodies match. So the port started there, and the first thing written was not the RNG but the test for it — vectors generated from the TypeScript build, committed, and asserted against.
Matching a JavaScript PRNG in Python is not a matter of translating the arithmetic. It is a matter of reproducing JavaScript's semantics precisely, which are not Python's:
- Math.imul is a 32-bit signed multiply. Python integers are unbounded, so every product needs masking back to 32 bits — miss one and the streams diverge after a few draws.
- charCodeAt yields UTF-16 code units, not code points. An emoji is two iterations of the seed hash, not one. Get that wrong and every seed containing an astral character hashes differently.
- The >>> operator is an unsigned shift with no Python equivalent, and the coercion to int32 that JavaScript performs before a bitwise xor has to be written out explicitly.
All three are the kind of detail that a transliteration gets wrong invisibly — the output still looks like plausible fake text, it is simply a different plausible fake text. The vectors caught none of them, because writing the semantics down carefully meant there was nothing to catch: eighteen parity tests passed on the first run, emoji seeds included.
Do not transcribe data
The language registry is 22 languages, each with roughly forty words, ten nouns and ten adjectives — about nine hundred strings. Retyping those into Python would be a guaranteed source of silent divergence: one typo in a Dutch adjective and the two implementations produce different text for that seed, forever, and nobody notices because both outputs look fine.
So the Python package does not contain the word banks. It contains a generator's output. A script in the registry package emits JSON, the Python side loads it, and adding a language means running the generator — there is no path by which the two can hold different data.
The rule that fell out of this: if two implementations must share data, generate it. If they must share behaviour, test it. Never transcribe either.
Three levels, because one is not enough
Parity is not a single claim, and testing it at one level gives false confidence. The suites stack:
| Level | What it proves |
|---|---|
| RNG | The random streams are identical, including for unicode and emoji seeds. |
| Text | The generated prose matches, in all 22 languages. |
| Response | Whole provider envelopes match — ids, timestamps, usage, error shapes. |
| HTTP | The Flask and Django APIs return the same bytes as the deployed TypeScript one. |
The HTTP level matters separately from the library level, and it is the one most people would skip. The shared library being correct says nothing about whether each framework's routing, body parsing and language negotiation build the same settings from the same request. A request through Flask's OpenAI-shaped endpoint takes an entirely different code path to reach phantom-core than the Convex deployment does. Asserting that it still lands on the same bytes is what makes "drop-in compatible" a fact.
The gap the vectors did not cover
Later, a test in the registry package failed for a reason that had nothing to do with the port: multi-word display names like "Pirate Speak" did not resolve. Someone reading a language name out of a /languages response and sending it back verbatim would silently get English. Fixing it meant a separator-stripped fallback — and then that failed too, for Greek, Arabic and Japanese, because the comparison stripped everything outside [a-z0-9] and therefore erased those endonyms entirely.
Both were real bugs. But the important part came after: fixing them made the TypeScript resolver accept inputs the Python one still rejected. That is a parity break, and none of the existing vectors could have caught it — they prove both libraries *generate* the same text, and say nothing about whether both *interpret* the same input.
So a fourth vector set was added: 63 inputs — codes, regional tags, private-use tags, every name and endonym, separator variants, aliases, and deliberate nonsense — each asserted to normalise identically, or to null identically, on both sides. The class of drift is now closed rather than the instance.
What it cost, and what it bought
The parity infrastructure is perhaps a fifth of the Python package by volume: four vector files and four suites. That is a real tax, and on a library nobody else consumes it would be hard to justify.
What it buys is the ability to change the TypeScript side without wondering. Regenerate the vectors, run the Python suite, and either it passes or it names exactly which language, which seed, which input diverged. Without that, every change to a shared library with two implementations is a small act of faith.