Build once.
Understand everything.
There is no Makefile — CMake drives everything. The engine needs a toolchain, but no third-party libraries:
no LLVM, no runtime package to install. Python appears at exactly one step, turning a .py file
into IR; once IR exists the native program never touches CPython.
$ cmake -B build -DCMAKE_BUILD_TYPE=Release $ cmake --build build -j$(nproc) $ python3 src/frontend/frontend.py tests/programs/float.py > /tmp/float.ir $ ./build/tier_runner /tmp/float.ir --strict [tier1] native 0.3333333333333333
[tier1] native goes to stderr; the program's own output goes to stdout.
--strict means “native only, refuse rather than fall back”, so a clean exit status is proof the
code was really emitted and executed. Use it when testing: --auto can hide a total fallback
behind correct output.
- build system
cmake, ≥ 3.20- compiler
C++20· tested GCC 12, Clang 17- Python
≥ 3.10, compile step only- third-party dependencies
- none
The optional lithon wrapper does the two-step dance for you:
pip install -e ., then lithon tests/programs/float.py --strict. Add
--ir to print the IR and stop, or -v to see which tier ran and why.
01The mental model
Lithon is a two-lane road. A block whose types are provably static takes the Tier-1 native path and is
compiled straight to x86-64. Anything the verifier cannot establish falls to a Tier-0 C++ interpreter,
which is a real fallback rather than a guess — and --strict turns that fallback into a refusal.
Types are settled before execution begins. A flow that cannot be established is never emitted.
Typed AST blocks become guard-free x86-64 in executable memory. No guards on the hot path.
Tier-0 runs the same program. --strict refuses rather than quietly taking the slow lane.
02Types & flow
Every binding carries an explicit annotation, and numeric types carry an explicit width. This is not
ceremony — it is the contract that lets Lithon skip boxing, dynamic dispatch, and hot-path checks. A bare
int is not a type in Lithon: the checker rejects it by name.
- signed integers
int[8] int[16] int[32] int[64]- floating point
float[32] float[64]- boolean
bool· takes no width- unannotated binding
- rejected
- widening a value
- automatic
- narrowing a value
- never allowed
- int → float
- automatic
- float → int
- does not exist · no cast syntax
- bare integer literal
- is
int[64] range(N)vs loop width- checked at compile time
n: int[8] = 100 wide: int[64] = n ratio: float[64] = n
narrow: int[8] = wide error: cannot narrow int[64] into int[8]
Because a bare literal is int[64], passing one straight into a narrower parameter is always a
narrowing error. The value has to come from an already-narrower-typed variable instead. Function contracts
are checked the same way: parameters and the return type are mandatory, call sites are checked against the
signature, and a declared return type must be provably wide enough for what is actually returned — the
compiler never auto-widens it for you.
def add(a: int[64], b: int[64]) -> int[64]: return a + b x: int[64] = add(3, 4) print(x)
03The language subset
The engine is fast and well tested on the subset it supports. That subset is small on purpose, and the boundaries are worth knowing before you write anything against it.
- assignment
- typed and untyped
- augmented assignment
+= -= *= /=- literals
- int · float · bool
- arithmetic
+ - * /- comparisons
- single, non-chained
- boolean operators
andornot· two operands- control flow
if·elif·else·while- loops
for x in range(N)· one argument- functions
- recursion, self tail calls
- chained comparison
- not supported
for/else- rejected, never silently dropped
A loop variable must be declared before the loop, because the verifier checks what
range(N) produces against the width already on that name:
total: int[64] = 0 i: int[64] = 0 j: int[64] = 0 for i in range(5): for j in range(5): total = total + i * j print(total)
04Runtime notes
The backend is a hand-rolled x86-64 encoder written in C++20. Emitted machine code is written straight into
memory the OS marked executable, and called through a function pointer — mmap with
PROT_READ | PROT_EXEC on Linux, VirtualAlloc on Windows. There is no LLVM, no
Cranelift, and no external runtime stack in the picture.
Parsed inside the engine, so the native tier has no Python dependency of any kind.
SysV and Win64 ABI alignment is implemented and audited for the host convention.
SSE2 end to end. One formatter serves both tiers, so their output agrees byte for byte.
Floats are the part of the engine that took the most care, because both tiers have to agree exactly.
Division by zero traps like Python's ZeroDivisionError rather than producing IEEE
inf/nan, and a NaN divisor must not trap — Python propagates it, and
-0.0 must, because it compares equal to 0.0. A float live across a call spills,
since every XMM register is caller-saved on both ABIs.
Register allocation and liveness are shipped and tested. So is a set of optimizations that can each be switched off individually, so their effect can be measured rather than assumed: strength reduction, callee-saved borrowing, constant folding, dead code elimination, and loop rotation.
05Honest limits
A green test run covers the subset above and should not be mistaken for a finished language. These are the gaps as they stand today.
- IR opcodes emitted
19 of 20Phi- no emitter yet
- arguments per function / call
- capped at 2
- AOT binary emit
- none · everything runs in-process
- architectures
- x86-64 only
- diamond unrolling
- opt-in via
--unroll-diamonds
Phi is the single unemitted opcode, and it is what blocks if-as-expression
lowering once both arms have to merge without a stack round-trip. Lifting the two-argument cap is the other
blocker: most of the remaining test programs are waiting on it. Diamond unrolling is implemented,
correct, and fuzzed — but it measured slower on a branchy loop, so it stays behind a flag rather than
being deleted.
Everything below is how those claims get checked rather than taken on trust:
$ ctest --test-dir build --output-on-failure $ bash tools/verify_all.sh $ python3 tools/run_typed_regression.py # 12/12 $ python3 tools/run_tier_diff.py # 33/33 $ python3 tools/fuzz_diff.py --count 300 --floats
run_tier_diff.py is the highest-value of those: it runs every program through both tiers and
requires byte-identical stdout, then reports which tier actually ran — so a pass cannot hide “everything
silently fell back to the interpreter”.