Graph search and shortest path problems arise in a wide range of applications, from routing and scheduling to dependency analysis and network optimisation. They make excellent specification examples because they involve several interacting concepts — nodes, directed edges, connectivity, path validity — and because getting the specification right is non-trivial: a naive first attempt turns out to be unimplementable, a second attempt admits a trivially wrong implementation, and a correct specification requires the disjoint sum type to cleanly separate the “path found” and “no path exists” cases. This example also introduces non-empty lists as a structure with an invariant field, and illustrates the generalDocumentation Index
Fetch the complete documentation index at: https://mintlify.com/paulch42/lean-spec/llms.txt
Use this file to discover all available pages before exploring further.
inv naming convention for type-level constraints.
Non-Empty Lists
Before defining graphs and paths, we need a type that guarantees a list is non-empty. A path through a graph must contain at least one edge, so an ordinaryList is inadequate. A natural model is:
List₁ α is a pair of a core Lean list and a proof that the list is non-empty. The field inv is not data — it carries no computational content — but it is a constraint on the list field. The empty list is excluded from the type because [] ≠ [] is a false proposition and can never be proved.
This illustrates a powerful feature of Lean structures that distinguishes them from records in most traditional languages: a field can be a proposition that restricts which values the other fields may take. The convention adopted throughout this example is that fields named inv are invariant fields — they specify constraints that any instance of the type must satisfy, and contain no data themselves.
We can define a HasSubset instance for List₁ by delegating to the underlying list:
Option):
List₁.first matches on the anonymous constructor ⟨a::_, _⟩, extracting the head of the list (the _ ignores the tail and the inv proof). List₁.last recurses: a singleton list [a] has last element a; for a longer list _::a::as, the last element is the last element of the non-empty tail a::as.
Graphs
Nodes
All we need from a node is the ability to distinguish it from other nodes, soString is sufficient:
Edges
An edge is directed — it has a start node, an end node, and a cost to traverse it:deriving DecidableEq clause gives us decidable equality on Edge, which is needed for the path invariant. Edges are directed: traversing from starts to ends has cost, but the reverse direction is a different edge (or may not exist at all).
Graphs
A graph is simply a set of edges:Paths
With edges and graphs defined, we can describe a path.Path is dependent on Graph: the type of paths over graph g is Path g, and a path over one graph is not a path over another.
pathis a non-empty list of edges that constitutes the path.invis the conjunction of two invariant conditions:inv.left: every edge in the path belongs to the graphg.inv.right: consecutive edges are connected — theendsnode of each edge equals thestartsnode of the next. (This usesList.Chain'from std4.)
inv field is named according to the convention: it captures constraints, holds no data, and restricts which path values are valid.
Where there are multiple constraints, they are combined into a single inv field using conjunction (∧). This keeps the structure clean and means that any instance of Path g is structurally guaranteed to be a valid path over g.
Path Start and End Nodes
Two convenience functions extract the start and end nodes of a path:p.path is a List₁, the first and last functions are total.
An Example Path
Here is a concrete path overexampleGraph:
by simp discharges the non-emptiness proof automatically. The sorry stands in for the conjunction of the two invariant proofs (membership in the graph and chain connectivity). When specifying rather than implementing, sorry is a useful placeholder for proof obligations that are not the focus of attention. A fully verified implementation would replace it with an actual proof.
Graph Search
FindPath₁ — Unimplementable
A natural first attempt at specifying graph search is:g and nodes s and e, return a path over g from s to e.
FindPath₂ — Trivially Implementable in the Wrong Way
Wrapping the result inOption appears to fix the problem:
none to be returned when no path exists. However, it overcorrects:
FindPath₂ admits a trivially correct but useless implementation. The function that always returns none satisfies the specification:FindPath₂. The specification does not distinguish between “no path exists” and “I didn’t bother to look”.FindPath₂ makes no distinction between the two possible outcomes. A correct specification must require that none is returned only when no path exists — that is, none must be accompanied by proof that no path exists.
IsPath — Naming the Concept
Before fixing the specification, it is useful to give the concept of “a path froms to e over g” its own name:
IsPath g s e is the type of paths from s to e over g. This is the same type as in FindPath₁, simply renamed to reflect its semantic role.
FindPath₃ — The Correct Specification with Disjoint Sum
To correctly handle both outcomes we need a type that either contains a path or contains evidence that no path exists. At the propositional level, the negation of a propositionP is ¬ P, which is definitionally equal to P → False. At the type level, the analogous statement for a type T is T → Empty. This motivates the final specification:
⊕ is the disjoint sum (also called Sum), which packages exactly one of two alternatives:
- Left injection (
Sum.inl): a value of typeIsPath g s e, i.e. an actual path fromstoe. - Right injection (
Sum.inr): a value of typeIsPath g s e → Empty, i.e. a proof that no such path exists (a function that would produce an element of the empty typeEmptyfrom any hypothetical path — which is impossible unless no such path exists).
none escape hatch from FindPath₂ is closed.
Shortest Path
Path Cost
The cost of a path is the sum of the costs of its constituent edges:ShortestPath
Finding the shortest path is an optimisation problem, and follows the same pattern as the Knapsack specification:s to e over g — of type IsPath g s e — such that every other path from s to e costs at least as much.
The key difference from the FindPath specifications is the precondition (_ : IsPath g s e). By requiring the caller to supply evidence that at least one path from s to e exists, the disjoint sum from FindPath₃ is avoided: we know a solution exists, so the specification simply asks for the best one. The anonymous argument name _ signals that the precondition value is required for type-checking but is not referenced by name in the body.
This precondition pattern — (_ : T) — is a common idiom for specifying functions that are only meaningful under certain conditions, without exposing the proof as a named variable.
Summary
This example has introduced several important patterns:- Invariant fields named
inv:structurefields that are propositions restricting the values of data fields. They carry no runtime content but enforce type-level correctness guarantees. - Dependent types:
Path gdepends ong : Graph, ensuring that path membership and connectivity are always checked relative to a specific graph. - Disjoint sums for fallible search:
T ⊕ (T → Empty)cleanly separates the “found” and “provably not found” cases, ruling out both unimplementable all-success specifications and trivially satisfiable all-failure implementations. - Preconditions that unlock simpler specifications: supplying
(_ : IsPath g s e)as an argument allowsShortestPathto use a simple subtype rather than a disjoint sum.
Exercises
Exercises
-
Modify the specification of
ShortestPathsuch that it does not assume the existence of a path. - Specify the property that a graph has no edges with identical start and end nodes.
- Specify the property that a graph is acyclic.
- Define non-empty lists as a subtype instead of a structure, and modify the remainder of the specification as necessary.