Haskell implementation of Bob Harper, Furio Honsell, and Gordon Plotkin's Edinburgh Logical Framework which captures first-order dependent types. It is STLC, but with a generalised Pi type, type-level application of terms, and type families. Dependent type machinery isn't useful on its own so we add Nats, and size-dependent Vectors of Nats to demonstrate how to instantiate types based on the size of their terms. We do not want to conflate LF with System F, and so do not include term-level types or polymorphism.
This strongly normalising calculus works on the notion of terms that depend on types under the banner of propositions as types. Dependent types allow us to encode predicate logic in a language with programs as witness or proof of propositions.
You need Haskell, this compiles with GHC 8.2.2 at least (Stack resolver: lts-11.0).
You can use cabal to build and run this, see this README, alternatively you can use vanilla ghc to build:
To compile and run do:
ghc -O2 -o lf Main
then run ./lf
Alternatively to use the GHCi Interpreter do:
ghci Main
then type main
In either case you get something like the following:
Welcome to the LF REPL
Type some terms or press Enter to leave.
>
Note: When run in GHCi, you don't have the luxury of escaped characters, backspace, delete etc... Compile it using GHC if you need this.
Where you can then have some fun, try these examples:
\x:Nat.x
the identity function on Nats.nil
the empty list of typeVec Nat 0
cons 0 1 nil
the singleton list of typeVec Nat 1
cons 1 42 (cons 0 43 nil)
a two element list.(\n:Nat.cons n) 2 42
a function that takes a number and instantiatescons 2
with typeNat->Vec Nat 2->Vec Nat (succ 2)
. 42 is then applied to return a term of typeVec Nat (succ 2)
(\n:Nat. cons (succ n)) 0 42 (cons 0 9001 nil)
Similar to the above but a two element set with 42 and 9001 in it.\x:Pi n:Nat.Nat.x
\x:(\n:Nat.Vec Nat n) 0.x
type-level abstraction to make clear that Pi types operate on terms from the term level, and abstractions operate on type level terms.
Note: Π
is the dependent product type and Πx:A.B
is the same as A -> B
when x
does not occur in B
. Π
is alternatively typed as Pi
, respectively.
The parser is smart enough to recognise λ, Π , Nat, and Vec; so you can copy and paste from the output:
> \x:Nat.x
= λx:Nat.x
> (\x:Vec Nat 1.x) (cons 0 42 nil)
~>* cons 0 42 nil
>
denotes the REPL waiting for input, =
means no reductions occurred (it's the same term), ~>
denotes one reduction, and ~>*
denotes 0 or more reductions (although in practice this is 1 or more due to =
).
There is also a reduction tracer, which should print each reduction step. prefix any string with '
in order to see the reductions:
> '(\n:Nat.\x:Nat.cons (succ n) x) 0 42 (cons 0 42 nil)
~> (λx:Nat.cons (succ 0) x) 42 (cons 0 42 nil)
~> cons (succ 0) 42 (cons 0 42 nil)
~> cons 1 42 (cons 0 42 nil)
Note: succ n
stands for the succ
essor of n
, Peano style.
There is also a typing mechanism, which should display the type or fail as usual.
> (\x:Vec Nat 4.x) nil
Cannot Type Term: (λx:Vec Nat 4.x) nil
> (\x:Vec Nat 0.x) nil
~>* nil
Note: if you provide a non-normalizing term, the type checker will fail and reduction will not occur.
You can save terms for the life of the program with a let
expression. Any time a saved variable appears in a term, it will be substituted for the saved term:
> let singleton = \x:Nat.cons 0 x nil
Saved term: λx:Nat.cons 0 x nil
> singleton 2
~>* cons 0 2 nil
Note: Consequently let
and =
are keywords, and so you cannot name variables with these. Additionally Nat
, succ
, cons
, nil
, and Pi
are keywords in LF.
Note: We include introduction rules for Nats to show type-level term substitution works. We do not include Nat eliminators or any notion of type-based polymorphism however as the focus is on dependent types. Use the calculus with this in mind, like the next example.
Additionally we have type level lett
statements that allow you to define and use types:
> lett VECZ = Vec Nat 0
Saved type: Vec Nat 0
> (\x:VECZ.x) nil
~>* nil
Submit a PR with eliminators for Vectors and Natural numbers, or perhaps some machinery to introduce term constructors of a given type.
This makes it easier to define both terms and types, but does not allow type-level application (See Omega) or type-based polymorphism (see SystemF). lett
is also a keyword.
Note: we do not allow the ability to add types, constructors, or type families (something like assume (Bool::*) (True:Bool)
) as this would allow us to encode term-level types which is the remit of FOmega, and indeed may allow us to encode Girard's Paradox.
We base the language on the BNF for the typed calculus:
However we adopt standard bracketing conventions to eliminate ambiguity in the parser. Concretely, the parser implements the non-ambiguous grammar for terms as follows:
and types:
Some notes about the syntax:
- The above syntax only covers the core calculus, and not the repl extensions (such as let bindings above). The extensions are simply added on in the repl.
- Term variables are strings (excluding numbers), as this is isomorphic to a whiteboard treatment and hence the most familiar. Term variables are lower case.
- Terms are variables, functions
\x:Nat.x
, applicationsa b
(wherea
andb
are in scope), empty listsnil
, non-empty listscons 0 42 nil
which is a singleton list containing 42 of typeVec Nat 1
, or a non-negative natural number0,1,2,3,...
. - Types are dependent abstractions
Pi n:Nat.T
, type-level abstractions\x:Nat.x
applicationsA t
(wheret
is a term), arrowsA -> B
(which is the same as aPi
type where the variable doesn't occur inB
),Nat
s, or size bounded vectorsVec Nat t
wheret
is a term of typeNat
. - kinds are not part of the syntax, but play a role in typing. See
K
. - Type arrows associate to the right so that
X -> Y -> Z
is the same asX -> (Y -> Z)
but not((X -> Y) -> Z)
. - The Pi type binds weaker than arrows, so
Pi x:Nat. Nat->Nat
is the same asPi x:Nat. (Nat->Nat)
. - Nested terms don't need brackets:
\x:Nat.\y:Nat. y
unless enforcing application on the right. Whitespace does not matter(\x:Nat. x)
unless it is between application where you need at least one space. Like term-level functions, Pi must be put in brackets when applied to a term:\x:(Pi n:Nat.Vec Nat n) 0.x
. - To quit use
Ctrl+C
or whatever your machine uses to interrupt computations.
The semantics implements beta-reduction on terms and typed definitional equality as the Eq
instance of T
. The term semantics are the same as STLC with the addition of dependent abstraction of types over terms, term-dependent vectors and natural numbers. We reformulate the semantics as typing judgements:
Firstly, we have the standard rule for variables augmented with kinding information (variables must be of proper type):
For term abstractions we replace ->
with a dependent Π
type, which allows the type on the right-hand side of the type to depend on the value passed on the left (Πn:Nat.Vec Nat n
for instance). When the right-hand side does not use the variable Πx:Nat.Nat
, we pretty print it as Nat -> Nat
(where Π_:A.B
= A -> B
):
For term applications, we can now instantiate a type with a term provided to the application. The left-hand Π
type must be parametrized by the same type as the value on the right-hand side of the application, and the value can then be substituted into the type. For instance in (\n:Nat.cons n) 2 42
, n
and 2
are of the same type and so the expression has type Vec Nat 2->Vec Nat 3
(see the typing rules below for nats.)
We have the standard beta reduction from the untyped calculus at the term-level:
We have standard typing rules for Nats (with standard reduction rules under terms omitted):
or put another way (see typeof
in LF.hs):
where Π _:Nat.Nat
is pretty printed as Nat -> Nat
since _
does not occur in the right-hand side of the type.
Similarly we have typing rules for size-bounded Vectors. Since this is the first-order dependent types, we do not have type abstraction as the intention of this language is to show off dependent types in action. As a result, Vector types are parametrised only by Nats:
We now have term-dependent abstraction (Pi type), application, and type-constants (like Nat) at the type level with 'types of types' known as kinds and type families. Type families are kinds indexed by a term, which is also indexing types of this kind as a result of the term application rule above. Note: kinds do not appear in the syntax but aid-typing.
We have kind variables:
We have Pi
abstractions of proper type:
We have type-level abstractions:
And kinding rules for type-level application:
Nat
is a proper type *
, and cons
, succ
make use of the typing rules, and kinding rules as necessary.
- This means the typing context now also contains types, and terms occur in types. The phrase
X :: K
means type X is has kind K. We do not implement Agda style type hierarchies here. - There is the additional constraint that any term abstractions must take a term of proper type. This prevents nonsensical or partially-applied types terms such as
Vec Nat
. - This reflects the principle that any typeable term has a kindable type.
- The base calculus on its own does not have any proper types (such as Nat, Bool or Unit), or any dependent types, and so it is unusable as no terms can be introduced. To demonstrate dependent types, we add the proper type
Nat
, with constructorssucc
and0,1,2,3
as well as the typeVec
withnil
andcons
as constructors. - Type-level application is identical to term-level application in STLC, except it makes uses of dependent typing from terms to types. In applying a term
t
of typeT
to a termf
of typePi x:T.T2
we substitutet
intoT2
whereverx
is bound. - Arrows/Pi types have the constraint that both the domain and co-domain must be proper types. This prevents non-nonsensical types such as
Pair Nat _ -> Nat
. - LF provides a mean to define types from terms. By using abstraction and application to form types from terms. Type operators themselves do not define terms (there is no term of type
\x:Nat.Vec Nat x
) but are used to form proper types which do have terms:(\x:Nat.Vec Nat x) 0 ~>* Vec Nat 0
which has termnil
. - Unlike type operators, Pi types do have terms, Pi types are introduced by term-level abstractions and their type depends on the term applied to this abstraction. For instance
\x:Nat.cons x 42
has typeΠ x:Nat.Vec Nat x->Vec Nat (succ x)
. - Now that computation occurs at the type level, we can not simply use structural equality on types as we would not be able to equate type-level terms
(\x:Nat.x) 0
and0
. Thus we adopt a kind of computational definitional equality based on typed beta-reduction. That is two types are the same if they are the structurally the same with equivalent typeable terms after reduction (with alpha-equivalent normal forms wherever terms appear). Likewise typeable terms are equivalent if they match in structure after normalisation with equivalent types according to the first criteria. This mutually recursive definition is normalising according to arguments that always reduce in size. - This implementation follows a small-step operational semantics and Berendregt's variable convention (see
substitution
in LF.hs). - Reductions include the one-step reduction (see
reduce1
in LF.hs), the many-step reduction (seereduce
in LF.hs). Additionally there is a one-step type-level reduction (seereduce1T
in LF.hs).
- LF.hs contains the Haskell implementation of the calculus, including substitution, reduction, and other useful things.
- Parser.hs contains the monadic parser combinators needed to parse input strings into typed-term ASTs for the calculus.
- Repl.hs contains a simple read-eval-print loop which hooks into main, and into the parser.
- Main.hs is needed for GHC to compile without any flags, it also invokes the repl.
For contributions, see the project to-do list or submit a PR with something you think it needs.