Transpiling and Verifying Python in Lean 4 — without LLMs
Anirudh Gupta · IISc Bengaluru
Petros Markopoulos · UC San Diego
Swarnava Chakraborty · IISc Bengaluru
Siddhartha Gadgil · Professor · IISc Bengaluru
Summer School: Lean for Programming · IISc Bengaluru · 9 July 2026
Organized by IISc Bengaluru × Emergence AI
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
Pasta
+
Python
+
Lean 4
=
P·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.
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.
Lean 4 is a proof assistant and a general-purpose language (it compiles to C — genuinely fast).
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?.
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.
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.
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⌝⦄ :=
byn:ℤ⊢ ⦃⌜n ≥ 0⌝⦄ sum_to_n n ⦃PostCond.noThrow fun x ↦ ⌜True⌝⦄
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))⌝n:ℤh✝²:n ≥ 0pref✝:List ℕcur✝:ℕsuff✝:List ℕh✝¹:List.range (n +ₚ 1 - 0).toNat = pref✝ ++ cur✝ :: suff✝b✝:ℤtotal✝:ℤ := b✝ +ₚ (0 + Int.ofNat cur✝)h✝:2 *ₚ b✝ = ↑pref✝.length *ₚ (↑pref✝.length -ₚ 1)⊢ 2 *ₚ total✝ = ↑(pref✝ ++ [cur✝]).length *ₚ (↑(pref✝ ++ [cur✝]).length -ₚ 1)
simp_all [taste_ingr]n:ℤpref✝:List ℕcur✝:ℕsuff✝:List ℕb✝:ℤtotal✝:ℤ := b✝ +ₚ (0 + Int.ofNat cur✝)h✝²:0 ≤ nh✝¹:List.range (n + 1).toNat = pref✝ ++ cur✝ :: suff✝h✝:2 * b✝ = ↑pref✝.length * (↑pref✝.length - 1)⊢ 2 * total✝ = (↑pref✝.length + 1) * ↑pref✝.length; grind +locals +suggestionsAll goals completed! 🐙
#eval55 sum_to_n 10
55PastaLean 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 |
Converted to |
Lean's inbuilt |
Provable | Yes | Sometimes depending on function, not always. |
Computable | Sometimes. If ℝ is used then never. | Yes |
API differences( | Special non-IO Monad based functions are used to support proving. | IO Monad functions are used for real behaviour. |
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
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 |
|
Functions |
|
Data structures |
|
Comprehensions | list / dict / set / generator comprehensions |
Exceptions |
|
OOP |
|
Strings & misc |
f-strings, |
and more...
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.
list · dict · set · tuple, slicing, indexing, container methods:
Comprehensions → map / 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, iter → pyIter
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).
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
#checkobj : Point obj
obj : Point#eval5 norm
5Implementing 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)
Real programs — transpiled, type-checked, and (for the pure parts) proved. Full code on GitHub. Press ↓.
Showcase | Python | → Lean | Checks in | Exercises |
|---|---|---|---|---|
| 39 loc | 148 loc | 5.2 s |
exceptions · |
| 36 loc | 97 loc | 5.1 s |
|
| 186 loc | 386 loc | 7.8 s | vector algebra · conserved quantities · proved |
| 141 loc | 355 loc | 5.4 s | a from-scratch CNN class · forward + backprop |
| 51 loc | 134 loc | 5.2 s | a neural-net forward pass |
| 107 loc | 208 loc | 5.9 s | 2×2 matrix algebra · all proved |
~560 lines of Python → ~1,330 lines of verified Lean — each type-checked and verified within seconds.
Below are some Showcases for PastaLean's transpilation abilities.
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⊢ ∀ (m1 v1 m2 v2 j : ℚ), m1 *ₚ v1 +ₚ j +ₚ (m2 *ₚ v2 -ₚ j) = m1 *ₚ v1 +ₚ m2 *ₚ v2 introsm1✝:ℚv1✝:ℚm2✝:ℚv2✝:ℚj✝:ℚ⊢ m1✝ *ₚ v1✝ +ₚ j✝ +ₚ (m2✝ *ₚ v2✝ -ₚ j✝) = m1✝ *ₚ v1✝ +ₚ m2✝ *ₚ v2✝; simp_all (config := { zetaDelta := true }) [taste_ingr]All goals completed! 🐙
@[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⊢ ∀ (k dx dy dz : ℚ), cross_x dx dy dz (-k *ₚ dx) (-k *ₚ dy) (-k *ₚ dz) = ↑0 introsk✝:ℚdx✝:ℚdy✝:ℚdz✝:ℚ⊢ cross_x dx✝ dy✝ dz✝ (-k✝ *ₚ dx✝) (-k✝ *ₚ dy✝) (-k✝ *ₚ dz✝) = ↑0; simp_all (config := { zetaDelta := true }) [taste_ingr]k✝:ℚdx✝:ℚdy✝:ℚdz✝:ℚ⊢ cross_x dx✝ dy✝ dz✝ (-(k✝ * dx✝)) (-(k✝ * dy✝)) (-(k✝ * dz✝)) = 0; grind +localsAll goals completed! 🐙
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 +ₚ img⦋i +ₚ a⦌⦋j +ₚ b⦌ *ₚ self.kernel⦋a⦌⦋b⦌
row := PastaLean.pyAppend row s
out := PastaLean.pyAppend out row
return out)
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 _)
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.
Not all of Python — only the part that has a static meaning.
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.
Why — a faithful, provable Lean model only exists for the static part. Refuse loudly, never miscompile.
# 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
len(x) is one Lean call — the type system picks the implementation.
Problem — len, 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 : {α : Type} → [PyLen α] → α → ℤ @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
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.)
#checkPyHAdd : Type → Type → outParam Type → Type @PyHAdd
-- the result type is COMPUTED from the operands (the outParam γ):
PyHAdd : Type → Type → outParam Type → Typeexample : Int := (1 : Int) +ₚ (2 : Int)
example : Float := (1 : Int) +ₚ (2.0 : Float)
example : String := "a" +ₚ "b"
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)
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 do — float 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⊢ 1 / 10 + 2 / 10 = 3 / 10
native_decideAll goals completed! 🐙
-- --approx picks Lean Float; transcendentals → ℝ:
#checkFloat.sqrt 2.0 : Float (Float.sqrt 2.0)
Float.sqrt 2.0 : Float#check√2 : ℝ (Real.sqrt 2)
√2 : ℝ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 : {α : Type} → [PyLen α] → α → ℤ @pyLen -- a container protocol
@pyLen : {α : Type} → [PyLen α] → α → ℤ#check@pyAppend : {α : Type u_1} → List α → α → List α @pyAppend -- a list method
@pyAppend : {α : Type u_1} → List α → α → List α#check@pyNumpyMean : {α : Type} → [PyNumpyScalar α] → List (List α) → Float @Libraries.numpy.pyNumpyMean -- a library member
@pyNumpyMean : {α : Type} → [PyNumpyScalar α] → List (List α) → FloatHonest about what PastaLean does not do:
Not all of Python — only the static "logic" subset; no async, yield, eval, or monkey-patching.
One return type per function — returning int on one path and str on another has no single Lean type.
Proofs aren't free — taste? / 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.
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
Tactics — taste? → 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⦄
Tactics — mvcgen · 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.
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 / input → IO
raise / try / except → PyExcept = ExceptT PyException IO
loops / mutation → Id.run do (pure, but still a do block)
Tradeoff : Extensibility vs Harder to Prove
How do we even say what we want to prove?
Annotate Python with contracts — Requires, 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 _)
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 := bya:ℚb:ℚc:ℚd:ℚk:ℚ⊢ det (k * a) (k * b) (k * c) (k * d) = k ^ 2 * det a b c d
taste?All goals completed! 🐙Try this:
[apply] grind +locals +suggestions -- TryThis: grind +locals +suggestions
The found tactic is spliced back over taste? — the committed proof is concrete.
theorem factorial_specdeclaration uses `sorry` : ⦃⌜n ≥ (0 : Int)⌝⦄ factorial n ⦃⇓result => ⌜result ≥ (1 : Int)⌝⦄ :=
byn:ℤ⊢ ⦃⌜n ≥ 0⌝⦄ factorial n ⦃PostCond.noThrow fun result ↦ ⌜result ≥ 1⌝⦄
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)⌝n:ℤh✝²:n ≥ 0pref✝:List ℕcur✝:ℕsuff✝:List ℕh✝¹:List.range (n +ₚ 1 - 1).toNat = pref✝ ++ cur✝ :: suff✝b✝:ℤresult✝:ℤ := b✝ *ₚ (1 + Int.ofNat cur✝)h✝:(↑pref✝.length ≥ 1 ∧ n -ₚ ↑pref✝.length +ₚ 1 ≥ 0) ∧ b✝ ≥ 1⊢ (↑(pref✝ ++ [cur✝]).length ≥ 1 ∧ n -ₚ ↑(pref✝ ++ [cur✝]).length +ₚ 1 ≥ 0) ∧ result✝ ≥ 1n:ℤh✝:n ≥ 0⊢ (↑[].length ≥ 1 ∧ n -ₚ ↑[].length +ₚ 1 ≥ 0) ∧ 1 ≥ 1n:ℤh✝¹:n ≥ 0r✝:ℤh✝:(↑(List.range (n +ₚ 1 - 1).toNat).length ≥ 1 ∧ n -ₚ ↑(List.range (n +ₚ 1 - 1).toNat).length +ₚ 1 ≥ 0) ∧ r✝ ≥ 1⊢ r✝ ≥ 1
simp_all (config := { zetaDelta := true }) [taste_ingr]n:ℤpref✝:List ℕcur✝:ℕsuff✝:List ℕb✝:ℤresult✝:ℤ := b✝ *ₚ (1 + Int.ofNat cur✝)h✝²:0 ≤ nh✝¹:List.range n.toNat = pref✝ ++ cur✝ :: suff✝h✝:(1 ≤ pref✝.length ∧ 0 ≤ n - ↑pref✝.length + 1) ∧ 1 ≤ b✝⊢ 0 ≤ n - (↑pref✝.length + 1) + 1 ∧ 1 ≤ b✝ * (1 + ↑cur✝)n:ℤh✝:n ≥ 0⊢ (↑[].length ≥ 1 ∧ n -ₚ ↑[].length +ₚ 1 ≥ 0) ∧ 1 ≥ 1n:ℤh✝¹:n ≥ 0r✝:ℤh✝:(↑(List.range (n +ₚ 1 - 1).toNat).length ≥ 1 ∧ n -ₚ ↑(List.range (n +ₚ 1 - 1).toNat).length +ₚ 1 ≥ 0) ∧ r✝ ≥ 1⊢ r✝ ≥ 1;
sorryn:ℤh✝:n ≥ 0⊢ (↑[].length ≥ 1 ∧ n -ₚ ↑[].length +ₚ 1 ≥ 0) ∧ 1 ≥ 1n:ℤh✝¹:n ≥ 0r✝:ℤh✝:(↑(List.range (n +ₚ 1 - 1).toNat).length ≥ 1 ∧ n -ₚ ↑(List.range (n +ₚ 1 - 1).toNat).length +ₚ 1 ≥ 0) ∧ r✝ ≥ 1⊢ r✝ ≥ 1; sorryn:ℤh✝¹:n ≥ 0r✝:ℤh✝:(↑(List.range (n +ₚ 1 - 1).toNat).length ≥ 1 ∧ n -ₚ ↑(List.range (n +ₚ 1 - 1).toNat).length +ₚ 1 ≥ 0) ∧ r✝ ≥ 1⊢ r✝ ≥ 1; omegaAll goals completed! 🐙
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⌝⦄ :=
byn:ℤ⊢ ⦃⌜n ≥ 0⌝⦄ sum_to_n n ⦃PostCond.noThrow fun x ↦ ⌜True⌝⦄
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))⌝n:ℤh✝²:n ≥ 0pref✝:List ℕcur✝:ℕsuff✝:List ℕh✝¹:List.range (n +ₚ 1 - 0).toNat = pref✝ ++ cur✝ :: suff✝b✝:ℤtotal✝:ℤ := b✝ +ₚ (0 + Int.ofNat cur✝)h✝:2 *ₚ b✝ = ↑pref✝.length *ₚ (↑pref✝.length -ₚ 1)⊢ 2 *ₚ total✝ = ↑(pref✝ ++ [cur✝]).length *ₚ (↑(pref✝ ++ [cur✝]).length -ₚ 1)
simp_all [taste_ingr]n:ℤpref✝:List ℕcur✝:ℕsuff✝:List ℕb✝:ℤtotal✝:ℤ := b✝ +ₚ (0 + Int.ofNat cur✝)h✝²:0 ≤ nh✝¹:List.range (n + 1).toNat = pref✝ ++ cur✝ :: suff✝h✝:2 * b✝ = ↑pref✝.length * (↑pref✝.length - 1)⊢ 2 * total✝ = (↑pref✝.length + 1) * ↑pref✝.length; grind +locals +suggestionsAll goals completed! 🐙
Invariant / Ensures → pyPassInvariant / pyPassEnsures; mvcgen using Hoare Triples, discharges Goals to prove.
Deterministic transpiler, not an LLM translator — the elaborator is the oracle.
Broad language surface: control flow, comprehensions, exceptions, classes, libraries.
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.
Questions?
