lanky
Python for math you can check.
A lanky file is ordinary Python. A claim is a function whose annotations are
its statement, in the Python an engineer already writes: sorts for the
variables, comparisons for the hypotheses, all(... for i in Fin[n]) for a
quantifier. The bodies run. One command, lanky check, prints a ledger: every
claim, how strongly it is known (tested, decided or proved), who settled it,
and what it rests on.
Lean is underneath, and you write none of it. lanky reads each claim into a
term, prints the term as a Lean theorem, finds a proof, by omega, by an
induction whose steps sympy finds, or by a script over Mathlib's finite sums,
and has Lean check it. --show-lean prints the Lean for whoever asks.
from lanky import claim
from lanky.prelude import Fin, Fn, Nat
@claim
def scan_monotone(
n: Nat,
cnt: Fn[Fin[n], Nat],
off: Fn[Fin[n + 1], Nat],
h0: off(0) == 0,
hs: all(off(r + 1) == off(r) + cnt(r) for r in Fin[n]),
) -> all(off(a) <= off(b) for a in Fin[n + 1] for b in Fin[n + 1] if a <= b):
"""The offsets of an exclusive scan over counts are monotone."""
Parameters annotated with sorts are the variables, parameters annotated with
propositions are the hypotheses, and the return annotation is the goal. There is
no new syntax and no parser: lanky evaluates the annotations, and == inside one
builds a proposition instead of answering a bool.
Three commands:
$ python examples/gauss.py # claims run as property tests
$ pytest examples/gauss.py # the same claims, collected as test items
$ lanky check examples/gauss.py # the ledger: every claim and who decided it
STATUS EFFECTIVE BY WHERE OWNER STATEMENT
------ --------- ------------- ----------- ------------- -------------------------------------------------------------------------
tested tested property-test gauss.py:31 gauss n : Nat |- 2*sum(i for i in Fin(n + 1)) == n*(n + 1)
tested tested python gauss.py:31 gauss the term computes what the annotations compute
proved tested lean gauss.py:39 scan_monotone n : Nat, cnt : Fn[Fin(n), Nat], off : Fn[Fin(n + 1), Nat]
| h0: off(0) == 0
| hs: forall r in Fin(n). off(r + 1) == off(r) + cnt(r)
|- forall a in Fin(n + 1), b in Fin(n + 1) where a <= b. off(a) <= off(b)
tested tested python gauss.py:39 scan_monotone the term computes what the annotations compute
4 facts: 1 proved, 3 tested
WITHOUT MATHLIB: gauss is not proved, since only Lean with Mathlib can state it. To have Lean prove it: python -m lanky.mathlib ~/mathlib (about 7 GB), then export LANKY_LEAN_MATHLIB=~/mathlib.
(This run is on a machine with the lean extra installed, and no Mathlib,
which the last line says how to set up. CI runs it on one too, and checks
this output against what it prints.)
The third row is what the project is for: scan_monotone is proved by Lean.
Its statement is printed whole, a part to a line: the variables, each
hypothesis under the name of its parameter, and the goal after |-.
Under each claim is its reading. lanky reads an annotation by running it on
symbolic terms, and every oracle is handed the term that run builds, so a proof
is a proof of the term. To check that the term is what was written, the
annotations are run again as plain Python at drawn values and the term is
evaluated at the same values: tested python says they agreed at every draw.
The proof rests on that check, so the EFFECTIVE column says what it is worth,
tested. The same file, on a machine without Lean, reads tested property-test and exits 0 just the same: the status column says how strongly
each fact is established, and the EFFECTIVE column, there only when some fact
is worth less than its status, what it is worth. A WITHOUT LEAN line under
the table then names the claims Lean would prove, and the command that
installs it.
Facts rest on facts. A fact can rest on others, and the ledger shows it
worth no more than they are. scan_monotone's proof rests on its reading,
and is worth tested. In examples/sumpy_recurrence.py, sumpy's compressed
Taylor recurrence for the 2-D Laplace kernel is proved by Lean for every
order. The kernel's harmonicity is a claim the file proves, but the step to
its derivatives at every order is taken on a citation (#174). So the claim
reads proved under harmonic at every order, and is worth assumed until
someone proves that step. And a plugin's facts rest on lanky's: in loopty's
flagship, the row that applies the same claim to a kernel rests on the
claim's proof and on
the kernel's postcondition, which isl decided from what the kernel stores and
Lean proved again (two rows of its ledger, with Lean and Mathlib):
$ lanky check examples/l2p.py
...
proved proved lean l2p.py:263 reconstruct (forall i in Fin(nb), a in Nat, b in Nat where a < 2 and a + b <= 10. R[i, a, b] == C[i, a, b])
and (forall i in Fin(nb), a in Nat, b in Nat where a + b + 2 <= 10.
R[i, a + 2, b] == -R[i, a, b + 2])
proved tested lean l2p.py:263 reconstruct by on_every_triangle, for every D with pde and stored:
forall i in Fin(nb), a in Nat, b in Nat where a + b <= 10. R[i, a, b] == D[i, a, b]
...
The first row is reconstruct's postcondition, the copies and the
recurrence, two of the claim's hypotheses. The second is the claim's
conclusion about the kernel's array, for every table D the other two hold
of, and is worth tested because the claim is.
A proof reads as an engineer writes checks. A claim lanky cannot prove
alone gets a body, and the body is its proof: assert for a step, with its
reason after it, if for cases, and a loop over the points of the
conclusion, each case closed with yield by(...):
@claim
def compressed_taylor(
D: Fn[Nat, Nat, Int],
R: Fn[Nat, Nat, Int],
pde: all(D[a + 2, b] + D[a, b + 2] == 0 for a in Nat for b in Nat),
stored: all(R[a, b] == D[a, b] for a in Nat for b in Nat if a < 2),
recurrence: all(R[a + 2, b] == -R[a, b + 2] for a in Nat for b in Nat),
) -> all(R[a, b] == D[a, b] for a in Nat for b in Nat):
"""What sumpy stores of a harmonic table, reconstructed, is the table."""
for a, b in compressed_taylor.conclusion.domain:
if a < 2:
yield by(stored)
else:
yield by(recurrence, compressed_taylor.conclusion[a - 2, b + 2], pde)
Read it aloud: "For each point (a, b) of the conclusion, in order. If a <
2, by what is stored. Otherwise by the recurrence, the conclusion at (a − 2,
b + 2), and the PDE." The claim is a table of true and false, and the proof
is a loop that fills it in; each entry may use entries already filled, which
is induction, with no function standing for a proof. Lean checks each step,
and the row says how; in the quickstart, where this claim is table.py, it
reads by its proof, table.py:16-20: induction on a, 2 cases, 2 steps.
Before Lean is asked, the body also runs as plain Python at drawn values, so
a step written wrong is caught at its line, with its numbers: At a = 2, b = 0: the left side is 5, the right side -5. The quickstart's
Write a proof has the whole language on
one card.
Conditions have names. Results often share their conditions, and
engineers name them: the CFL condition, an expansion point that is not zero.
@condition states one once, with no goal and no body, and the claims in a
with block hold under it. A claim that fails at its draws is a red test,
and lanky names any condition in the file that rules out the values that
fail it:
REFUTED upwind_bounded at upwind_step.py:12: forall nu in Real, u0 in Real, u1 in Real. abs(u0 - nu*(u0 - u1)) <= max(abs(u0), abs(u1))
counterexample: {'nu': Fraction(-7, 3), 'u0': Fraction(2, 1), 'u1': Fraction(1, 4)}
the goal is false at this assignment
The condition cfl in this file excludes every failing draw: put the claim under with cfl as (nu, stable).
Put the claim under it, and it is green:
@condition
def cfl(nu: Real, stable: (0 <= nu) & (nu <= 1)):
"""The CFL condition: the Courant number is between 0 and 1."""
with cfl as (nu, stable):
@claim
def upwind_bounded() -> all(
abs(u0 - nu * (u0 - u1)) <= max(abs(u0), abs(u1)) for u0 in Real for u1 in Real
):
"""One upwind step does not grow the solution."""
Its row reads under cfl (stable): .... with also assumes another
claim's conditions, with some_claim.conditions as (...), or a whole claim,
its goal included, with some_claim as (...). The quickstart's
Name a condition tells the story with
its ledgers.
Kernels: loopty
lanky knows nothing about arrays or loops. loopty
plugs in a theory of loop kernels, an isl oracle, a loopy executor and a
run verb, so that a kernel written as a typed Python function, and every
transform of it, becomes facts in the same ledger: isl decides each one,
Lean proves it again from loopty's own description of the kernel, and a
kernel cites the claim it computes. Its five demonstrations, each written
after a kernel of a scientific code, and what lanky proves in each with
Mathlib:
| loopty demo | written after | what lanky proves, for every order or size |
|---|---|---|
examples/l2p.py |
sumpy's L2P for the 2-D Laplace kernel | the compressed Taylor recurrence rebuilds every derivative, applied to the kernel that rebuilds each box's triangle |
examples/hexahedra.py |
grudge PR #418's weak gradient and divergence | the Kronecker operator is three one-axis passes, cited by the programs, whose postconditions Lean proves from it |
examples/advection.py |
grudge #418's weak advection right-hand side | the inverse mass pushed inward, and the lift onto the face nodes the face gather's transpose |
examples/layer_potential.py |
pytential's direct QBX with sumpy's line-Taylor expansion | the line-Taylor coefficients, from the recurrence a logarithm's coefficients satisfy (which is cited), tied to the terms the single layer adds |
examples/assembly.py |
PyOP2's P1 mass assembly, from firedrake's tests | the rule exact on quadratics, the element's mass in closed form, as the kernel's postcondition, and the scatter keeping the total |
loopty's README has the demonstrations, their ledgers, and how each compares with the code it was written after.
Run it
lanky's own examples and loopty's demonstrations run on the 0.6.0 release on PyPI. The examples are in the repository and not in the package, so clone the release's tag for them. On Python 3.12 or later, in a virtual environment of its own:
python3 -m venv .venv && . .venv/bin/activate
pip install "lanky[lean,cas]"
git clone --branch v0.6.0 https://github.com/xywei/lanky.git && cd lanky
lanky check examples/gauss.py
The first line matters on Debian, Ubuntu and Homebrew, whose Python refuses
a pip install outside a virtual environment. examples/line_taylor.py and
examples/sumpy_recurrence.py check sumpy's own code, so they need
pip install sumpy as well; without it, lanky check says so.
That much runs without Lean, and the ledger says what is missing, with the command for each piece. Lean comes from elan, at the version the Lean REPL supports, and Mathlib, for claims about real numbers and finite sums, is one command that fetches its compiled files:
elan default leanprover/lean4:v4.29.1
python -m lanky.mathlib ~/mathlib # about 7 GB
export LANKY_LEAN_MATHLIB=~/mathlib
The first check with Lean builds Lean's REPL, which takes about a minute and the network. Checking a file again reads Lean's answers from a cache and starts no Lean, also for a question the first check ran out of time on. Install has the rest.
Where the limits are
- A proof is a proof of the term lanky read. The reading is checked by
running the annotations at drawn points, not proved, so a proof is worth
tested; Known limits says what that check can miss. - Some steps stay trusted: the printer from lanky's terms to Lean, which is tested and not proved, and the Lean kernel.
- The provers are strategies, not search. Core Lean's ladder and
omega, an induction whose steps are a linear combination, an exchange of finite sums, the total of a scatter. A claim that needs a lemma by name falls through to the tester, and its row says so. - Claims about real numbers need Mathlib, about 7 GB on disk; without it
they read
testedorassumed. The Lean REPL runs a Lean release up to v4.32.0; CI uses v4.29.1.
What nothing else does
- One ledger for evidence of every strength. A random-draw pass, an isl
decision and a Lean proof are all facts in one table, each with a status, a
decider, a provenance and a source location. Property-testing tools give you
the first, SMT wrappers the second, proof assistants the third, and none of
them will tell you, for one file, which is which. A fact can rest on others,
and is worth no more than they are: a proof from a cited claim reads
proved underthat claim, and is worth what the claim is. - Statements are the signature, and proofs are checks. A claim is a
typed Python function. It imports, it answers
TrueorFalsewhen called at values, it is collected by pytest, and it is readable by someone who has never seen a proof assistant. Its proof, where it needs one, isassert,ifandfor, with the reason for each step after it. - A refutation is a witness, not a verdict.
REFUTEDcarries the counterexample, the offending point, or the pair of statement instances that makes an ordering wrong. So does the exception a caller sees. - A thin host with real plugins. lanky knows nothing about arrays or loops,
and never imports loopty (above): it
finds it through entry points and asks it what it can do.
examples/pytential_skie.pyis a second consumer in miniature: a rule engine decides, under eight cited claims, which of five integral representations give a boundary equation of the second kind. Andexamples/sumpy_recurrence.pyis a third: sumpy's compressed Taylor wrangler for the 2-D Laplace kernel claims that its recurrence reconstructs every derivative, and the ledger has the claimtestedby mpmath anddecided (heuristic)by sympy through order 6, and, with Mathlib,proved under harmonic at every orderby Lean for every order and on the trianglea + b <= pof every orderp. The kernel's harmonicity is proved by its body, and the step from it to every order isassumedon a citation.examples/sum_factorization.pyproves, for every size, that a tensor-product operator on a hexahedron is three one-axis passes, as grudge applies it, andexamples/line_taylor.pythat the line-Taylor coefficients pytential's QBX evaluates are the series of a logarithm, at every order, with sumpy's own through order 6 decided beside them.
Status
This is 0.6.0. The core works; the edges are sharp. 0.6.0 is a breaking
release, in which proofs read as engineers write checks. A claim is
@claim, a claim taken on a source @claim(cite=...), and a named set of
assumptions @condition, under which the claims of a with block hold. A
proof's step is an assert with its reason after it, by(...); a claim
over a domain is proved by a loop over its points, for n in name.conclusion.domain:, each case closed with yield by(...); and under
lanky check the body also runs as plain Python at drawn values, so a
false step is caught at its line, with its numbers, before Lean is asked.
Every form of 0.5 is refused with the line to write, and the CHANGELOG has
the table from 0.5 to 0.6. Since 0.5.0, a claim's body can be its proof,
checked by Lean, citing facts lanky knows (lanky.known), and every Lean
command ends with an axiom audit read positively. Since 0.4.0, the ledger
says what happened where it said nothing: an oracle that raised, in a
RAISED line, a claim Lean was not asked about and why, under --verbose,
and a Mathlib project Lean cannot import, in the WITHOUT LEAN line; and a
check after the first starts no Lean, also where the first ran out of time.
Since 0.3.0, Lean checks what a plugin's solver decides, part by part, and
Mathlib mode proves claims about finite sums, scatters and complex numbers
for every size. Since 0.2.0,
each claim's reading is checked: the annotations are run again as Python at
drawn points and the term the oracles prove has to agree there, so a
misreading is refuted rather than proved, and a proof is worth tested, the
strength of that check.
Below is what works, what works in part and what does not work yet, and after
it, under Known limits, what the check cannot see and the open issues you are
most likely to meet, each with what to do about it: read that before you rely
on a proof. CI runs the suite on every change, without Lean on Python 3.12 and
3.13 and with Lean v4.29.1, and holds the tables on this page and in the
quickstart to what lanky check prints. Until 1.0 an interface a plugin uses
can change in a minor release; the CHANGELOG says when one does, and loopty
raises its floor to match.
Works.
-
lanky check FILE... [--json OUT] [--verbose] [--show-lean [TEXT]] [--proofs [TEXT]] [--steps [TEXT]], exit code 1 on any refutation or vacuous claim. Each refuted fact is repeated under the table with its counterexample, its witness and its reason, the three standard provenance keys, read the same way whichever oracle or plugin refuted it; or withno witness recordedwhen it carries none of them. Each cited claim is named under the table in aCITEDline with its citation, and a fact leftassumedthat an oracle looked at and declined, saying why, in aDECLINEDline with the reason it gave asdeclined, a standard provenance key too. An oracle that raised over a fact is named in aRAISEDline with the exception (raisedin the provenance;--verboseprints where it raised), so that a crash is not read as a claim it was never offered, and--verbosesays of a claim Lean's printer cannot state that Lean was not asked, and why (lean_declined). None of these changes the exit code. A statement that mentions a name nothing in it binds, a misspelt parameter or sort, gets one from the property tester, which names the name and draws nothing. Two claims with one fact id, which a factory gives the claims it makes since an id names a definition, fail the check: the first is checked and the others are named in aDUPLICATEblock, unchecked, rather than one silently replacing another. Files from different source roots are checked in a process per root, so two directories that each hold ahelpers.pyare each checked against their own. -
@claim: statement from the signature,.statement,.term,.fact(),.test(),.report(),.lean(), and the claim's tree,.conditionsand.conclusion. Called at values, by position or by name, a claim answersTrueorFalse; where one of its conditions fails there, the question does not apply, andlanky.ConditionNotMetnames the condition. Calling a claim never runs its proof. -
Written proofs. A claim whose body holds more than a docstring,
...orpassis proved by that body, in the Python an engineer already writes:assert x == y, by(f, g)for a step,iffor cases, andfor i, a, b in every_box.conclusion.domain:for each point of the conclusion, each case closed withyield by(...), orreturn by(...)for a claim proved without a loop;by()when the case follows directly. The claim's name is spelled out and its parts addressed through its tree:every_box.conclusion[i, a - 2, b + 2]is the conclusion at a point the loop has filled in, the induction, checked to come earlier in the loop, andevery_box.conclusion[i].domaina nested domain; brackets give a point of a domain (pde[i, a - 2, b]), and parentheses give another claim its inputs (gauss(n - 1)).lanky checkruns the body two ways after every run and draw (python file.pynever calls it). Natively, as plain Python at the check's draws, with exact numbers and families over the naturals filled in from the hypotheses that define them, so a false step is caught at its line, with its numbers, without Lean. Traced, on symbolic values, to one Lean command, with each hypothesis under its Python name and the conclusion over its points a Lean theorem of its own, with the measure lanky finds; the claim's row gets a line of its own:by its proof, every_box.py:113-119: induction on a, 3 cases, 3 steps. A calculation is Python's own chained comparison in oneassert, and a step left open,by(...), is named in the row. A proof that fails is reported at the Python line, with what is left over and the fix where lanky can name one, and fails the check, also for a claim lanky proves alone, whose row still readsproved. A case with no closing line, a claim that calls itself, a point the loop has not reached, a part not addressed through the tree and every proof form of lanky 0.5 are refused at their line, with the line to write, andlanky checkrefuses to run underpython -O, which removes everyassert. A claim the proof cites from another module is a row of the ledger, marked with its module.examples/every_box.py, lanky #140's claim, which lanky's search does not prove,examples/nicomachus.pyandexamples/log_of_the_modulus.py, which cite facts oflanky.known, are the worked cases; the quickstart's Write a proof has the whole language on one card. -
Named conditions.
@conditionstates a set of assumptions once, typed variables and named conditions with no goal and no body, andwith cfl as (nu, stable):puts the claims of its block under it: each claim's statement is the condition's variables and conditions, then its own, and its row readsunder cfl (stable): ....withalso assumes another claim's conditions,with some_claim.conditions as (...), or a whole claim, its goal included,with some_claim as (...), and what is built on a claim rests on it. A condition has a row of its own, the check by draws that it can hold at all, and is neverproved. Called at numbers, a condition says whether it holds; in an annotation,ok: cfl(c, dt, h), it is a hypothesis that also gives its variables their sorts. A refuted claim names any condition in its file that is false at every failing draw: "The condition cfl in this file excludes every failing draw: put the claim under with cfl as (nu, stable)."examples/upwind.pyand the quickstart's Name a condition are the worked cases. -
lanky.known: twenty facts under plain names (log_of_a_power,modulus_squared,one_more_term, ...), each proved once with Mathlib by a Lean proof kept in a table, and cited by a written proof like any claim. Where Lean does not prove a claim alone,lanky checknames the known facts that fit it. -
@claim(cite=...): a claim taken on a source. Its fact isassumed (cited), with the citation in its provenance; no oracle is asked to establish it, and the property tester still looks for a counterexample, so a citation copied down wrong is refuted (and one whose hypotheses nothing satisfies is caught as vacuous). -
The reading of every claim is checked (#91). lanky reads an annotation by running its Python on symbolic terms, and the oracles are handed the term that run builds. Each claim, cited or not, gets a second fact, of kind
faithfuland statementthe term computes what the annotations compute: the annotations are run again as plain Python at drawn values, six draws of small values and domain ends, one at each integer written in the claim or in a helper it calls and next to it, and then 32 of the property tester's, the term is evaluated at the same values, and the two have to agree. The annotations run again in a copy of the module taken just before the claim was read, its tables and arrays included, so a table the module changes after the claim is read as it was, and each draw starts from that copy. Both readings read the connectives and the quantifiers three-valued, as lanky does, so a point where both have no answer does not keep the points after it from being compared. A disagreement refutes the reading, with the draw, the annotation as written and both answers, and the claim is offered to no oracle and fails the check: a hash, a text,is, an attribute or a comparison of types that answered from the term object, in the annotation or in a helper or a library it calls, is refuted at a point instead of proved. Every pass, decision and proof of a claim rests on its reading, so a proof is worthtestedin theEFFECTIVEcolumn, andproved under faithful:namewhere the reading could not be run at a point, with the reason in aDECLINEDline. The pytest plugin fails a claim whose reading is refuted. -
Facts rest on facts.
@claim(uses=[...])names the claims, cited claims or fact ids a claim rests on, and a plugin setsFact.rests_onon the facts it builds. The ledger reads the graph: a row says what it is established under (tested under nicomachus), and anEFFECTIVEcolumn gives the weakest status over everything a fact rests on whenever that is weaker than its own.--jsoncarriesrests_on,effective,effective_heuristicandunder. An id no fact in the ledger has counts as an assumption, andlanky checknames it under the table. An id names a definition by the module its file's path gives it under its source root,claim:helpers.lemma@12, and not by the name the module was imported under, so a claim has the same id in its own file's ledger and in therests_onof a file that imports it. A plugin keys its facts the same way, withlanky.ledger.fact_idandlanky.check.module_name.examples/citation.pyis the worked case. -
@rewrite: a transformation as a claim. A function of no arguments returns(source, target),obligation=names what has to hold between them, and the fact readssource ~> target (obligation), with aRewriteTermfor an oracle that knows the obligation to decide. A plugin builds the same shape withlanky.rewrites.rewrite_fact, and a subclass ofRewritecan claim more about its target.examples/pytential_skie.pyis the worked case. -
Trust classes, strongest first:
kernel,decision-procedure,heuristic,test. A decider complete for the fragment it accepts is a decision procedure; one that can fail to answer inside it, or whose answers are not guaranteed, such as a simplifier, is aheuristic. The table marks a fact one decideddecided (heuristic), and a counterexample the property tester finds overrules it. What rests on such a fact is worthdecided (heuristic)at most, in theEFFECTIVEcolumn and aseffective_heuristicin the JSON. The oracle that settles a fact leaves its trust class in the provenance. -
The CAS oracle,
cas, aheuristic, where sympy is installed (thecasextra). It decides a statement that asserts equations, alone, in a conjunction or under universals over numbers, with sides built from arithmetic,abs,exp,logandsqrt, whensympy.simplifytakes the difference of the sides of every equation to0, and it declines everything else, a difference left over included, with the reason: what sympy cannot simplify away is no counterexample, and the property tester is asked next. A variable is a symbol with what its sort grants, sosqrt(x**2) == abs(x)is decided for a realxand declined for a complex one, and the hypotheses are not read.lanky.casis the bridge, both ways, and refuses what it cannot translate faithfully: a family, a reduction, a floor division, a variable whose sort is not a set of numbers, a reallogorsqrtof what sympy cannot show is not negative, where sympy's value is complex and Python'smathraises. sympy is imported on the first fact, never with lanky; before its deadline the oracle reads only a statement's shape, and it translates the statement within it. -
The ledger: six statuses, provenance, JSON, a rendered table.
-
The prelude:
Nat,Int,Real,Complex,Bool,Prop,Fin[n],Fn[A, B]andFn[A, B, C], a family with two indices (a matrix,Fn[Fin[n], Fin[m], Real], which isFn[Fin[n], Fn[Fin[m], Real]]), applied asF[i, j],F(i, j)orF(i)(j), one entry to every reading and to Lean, and given to a claim called as a function as nested lists, an array or a function, refinement byT & prop, the sumFin[n] + Fin[m]of index types (carried for plugins, not yet a binder domain), exactness classes; andlanky.exp,lanky.logandlanky.sqrt, which build a term from a term, and from a number in an annotation, so thatexp(1)there iseto every oracle and not the float nearest it, and aremath's (orcmath's) functions at a number anywhere else: in the file run as a program, and in a function an annotation calls. Python'sabsof a term is lanky'sAbs, so a comparison of it is a proposition. -
Property testing, including satisfying a definitional hypothesis by construction rather than rejection sampling, so a claim about a scan is genuinely tested and not vacuously passed. A synthesized value has to land inside the family's codomain, so an unsatisfiable definition drops the draw rather than putting a point outside its sort into it. A pass over zero valid draws is never reported as
TESTED: the fact staysASSUMEDand its provenance saysuntestedand why. The reals are read exactly, as Lean reads them:RealandComplexare drawn as fractions whatever their exactness class, andexp,logandsqrtare enclosed in intervals where their values are not rational (src/lanky/intervals.py), so a claim is refuted only where the enclosures exclude it. -
Refusing the ways a statement can silently mean something other than what was written: a guard joined with Python's
or, anandororused as a value, anotin a guard, anifstatement inside a function an annotation calls, and a symbolic guard over a concrete domain (if n > 100in a sum overFin[3]), which a walk of the domain would drop; a body that is a term there is kept,allandanyof it being the conjunction and the disjunction of its values at the points. The connectives are&,|and~. Only a generator'sifclause records a guard: a conditional expression (a if i < 3 else b), anotin a body and a comparison of two tuples or lists, which compares their items, are refused inside a quantifier, where each used to be read as its guard. A builtin of Python's in an annotation is Python's at concrete arguments (round(0.5)is0, andcomplex(-1, -0.0)keeps its negative zero) and refused at a variable, naming it (complex(x, 1)), where it used to be a free name; so is a truth value a builtin asks for inside a quantifier (i in range(3)), which used to be recorded as the quantifier's guard, and a symbolic domainziporenumeratewalks.maxandminof terms are lanky's own, the larger and the smaller of their arguments:abs(u0 - nu * (u0 - u1)) <= max(abs(u0), abs(u1))reads as written, and Lean's ismax a b. A claim refuses a parameter annotated with a builtin type,x: int, or naming one anywhere in its annotation,f: Fn[Fin[n], float], and names the sort meant. A term is no key of a dict and no member of a set in an annotation:{0: 1}.get(i, 0)andi in {0, 1}are refused, where the lookup answered from the term's hash as if the key were absent and Lean proved what that left. Nor is a term text,f"{i}", or a truth value,1 if i else 0, which answered the same at every value; compare it instead,i != 0. -
One reading of arithmetic for every oracle.
Natmeans an integer that is not negative, and the Lean printer says so: a natural is anIntwith0 ≤ nas a hypothesis, and//and%areInt.fdivandInt.fmod, which round the way Python's do. What Lean proves is what the property tester tests, so a claim is refuted, or not, whether or not Lean is installed:n - 1 >= 0overNatis refuted everywhere, andn - 1 <= nis still proved where Lean is. A comparison with no variable in it, which only a plugin builds, is ascribedIntas well. AFractionis a number like anint:x ** Fraction(1, 3)is a cube root, and not a power to the float nearest a third. Every declaration Lean is sent is elaborated withautoImplicitoff, so a name its signature does not bind is an unknown identifier to Lean and never a variable Lean picks a type for. -
Vacuous claims are caught. When no draw satisfies a fact's hypotheses, the stronger oracles are asked whether the hypotheses alone prove
False. If one does, the row readsproved (vacuous)andlanky checkexits 1: the claim is true and says nothing, which is usually a mistake in the hypotheses. If none can, a warning says the hypotheses were never satisfied. A goal whose own guard never holds, a flipped or off-by-one generator guard, is caught the same way: when no draw gets through the goal quantifier's guard, the stronger oracles are asked whether it is empty wherever the hypotheses hold, and a guard empty only for some values, asFin[n]is atn = 0, is never flagged. -
A statement the annotation already answered is a claim like any other:
-> 1 == 2isrefuted,lanky checkprints why under the table, and it exits 1 on it. -
The Lean oracle over core Lean 4, with a tactic ladder and an induction strategy read off the term. No Mathlib is fetched or needed. CI runs the suite against Lean v4.29.1 as well as without Lean. A name Python accepts and Lean reserves, a variable
funor a claimscoped, is printed quoted («fun»). A claim is declared in Lean asLanky.<name>, so a claim named like a core declaration (and_comm,id,trivial) is not refused as already declared; a variable named like a root name the printer writes (Int,Nat,Bool,Finset) has those printed_root_.Intin its statement; and a variable namedrflor_, whichintroandrcasesread as patterns, is introduced under a fresh name. A statement that mentions a name nothing in it binds is declined, naming it: Lean would read the name as its own declaration of it, Mathlib'sroundfor Python's, or bind it implicitly at a type of its own choosing, and prove another statement. An existential's conditions are conjuncts, so a disjunction among them is bracketed, as Python groups it. An attempt that runs pastLANKY_LEAN_TIMEOUTcosts that attempt alone: the REPL the driver killed is started again for the next. The ladder as a whole has a budget per fact,LANKY_LEAN_BUDGET(120 s), and a smaller one for the questions whether a fact is vacuous,LANKY_LEAN_VACUITY_BUDGET(30 s), which also leave out the attempts that cannot showFalse; the search for an induction's certificates counts against it, and a fact whose budget runs out says after how many attempts. And a REPL ends with the process that started it, however that process ends, aSIGKILLincluded: a small reaper process per lanky process kills it when the pipe from lanky closes, and Ctrl-C during an attempt stops it at once. -
Mathlib mode, opt-in:
LANKY_LEAN_MATHLIBnames a Lake project with Mathlib fetched, andpython -m lanky.mathlib DIRsets one up from the project lanky ships, pinned to Mathlib v4.29.1 and every dependency at a commit. The oracle imports Mathlib once and prints statements overRealandComplex(ℝandℂ), sums (∑overFinset.Ico), absolute values, true division, floats as the rationals Python holds, andexp,logandsqrt(Real.expand the rest, andComplex.exp,Complex.logandComplex.sqrt). A complexlogorsqrtis Python's principal branch off its branch cut and not on it, so Lean proves a statement that takes one only after it proves that every argument stays off the cut (#59). The ladder goes on tonorm_num,positivity,ring,field_simp,linarithandnlinarith, and to an induction for a sum whose bound a natural parameter sets, so Gauss's sum inexamples/gauss.pyreadsproved lean. With the variable unset, the oracle is the core one, exactly. docs/quickstart.md walks through it. -
Induction over families, in Mathlib mode: a claim about every order, a universal over naturals whose body is an equation between applications of the statement's families, is proved by strong induction on the order, and each case is closed by
linear_combinationover the hypotheses and the induction hypothesis, normalized byring, or byfield_simpandring. Python finds the combination (lanky.induction, with sympy, thecasextra): which hypothesis to use at which point, and the multipliers, which may be expressions in the statement's other variables; Lean checks it.lanky.oracles.lean.use_certificateis the hook through which a plugin hands over multipliers of its own, andlanky.oracles.lean.declineleaves a claim to the oracles after Lean, saying why. The sumpy demonstration's claim for every order is proved this way, and so is its corollary on the trianglea + b <= pof every orderp, whose recurrence holds only inside the order: a hypothesis is used where its guards follow from the bounds of the variables and from the goal's own guards,k + b + 2 <= pfrom the goal'sa + b <= pata = k + 2. So is a claim from an order on,all(c(n) == -(-beta / alpha) ** n / n for n in Nat if n >= 1): the guard's lower bound raises the step, the orders it rules out are base casesomegacloses from the guard, and a step that divides by the order overℝorℂshows first that the order is not zero there, whichfield_simpcannot see for itself. -
Exchanging finite sums, in Mathlib mode: an equation between sums, products of sums and quotients of a sum, under any universals, is proved by distributing the products into the sums (
Finset.mul_sum,Finset.sum_mul,Finset.sum_div), exchanging the left side's sums one adjacent pair at a time until they are in the right side's order (Finset.sum_comm), and comparing the summands under all of them withFinset.sum_congrandring. Python finds the order (lanky.sums), comparing the summands with sympy where it is installed; Lean checks it. Sum factorization, a tensor-product operator applied as three one-axis passes, and the Kronecker product's mixed product are proved this way for every size (examples/sum_factorization.py). -
A plugin's claims checked by Lean (
lanky.obligations). A plugin with a decision procedure of its own states each claim it decides for Lean too, as parts, each a closed lanky term, through one method of the fact's term,lean_obligation(), with the hints its solver found: the points to take a universal hypothesis at, a piecewise witness for an existential goal, case splits. The hints carry no trust: Lean proves each part,omegafirst and then with the hints, and a wrong hint leaves a goal it cannot close. The parts are checked at once (LANKY_LEAN_JOBS, up to four sessions) within the fact's budget; a fact whose parts are all proved readsproved lean, aLEANline under the table says how many of the solver's facts Lean proved, and how, and how many the plugin did not state for Lean, and one that is not proved keeps the status its solver gave it, with aNOT PROVEDline naming the part and why. A refuted claim comes with its counterexample, and Lean proves it false there, which theREFUTEDblock says; a refuted fact is never turned into a proof. Lean's answers are kept by the hash of their source (LANKY_LEAN_PROOF_CACHE), so a file checked again is quick, and when every part's answer is kept, no REPL is started at all. A part about real numbers (Part(..., mathlib=True)) is stated withℝand checked where Mathlib is, closed bylinarith; without Mathlib the claim is left to the oracles after Lean with that reason. A hypothesis that is itself a claim is applied as one (Hints.applications): Lean is asked forapply h (p := ...) (c := ...)with the values the plugin matched, and checks that the cited claim's conclusion is the claim. Where unknowns multiply, a layout's addressi + n * jat everyn, the plugin gives the stepsomegacannot take: products not negative (Hints.products,0 ≤ n * (m - 1 - j)byInt.mul_nonneg), what Lean knows ofc // nandc % n(Hints.quotients), and the arithmetic multiplied out (Hints.ring), soomegareads each product of unknowns as one atom; all in core Lean. loopty states its isl facts this way, and they readproved lean; it states a kernel that cites a claim the same way:# claim: Forall(..., conclusion, guard=theorem & postcondition) hints = Hints(applications=(Application(0, (("p", p), ("c", c))),)) Part("applied", "on_the_triangle gives R == D", claim, hints, mathlib=True)
-
--show-leanprints the Lean of every row that has some, or of the rows whose owner, id or statement contains the text it is given: the file that replays a proof, a plugin's parts as they were tried, a refutation's theorem. It is the one place Lean's words appear. Each theorem is shown under a name of its own in the ledger: the facts of one owner whose parts have one name, a kernel's accesses, are told apart asLanky.jacobi_in_bounds,Lanky.jacobi_in_bounds_2and on. -
--proofsprints the proof lanky found for each proved claim, in Python where Python can say it: for an induction over families, the body a written proof would have, in the claim's own names and at the points the search found, ready to paste under the docstring (for a, b in ct.conclusion.domain:,yield by(recurrence[a - 2, b], ct.conclusion[a - 2, b + 2], pde[a - 2, b])), printed only where the loop's order fits the induction found; for an exchange of sums, the chain through the left side with its sums in the order found; for any other rung of the ladder, the rung, in words.--stepsprints its outline in lanky's notation, each case a chain of equations whose links name the fact, the point and the multiplier; for a claim with a written proof, that proof's outline, a row per step at its Python line. Both take a text as--show-leandoes. -
Plugin discovery by entry point, and
lanky <verb>from the registry. -
The pytest plugin.
Partial.
- The property tester satisfies hypotheses of the shape
f(i) == eby assignment and rejection-samples everything else, so an awkward hypothesis can end with no valid draws. The fact is thenASSUMED, never falselyTESTED. - Division by zero is the one place the readings part. Lean's division is
total, so
n // 0 == 0is a theorem there and aZeroDivisionErrorin Python; the fact carries a note, the ledger says what the sampled reading could not do, and the exit code stays 0. Seesrc/lanky/semantics.py. - Vacuity is found by sampling first, and sampling cannot tell hypotheses that
hold nowhere from hypotheses that hold only where it does not look
(
n == 1000, with naturals drawn up to five). Both get the warning until an oracle proves the hypotheses inconsistent; without one, a vacuous claim warns and does not fail the check. A statement over a sort the tester cannot draw, such as a family overNat, gets no warning, because no draw reached its hypotheses. A goal's guard is read the same way, and only the goal's outermost quantifier is: a guard nested deeper in the goal is not examined. That holds for a claim with no parameters as well, whose goal is a goal and has no hypotheses to be mistaken for. - The native run of a written proof compares exact numbers, so a step whose sides are not rational at the draws, one about a logarithm say, is left to Lean. A family over the naturals is filled in over the first 14 of them, so the points of a hypothesis past those are left out of the run.
- A proof over nested domains cites the conclusion at the levels it loops
over: a citation deeper than those is refused, and loops that walk their
levels in different directions find no measure for Lean.
--proofsprints nested loops only for a proof as written, not for one lanky finds. - A claim whose conditions quantify over
Natcannot yet be called at values: the call raises where it should answer. - Python's
andbetween two propositions in a generator'sifclause happens to produce the conjunction that was written, because of how CPython compiles a comprehension filter, so it is not refused. It cannot be told apart from twoifclauses in the bytecode. Write&. - The Lean induction strategy is a shape matcher, not proof search. A statement
needing a different induction or a lemma falls through to the tester;
lanky.oracles.lean.use_tacticpins a script by hand (one the screen refuses is not sent, andlanky checknames it in aREFUSEDline with the fix, and fails). The induction over families searches, but only for a linear combination: a step that needs a hypothesis used other than as an equation to combine, an inequality, or an instance whose guards are not linear arithmetic over the integers is not found, nor is a base case that holds only because the goal's own guard excludes it, unless that guard is a lower bound on the order by a number (n >= 1): one by another variable, or by anything else, leaves its base cases with no equation to combine. A function Mathlib does not have, a Bessel or Hankel function, has no form yet in which its recurrence is declared and a proof rests on it (#82). - The exchange of finite sums reads a sum over a
Finwith noif, whose bound mentions no other sum's binder: a filtered sum, a triangle (for i in Fin[n] for j in Fin[i], whose sumsFinset.sum_commdoes not exchange) and a sum inside anything but a product, a quotient's numerator or another sum are left to the rest of the ladder, and so is a side with more than six sums. The order of the sums after distributing is the one Lean'ssimpwas seen to leave, the right factor's sums outside the left's; asimpthat leaves another costs the attempt, not a wrong proof (#128). - The Lean printer covers core Lean:
Sum,Abs,Real, true division and an exponent that could be negative raise rather than emit source Lean would reject. In Mathlib mode all but the last are printed, and what is still declined is a sum overNat, aFinwith a real bound, a floor division or a remainder of a real, an order between complex numbers, and a complex logarithm or square root that Lean cannot show stays off its branch cut: each would be printed with a meaning Python does not give it. Showing that takes the hypotheses and guards around the argument and two cheap attempts, soexp(log(z)) == zfor a nonzeroz, true on both sides of the cut, is declined all the same. - Over
RealandComplexthe tester's enclosures decide less than Lean does. Two enclosures can never show two transcendental numbers equal, so an equality counts as holding where the statement asserts it and its two sides agree to 64 bits:exp(x + y) == exp(x) * exp(y)istested, as a sampled universal is, on evidence. Under a negation, in a hypothesis, or as a value, the same agreement decides nothing, and neither does an order whose enclosures overlap, such asexp(x) * exp(-x) <= 1at anyxbut0. Its pass rests on the draws atx = 0alone, and says so: the row readstested, and aWARNINGunder the table says that the pass rests on thin evidence. A complexlogandsqrtare Python's principal branches, enclosed off their branch cut, the non-positive real axis, where Python picks a side by the sign of a zero, which an exact number does not have. On the cut, and at anexpof an argument past2**14, the draw is left undecided. A negative number to a power that is not an integer is Python's complex principal value, enclosed part by part, so(x ** 3) ** Fraction(1, 3) == xis refuted atx = -1. Where Lean is total and Python raises (a division by zero, the logarithm of zero, the square root of a negative number), the fact carries a note in Mathlib mode, as an integer division by zero does in either mode. - A family prints as a total function, so every application of one has to be shown in bounds before the statement can go to Lean. The check is affine arithmetic over the enclosing binders, not a solver, so an argument it cannot settle is declined rather than assumed: the statement falls through to the tester, which is a proof fewer and never a proof too many.
- Sampling quantifiers over
Natdraws a handful of points. That is evidence of the weakest kind and the ledger says so. A sampled quantifier can be refuted but never confirmed: aforallthat a draw breaks is really false, and ananythat a draw witnesses really true, but ananythat no draw witnesses is undecided, not false, and aforallthat holds at every draw counts as evidence only where the statement asserts it. Under~, in a hypothesis or the guard of anall, and inside a sum or a comparison it is undecided, and so is aforallwhose guard or refinement no draw passes, and a sum over draws ofNat. The tester declines such a draw and, when no draw decides the statement, the fact staysASSUMEDwith the reason. A declined draw is replaced by another, so a pass counts only the draws it could decide; when those are outnumbered by the declined ones, or take fewer than three distinct assignments when the draws, decided or not, took more, the pass staystestedand gets areason, printed under the table as aWARNING. OverFinthe domain is enumerated and both answers hold. The connectives are three-valued: an operand the draws leave open does not decide&or|, and a false conjunct or a true disjunct settles it however the operands are ordered. So are the quantifiers, anallbeing the conjunction of its points and ananytheir disjunction: a point the draws leave open, or whose guard has no answer, does not end the walk, andall(p(i) for i in Fin[2])reads asp(0) & p(1)does. A refinement is read as the hypothesis it is, so one that quantifies overNatrejects a draw that breaks it and leaves one it held at undecided. A quantifier over a refined domainT & p, which a plugin building terms by hand can write, ranges over the points ofTwherepholds, as the Lean printer reads it: enumerated whenTis, filtered draws when it is sampled.
Not yet.
CERTIFIED. The Lean source and the tactic script are in provenance; nothing replays them under a checker yet.- Real analysis beyond what the ladder finds: Mathlib mode states it, and the
ladder is a fixed set of tactics, not proof search, so a statement that needs
a lemma by name falls through to the tester, until a written proof cites
the lemma from
lanky.known(use_tacticpins a Lean script by hand). - An interactive proof. A written proof is checked as a whole when
lanky checkruns, and nothing shows the goal left after each step as you write it.--stepsprints the outline afterwards, and a step that fails says what is left over.
Known limits
An annotation is traced Python: lanky runs it on symbolic terms, and the claim
the oracles are handed is the term that run builds. Python that answers from
the term object instead of through its overloads, is, an attribute, or a
hash or a truth value asked for inside a helper, gives the same answer at every
value, and the term then says something other than what was written. lanky
refuses the cases it can see while it reads, and checks the rest by their
results: each claim's faithful fact runs the annotations again as plain
Python at about forty drawn points, in a copy of the module taken just before
the claim was read, and the term has to compute the same at each. A
disagreement refutes the reading, and no oracle is asked about the claim.
The check is sampled, as the property tester is: small values and a domain's
ends, each integer the claim or a helper it calls writes and its neighbours,
and 32 of the tester's draws. A misreading only at a point none of them
reaches is not seen, which is why a proof is worth tested in the EFFECTIVE
column. It runs your helpers at every draw: a change they make to the
module's data is made to the copy, but a side effect outside the module, a
file written or a line printed, happens at every draw. And a claim it cannot
run at a point keeps an assumed reading, so a proof of it reads proved under faithful:name, with the reason in a DECLINED line: a variable of a
sort no value can be drawn of, an annotation Python evaluated when the
function was defined (add from __future__ import annotations), or what the
copy cannot hold as it was, such as an instance of a class of yours.
The open issues you are most likely to meet:
- Calling a claim does not check its reading
(#96).
gauss(n=4),gauss.report()andgauss.test(), and sopython examples/gauss.py, evaluate the term, even where the reading is refuted. Uselanky checkorpytest, which make the check, or callgauss.faithful_fact(). - A claim about a family over
Natis not tested (#98). The property tester cannot tabulateFn[Nat, Nat], so without Lean such a claim readsassumed, andpytestskips it. Give the family a bounded domain with a size parameter,n: Nat, a: Fn[Fin[n + 1], Nat], and it is drawn. A written proof's native run does fill such a family in, over the first naturals, from the hypotheses that define it, so its steps are tested. - A long step in C, or in a library, holds the check up
(#108). Each draw has five
seconds, and a function of yours is stopped at its next call or turn of a
loop once they are spent. One step Python takes in C,
sum(range(10 ** n))or2 ** 2 ** ninside a helper, and a library's own code are not: they run to their end. Keep such work out of the helpers a claim calls, or cheap at the values drawn, which include each integer the claim writes. - An installed package's state is read as it is at the check
(#104). The copy holds your
modules, the claim's and those under its source root. A helper from an
installed package, one installed editable from elsewhere included, reads
its package's tables as they are when the check runs, and so does code that
reaches a module through
sys.modulesorimportlib, so a table changed after the claim can hide a misreading. Keep the data a claim's helpers read in a module beside the claim. - After a helper's operation with no value, the rest of that annotation is
not compared at that draw
(#107). Where both readings
have no value, the draw is read again with Lean's values for Python's
errors,
x // 0as0and so on, but only for the operators the annotation itself writes: a helper's own1 // 0, or an overflow anywhere, still ends the comparison there. Write such an operation in the annotation rather than in a helper. - On Windows, which CI does not run, a check of several source roots can
leave processes behind.
lanky checkruns one child process per source root. On Windows a child is not stopped when the command is killed, an interrupted child leaves its Lean REPL running (#48), and a background process a checked file starts holds the command up until it ends (#47). Check the files of one source root per command, which runs no child process.
Install
pip install lanky
That is the 0.6.0 release, which loopty's demonstrations need
(Run it). lanky needs Python 3.12 or later. In a uv project, uv add lanky, and the
same for the extras below. The examples and the quickstart are in the
repository and not in the package; clone it to follow them.
The CAS oracle is an extra, which installs sympy:
pip install "lanky[cas]"
Where sympy imports, the oracle is on: an identity sympy simplifies reads
decided (heuristic) cas rather than tested property-test.
LANKY_CAS_DISABLE=1 turns it off, and LANKY_CAS_TIMEOUT caps the seconds
sympy may take over one fact (60), translating the statement and simplifying
it.
The Lean oracle is an extra, because it pulls a sizable dependency tree and needs
a Lean toolchain on PATH:
pip install "lanky[lean]"
Lean comes from elan, its toolchain manager
(installation).
The toolchain the oracle runs on has to be one the Lean REPL has a build for.
With lean-interact 0.11.5 that is a Lean release up to v4.32.0 (or
v4.33.0-rc1), which elan's current stable is not. Given a newer lean, the
oracle tries each other toolchain elan has installed, newest first, and then
the newest version the REPL has, which elan downloads when the REPL is first
built. CI uses v4.29.1:
elan toolchain install leanprover/lean4:v4.29.1
elan default leanprover/lean4:v4.29.1
Mathlib mode needs the same extra and toolchain, and a Lake project with Mathlib fetched, about 7 GB on disk. lanky ships the project, pinned, and one command writes it and fetches Mathlib:
python -m lanky.mathlib ~/mathlib # writes the pinned project, runs `lake exe cache get`
export LANKY_LEAN_MATHLIB=~/mathlib
The command never builds Mathlib from source; it fetches the compiled files
Mathlib publishes. Everything it fetches stays in the project, the packed
downloads (about 400 MB) included, in .lake/mathlib-cache, so deleting
~/mathlib deletes all of it; a MATHLIB_CACHE_DIR you set keeps them
there instead, and the command says where they went. The first session of a
process imports Mathlib, which takes seconds and about 1.5 GB of memory, and
checks that it did: a project whose compiled files are damaged, by a lake exe cache get that stopped half way, say, makes the oracle unavailable, and
the WITHOUT LEAN line says what Lean said and to run lake exe cache get!
in the project. The induction over families finds its
combinations with sympy, so a claim for every order wants both extras,
pip install "lanky[lean,cas]".
Without the extra, every Lean test skips with a one-line reason and the weaker
oracles do the work. lanky check --verbose prints each oracle and whether it
is available. The first use builds a Lean REPL, which takes about a minute and
the network, and caches it in $XDG_CACHE_HOME/lanky/lean-repl
(~/.cache/lanky by default), outside the virtual environment so that a
reinstall does not throw it away. Useful environment variables:
LANKY_LEAN_DISABLE=1 makes the oracle a declared no-op, and the check prints
what a machine without Lean prints (the main CI job sets it),
LANKY_LEAN_VERSION pins a toolchain and skips the probe,
LANKY_LEAN_CACHE_DIR moves the cache, LANKY_LEAN_TIMEOUT caps each
tactic attempt (60 s), LANKY_LEAN_BUDGET the whole ladder of one fact
(120 s) and LANKY_LEAN_VACUITY_BUDGET that of a question whether a fact
is vacuous (30 s), 0 for no budget, LANKY_LEAN_MATHLIB turns Mathlib
mode on, and LANKY_LEAN_MATHLIB_IMPORT_TIMEOUT caps the Mathlib import
(600 s).
Lean's answers are kept in $XDG_CACHE_HOME/lanky/proofs
(~/.cache/lanky/proofs by default; LANKY_LEAN_PROOF_CACHE moves it, and
off turns it off), so a check after the first starts no Lean. A question the first check ran out of time on, its
budget spent or an attempt timed out, is kept as where it stopped: the next
check reads the same row back, with Mathlib as without it, and asks Lean
again only when it is given more time, a larger budget or timeout, or has
other attempts to send, such as the new case splits of a plugin's solver.
For work on lanky itself:
uv sync --group dev --extra lean --extra cas
uv run pytest -q
uv run ruff check .
The suite runs with the CAS oracle off, as it runs without Mathlib, and turns
it on in the tests that are about it, which skip without sympy.
tests/test_sumpy_recurrence.py skips without sumpy, which no extra
installs: uv pip install sumpy puts it in the environment.
CI runs the suite twice. The main job, on Python 3.12 and 3.13, installs no
Lean and sets LANKY_LEAN_DISABLE=1, so it sees what a user without the
extras sees. The job named test with Lean installs elan, Lean v4.29.1, the
lean and cas extras and sumpy, caches the toolchain and the built REPL
between runs, and runs the same suite with the Lean oracle on and
LANKY_LEAN_TEST_REQUIRED=1, under which a Lean test that cannot get a Lean
session fails instead of skipping. It then runs
lanky check examples/gauss.py and checks that the proved lean row at the top
of this page is a row the check printed. The suite compares the rest of that
table, and the quickstart's, with the real output in both jobs, reading the
row as tested property-test in the main one.
A third job, test with Lean and Mathlib, is optional: it may fail without
failing the run, since it depends on fetching Mathlib from outside GitHub. It
sets up the pinned project with python -m lanky.mathlib, caching Mathlib's
compiled files between runs, installs sumpy, runs tests/test_mathlib.py
and tests/test_sumpy_recurrence.py with the project required, and checks
that lanky check examples/gauss.py proves both rows and that the sumpy
demonstration's claim for every order is proved under harmonic at every order.
The suite otherwise runs in core-Lean mode wherever it runs: it takes
LANKY_LEAN_MATHLIB out of its environment and hands it to the Mathlib tests
alone.
A file that carries statements needs from __future__ import annotations and a
ruff F821 per-file ignore, because a size such as n is a symbolic variable
lanky invents while evaluating the annotation and has no binding a static checker
can see.
Architecture
Four ideas, and everything else is one of them.
Terms. pymbolic is the expression language, the same one loopy uses, so a
statement and a generated kernel cannot drift apart in translation. lanky adds
Forall, Exists, Sum, Abs and Elementary (exp, log, sqrt) as
pymbolic subclasses, and comparison operators on them build propositions. The
property tester evaluates them in an exact reading of the reals, rationals and
interval enclosures (lanky.intervals), and the file run as a program
evaluates them as Python does.
Facts and the ledger. A Fact is a statement, a term, a status, a decider,
a provenance, a source location, and the ids of the facts it rests on. Status
is tested, decided, proved, certified, assumed, refuted. The ledger is the
output of a check and the thing a reviewer reads; it reads the facts' graph as
well, so a fact's status is shown with the assumptions it rests on and what it
is worth given them.
Four plugin interfaces. Theories turn decorated objects into facts.
Oracles establish facts and report a trust class. Executors run a decorated
object. Verbs are CLI subcommands. Each is an entry-point group:
lanky.theories, lanky.oracles, lanky.executors, lanky.verbs.
Oracles strongest first. Each plugin is installed once per name, so a
theory that arrives both in process and through an entry point does its work
once. Trust classes are ordered kernel (Lean) >
decision-procedure (isl) > heuristic (a simplifier: sympy, the cas
oracle) > test (property test). Each oracle answers
can_establish(fact); check_path offers each fact to the strongest one that
says yes and stops at the first answer. A fact nobody establishes is ASSUMED,
which is not a failure. An oracle that cannot answer declines, so a timeout is
never read as a counterexample. One that took a fact and found it outside what
it decides returns it unchanged with the reason as declined, and lanky check prints that under the table for a fact that stays ASSUMED.
Decorators are inert and registering: @claim returns a callable object that
runs natively and puts itself in the registry. lanky check FILE imports the
file and reads the registry, keeping the claims defined in that file and none
from the modules it imports; lanky check a.py b.py checks both, each for its
own. A process imports a module of one name once, so files whose source roots
differ (the directories a check puts on sys.path: the file's own, and the one
its package is found from) are checked in child processes, one per root, while
files that share their roots share a process as they always did. A child runs
with the command's interpreter options (-O, -W, -X). On Linux and macOS
it is also sent SIGTERM when the command ends, however it ends, and kills
the Lean REPLs it started before it goes; and once it has exited, what it
wrote is copied and nothing a checked file left running holds the command up.
check_path, the function
underneath, imports into the process that calls it. No environment variable
changes what the code means.
Name
- "Lean Annotations Natively Kernel-check Your math".
- LANK + y, where LANK = Lean Annotations, Native Kernel, and lank means long and thin, next to lean.
Documentation
- docs/quickstart.md: the worked file, end to end, with the output the commands actually print; named conditions and the CFL story, red then green; a claim taken on a citation; the pytential demonstration, where a rule engine checks a derivation under the cited claims it rests on; the sumpy demonstration, where a recurrence is tested by mpmath and decided by sympy through an order, and proved by Lean with Mathlib for every order; and proofs written in Python, with the whole language on one card.
- CHANGELOG.md.
- loopty: the sister project and lanky's first plugin: a typed polyhedral layer over loopy, where the facts are about loop kernels and isl decides them.
AI disclosure
This project is developed with substantial assistance from AI coding agents (Anthropic's Claude, via Claude Code). Design, direction, and review are by Xiaoyu Wei. Generated text and code are reviewed before release, but readers should assume AI involvement throughout.
License
MIT. See LICENSE.
Metadata
Release files for lanky 0.6.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| lanky-0.6.0.tar.gz | 1.1 MB | With provenance | Details for lanky-0.6.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| lanky-0.6.0-py3-none-any.whl | Python 3 | none | any | Details for lanky-0.6.0-py3-none-any.whl |
Total release size: 1.7 MB
Release files / lanky-0.6.0.tar.gz
| Download URL | lanky-0.6.0.tar.gz |
|---|---|
| Size | 1.1 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
922fddf928683919bfccfc8270784e01adec4c57684254d15c385b02dc4f956f
|
|
BLAKE2b-256 checksum How to use checksums |
0cae1ab465364f6bf74312c106cc48ba2f21a6931945f16520b3565427b83094
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.
Transparency logRelease files / lanky-0.6.0-py3-none-any.whl
| Download URL | lanky-0.6.0-py3-none-any.whl |
|---|---|
| Size | 591.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e0c364657d11fbaa1bfb5bb21374323879122becb6a03244647b4a5d68221390
|
|
BLAKE2b-256 checksum How to use checksums |
5679935768679cb41b5c89022a841a1b37ac96d5b105951c55bb24f48dd09c56
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.
Transparency logSecurity for lanky 0.6.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for lanky-0.6.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.