Core Erlang executable semantics in Lean 4
The target runtime is Erlang/OTP 29. The project aims to verify compiled Erlang, Elixir, and Gleam modules using executable semantics and proofs in Lean.
The initial slice imports real modules from all three languages, proves their identity functions for arbitrary modeled values, proves list reversal and a higher-order map example, and reuses a dependency contract across linked modules. Captured closures, recursive groups, and class/reason exception handlers execute. Finite maps support data keys, nested values, updates, literal-key subset patterns, and direct lookup/update BIFs. Reusable proofs cover canonical form, lookup frame rules, public-value preservation, and extensional equality. See the map profile for unsupported keys and exact-update failure restrictions. Finite floats can cross payload containers without losing their binary64 bits. Float arithmetic, comparison, and map keys remain outside the execution profile. The byte codec has a bounded-input round-trip proof. The restricted actor runtime supports explicit scheduling, signal delivery, receive, monitor/link lifecycle, and replay. Tests include 91 sequential cases and 17 actor scenarios against OTP; local lexical preservation and actor cursor/signal bounds are proved. The imported closed request/reply exchange has safety proofs over every finite accepted schedule: a finished root returns the original payload, and pending replies preserve its reference and payload. This does not prove termination or OTP equivalence.
See the design document for the architecture, trust boundary, verification interfaces, and implementation milestones.
All repository content is maintained in English.
Run all commands below from the repository root in a POSIX shell.
Install Git, a C compiler toolchain, Node.js (tested with 20.19.2), and elan. There are no npm packages or external Lean libraries to install. To build and use the retained Core JSON fixtures, you do not need Erlang, Elixir, or Gleam installed:
elan toolchain install leanprover/lean4:v4.33.1
node tools/build.mjsThe repository's lean-toolchain selects the pinned Lean version. The build
checks the proofs and produces .lake/build/bin/erlean. Use this binary in the
following commands to avoid triggering another build.
Fresh source imports and the full compatibility suite also require
asdf and the versions in
.tool-versions: Erlang/OTP 29.0.6, Elixir 1.20.0, and Gleam 1.18.1. Add only
plugins that are not already installed:
asdf plugin add erlang https://github.com/asdf-vm/asdf-erlang.git
asdf plugin add elixir https://github.com/asdf-vm/asdf-elixir.git
asdf plugin add gleam https://github.com/vic/asdf-gleam.gitInstall your platform's Erlang build prerequisites before installing OTP. These options omit applications unnecessary for this profile:
KERL_CONFIGURE_OPTIONS='--without-javac --without-wx --without-odbc' KERL_BUILD_DOCS=no asdf install erlang 29.0.6
asdf install elixir 1.20.0
asdf install gleam 1.18.1
export ERL_FLAGS='+S 2:2 +SDcpu 1 +SDio 1'Manage OTP through asdf, not a separate system installation. Exporters check the exact patch version, not merely the major release number.
Start with the retained artifact for identity.erl:
.lake/build/bin/erlean inspect tests/fixtures/erlang/identity/core.json
.lake/build/bin/erlean run tests/fixtures/erlang/identity/core.json answer '[]'
.lake/build/bin/erlean run tests/fixtures/erlang/identity/core.json identity '[{"tag":"integer","value":"42"}]'Both executions return the modeled integer 42. inspect lists accepted and
rejected functions and call obligations; an imported call is not automatically
a supported BIF or a verified dependency.
Arguments are a JSON array of tagged RawCore terms. Integers use decimal
strings to preserve arbitrary precision; atoms use
{"tag":"atom","value":"ok"}, tuples use {"tag":"tuple","items":[...]},
and lists use {"tag":"list","items":[...],"tail":{"tag":"nil"}}.
The empty list is {"tag":"nil"}. Returned nonempty lists are printed as nested
{"tag":"cons","head":...,"tail":...} objects. The decoder also accepts this
form, so returned values need no list-format conversion before another call.
Both forms remain subject to the decoder's nesting-depth limit. See the
term schema for details; transport support
for a term does not imply executable support.
run ARTIFACT FUNCTION JSON_ARGUMENTS [FUEL] defaults to 100000 machine steps.
An exhausted budget is inconclusive, not proof of divergence. Read the outcome:
a modeled Erlang exception is distinct from an unsupported semantic operation.
For repeated calls, use run-batch ARTIFACT CASES_JSON_FILE [FUEL]. The file holds
an array of objects with function and arguments fields. The command imports
the module once and returns an ordered JSON array using the same outcome schema.
Fuel applies to each call. A malformed case, model fault, or exhausted call fails
the batch without partial output.
For OTP compatibility checks, feed the same case file to the reusable oracle:
asdf exec escript tools/otp_oracle.escript --batch SOURCE.erl CASES_JSON_FILEIt defaults to exact OTP 29.0.6 and also accepts an explicit --otp 29.0.2
before --batch. Select the matching asdf runtime. It executes trusted test
modules and reports ordered outcomes, not a correctness proof.
A contract states which inputs are allowed and what the function must return. For example, the imported identity function has a total-correctness theorem for every value in the modeled domain, not just the integer used above.
Run mkdir -p build/tutorial and save this as
build/tutorial/CheckContract.lean:
import Erlean.Examples.Identity.Contract
open Erlean.Core Erlean.Logic Erlean.Examples
example :
TotalCorrect [importedModule] "identity" "identity"
(fun args => ∃ value, args = [value])
(fun args result => result = .returned args) :=
identity_totalCorrect
#print axioms identity_totalCorrectCheck it with:
lake env lean -j1 -M2048 build/tutorial/CheckContract.leanThe precondition requires exactly one argument. The postcondition says that the returned values equal the arguments; total correctness also establishes termination in the modeled semantics. See the identity contract for the underlying execution proof. The axiom audit lets you inspect the theorem's logical dependencies.
With the pinned OTP installed, export a fresh copy into an ignored build directory:
asdf exec escript tools/export_core.escript tests/fixtures/erlang/identity.erl build/tutorial/identity
.lake/build/bin/erlean inspect build/tutorial/identity/core.json
.lake/build/bin/erlean run build/tutorial/identity/core.json answer '[]'
.lake/build/bin/erlean emit build/tutorial/identity/core.json importedTutorialModule > build/tutorial/ImportedTutorial.lean
lake env lean -j1 -M2048 build/tutorial/ImportedTutorial.leanThe exporter writes core.json, manifest.json, and inventory.json. The manifest
records OTP 29.0.6, source provenance, and the compiler options
[to_core, binary, no_copt, deterministic, return_errors, return_warnings].
Keep these files together. Substitute your own .erl path to try another module;
unsupported constructs are reported, and partially lowered modules cannot be
executed or emitted. Includes, parse transforms, and external dependencies need
additional provenance beyond this fixture profile.
emit creates an auditable syntax literal, not a correctness proof. Proving your
own module requires an explicit input contract, postcondition, and proof about
that literal in the implemented semantics. Start with
the identity contract and
the existing example proofs. The source compiler and importer
remain outside the verified trust boundary. The
import notes also describe the Elixir and Gleam adapters.
For reusable map-call contracts, import Erlean.Semantics.Maps. These rules keep
canonical public-map and supported-key assumptions explicit. To compose a private
calculation with a caller continuation, import Erlean.Logic.Frames and use
reachesBoundary_appendStack on a checked execution prefix. The rule stops before
the boundary transition. It does not preserve arbitrary halts or change the linked
code world. Helpers do not need new runtime exports for proof composition.
Import Erlean.Logic.Step for erlean_step [contracts], a tactic for one local
transition equation. It uses checked equations and a finite rewrite list, not
native evaluation. It rejects remaining execution-relation goals and does not
choose fuel or unfold symbolic BIF implementations by default.
Supply each dependency explicitly for a linked call:
.lake/build/bin/erlean run-linked modular_client relay '[{"tag":"integer","value":"42"}]' tests/fixtures/erlang/modular_client/core.json tests/fixtures/erlang/identity/core.jsonThis returns 42; duplicate module names are rejected. To record and replay the
restricted actor request/reply example:
mkdir -p build/tutorial
.lake/build/bin/erlean actor-run tests/fixtures/erlang/actor_protocol/core.json exchange '[{"tag":"atom","value":"hello"}]' build/tutorial/exchange.json
.lake/build/bin/erlean actor-replay tests/fixtures/erlang/actor_protocol/core.json exchange '[{"tag":"atom","value":"hello"}]' build/tutorial/exchange.jsonThe debugging scheduler is bounded; replay validates one schedule, not all possible schedules. The protocol's separate arbitrary-schedule safety theorem does not establish eventual delivery or termination. See the design document for actor lifecycle and timing restrictions.
After building and installing all pinned source-language toolchains:
node tools/check_all.mjsTesting supplements the Lean proofs and does not establish compiler correctness or full OTP compatibility.
GitHub Actions CI runs the serialized build and full
suite on pull requests and pushes to main, using the pinned Lean toolchain and
asdf-managed source-language compilers. Lean build outputs are not cached, so
each run checks the proofs from source.
Explore list reversal, cross-module contracts, actor protocol safety, and Dijkstra shortest paths. Each example states its own input domain and semantic assumptions.
Implementation coverage and the next work items are tracked in the design document.
Builds are serialized by the build script, and each Lean process is limited to one thread and 2 GiB. Run only one verification suite at a time; subagents must not launch overlapping builds. The full suite also limits Erlang scheduler counts.