Converting Python to Mojo, with an agent for the rest
A transpiler that follows rules can be trusted and refuses most code; an agent converts anything and can be trusted about nothing. So the agent's output goes through the same gate — compile, compare against CPython, measure. Plus what fuzzing the transpiler found.
mojosub compiles a subset of Python. That is not a temporary limitation, it is the design: a transpiler that only accepts typed numeric code can be a set of rules, and a set of rules can be trusted. The price is that it refuses most of what people write.
So there are two ways to get a Python function into Mojo, and the interesting thing about them is not speed — it is trust.
| Transpiler | Coding agent | |
|---|---|---|
| Cost | ~1 ms | minutes, and real money |
| Coverage | the numeric subset | anything it can write |
| Why you believe it | it followed a rule | no reason at all |
That last row is the whole problem. A generated .mojo
file looks exactly as convincing whether it computes the right function or not,
and "the agent said it was equivalent" is not evidence.
The two tiers
bin/convert.py kernels.py # both tiers bin/convert.py kernels.py --no-agent # transpiler only, report what it refuses
Tier one is mojosub. When it
accepts a function there is nothing to review. When it raises
Unsupported it says exactly why, and that reason is the
most valuable thing to hand the agent — it names the construct that has to go.
Tier two hands the function to codex with three
things: the refusal reason, the accumulated Mojo dialect notes, and the C ABI it
must export, spelled out character for character:
@export("conv_median_abs_deviation")
def conv_median_abs_deviation(xs_addr: Int, xs_len: Int) abi("C") -> Float64:
Written out rather than described, because the gate binds that symbol by name without asking the agent what it produced. An agent that is free to choose its own interface is an agent whose output you have to read before you can test it.
What the gate actually does
- Compile. The agent's file, with the real compiler.
- Bind.
ctypes, against the declared ABI. A missing or misnamed symbol fails here. - Compare. Six generated inputs, from different seeds. Return values and every buffer the function might have written. A kernel that agrees on the author's example and disagrees on a negative number is caught here.
- Measure. Below 1.5x CPython it is thrown away. A conversion that ties is a second implementation to keep correct, for nothing.
A failure at any stage goes back to the agent once, as text, because a dialect mistake is usually a one-line fix. Two failures and the function is reported unconverted. Nothing is accepted on the agent's say-so at any point.
Testing the gate itself
A gate nobody has checked is a gate you are trusting for the same reason you
were not willing to trust the agent. So: take the conversion it accepted, change
one index — xs_len // 2 to
xs_len // 2 + 1 — and run it through again.
agent's own file -> GATE ACCEPTS
corrupted by one index -> GATE REJECTS: disagrees on seed 0:
python=1.9577175 mojo=1.9536765
Rejected on the first seed, on a difference in the third decimal place. That is the whole argument for generated inputs over an example: nobody eyeballing that Mojo would have caught it.
The buffers are the part people forget
Comparing return values is the obvious half. The half that catches real bugs is comparing the arguments afterwards: a kernel that writes through a buffer the Python function does not write to — or fails to write one it does — returns a perfectly correct number and leaves the caller with wrong data. Both tiers are checked for it.
Fuzzing the transpiler first
Before trusting tier one to be the trustworthy tier, it seemed worth asking
whether it was. bench/fuzz.py generates random programs
inside and just outside the subset — grammar-directed, because random bytes are
rejected by ast.parse and test nothing — runs each under
CPython and through the transpiler, and compares.
Three of the first dozen programs found bugs. All three were the same root cause, and it is a good one:
Python scopes a local to its function. Mojo scopes a
var to its block.
def f(n: int) -> float:
n = n * 2 # 1. Mojo parameters are immutable bindings
total = 0.0
for i in range(n):
if i % 2 == 0:
hit = 1.5 # 2. `var hit` here is scoped to the `if`
total += hit
return total + hit # ...and unknown here
def g(n: int) -> float:
for i in range(n):
pass
return float(i) # 3. Python leaves `i` bound; Mojo does not
Every one of those is ordinary Python. Every one of them emitted Mojo that did not compile. And in a service that falls back to CPython on a failed compile, none of them looked like anything — the program returned the right answer, slowly, for ever, and the only symptom was a compile that never succeeded.
The fix is not three special cases. It is to emit declarations where Python puts them: all locals hoisted to the top of the function, a reassigned parameter copied into a mutable local, and a loop counter that outlives its loop driven by a generated iteration variable that copies into a hoisted one. Crashes across a 60-program run went from 13 to 0 with no disagreements.
One subtlety worth stating: hoisting is done only for the functions that need it. A hoisted declaration in every loop would rewrite every unit's content hash and invalidate a cache full of correct libraries for no benefit.
And one bug the converter itself surfaced
The first run of convert.py over a module reported
call to sorted for every function in it — including
functions with no sorted anywhere near them. A unit is
emitted whole or not at all, and mojosub was putting every annotated sibling
function into it. So one unrelated helper made the entire file
uncompilable, and the reason it gave pointed at code that was not in the kernel.
Its own docstring said "reachable from entry". The
implementation did not compute reachability. It does now — transitively, since a
helper's helpers are needed too.
Results
A module of seven functions, deliberately mixed. Measured on the machine serving this page:
| function | route | vs CPython | why |
|---|---|---|---|
| rolling_sharpe | transpiler | 88.3x | in the subset |
| ewma | transpiler | 53.6x | in the subset |
| gram_diagonal | transpiler | 39.2x | 2-D buffer |
| run_length_max | transpiler | 21.7x | integer loop |
| median_abs_deviation | agent | 27.9x | needs a sort |
| top_k_mean | agent | 24.3x | needs a heap |
| unique_count | agent | 7.1x | needs a set |
The transpiler handles four of seven for a millisecond each and no review. The agent handles the rest for minutes each — and those three are exactly the functions where a human would have had to make a decision: quickselect or a full sort, a heap or a partial sort, a hash set or a bitmap. It chose quickselect, and the gate is what says the choice was implemented correctly.
Two of the three needed the repair pass, and both for the same kind of reason:
the first attempt at median_abs_deviation used
List[Float64], which did not compile against this
toolchain, and the second used a raw allocation. That is exactly the class of
mistake worth one retry and not worth a second full attempt — the algorithm was
right the first time, the dialect was not.
codex for any
other agent and nothing about the guarantees changes, because none of them came
from the agent.
What this is not
It is not a general Python-to-Mojo compiler and the agent tier does not make it one. Everything it converts still has to be a function with annotated numeric parameters and a scalar return, because that is what can be given a C ABI and tested against CPython automatically. A function that takes a DataFrame and returns a dict has nothing for the gate to check, and a conversion nothing can check is a conversion nobody should ship.
The converter is
bin/convert.py in the port factory, the fuzzer is
bench/fuzz.py in mojosub, and every scoping fix above has
a named regression test in tests/test_transpile.py. The
factory that does the same thing at whole-library scale — 184 ports so far — is
described in its own README.
Measured on the machine that served this page: Intel Xeon E5-2697 v4 (Broadwell, AVX2), CPython 3.13, Mojo 1.0.0b3.dev2026072406. Numbers move between machines; the shape of the result is what we are claiming.
