Build a precise contract for a small memory allocator and use an agent to help
construct proofs of that contract. This project is about allocation invariants,
freshness, failure, and preservation of unrelated state.
Allocator/Machine.lean: 64-bit words, byte memory, and half-open blocks.
Allocator/Basic.lean: an executable bump allocator, an independent step
relation, a checked initialization lemma, and a checked payload-frame lemma.
Allocator/Exercises.lean: precise propositions for students to prove.
Checks.lean: regression checks for representative executions and boundaries.
The universal exercise propositions are not yet proved. Defining a proposition
is not proving it, and a successful starter build does not complete the project.
There are no proof placeholders or assumed solutions in the supplied library.
Build and explore
From this repository's root:
cd projects/allocator
lake build
Open this directory as a project in your Lean editor. It has its own pinned
toolchain, Lean 4.27.0.
lake build checks both the library and every #guard in Checks.lean.
The examples check invalid sizes, exhaustion, exact fits, sequential allocations,
payload preservation, invalid initial memory, and the top of the address space.
They supplement the proof work; they do not replace it.
Machine.lean: Words, Blocks, and Backing Memory
Start with Allocator/Machine.lean. This module defines the vocabulary used by the allocator.
It describes addresses and storage; it does not execute instructions or allocate blocks.
All its definitions live in the namespace Course.Allocator.
Word is a 64-bit bitvector. A pointer or request represented by a Word has an unsigned
value between zero and 2^64 - 1, obtained with .toNat. The address-space limit itself
is a Nat: it is one past the greatest representable pointer. wordBytes is eight because
one 64-bit word occupies eight bytes. Addresses and allocation sizes count bytes, not words.
A block describes the half-open interval [start, finish). A block starting at 0x1000
with size eight contains addresses 0x1000 through 0x1007; its end is 0x1008.
The end is computed using natural-number arithmetic, so it cannot silently wrap.
Contains says that a byte address lies in that interval. Disjoint says that one block
ends before or exactly where the other starts. Adjacent blocks are therefore disjoint.
Neither predicate checks that a block is backed by memory or fits the address space:
those requirements appear in the allocator's contracts.
deriving DecidableEq, Repr asks Lean to supply equality comparison and a representation
for displaying values. It does not establish any of the project's correctness properties.
Memory is the backing storage. Array index zero corresponds to address base; index i
corresponds to address base + i. Its interval is [base, base + bytes.size).
UInt8 represents one byte, while Array.size counts how many bytes exist.
The distinction between Block and Memory matters: returning a block does not create
new backing bytes. The allocator must carve every returned block out of the supplied memory.
Basic.lean: State and the Allocation Contract
Allocator/Basic.lean imports the memory vocabulary, then adds allocator state, initialization,
replies, a specification of permitted steps, and an executable allocation function.
Heap.memory is the backing storage, and Heap.next is the next free byte address.
This is a bump allocator: successful allocations advance that frontier, without searching
for gaps or maintaining a free list. Its history is implicit. Bytes before next have been
consumed by earlier allocations; bytes from next to the end remain available.
Validity and Initialization
openCourse.Allocator
The memory model stores bytes at an absolute base address. Blocks describe half-open intervals:
the start belongs to the block, and the end does not. The cursor identifies the next free byte.
The starter defines the heap invariant as follows:
Read the conjunction from top to bottom: base and cursor are aligned, the cursor is not
before the backing storage, the cursor is not past its end, and the backing storage itself
does not cross the address-space limit. Validity is a proposition about a Heap, rather
than an extra field of the structure. Constructing a Heap value does not automatically
prove that it is valid.
The caller supplies a finite byte array at an absolute byte address. init
rejects a misaligned base or backing interval extending beyond 2^64. An empty
backing array is allowed. A valid heap has an eight-byte-aligned cursor between
the base and the end of its backing storage.
some ⟨memory, memory.base⟩ constructs a heap with its cursor at the base; none reports
that initialization rejected the supplied memory. The provided theorem init_valid states
that every heap returned by init satisfies Heap.Valid.
An inductive type describes alternatives. A Reply either contains an allocated Block
or an Error explaining the failure. Clients distinguish these cases by pattern matching.
This keeps an error separate from every successful address, including address zero.
Specifying an Allocation
The allocator's specification is a relation: Step heap request reply next states that a
particular reply and next state are permitted for a request. Requests are 64-bit unsigned
words interpreted as byte counts. We define the permitted behavior before considering a
function that computes an allocation:
Each constructor is a rule. Its named arguments are the premises needed to justify the
conclusion after the colon. For example, success requires a nonzero, aligned request that
fits in the remaining storage. The rule then fixes the returned block and the updated cursor.
The error rules specify the priority of failures: a zero request is rejected first, then
an unaligned request, then a request that does not fit. Each error returns the original heap.
These requirements belong to the specification; an implementation must respect them.
The relation describes the allowed behavior without calling an allocation function. We can
use it to judge different implementations of the same interface.
Executing an Allocation
We now implement the specification with allocate heap request, a function that computes
a reply and the next heap. Its branches are intended to realize the cases specified by Step:
A zero request returns zeroSize.
A size not divisible by eight returns unalignedSize.
If the whole request fits, return the block starting at the current cursor
and advance the cursor by exactly the requested size.
The result type Reply × Heap is a pair: .1 selects the reply and .2 selects the
next state. { heap with next := ... } constructs a state with an updated cursor and
the same memory. Returning heap on an error leaves the entire state unchanged.
Every error preserves the whole input heap. Every successful allocation preserves
all backing bytes. Allocation grants a logical block; it does not initialize its
contents or modify hardware permissions. Address zero is a legitimate address,
so failure has a separate constructor, not a null-pointer encoding.
The API has a valid-heap precondition for the safety results. Heap fields remain
visible so students can state and reason about invalid representations; arbitrary
hand-constructed heaps are not guaranteed to be safe. init_valid establishes
the invariant at the entry point, and preservation must establish it after calls.
The heap cursor is a Nat, rather than a machine word. It can equal 2^64 as
a one-past-the-end sentinel. A successful positive request must start strictly
below that limit: proving that fact is necessary before treating BitVec.ofNat
as an exact pointer conversion. Bounds arithmetic uses Nat to check for
overflow instead of allowing machine addition to wrap silently.
Our correctness goal is to show that this implementation agrees with the specification.
Soundness says that, for a valid input heap, the function's computed result satisfies Step.
Completeness says that every result permitted by Step is the result the function computes.
Together they establish equivalence between the specification and the executable result.
We will explore two ways to establish validity preservation: reason directly about the
function, or prove that the specification preserves validity and transfer that guarantee
to a sound implementation.
The module ends with two supplied proofs: init_valid establishes validity after initialization,
and allocate_memory establishes that allocation preserves the backing memory. The latter
is used below. The remaining project obligations are stated in Allocator/Exercises.lean.
Try Some Calls
The following examples are checked as part of this page. This heap has 24 bytes, initially
filled with 0xA5. One allocation consumes the first eight bytes; the next consumes the
remaining sixteen. A further request fails and preserves the full state:
Inspecting the proof states can help distinguish what computation establishes in the concrete
examples from what the frame theorem establishes universally.
Work in stages
The project states proof goals as propositions. For example, invariant preservation is exactly:
This definition states the obligation; it does not prove preservation. Your task is to
prove it first by examining the branches of allocate, without using Step or a theorem
relating the function to Step.
Then establish the corresponding property of the specification itself:
Here the proof proceeds by cases on the derivation of Step. Error rules leave the heap
unchanged. The success rule supplies alignment and room premises from which the next state's
validity must follow. This proof should reason about the specification without unfolding
allocate or using an equivalence theorem.
The connection between specification and implementation can be stated as an equivalence:
Prove this implication using only the two hypotheses and the definitions of the three
propositions. Then apply it to your soundness and specification-preservation theorems to
obtain a second proof of Preservation. Soundness suffices for this transfer: every computed
result is a permitted step, and every permitted step from a valid heap preserves validity.
Completeness establishes that the function realizes all permitted behavior; it is not
needed to conclude that the behavior it actually computes is safe.
Compare the dependencies of your two preservation proofs. The direct proof follows the
implementation's branches; the specification-based proof separates the safety argument
from the implementation's conformance argument. This separation becomes useful when an
implementation changes but the specification stays fixed.
Preservation Alone Does Not Specify an Allocator
Consider an implementation that refuses every request and leaves the heap unchanged:
This function preserves validity perfectly. It also preserves every byte and every previously
allocated block. Yet it never allocates anything, even when a valid request fits. Those safety
properties alone do not establish that it implements an allocator.
Soundness with respect to Step rules out this behavior. For example, our initial heap has
room for an eight-byte request, so the specification does not permit an out-of-memory reply:
Validity preservation constrains the next state. Soundness constrains the whole transition:
the returned block or error, its relationship to the request, and the next heap. Because this
specification requires success whenever a positive, aligned request fits, an implementation
cannot satisfy soundness by simply refusing to work. Together with StepPreservation,
soundness implies validity preservation; the counterexample shows that the converse fails.
This distinction depends on what the specification permits. If we added a rule allowing an
arbitrary request to fail without changing state, refusing every request could be sound for
that weaker specification. Mechanical checking establishes conformance to the stated contract;
we must still ensure that the contract requires the useful behavior we intend.
Proof Tasks
Read the model. Explain what each invariant conjunct rules out. Predict
the checks before executing them. Explain why returning the empty block for
every request would fail the success contract.
Prove preservation directly. Prove Exercises.Preservation from the definition
of allocate, without relating it to Step.
Establish safety of the specification. Prove Exercises.StepPreservation by
cases on Step, without referring to allocate.
Connect function and relation. Prove Exercises.Soundness and
Exercises.Completeness, then derive Exercises.Equivalence and determinism of Step.
Prove Exercises.PreservationViaSpec and use it to obtain a second preservation proof.
Explain why completeness is unnecessary for the preservation transfer, and why an allocator
that always refuses requests can preserve validity without satisfying soundness.
Establish the remaining contracts. Prove Exercises.FailureAtomicity and
Exercises.SuccessContract. In particular,
establish alignment, positive size, exact pointer conversion, containment,
and cursor advancement. Do not assume these conclusions as hypotheses.
Compose calls. Prove Exercises.Freshness and
Exercises.ConsecutiveDisjointness. Generalize to a trace with a history of
returned blocks: all successful allocations are pairwise disjoint, errors
leave the history unchanged, and old blocks stay within the backing interval.
Review the frame. Read the supplied allocate_memory proof and explain
why it implies preservation of previously allocated contents. State what
additional proof would be needed for an allocator that clears new blocks.
Replace the implementation. Implement a second version that compares the
request against remaining capacity, memory.finish - next, before advancing
the cursor. Prove agreement with the reference on valid heaps and transfer
the contracts. Identify which parts of the specification-based safety argument
can be reused. Explain why the validity precondition matters when subtraction
on natural numbers truncates at zero.
Create Allocator/Solution.lean, import Allocator.Exercises, and write proofs
such as theorem preservation : Exercises.Preservation := by .... Import your
solution from Checks.lean so the ordinary build checks it. Keep the supplied
definitions and proposition statements fixed. Submit the implementation and
proofs together with a short account of the specification, assumptions, trusted
boundary, and your use of an agent.
Start by asking the agent to explain a theorem and propose a proof plan. Then
have it attempt one lemma at a time, inspect Lean's feedback, and explain the
finished argument. Audit transitive assumptions; removing visible placeholders
alone does not establish that a proof is complete. The starter's
Audit.lean demonstrates #print axioms for the two supplied theorems:
lake env lean Audit.lean
Add the names of your own theorems to that audit. Do not add axioms or use
sorry/admit to discharge project obligations. If a proof seems to require
changing a contract, first explain the proposed change and obtain instructor
agreement. Retain tests for boundary cases even after the universal proofs pass.
Deliberate limits and extensions
This first allocator only advances a cursor. There is no deallocation, reuse,
rounding of request sizes, concurrency, or persistence. Ownership is represented
by non-overlapping returned intervals; it is not dynamically enforced on arbitrary
memory accesses. Fresh storage is not necessarily zeroed.
Possible later tasks are to accept arbitrary sizes with checked alignment
rounding; add byte operations with a frame theorem; introduce a free list and
prove reuse safety; or lower a routine to a small instruction set. Those are
extensions, not prerequisites for completing the initial project. Deallocation
also raises a new problem: disjointness of live blocks does not alone prevent a
client from using a stale reference.