PastaLean

Transpiling and Verifying Python in Lean 4 — without LLMs

21 July 2026

Overview

  • Why verify Python at all — and why in Lean

  • How PastaLean turns Python into Lean, node by node

  • What we support: control flow, classes, libraries…

  • Proving the generated code correct

What are we cooking?

pastaPasta + pythonPython + leanLean 4 =

ast·aLean → PastaLean

How to eat(use) PastaLean?

  • Take ordinary, logical Python — the kind data scientists and practical logical code's LLMs actually write.

  • Preprocess Python code for Type annotations, obtain it's Abstract Syntax Tree(AST) and give Lean it's JSON format.

  • Transpile it into Lean 4 — deterministically, with no LLM, so the meaning never drifts.

  • Prove it correct — and let Lean's proof automation do the heavy lifting with a little help by LLM's.

The Goal: write in Python, get the guarantees of a proof assistant.

What is the Inspiration for this dish?

  • Two things dominate how code gets written today: Python (the language) and LLMs (increasingly, the author).

  • A huge and growing share of the Python running in the world was drafted by an LLM.

  • But here's the uncomfortable question:

Who checks that the generated code is actually correct?

  • Tests only catch the bugs you thought to write — never the ones you didn't.

  • To prove correctness (not merely test it), we reach for formal methods: Hoare logic, model checking, multi-modal verification…

  • Proof assistants make that practical — and there are several: Lean 4, Rocq (Coq), Isabelle, Dafny, …

  • We use Lean 4: a proof assistant that is also a fast, real programming language.

Why Lean?

Lean 4 is a proof assistant and a general-purpose language (it compiles to C — genuinely fast).

1.9M+lines in Mathlib, its math library
282k+theorems · 134k definitions
772contributors · 1 tiny trusted kernel
  • Powerful automation — tactics like simp, grind, aesop, etc. search proofs and close goals for you, often in one line.

  • Endlessly customizable — custom syntax, macros, and tactics let you extend the language itself — exactly how PastaLean is built.

  • Trusted where it matters — used by Fields medallist Terence Tao, backed by the Lean FRO, applied to industrial verification.

  • A small, auditable kernel re-checks every proof — nothing is taken on faith.

See Leo de Moura's blog Why Lean?.

Why convert Python into Lean?

  • Blindly trusting an LLM's Python means shipping logic no one has proved — silent wrong answers, bad edge cases, security holes.

PastaLean gives that code a home where the compiler is the reviewer:

  • if it type-checks against our runtime, the translation is faithful;

  • if the proof closes, the logic is correct.

Write it in Python. Trust it like Lean.

And because pure Python becomes bare Lean terms, we can state and prove theorems about it.

Why transpile — and not just ask an LLM?

The obvious shortcut: "LLM, translate this Python to Lean." We deliberately do not do that for the core translation. LLMs earn their keep elsewhere — proposing contracts — never as the translator.

Prompt an LLM to translate

PastaLean (AST transpiler)

Hallucinates; silently changes semantics

Deterministic — same node, same Lean, always

Not reproducible, not auditable

One runtime pins what each operation means

Breaks on long / scaled programs

Compositional: per-statement, streamed

No ground truth that it is correct

Output is checked by the Lean elaborator

Other Possible methods maybe?

  • Other Methods like using FFI is possible, however theorem proving would not be possible.

  • Converting Lean to Python is possible, however legacy python checking would not be possible and there are a lot more python programmers.

How does PastaLean taste code?

It reads Python's AST and emits Lean you can prove:

def sum_to_n(n: int) -> int:
    Requires(n >= 0)
    total = 0
    for i in range(n + 1):
        Invariant(2*total == i*(i - 1))
        total = total + i
    Ensures(2*total == n*(n + 1))
    return total

ast-parse

FunctionDef(name='sum_to_n',
  args=[arg('n', Name('int'))],
  body=[
    Expr(Call(Requires,
      Compare(Name'n', GtE, 0))),
    AnnAssign('total', Name'int', 0),
    For('i', Call(range, …), body=[
      Expr(Call(Invariant, …)),
      Assign('total', BinOp(…, Add, …))]),
    Expr(Call(Ensures, …)),
    Return(Name'total')])

transpile

def sum_to_n := fun (n : Int) (do let mut total := (0 : Int) for i in (PastaLean.pyRange (n +ₚ (1 : Int)))do let _ := Libraries.passta.pyPassInvariant ((2 : Int) *ₚ total == i *ₚ (i -ₚ (1 : Int))) total := total +ₚ i let _ := Libraries.passta.pyPassEnsures ((2 : Int) *ₚ total == n *ₚ (n +ₚ (1 : Int))) return total : Id _)

prove — automatically

theorem sum_to_n_spec : n (0 : Int) sum_to_n n _ => True := by mvcgen [sum_to_n, PastaLean.pyRange_forIn, PastaLean.pyRange_forIn_start] invariants · cur, total => let i := (cur.prefix.length : Int); (2 : Int) *ₚ total = i *ₚ (i -ₚ (1 : Int)) simp_all [taste_ingr]; grind +locals +suggestions
#eval sum_to_n 10
55

Two Functions for each code

PastaLean converts each function in Python into 2 functions each with it's own purpose:

  • Provable - This function is meant to be used for property proving of the function.

  • Computable - This function is meant to be computable with #eval. These are marked with 'rn in function name, which stands for run.

Difference

Provable

Computable

Python float

Converted to or (if transcendental functions are used).

Lean's inbuilt Float is always used.

Provable

Yes

Sometimes depending on function, not always.

Computable

Sometimes. If ℝ is used then never.

Yes

API differences(print, exceptional-handling)

Special non-IO Monad based functions are used to support proving.

IO Monad functions are used for real behaviour.

Example of Provable/Computable Function

One Python function forks into two Lean definitions:

def euclidean_distance(p1, p2):
    if len(p1) != len(p2):
        raise ValueError("Points must have the same number of dimensions")

    # Using zip, list comprehension, and math.pow
    sq_diffs = [math.pow(a - b, 2) for a, b in zip(p1, p2)]
    return math.sqrt(sum(sq_diffs))

provable

noncomputable def euclidean_distance := fun (p1 : List Int) fun (p2 : List Int) ((do if h_1 : PastaLean.pyLen p1 PastaLean.pyLen p2 then throw (PastaLean.PyException.Raise "ValueError" (ToString.toString "Points must have the same number of dimensions")) else let _ := () -- Using zip, list comprehension, and math.pow let mut sq_diffs := (PastaLean.pyIter (PastaLean.pyZip p1 p2)).map fun _pair_1 => let a := Prod.fst _pair_1; let b := Prod.snd _pair_1; Libraries.math.pyMathPowExact (a -ₚ b) (2 : Int) let __py_ret_1 := Libraries.math.pyMathSqrtR (PastaLean.pySum sq_diffs) return __py_ret_1) : PastaLean.PyExceptId _)

computable

def euclidean_distance'rn : List Int List Int PastaLean.PyExcept Float := fun (p1 : List Int) fun (p2 : List Int) do if h_1 : PastaLean.pyLen p1 != PastaLean.pyLen p2 then throw (PastaLean.PyException.Raise "ValueError" (ToString.toString "Points must have the same number of dimensions")) else let _ := () -- Using zip, list comprehension, and math.pow let mut sq_diffs := (PastaLean.pyIter (PastaLean.pyZip p1 p2)).map fun _pair_1 => let a := Prod.fst _pair_1; let b := Prod.snd _pair_1; Libraries.math.pyMathPow (a -ₚ b) (2 : Int) let __py_ret_1 := Libraries.math.pyMathSqrt (PastaLean.pySum sq_diffs) return __py_ret_1

How much Python is convertable?

Most everyday Python is convertable into Lean4. Extending new Python syntax is made very trivial with the help of our underlying design architecture.

Area

Python constructs handled

Control flow

if / elif / else, for, while, break, continue, match

Functions

def, lambda, return, default args, nested defs

Data structures

list, dict, set, tuple, slicing, x[i], x[i] = v

Comprehensions

list / dict / set / generator comprehensions

Exceptions

try / except / finally, raise, assert

OOP

class, methods, self, fields, dunder methods, inheritance

Strings & misc

f-strings, import / from … import, del, augmented assign

and more... ↓ press down to see the constructs by area, with examples.

Control flow

Branches & recursion

def fibonacci(n: int):
    if n <= 0:
        return 0
    elif n == 1:
        return 1
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

partial def fibonacci : Int Int := fun (n : Int) if decide (n (0 : Int)) then (0 : Int) else if n == (1 : Int) then (1 : Int) else fibonacci (n -ₚ (1 : Int)) +ₚ fibonacci (n -ₚ (2 : Int))

Loops & mutation → an Id.run do block:

def classify(nums: list[int]):
    total = 0
    for n in nums:
        if n % 2 == 0:
            total += n
        else:
            total -= n
    return total

def classify := fun (nums : List Int) Id.run (do let mut total := (0 : Int) for n in (PastaLean.pyIter nums)do if h_1 : n %ₚ (2 : Int) = (0 : Int) then total := total +ₚ n else total := total -ₚ n return total)

Statements outside functions(in open) are also well supported.

Data structures & comprehensions

list · dict · set · tuple, slicing, indexing, container methods:

Comprehensionsmap / filter

squares = [x*x for x in range(10)
               if x % 2 == 0]

cubes = {n: n**3 for n in range(5)}

evens = {x for x in range(10)
             if x % 2 == 0}

def squares := (List.filter (fun x => x %ₚ (2 : Int) == (0 : Int)) (PastaLean.pyRange (10 : Int))).map fun x => x *ₚ x def cubes := Std.HashMap.ofList ((PastaLean.pyRange (5 : Int)).map fun n => (n, n ^ₚ (3 : Int))) def evens := PastaLean.pySetFromList ((List.filter (fun x => x %ₚ (2 : Int) == (0 : Int)) (PastaLean.pyRange (10 : Int))).map fun x => x)

List & string ops

nums = [3, 1, 2, 4]
head = nums[0]         # index
tail = nums[1:]        # slice
size = len(nums)

s = "hello world"
words = s.split(" ")
loud = s.upper()

def nums := [(3 : Int), (1 : Int), (2 : Int), (4 : Int)] def head := nums(0 : Int) def tail := PastaLean.pySlice nums (some (1 : Int)) none none def size := PastaLean.pyLen nums def s := "hello world" def words := PastaLean.pyStringSplit s " " def loud := PastaLean.pyStringUpper s

x[i]pyGetItem, slicing → pySlice, iterpyIter

Exception handling

try / except / finally, raise, typed except ValueError as e — one function, two twins:

def divide_add(a, b, c):
    try:
        result = a / b
        while result < c:
            result += 1
        return result
    except ZeroDivisionError:
        return "Division by zero error"
    finally:
        print("PastaLean handles exceptions gracefully.")

provable

def divide_add := fun a fun b fun c ((do try let mut result := a /ₚ b while (result < c) do result := result +ₚ (1 : Int) return result catch caught => if (caught).OfKind == "ZeroDivisionError" then let _ pyPrintNoop [pyPrintArg "Division by zero error"] let __py_ret_1 := -(1 : Int) return __py_ret_1 else throw caught finally do let _ pyPrintNoop [pyPrintArg "PastaLean handles exceptions gracefully."]) : PastaLean.PyExceptId _)

computable

def divide_add'rn := fun a fun b fun c ((do try let mut result := a /ₚ b while (result < c) do result := result +ₚ (1 : Int) return result catch caught => if (caught).OfKind == "ZeroDivisionError" then let _ pyPrintIO [pyPrintArg "Division by zero error"] let __py_ret_1 := -(1 : Int) return __py_ret_1 else throw caught finally do let _ pyPrintIO [pyPrintArg "PastaLean handles exceptions gracefully."]) : PastaLean.PyExcept _)

PyExceptId (pure, provable) vs PyExcept = ExceptT PyException IO (real IO).

Classes / OOP

A Python class becomes a Lean structure plus namespaced method definitions. self is the structure value, and mutation is value-semantics (C.new builds one):

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def norm_sq(self):
        return self.x*self.x \
             + self.y*self.y

obj = Point(1,2)
norm = obj.norm_sq()
structure Point where x : Int y : Int deriving Inhabited, Repr, BEq def Point.new := fun x fun y ({ x := x, y := y } : Point) def Point.norm_sq := fun (self : Point) self.x *ₚ self.x +ₚ self.y *ₚ self.y def obj := Point.new (1 : Int) (2 : Int) def norm := Point.norm_sq obj
#check obj
obj : Point
#eval norm
5

Libraries

Implementing Library and integrating it in PastaLean is very trivial.

  • All you do is write the Lean function for your Python implementation, use our builtin typeclasses support if needed. All Libraries are independently implementable.

  • Now just map the member name to a Lean runtime function. This will automatically integrate with our code generation pipeline.

  • numpy, pandas, scipy, math, typing ship today; anything unsupported degrades to a flagged pyUnsupported so the file still compiles.

A slice of numpy:

open Libraries.numpy in example : String Lean.Name := fun member => match member with | "array" => ``pyNumpyArray | "dot" => ``pyNumpyDot | "mean" => ``pyNumpyMean | "std" => ``pyNumpyStd | "linspace" => ``pyNumpyLinspace | "norm" => ``pyNumpyNorm | _ => .anonymous

transpile

where pyNumpyNorm and other functions are as simple as:

def pyNumpyNorm {α} [PyNumpyScalar α] (xs : List α) : Float := let ys := xs.map toFloat Float.sqrt (ys.foldl (fun acc x => acc + x * x) 0.0)

Showcases

Real programs — transpiled, type-checked, and (for the pure parts) proved. Full code on GitHub.

6real programs
~560lines of Python
~1,330lines of verified Lean
< 8seach · type-check + prove

Showcase

Python

→ Lean

Checks in

Exercises

eg1

39 loc

148 loc

5.2 s

exceptions · zip · comprehensions · math

eg2

36 loc

97 loc

5.1 s

numpy mean · matmul · try/except

orbital

186 loc

386 loc

7.8 s

vector algebra · conserved quantities · proved

cnn

141 loc

355 loc

5.4 s

a from-scratch CNN class · forward + backprop

nn

51 loc

134 loc

5.2 s

a neural-net forward pass

linalg

107 loc

208 loc

5.9 s

2×2 matrix algebra · all proved

~560 lines of Python → ~1,330 lines of verified Lean — every one type-checked, the pure parts proved, all in seconds.

↓ press down to walk through three of our showcases.

Orbital Physics · conserved quantities

Vector algebra where the Python asserts — conserved momentum, a central spring force — become theorems, closed automatically.


def cross_x(ax: float, ay: float, az: float, bx: float, by: float, bz: float) -> float:
    """x-component of a x b."""
    return ay * bz - az * by

def norm_sq(ax, ay, az):
    return ax*ax + ay*ay + az*az

def kinetic(m, vx, vy, vz):
    return 0.5 * m * norm_sq(vx, vy, vz)

def momentum(m, vx, vy, vz):
    return m * norm_sq(vx, vy, vz)

def spring_energy(k: float, dx: float, dy: float, dz: float) -> float:
    """Hooke potential energy (1/2) k |d|^2 stored in a spring stretched by displacement d."""
    return 0.5 * k * norm_sq(dx, dy, dz)

def momentum_conserved(m1: float, v1: float, m2: float, v2: float, j: float):
    """Equal and opposite impulses +j / -j conserve total linear momentum (1-D component)."""
    assert (m1 * v1 + j) + (m2 * v2 - j) == m1 * v1 + m2 * v2

def spring_force_is_central(k: float, dx: float, dy: float, dz: float):
    """The Hooke force F = -k d is central (anti-parallel to the displacement d), so it too exerts
    no torque about the spring axis: d x F = 0 (x-component)."""
    assert cross_x(dx, dy, dz, -k * dx, -k * dy, -k * dz) == 0

def cross_x := fun (ax : Rat) fun (ay : Rat) fun (az : Rat) fun (bx : Rat) fun (cy : Rat) fun (bz : Rat) ay *ₚ bz -ₚ az *ₚ cy def norm_sq := fun (ax : Rat) fun (ay : Rat) fun (az : Rat) ax *ₚ ax +ₚ ay *ₚ ay +ₚ az *ₚ az def kinetic := fun (m : Rat) fun (vx : Rat) fun (vy : Rat) fun (vz : Rat) (0.5 : Rat) *ₚ m *ₚ norm_sq vx vy vz def momentum := fun (m : Rat) fun (vx : Rat) fun (vy : Rat) fun (vz : Rat) m *ₚ norm_sq vx vy vz def spring_energy := fun (k : Rat) fun (dx : Rat) fun (dy : Rat) fun (dz : Rat) /- Hooke potential energy (1/2) k |d|^2 stored in a spring stretched by displacement d. -/ (0.5 : Rat) *ₚ k *ₚ norm_sq dx dy dz @[taste_ingr] theorem momentum_conserved : (m1 : Rat), (v1 : Rat), (m2 : Rat), (v2 : Rat), (j : Rat), m1 *ₚ v1 +ₚ j +ₚ (m2 *ₚ v2 -ₚ j) = m1 *ₚ v1 +ₚ m2 *ₚ v2 := by intros; simp_all (config := { zetaDelta := true }) [taste_ingr] @[taste_ingr] theorem spring_force_is_central : (k : Rat), (dx : Rat), (dy : Rat), (dz : Rat), cross_x dx dy dz (-k *ₚ dx) (-k *ₚ dy) (-k *ₚ dz) = (0 : Int) := by intros; simp_all (config := { zetaDelta := true }) [taste_ingr]; grind +locals

cnn · a CNN, transpiled

A from-scratch convolution — nested loops, self.kernel, value-semantics mutation — transpiled verbatim.

class CNN:
    def conv(self, img):
        # valid 2x2 conv: 8x8 → 7x7
        out = []
        for i in range(7):
            row = []
            for j in range(7):
                s = 0.0
                for a in range(2):
                    for b in range(2):
                        s += img[i+a][j+b] \
                           * self.kernel[a][b]
                row.append(s)
            out.append(row)
        return out

structure CNN where kernel : List (List Real) dense_w : List Real dense_b : Real deriving Inhabited noncomputable def CNN.conv := fun (self : CNN) fun (img : List (List Rat)) Id.run (do let mut out := [] for i in (PastaLean.pyRange (7 : Int))do let mut row := [] for j in (PastaLean.pyRange (7 : Int))do let mut s := (0.0 : Real) for a in (PastaLean.pyRange (2 : Int))do for b in (PastaLean.pyRange (2 : Int))do s := s +ₚ imgi +ₚ aj +ₚ b *ₚ self.kernelab row := PastaLean.pyAppend row s out := PastaLean.pyAppend out row return out)

numpy + exceptions

np.mean and matmul inside try/except — a library call, real control flow, both twins.

def process_data(data, weights):
    try:
        m = np.mean(data)
        print(f"Mean: {m}")
        centered = np.subtract(data,
                       [[m, m], [m, m]])
        return np.matmul(centered, weights)
    except ValueError as e:
        print(f"Failed: {e}")
        return np.zeros((2, 2))

def process_data := fun (data : List (List Rat)) fun (weights : List (List Rat)) ((do try let mut m := Libraries.numpy.pyNumpyMean data let _ pyPrintNoop [pyPrintArg s! "Mean: {m}"] let mut centered := Libraries.numpy.pyNumpySubtract data [[m, m], [m, m]] let mut result := Libraries.numpy.pyNumpyMatmul centered weights return result catch caught => if (caught).OfKind == "ValueError" then let e := caught let _ pyPrintNoop [pyPrintArg s! "Failed: {e}"] let __py_ret_1 := Libraries.numpy.pyNumpyZeros ((2 : Int), (2 : Int)) return __py_ret_1 else throw caught) : PastaLean.PyExceptId _)

Design & architecture

The choices that make a faithful, provable translation possible — press ↓.

  • Problem → What we did → Why, one architectural decision at a time.

  • Pure architecture — the elaborator and instance resolution do the heavy lifting.

1 · The logic subset

Not all of Python is supported, however, efforts into supporting the provable subset are made.

  • Problem — Python is dynamic: eval, yield, monkey-patching — no single static meaning.

  • We do — target the typed "logic" subset; anything else degrades to a flagged pyUnsupported.

# in scope — a static, provable core
xs: list[int] = [1, 2, 3]
total = sum(x * x for x in xs)

# out of scope → `pyUnsupported`, flagged
async def fetch(): await conn.read()   # async
result = eval(user_input)              # reflection
def gen(): yield 1                     # laziness

2 · One name, many types

len(x) is one Lean call — the type system picks the implementation.

  • Problemlen, x[i], x in y, x+y work on many types; codegen often can't see the type yet.

  • We do — emit one stable name; Lean's instance resolution (after elaboration) selects the impl.

  • Why — codegen stays type-blind; a new container = one new instance, zero codegen change.

-- one Lean name; instance resolution picks the impl per type: #check @pyLen
@pyLen : {α : Type} [PyLen α] α
example : Int := pyLen [1, 2, 3] -- List example : Int := pyLen "hello" -- String example : Int := pyLen (Std.HashMap.ofList [(1, 2)]) -- dict

3 · Operators via outParam

1 + 2.0 : Float falls straight out of instance resolution.

  • Problem — Python mixes numeric types (int+float, int+Rat); Lean's HAdd has no widening instances.

  • We do — custom +ₚ -ₚ *ₚ over PyHAdd α β γ, the result γ an outParam.

  • Why — the result type reduces to a concrete type, so a later pyLen/print/index can resolve. (Rat defaults stay off — subtle, load-bearing.)

#check @PyHAdd -- the result type is COMPUTED from the operands (the outParam γ):
PyHAdd : Type Type outParam Type Type
example : Int := (1 : Int) +ₚ (2 : Int) example : Float := (1 : Int) +ₚ (2.0 : Float) example : String := "a" +ₚ "b"

4 · Classes are values

self.x = 5 — on an immutable Lean structure.

  • Problem — Python objects mutate in place; Lean structures are immutable values.

  • We do — mutation = rebuild-and-return; the receiver is reassigned at the call site.

  • Why — classes stay pure & provable (no heap / IORef monad). Cost: no aliasing — surfaced as an error, never a silent bug.

-- `self.n = self.n + 1` ↦ record update, rebuilt & returned: structure Counter where n : Int deriving Inhabited, Repr, BEq def Counter.new : Int Counter := fun (start : Int) ({ n := start } : Counter) def Counter.bump := fun (self : Counter) Id.run (do let mut self := self self := { self with n := self.n +ₚ (1 : Int) } return self)

5 · Numbers: exact by default

Python float is lossy — a proof can't trust 0.1 + 0.2.

  • Problem — Python float is IEEE-lossy; 0.1 + 0.2 ≠ 0.3, so a proof can't rely on it.

  • We dofloat defaults to exact (provable); --approx → Lean Float; transcendentals → noncomputable .

  • Why — exact rationals are decidable and provable; you opt into Float only when raw speed beats proof.

-- Python floats default to EXACT rationals (ℚ): example : (1 / 10 + 2 / 10 : Rat) = 3 / 10 := by native_decide -- --approx picks Lean Float; transcendentals → ℝ: #check (Float.sqrt 2.0)
Float.sqrt 2.0 : Float
#check (Real.sqrt 2)
2 :

6 · Built to extend

New syntax, new library, new container — each is a local, additive change.

  • Problem — normally, adding syntax or a library means touching the whole pipeline.

  • We do — four open extension points: a @[pygen] registry, a name→name table, per-library Mapping.lean, outParam protocol instances.

  • Why — codegen, IR, and backend never change; the elaborator + instance resolution absorb the variation — new features stay cheap.

-- every extension point produces a real runtime name: #check @pyLen -- a container protocol
@pyLen : {α : Type} [PyLen α] α
#check @pyAppend -- a list method
@pyAppend : {α : Type u_1} List α α List α
#check @Libraries.numpy.pyNumpyMean -- a library member
@pyNumpyMean : {α : Type} [PyNumpyScalar α] List (List α) Float

A major Problem: Python is dynamically typed

  • In Python a name has no fixed type — it is just a label on a value, and the type is checked only at runtime.

  • One function serves every type (duck typing), and a variable can even change type mid-body.

# one function, many types
def add(a, b):
    return a + b

add(1, 2)          # 3
add("Hi", "!")     # "Hi!"

# one name, two types
x = 1              # int
x = "hello"        # now str

  • Lean is statically typed — every binder needs one type, fixed before the body runs.

  • def add (a : ?) (b : ?) — which type? Int? String? both?

  • let mut x : Int cannot later hold a String.

  • Native Lean can't paper over it — you'd write an explicit, closed union Int ⊕ String ⊕ … and pattern-match at every single use.

  • Python code never declares that union — and it spreads virally through every caller and return.

  • Worse. Even len(x), x[i], + need a concrete type for instance resolution; a bare "any" leaves them stuck.

…and Python ignores its own type hints

  • Moreover, Python allows multiple return Types from a function!

  • Python doesn't even follow it's own type hints, so we can only rely on them with a pinch of salt.

# one function, many types
def add(a: int, b: int) -> int:
    return a + b

add("Hi", "!")  # This works!

  • Lean is statically typed — every binder needs one type, fixed before the body runs.

  • How do we Solve? this big issue?

We need Python's dynamism without poisoning everything with unions.

PyAny — opening the door to dynamic typing

  • PyAny is a total fallback type inspired from Gradual Typing: infer a concrete Lean type wherever we can, and box only the slots we genuinely can't pin.

  • Why — one definition then runs at every type; +ₚ dispatches on the runtime tag.

  • Assumption - Any type hints given to Python, are correct. Misuse of type hints i.e. wrong input type(which may work in Python), will be flagged as an error in Lean.

# no types given → one def, every type
def add(a, b):
    return a + b

-- un-inferred params box to PyAny: def add := fun (a : PyAny) fun (b : PyAny) a +ₚ b

PyAny is a tagged union; +ₚ inspects both tags and delegates.

  • PastaLean also has it's own Type Inference engine, which infers the types of the variables and functions, and boxes them to PyAny only when it is necessary.

More below ↓

Type Mutations and Multiple Return Types

If two branches of a function return different types, the function is boxed to PyAny.

# branches disagree → PyAny
def classify(n):
    if n > 0:
        return "positive"   # str
    return 0                # int

def classify_num := fun (n : Int) (if decide (n > 0) then "positive" else (0 : Int) : PyAny)

If a variable is reassigned types, then it can be inferred to be of type PyAny:

def reassigned():
    x = 1
    x = x + 5          # int arithmetic
    x = "hi"           # now a string
    x = x + "world"    # string concatenation on the new type
    return x

def reassigned := (let x := (1 : Int) let x := x +ₚ (5 : Int) let x := "hi" let x := x +ₚ "!" x : PyAny)
  • By default, only one return Type is allowed in a function, but if two branches of a function return different types, the function is boxed to PyAny.

  • Useful for Exceptional Handling, where you may return a value in try case, but an error/string in except case.

Computation · dispatch on the tag

At runtime the box carries its type; +ₚ (PyAny.add) unwraps it, runs the real operation, and re-boxes into PyAny:

#eval add 3 4 -- int + int → int
PastaLean.PyAny.int 7
#eval add "x" "y" -- str ++ str → str -- In python, `True + True = 2`
PastaLean.PyAny.str "xy"
#eval add true true -- bool + bool → int
PastaLean.PyAny.int 2

A genuine mismatch (1 + "a") folds to none — total, never a crash.

Limitations — where the line is

Honest about what PastaLean does not do:

  • Not all of Python — only the static "logic" subset; no async, yield, eval, or monkey-patching.

  • Proofs aren't freetaste? / mvcgen are best-effort automation; hard goals still need a manual proof.

  • Libraries are hand-written — a new library's functions are implemented + mapped once, then reused everywhere.

  • Unsupported → pyUnsupported — anything out of scope degrades to a flagged placeholder, so the rest of the file still compiles.

When we can't translate faithfully, we refuse loudly — never miscompile.

Proving we cooked correctly

Is it correct? The proof path forks on one thing — a bare term or a do block:

Non-Monadic

def transform_and_cube := fun a fun b let c := a +ₚ b let d := a -ₚ b let e := c *ₚ d e ^ₚ (3 : Int)
  • Proves — equalities, identities

  • Tacticstaste?ring · grind · nlinarith

  • When — pure terms, no effects

  • Best-Effort use of taste?

Monadic verification

def total (xs : List Int) : Int := Id.run do let mut s := 0 for x in xs do s := s +ₚ x return s
  • Proves — Hoare triples ⦃P⦄ prog ⦃Q⦄

  • Tacticsmvcgen · taste?

  • When — loops · mutation · raise / IO

  • Uses LLM's for contracts, mvcgen is feed Loop invariants and subgoals are proven(best-effort) with taste?

A preference to Non-monadic pure functional code is given while converting. However it will be lowered to Monadic code with do, when necessary.

Why lower into a monad at all?

Pure functions are total and side-effect free, so they cannot directly express state updates, early exits, or external effects like IO. Lowering into a monad gives these behaviors a precise semantic model while keeping the code composable and type-safe.

  • print / inputIO

  • raise / try / exceptPyExcept = ExceptT PyException IO

  • loops / mutation → Id.run do (pure, but still a do block)

Tradeoff : Extensibility vs Harder to Prove

The one place an LLM helps: contracts

How do we even say what we want to prove?

  • Annotate Python with contractsRequires, Ensures, Invariant, Decreases, or a plain assert.

  • PastaLean extracts them as preconditions, postconditions, and per-loop obligations.

  • The LLM proposes the contracts; mvcgen + taste? discharge the proof.

Before · Python + contracts

def factorial(n: int) -> int:
    Requires(n >= 0)
    Ensures(Result() >= 1)
    result, i = 1, 1
    while i <= n:
        Invariant(i >= 1)
        Invariant(n - i + 1 >= 0)
        Invariant(result >= 1)
        Decreases(n - i + 1)
        result = result * i
        i = i + 1
    return result

contracts

After · transpiled Lean

def factorial := fun (n : Int) (do let mut result := (1 : Int) for i in (PastaLean.pyRange (n +ₚ (1 : Int)) (1 : Int))do let _ := Libraries.passta.pyPassInvariant (decide (i (1 : Int))) let _ := Libraries.passta.pyPassInvariant (decide (n -ₚ i +ₚ (1 : Int) (0 : Int))) let _ := Libraries.passta.pyPassInvariant (decide (result (1 : Int))) let _ := Libraries.passta.pyPassDecreases (n -ₚ i +ₚ (1 : Int)) result := result *ₚ i return result : Id _)

Best Effort Automatic Proving with taste?

A Python assert on a pure function becomes theorem … := by taste? — the Pastafolio engine searches (simp · grind · ring · nlinarith · fun_induction) and splices the found proof back:

# showcase/linalg/matrix_model.py
def det(a, b, c, d):
    return a*d - b*c

# scaling a 2×2 by k scales det by k²
assert det(k*a, k*b, k*c, k*d) \
    == k**2 * det(a, b, c, d)

prove

def det (a b c d : Rat) : Rat := a * d - b * c example (a b c d k : Rat) : det (k*a) (k*b) (k*c) (k*d) = k ^ 2 * det a b c d := by taste? -- TryThis: grind +locals +suggestions

The found tactic is spliced back over taste? — the committed proof is concrete.

theorem factorial_spec : n (0 : Int) factorial n result => result (1 : Int) := by mvcgen [factorial, PastaLean.pyRange_forIn, PastaLean.pyRange_forIn_start] invariants · cur, result => let i := (cur.prefix.length : Int); (i (1 : Int) n -ₚ i +ₚ (1 : Int) (0 : Int)) result (1 : Int) simp_all (config := { zetaDelta := true }) [taste_ingr]; sorry; sorry; omega

Proving monadic defs: mvcgen

do-block functions use Std.Do's mvcgen with Hoare triples ⦃P⦄ prog ⦃Q⦄; Python contracts ride along as Invariant / Ensures:

def sum_upto_n(n: int) -> int:
    Requires(n >= 0)
    total = 0
    for i in range(n + 1):
        Invariant(2*total == i*(i - 1))
        total += i
    Ensures(2*total == n*(n + 1))
    return total

prove

theorem sum_upto_n_spec : n (0 : Int) sum_to_n n _ => True := by mvcgen [sum_to_n, PastaLean.pyRange_forIn, PastaLean.pyRange_forIn_start] invariants · cur, total => let i := (cur.prefix.length : Int); (2 : Int) *ₚ total = i *ₚ (i -ₚ (1 : Int)) simp_all [taste_ingr]; grind +locals +suggestions

Invariant / EnsurespyPassInvariant / pyPassEnsures; mvcgen using Hoare Triples, discharges Goals to prove.

Proving in PyAny with pyany_cases

PyAny is not a commutative ring, so ring/grind stall on it directly. pyany_cases splits a goal into one goal per relevant runtime type; the mismatched tags close on their own:

  • All goals must be satisfied for the proof to be complete.

  • They are unfolded from the instances supported by the operation applied to the PyAny variables.

  • Any goal that makes no sense / has no instance is discharged automatically — e.g. 1 + "a".

  • pyany_cases is wired into taste? — whenever PyAny appears, a best-effort proof attempt runs in the pipeline.

example (a b : PyAny) : (a +ₚ b) +ₚ b = a +ₚ (b +ₚ b) := by pyany_cases a b <;> grind +locals

10 goals
case int.int
a b : ℤ
⊢ a + b + b = a + (b + b)
case int.bool
a : ℤ
b : Bool
⊢ ((a + if b = true then 1 else 0) + if b = true then 1 else 0) =
  a + ((if b = true then 1 else 0) + if b = true then 1 else 0)
...

Recap

  • Deterministic transpiler, not an LLM translator — the elaborator is the oracle.

  • Broad language surface: control flow, comprehensions, exceptions, classes, libraries.

  • Python behaviours like dynamic typing, multiple return types, and mutation are faithfully modeled in Lean with PyAny.

  • Monadic vs non-monadic picks the proof strategy:

    • Non-monadic pure function → taste?

    • Monadic functions in do blocks → mvcgen

  • LLMs propose contracts; Lean checks them.

Pasta + Python + Lean = Python you can prove.

Thank you

Questions?

PastaLean