SymPy Tactic
This example implements by sympy – a real Lean tactic that closes
arithmetic goals by delegating to SymPy. When Lean sees a goal like
(1 : Int) + 1 = 2, it sends the entire Lean.Expr tree to Python,
SymPy verifies it, and the goal is closed.
The architecture
The data flow looks like this:
The
sympytactic extracts the goal’s type as aLean.Expr.Py.ofLeanObjwraps the expression and sends it to Python.On the Python side,
derive_pythonforLean.Expr(registered inLeanPy/Reflect.lean) means the expression arrives as a fully-typedLeanInductiveValuetree.A recursive converter walks the tree and builds the equivalent SymPy expression.
sympy.simplifychecks whether the proposition holds.If SymPy accepts, the tactic closes the goal with an oracle axiom.
The oracle axiom
The soundness story is explicit: an axiom declares that anything SymPy accepts is true:
axiom sympy_oracle (p : Prop) : p
This is the only point of unsoundness. If SymPy is wrong about something, this axiom is where the blame falls. In practice, SymPy is reliable for the arithmetic fragment.
The Lean tactic
The tactic itself is small. It gets the goal type, sends it to Python, and assigns the oracle proof if accepted:
@[python "sympy_expr_check_prop"]
def sympyExprCheckProp (e : Lean.Expr) : IO Bool := do
init ()
let handle ← Py.ofLeanObj e
let mod ← import_ "lean_to_sympy"
let checkFn ← mod.getAttr "decode_and_check_prop"
(← checkFn.call #[handle]).toBool
syntax (name := sympyTac) "sympy" : tactic
@[tactic sympyTac]
def evalSympy : Tactic := fun stx =>
match stx with
| `(tactic| sympy) => do
let goal ← getMainGoal
let goalType ← goal.getType
let accepted ← sympyExprCheckProp goalType
if !accepted then
throwError "sympy: oracle rejected the goal"
let proof ← Meta.mkAppOptM ``sympy_oracle #[goalType]
goal.assign proof
| _ => throwUnsupportedSyntax
The key line is Py.ofLeanObj e – this takes a Lean Expr and
makes it available to Python as a LeanInductiveValue tree, thanks to
the derive_python registrations for Lean.Expr, Lean.Name, etc.
Converting Expr to SymPy
The Python side walks the LeanInductiveValue tree recursively.
A Lean.Name like HAdd.hAdd gets pattern-matched and mapped to
SymPy’s + operator. Here’s the core dispatch:
_DISPATCH = {
"HAdd.hAdd": _binop(lambda a, b: a + b),
"HSub.hSub": _binop(lambda a, b: a - b),
"HMul.hMul": _binop(lambda a, b: a * b),
"HDiv.hDiv": _binop(lambda a, b: a / b),
"HPow.hPow": _binop(lambda a, b: a ** b),
"Eq": (2, lambda args: Eq(expr_to_sympy(args[-2]),
expr_to_sympy(args[-1]))),
"Neg.neg": _unop(lambda x: -x),
"Nat.succ": (1, lambda args: expr_to_sympy(args[-1]) + 1),
"Int.ofNat": (1, lambda args: expr_to_sympy(args[-1])),
...
}
The main converter handles literals, variables, mdata (unwrapped
transparently), and application nodes (uncurried and dispatched):
def expr_to_sympy(expr):
if expr.ctor == "lit":
return Integer(expr.fields[0].fields[0])
if expr.ctor == "app":
head, args = uncurry_app(expr)
entry = _DISPATCH.get(name_to_str(head.fields[0]))
if entry:
_, builder = entry
return builder(args)
...
The proposition checker just simplifies and checks:
def sympy_prop_check(prop):
return simplify(prop) is sympy.true
Using the tactic
With everything in place, proofs look like this:
import SymPyTactic
example : (1 : Int) + 1 = 2 := by sympy
example : (3 : Int) * 4 = 12 := by sympy
example : (5 : Int) * 6 = 30 := by sympy
Running
cd examples/04_sympy_tactic
cd lean && lake build && cd ..
uv run --project python python/main.py
What to take away
Py.ofLeanObjbridges Lean’s kernel AST into Python as structured data.derive_pythonforLean.Expr/Lean.Namemakes pattern-matching on Lean’s internal representation possible from Python.The dispatch table pattern is extensible – add entries to support more operations.
The oracle axiom makes the trust boundary explicit.
This architecture generalises: any decision procedure available from Python (Z3, CAS systems, ML models) can back a Lean tactic this way.