01 / Documentation

Know the path
from source to speed.

How Lithon is built and run: the annotation grammar the verifier actually accepts, the two execution tiers, and the gaps that are still open.

Mandatory static typesDual-tier executionZero dependencies
Start here

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.

terminal · compile and run
$ 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.

Prove before run

Types are settled before execution begins. A flow that cannot be established is never emitted.

Emit native

Typed AST blocks become guard-free x86-64 in executable memory. No guards on the hot path.

Fall back honestly

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
widening · accepted
n: int[8] = 100
wide: int[64] = n
ratio: float[64] = n
narrowing · rejected
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.

tests/typed_regression/function.py
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
and or not · 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:

tests/typed_regression/nested_loop.py
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.

IR text format

Parsed inside the engine, so the native tier has no Python dependency of any kind.

Calling conventions

SysV and Win64 ABI alignment is implemented and audited for the host convention.

Float is real

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 20
Phi
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:

verification
$ 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”.