mojojojo mojojojo

← Research

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.

TranspilerCoding agent
Cost~1 msminutes, and real money
Coveragethe numeric subsetanything it can write
Why you believe itit followed a ruleno 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 agent is a generator, not an authority. Its output goes through the same gate as everything else: it has to compile, it has to agree with CPython on generated inputs element by element, and it has to be measurably faster. Fail any one and the conversion is reported as failed.

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

  1. Compile. The agent's file, with the real compiler.
  2. Bind. ctypes, against the declared ABI. A missing or misnamed symbol fails here.
  3. 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.
  4. 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.

Building the second tier is what found the bugs in the first. Running the transpiler over whole modules rather than hand-picked kernels, and reading the reason it gave rather than only whether it succeeded, is what surfaced all of this. The tests passed throughout.

Results

A module of seven functions, deliberately mixed. Measured on the machine serving this page:

functionroutevs CPythonwhy
rolling_sharpetranspiler 88.3xin the subset
ewmatranspiler 53.6xin the subset
gram_diagonaltranspiler 39.2x2-D buffer
run_length_maxtranspiler 21.7xinteger loop
median_abs_deviationagent 27.9xneeds a sort
top_k_meanagent 24.3xneeds a heap
unique_countagent 7.1xneeds 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.

The gate is the product, not the agent. Everything interesting about this tool is in what it refuses to believe. Swap 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.