I’ve been thinking about how we can bring Lean closer to the code people already write for machine learning. If we have a model in JAX or PyTorch, can we keep that code and prove something about its computations without writing the model again in Lean? That’s what we’re working toward with TorchLean’s intermediate representation (IR).
The idea is to capture the tensor operations from the original code and translate the supported ones into our IR. Then we can work with their Lean definitions, reuse mathlib and our tensor lemmas, and bring in FloatLib when rounding matters. We need to keep the shapes, axes, constants and precision choices along the way. Otherwise, we could end up proving something about a different computation.
Thinking about the future, I’d love to be able to take an attention implementation and ask whether it computes the formula we intended, or take a normalization layer and prove which transformations leave its output unchanged. We could also study what changes when we train or run a model at a lower precision. The model would still run with its usual backend; we’d use Lean to work through these questions about the imported computation.
We’re still building the IR and its importers. Today we accept supported Jaxpr and captured PyTorch graphs, check them, and run reference computations. Some graphs also have Lean proofs, including the permutation theorem below. Importing a graph does not itself prove the translation faithful or verify the kernels that JAX or PyTorch executes.
Let’s start with a JAX function we might already have in a project:
import jax.numpy as jnp
def sum_squares(x):
return jnp.sum(x * x)
Reordering the entries should not change the sum of their squares. We can test a few permutations in Python, but here we’ll state that property for every real vector and every permutation, and prove it about the graph’s Lean semantics.
We’ll start small so we can follow the whole computation. After that, we’ll bring in a two-layer JAX network, including its weights and biases, through the same IR.
Capture the computation
Let’s capture it from the TorchLean repository, using our existing JAX environment.
If JAX isn’t installed yet, we can install the CPU package with
python3 -m pip install 'jax[cpu]'.
import json
import jax
import jax.numpy as jnp
from NN.Runtime.JAX.jaxpr import export
jax.config.update("jax_enable_x64", True)
x = jnp.arange(1, 5, dtype=jnp.float64)
graph = export(sum_squares, x)
print(json.dumps(graph, indent=2))
jax.make_jaxpr
records a Jaxpr at this input shape. Our adapter reads its equations and emits
three TorchLean IR nodes: the input, elementwise multiplication, and a scalar sum.
Constants in other computations are serialized alongside the graph.
input [4] → multiply input by itself [4] → sum []
On the Lean side, Import.JAX.parseProgram checks dependencies, operator arguments,
shapes, and the element type of each node. Import.JAX.parseGraph remains available
when we want a homogeneous graph with a scalar type chosen in Lean. Both use the
existing graph syntax; JAX does not get a separate tensor representation.
What comes out of the pipeline?
For JAX, we capture a Jaxpr and translate its supported equations into TorchLean IR. For PyTorch, we read a captured graph and translate its supported operations. We are importing these frontend graphs, not XLA’s or LibTorch’s compiled kernels.
Here’s what we get back, depending on which steps we run:
| Output | What we get |
|---|---|
| Graph artifact | The operations, dependencies, constants, tensor shapes, element types, explicit casts and designated outputs. |
| Check report | Results for the requested checks, including failures and unsupported cases. A successful report applies to those checks. |
| Reference results | Computed output tensors when evaluation is requested, with conversion flags on the configured binary path. Sample comparisons relate these results to recorded frontend outputs. |
| Lean theorem | A separately written, kernel-checked proof about the graph’s Lean meaning. Passing the checks does not generate this proof. |
For our input [1, 2, 3, 4], the sum-of-squares result is 30. That’s a concrete
output we can compare with JAX. Later, we’ll prove that reordering any real input
vector leaves this graph’s result unchanged. That lets us say something about
inputs beyond the one we ran.
Run the examples
The companion script captures a small MLP, sums, broadcasts, reshapes, axis permutations, a batched contraction, and functions returning several tensors. It reports unsupported captures by name and continues with the supported ones:
python3 NN/Examples/Interop/JAX/export.py /tmp/torchlean-jax-examples
scripts/lake.sh exe torchlean jaxpr /tmp/torchlean-jax-examples/sum_squares.json
scripts/lake.sh exe torchlean jaxpr /tmp/torchlean-jax-examples/mlp.json
Try --width 7 --batch 3 when capturing the examples. Those are example dimensions,
not restrictions of the importer or the permutation theorem.
The script also captures jax.grad(sum_squares). Its result is another tensor
program that can enter the same IR; comparing its outputs is not, on its own,
a proof that the captured program is the mathematical derivative.
The Lean runner loads each artifact, checks its graph and constants, and compares
all designated outputs with the recorded JAX results. Homogeneous examples use
native Lean Float, which is binary64, at absolute tolerance 2e-5. Mixed-precision
examples use FloatLib at each node’s declared precision and compare interchange
words. These are checks of the recorded inputs, not proofs of agreement for all
inputs or a replay of XLA’s chosen kernels.
Bring in a neural network
Here is a two-layer network with a ReLU between its affine layers:
\[F(X;W_1,b_1,W_2,b_2) =\operatorname{ReLU}(XW_1+b_1)W_2+b_2.\]We’ll pass the parameters as tensor arguments, so they remain visible in the captured interface. The same function works at different compatible widths; the dimensions below choose one example to capture.
From the repository root, first build the checker with
scripts/lake.sh build verify. Then run:
import jax
import jax.numpy as jnp
import torchlean
jax.config.update("jax_enable_x64", True)
def mlp(x, w1, b1, w2, b2):
hidden = jax.nn.relu(x @ w1 + b1)
return hidden @ w2 + b2
batch, input_width, hidden_width, output_width = 3, 7, 5, 2
dtype = jnp.float64
x = jnp.arange(batch * input_width, dtype=dtype)
x = x.reshape(batch, input_width)
x = (x - batch * input_width // 2) / 8
w1 = jnp.arange(input_width * hidden_width, dtype=dtype)
w1 = w1.reshape(input_width, hidden_width) / 16
b1 = jnp.full((hidden_width,), -0.25, dtype=dtype)
w2 = jnp.arange(hidden_width * output_width, dtype=dtype)
w2 = w2.reshape(hidden_width, output_width) / 32
b2 = jnp.zeros((output_width,), dtype=dtype)
report = torchlean.verify(
mlp, x, w1, b1, w2, b2, frontend="jax",
checks=("structure", "types", "compare"),
)
print(report["ok"])
print(report["checks"])
The divisions above prepare the example tensors before capture. The network
function itself uses matrix multiplication, addition and ReLU. We checked this
five-input example: the graph passed its structure and type checks, and the
reference output matched JAX’s complete binary64 words for these arguments.
The result has shape [batch, output_width], here [3, 2].
Inside the graph, bias addition includes explicit broadcasting. That matters when we reason about the model: adding one bias per hidden feature is a different operation from reducing across the batch. The node shapes and broadcast axes tell us which calculation we imported.
import torchlean currently uses the Python directory in this checkout; it is
not a separately published package. The
captured-computation page explains the
individual report fields. We can still use export(mlp, x, w1, b1, w2, b2) when
we want to save or inspect the artifact directly.
What we have checked
We ran the companion examples at width 4 with batch size 2, then at width 7 with batch size 3. Every supported companion artifact replayed successfully at both sizes. The checks covered several parts of a network rather than only scalar arithmetic:
| Computation | What we checked |
|---|---|
| Two-layer MLP | Matrix products, bias broadcasting and ReLU; the binary64 diagnostic output passed its 2e-5 tolerance. The five-input network above also passed an exact-word comparison. |
| Batched contraction | The contraction axes lowered through shared tensor transformations and matrix multiplication; the supplied batched outputs agreed. |
jax.grad(sum_squares) |
JAX’s generated gradient program entered the IR and its sample output agreed. This did not prove the derivative rule. |
| Reshape, transpose, broadcast and concatenation | The imported shapes and sample tensor entries agreed after the transformations. |
| Several returned tensors | Every designated output was checked in the captured order. |
| Float16, bfloat16 and mixed precision | Explicit casts and accumulation precisions were retained; the selected examples matched complete output words. |
These runs compared CPU JAX results with Lean’s reference execution. They did not
measure GPU performance or verify XLA’s compiled kernels. The companion’s
homogeneous diagnostic evaluator uses binary64 even for a float32 capture; the
exact-word checks use the typed reference interpreter instead. A printed error
of 0.000000 in the diagnostic output is not a claim of bitwise equality.
We also checked captured PyTorch LayerNorm and a matrix-product/softmax attention
calculation on supplied inputs. Those passed sample comparisons through the
same verification interface.
JAX expands those operations differently: its LayerNorm needs division, and its
softmax needs reduce_max, which this Jaxpr adapter does not yet lower. A passing
PyTorch example therefore does not imply that the corresponding JAX function can
already be imported. The batched-contraction example covers attention’s dot-product
linear algebra, not a complete Transformer.
Numerical differences we found
Inspecting the captured operations helped us catch assumptions that shapes alone
would miss. We corrected the adapter to reject several unsafe arithmetic rewrites.
Replacing x / y with x * (1 / y) introduces another rounding step and can change
exceptional values. Replacing -x with 0 - x changes signed-zero behavior.
Expanding a general integer power into another multiplication schedule can change
rounding too. These captures now report an unsupported operation rather than
silently choose a different calculation.
Precision labels also need executable meaning. We changed a multiplication’s
declared output to float64 while leaving its operands float32; the type checker
rejected the artifact and requested an explicit cast. In a separate valid
float64 → float32 → float64 round trip, the input 16777217 became 16777216.
The outputs agreed with JAX, and the interpreter reported inexact = true.
So both implementations agreed, even though the conversion lost the final digit.
Square root needs similar care. JAX’s raw square root and the IR’s guarded square root behave differently for negative inputs and signed zero. The recognizer checks expression patterns that exclude negative values and negative zero; a positive capture sample cannot justify accepting the function for other inputs.
These are findings about our import and reference-execution paths. We have not identified them as bugs in JAX or XLA. For a network, such details matter before we attach a numerical theorem: we need its casts, domains and reduction order to match the operations that theorem describes.
Prove a property of the graph
The source is
JAX.lean.
Its sumSquaresGraph has the same three nodes as the captured example, with a
parameterized input shape. sumSquaresGraph_denote proves that evaluating the
complete graph over the reals returns Tensor.sum (x * x).
sumSquaresGraph_permutation then proves that this evaluation is unchanged for
any vector length n, any permutation of its indices, and any two tensors whose
entries agree under that permutation. The proof reuses TorchLean’s sum and
pointwise-product lemmas, then mathlib’s theorem that a finite sum is unchanged
by reindexing with an equivalence.
The runner recognizes this graph pattern and points to that theorem. A different graph can have a different property: importing it does not automatically produce a permutation-invariance proof.
Exact real addition is important here. A floating-point sum can change when we reorder its terms. To study that difference, we need a numerical error statement and an explicit reduction schedule, rather than the real-valued equality above.
For a larger network, we start in the same way: identify the exact imported graph and state a property of its evaluation. For example, we might ask whether a row-wise MLP commutes with a permutation of the batch, or whether every output stays inside a chosen interval when the input ranges over a box. The proof must account for the graph’s actual axes, constants and operations. We can reuse TorchLean’s tensor and network lemmas and mathlib where their hypotheses match.
The MLP comparisons above do not include either of those universal theorems. The completed graph theorem on this page is the real-valued sum-of-squares permutation result. Numerical enclosures and classifier guarantees have their own worked examples and verification tools; applying them to an imported network requires the corresponding operation coverage and proofs. A manual proof or an agent-assisted proof still needs Lean to check that connection.
Which computations can we import?
The adapter handles static tensor computations built from supported
elementwise arithmetic and elementary functions, explicit broadcasting, reshape,
transpose, concatenation, sums, and dot_general contractions. Contractions expose
their batch and contracted axes using permutations and reshapes before the existing
IR matrix product. Supported jit and custom-JVP wrappers are inlined using their
forward bodies; this does not verify their custom derivative rules.
Close over parameter tensors or pass them as runtime arguments with
export(function, *inputs). Each argument keeps its own shape and dtype; the
returned tensors may also have different shapes and dtypes. Version-two artifacts
record the argument order in input_ids. Existing one-input captures continue to
use version one. A nested input pytree needs a container interface and is rejected.
Dynamic control flow, effects, gather/scatter, and primitives without a lowering are rejected
by name. Division is also rejected: replacing x / y with x * (1 / y) changes rounding and
exceptional values. This currently excludes JAX’s mean and LayerNorm expansions.
Negation and integer powers other than 0, 1, and 2 are also rejected; their lowering needs
explicit operators rather than arithmetic rewrites that can change floating-point results.
Square root requires a structurally nonnegative operand, because the existing IR square root
clamps negative inputs. The capture input alone cannot establish that condition for every input.
The adapter does not import JAX’s full softmax expansion, which includes reduce_max and
non-finite constants, so a conventional attention function using that expansion is unsupported.
Mixed precision
Let’s make the precision changes visible:
def mixed_square(x):
y = x.astype(jnp.float32)
return (y * y).astype(jnp.float16)
artifact = export(mixed_square, jnp.array([1.25, 2.5], dtype=jnp.float64))
The graph contains two casts and a multiplication. Its input is binary64, its multiplication is binary32, and its result is binary16. A cast is an operation, not a new label on an unchanged tensor. The exporter also records complete binary words for constants, so loading a value does not first round it to a Python or Lean binary64 number.
NN.IR.Program attaches these types and the input/output interface to the existing
graph. Ordinary arithmetic requires matching operand and result types. A missing
cast is rejected. NN.IR.Interpreter.binary executes the numerical nodes with
FloatLib’s configured arithmetic and casts with its explicit rounding semantics:
import NN.Runtime.JAX
-- After reading the exported JSON into `artifact`:
let program ← IO.ofExcept (Import.JAX.parseProgram artifact)
let payload ← IO.ofExcept (Import.JAX.parseBinaryPayload artifact)
-- `input` is a typed tensor value, loaded from its exact interchange words.
let (outputs, conversionFlags) ← IO.ofExcept
(NN.IR.Interpreter.binary.eval program payload #[input])
Try the companion’s mixed_precision.json, float16_sum.json, and
bfloat16_sum.json with the same torchlean jaxpr command. The last two show JAX’s
explicit conversion to float32 before accumulation and conversion back afterward.
The binary interpreter accepts any validated FloatLib binary descriptor, including binary128 and custom exponent and fraction widths. JAX capture currently supplies float16, bfloat16, float32, and float64. Those are different limits: a precision can be supported by the Lean interpreter without being a JAX array dtype.
NN.IR.Binary.cast_value proves that a successful cast returns FloatLib’s specified
directed conversion, including exceptional encodings. It does not verify XLA’s
implementation. Reductions follow TorchLean’s reference order, and FloatLib’s
elementary functions are approximations. Heterogeneous dot products with a separate
accumulator precision are rejected until their input and accumulation rules are
represented explicitly. Jaxpr capture also preserves Boolean, integer and complex
component types and exact constant words. Complex words alternate real and imaginary
components. The default torchlean.verify reference can execute Boolean and fixed-width
integer layout operations, using the same shared tensor transformations. Their arithmetic
and cross-type integer casts remain unsupported, as does complex execution. The explicit
Interpreter.binary used above remains binary-only.
The proofs on this page concern the accepted graph’s Lean semantics. The Python translation and the link to XLA execution remain outside those proofs.
Where we’re taking the IR
I’d like us to be able to use this for general tensor programs too, whether we’re working on a neural network, a numerical algorithm, or a smaller calculation like the one above. As we add frontends, we should be able to reuse the proofs for operations that already have the same meaning in our IR.
We’re still building more of the IR. We need more operators, ways to represent control flow and accumulation choices, and proofs connecting the captured code to the imported graph and its backend. I’d also like the proof to stay connected to the implementation as we edit it, so we know when a code change means we need to revisit the theorem.
For now, try changing the network’s widths or the casts in the mixed-precision example and inspect what enters the graph. I’d love to hear which computations you’d want to bring into Lean next : )