LeanElpi

Sharing types between Lean and λProlog

A λProlog program that cannot talk about your data is a toy. This chapter shows how types, functions and whole inductive declarations move between Lean and ELPI without hand-written glue.

It assumes the previous chapter, Using it inside Lean. Every example here is a test in the repository, so the output shown is real.

The problem

Lean and ELPI describe data differently. Lean has dependent types, ELPI has simple types with type variables. Lean has inductive Tree (α : Type), ELPI has kind tree type -> type. In the first release you could pass a Nat or a List Nat across the boundary, derive the ELPI declarations of a simple Lean type, and call MetaM from ELPI. Three things were missing:

Coq-Elpi, the λProlog layer of the Coq proof assistant, solved similar problems with one idea: declarations are data, and host functions are typed predicates. LeanElpi now has the same, adapted to Lean.

What crosses

A type crosses the boundary when ELPI's simple types can express it. We call that plain data: an inductive type or structure whose parameters are Type, with no indices, and whose fields are plain data too.

LeanELPI
Nat, Int, String, Bool, Floatint, int, string, bool, float
List α, Option α, α × βlist A, option A, pair A B
Lean.Expr, Lean.Nameterm, name
inductive Tree (α : Type)kind tree type -> type.

Indexed families such as Vec α n and proofs do not cross as data. Reflect them as term, or use the LF layer.

From Lean to ELPI: deriving

Add deriving Elpi to a type and you get the ELPI declarations and the conversion functions. Parameters and recursion work:

inductive Tree (α : Type) where
  | leaf
  | node (l : Tree α) (a : α) (r : Tree α)
  deriving Elpi

#elpi_signature Tree

The infoview shows what ELPI now knows about the type:

kind tree type -> type.
type leaf tree A.
type node tree A -> A -> tree A -> tree A.

The Lean parameter α became the ELPI type variable A. Variables are named by position, A then B, because ELPI type variables must be capitalised. The instances [ToElpi α] → ToElpi (Tree α) and [FromElpi α] → FromElpi (Tree α) are generated too, so a Tree Nat goes to ELPI and a Tree Nat comes back:

#eval (fromElpi (toElpi (Tree.node .leaf 3 .leaf)) : Except DecodeError (Tree Nat)) |>.isOk

Decoding is checked. A Tree String decoded at Tree Nat is a DecodeError, not a crash.

From ELPI to Lean: elpi_data

Sometimes the type belongs to the logic program. Write it there, and Lean gets the inductive for free:

elpi_data lp%{
  kind shape type.
  type circle int -> shape.
  type square int -> shape.
  type beside shape -> shape -> shape.
}%

This defines inductive Shape with three constructors and deriving Elpi, so the ELPI declarations are the ones you wrote. Now a program can compute with shapes and Lean can read the answers at the right type:

elpi_program areas
elpi_accumulate areas lp%{
  accumulate lean.
  pred area i:shape, o:int.
  area (circle R) A :- A is 3 * R * R.
  area (square S) A :- A is S * S.
  area (beside X Y) A :- area X A1, area Y A2, A is A1 + A2.

  pred mirror i:shape, o:shape.
  mirror (beside X Y) (beside Y1 X1) :- !, mirror X X1, mirror Y Y1.
  mirror S S.
}%

#eval show MetaM _ from do
  let m ← Elpi.query areas "mirror (beside (circle 1) (square 2)) M"
  return m.head?.bind fun | .ok sol => (sol.get? "M" : Option Shape) | _ => none

The result is some (Shape.beside (Shape.square 2) (Shape.circle 1)), an ordinary Lean value. A kind with the single constructor point.mk becomes a Lean structure.

Some ELPI types have no Lean counterpart, and elpi_data says so instead of guessing. A constructor such as type lam (tm -> tm) -> tm is rejected: a Lean inductive cannot contain that function type. Use term for syntax with binders. Nothing is defined if there is any error.

Lean functions as predicates

elpi_extern turns a typed Lean function into an ELPI predicate. The ELPI signature is worked out from the Lean type:

def twice (n : Nat) : Nat := 2 * n
elpi_extern twice                          -- pred twice i:int, o:int.

def half (n : Nat) : Option Nat :=
  if n % 2 == 0 then some (n / 2) else none
elpi_extern partial half                   -- fails when the result is none

def divisors (n : Nat) : List Nat :=
  (List.range (n + 1)).filter fun d => d != 0 && n % d == 0
elpi_extern many divisors as "divisor"     -- one solution per element

The three forms match the three kinds of predicate. A total function has one solution, a partial function has at most one, and a multi-valued function has many, in order:

#elpi myprog lp%{ twice 21 X }%          -- X = 42
#elpi myprog lp%{ half 7 X }%            -- no solution
#elpi myprog lp%{ divisor 12 D }%        -- D = 1, then 2, 3, 4, 6, 12

Arguments can be any plain type, including your own derived types. def trSize (t : Tr Nat) : Nat becomes a predicate whose first argument has the ELPI type tr int. Because the signature is declared, ELPI's type checker rejects twice "x" X when the program is loaded.

Reading and writing inductive types from ELPI

Everything so far moves values. Sometimes a program should create a type. Two builtins do that:

Here is a command that copies an inductive type under a new name. It is about as long as the sentence describing it:

elpi_program cloner
elpi_accumulate cloner lp%{
  accumulate lean.
  pred main i:list argument.
  pred rename-ctor i:string, i:string, i:lean.ctor, o:lean.ctor.
  rename-ctor From To (lean.ctor CN T) (lean.ctor CN1 T1) :-
    lean.name->string CN CS,
    L is size From, N is size CS, M is N - L, Rest is substring CS L M,
    CS1 is To ^ Rest, lean.string->name CS1 CN1, copy T T1.
  main [str From, str To] :-
    lean.string->name From FN, lean.string->name To TN,
    lean.env.indt FN (lean.indt _ NP NI Ty Cs),
    ( pi L\ copy (const FN L) (const TN L) ) => (
      copy Ty Ty1, std.map Cs (rename-ctor From To) Cs1 ),
    lean.add_inductive (lean.indt TN NP NI Ty1 Cs1).
}%
elpi_command clone := cloner
elpi_export clone

clone "Option" "MyOption"

def MyOption.get (d : Nat) : MyOption Nat → Nat
  | .none => d
  | .some x => x

example : (MyOption.some 3).get 0 = 3 := rfl

MyOption supports pattern matching and rfl proofs because lean.add_inductive builds the same auxiliary definitions as the inductive command. Like lean.add_decl, it works only under commands: a tactic that tries it fails with an error and the environment stays unchanged. And the kernel still checks everything, so a wrongly built inductive is rejected whatever your ELPI program believed.

This is the Lean version of Coq-Elpi's coq.env.indt and coq.env.add-indt, the pair that the whole derive family of Coq tools is written with. With them, deriving a map function or a decidable equality for a type can be written as a short λProlog program.

What we learned from Coq-Elpi

Coq-Elpi has had ten years of use, and four of its lessons were worth copying:

One lesson did not transfer. Coq-Elpi shares the whole term language of Coq between host and logic program, because for Coq users the terms are the data. Lean programs are full of data that is not an Expr, so LeanElpi needs a second path next to reflection, which is plain derived data.

Limits

Next

The LF chapter covers the dependent side that this chapter deliberately leaves out, and the design paper has the full reasoning.