Inductive Types

Recursion for inductive types

We illustrate construction of inductive types, and defining functions on them recursively.

We begin with some imports. The import induction.TLImplicits gives the operations to construct inductive types.

import provingground._
import HoTT._
import induction._
import translation._
import TLImplicits._
import shapeless._

We do not define inductive types, but instead define the structure of an inductive type on a given, typically symbolic type.

The inductive structure is defined using a DSL to specify constructors. The Boolean type has constants true and false as constructors. Constructors are obtained using the ::: method on a Constructor pattern, which for constants is essentially the inductive type itself.

val Bool = "Boolean" :: Type
val BoolInd = "true" ::: Bool |: "false" ::: Bool =: Bool

From the inductive structure, we can obtain the introduction rules.

val tt :: ff :: HNil = BoolInd.intros
tt
ff

The most important methods on an inductive structure are the rec method for making recursive definition on the inductive type, and the corresponding method for dependent functions. The rec method takes as arguments the data giving the definition for the various constructors.

BoolInd.rec(Bool)
val recBoolBool = BoolInd.rec(Bool)
recBoolBool.typ

We can define functions recursively using terms obtained from the rec method. In the case of Booleans, the arguments are just the value of the function at true and false. The result is a function f: Bool ->: X for a type X

val not = recBoolBool(ff)(tt)
not(ff)
not(tt)
assert(not(ff) == tt && not(tt) == ff)

We can similarly define the and function by observing that and(true) is the identity and and(false) is the constant false function.

val b = "b" :: Bool
val recBBB = BoolInd.rec(Bool ->: Bool)
recBBB.typ
val and = recBBB(lmbda(b)(b))(lmbda(b)(ff))
and(tt)(tt)
and(tt)(ff)
and(ff)(ff)
and(ff)(tt)
assert(and(tt)(tt)== tt && and(tt)(ff) == ff && and(ff)(tt) == ff && and(ff)(ff) == ff)

The natural numbers Nat are an inductive type with two constructors, zero and succ, of types Nat and Nat ->: Nat, respectively. The method on constructors corresponding to function types we use if -->>:, which is used because the domain of the extension is also the type Nat. Note that extending the constructor by a constant type is very different (as we see with lists below), and a different method is used.

val Nat = "Nat" :: Type
val NatInd = ("0" ::: Nat) |: ("succ" ::: Nat -->>: Nat) =: Nat
val zero :: succ :: HNil = NatInd.intros

To define recursively a function f : Nat ->: X for a type X, the data is

val recNatBool = NatInd.rec(Bool)
recNatBool.typ
val n = "n" :: Nat
val even = recNatBool(tt)(n :-> (b :-> not(b)))
val one = succ(zero)
val two = succ(one)
val three = succ(two)
val four = succ(three)
even(two)
even(three)

A more complicated example is addition of natural numbers.

val recNNN = NatInd.rec(Nat ->: Nat)
recNNN.typ
val m = "m" :: Nat
val addn = "add(n)" :: Nat ->: Nat
val add = recNNN(m :-> m)(n :-> (addn :-> (m :-> (succ(addn(m))) ) ) )
add(two)(one)
assert(add(two)(one) == three)
add(two)(two) == four

Lists of elements of a type A form an inductive type ListA, again with two constructors:

A recursively defined function f to a type X is specified by data:

Note that f(a) does not make sense. Hence a different method, ->>:, is used for such extensions.

val A = "A" :: Type
val ListA = "List(A)" :: Type
val ListAInd = ("nil" ::: ListA) |: ("cons" ::: A ->>: ListA -->>: ListA ) =: ListA
val nil :: cons :: HNil = ListAInd.intros

We can define the size of a list as a natural number recursively.

val recLN = ListAInd.rec(Nat)
recLN.typ
val a = "a" :: A
val l = "l" :: ListA
val size = recLN(zero)(a :-> (l :-> (n :-> (succ(n)))))
size(nil)
size(cons(a)(cons(a)(nil)))

Another interesting inductive type is a binary rooted tree. This is our first description. We define the number of vertices recursively on this.

val T = "Tree" :: Type
val TInd = ("leaf" ::: T) |: ("node" ::: T -->>: T -->>: T) =: T
val leaf :: node :: HNil = TInd.intros
val ttt = node(node(leaf)(node(leaf)(leaf)))(node(leaf)(leaf))
val recTN = TInd.rec(Nat)
recTN.typ
val t1 = "t1" :: T
val t2 = "t2" :: T

val vertices = recTN(one)(t1 :-> (m :->( t2 :-> (n :-> (succ(add(n)(m))  ) ) ) ) )
vertices(ttt)
val nine = succ(add(four)(four))
vertices(ttt) == nine
assert(vertices(ttt) == nine)

We can implement binary trees in another way, which generalizes to binary rooted trees with varying degree. Instead of a pair of trees, a node corresponds to functions from Booleans to binary rooted trees.

This involves more complex constructors, with an additional method -|>:. The data for recursively defining f is also more complex. We define the number of leaves in such a tree recursively.

val BT = "BinTree" :: Type
val BTInd = ("leaf" ::: BT) |: ("node" ::: (Bool -|>: BT) -->>: BT )  =: BT
val bleaf :: bnode :: HNil = BTInd.intros
val recBTN = BTInd.rec(Nat)
recBTN.typ
val f = "f" :: Bool ->: BT
val g = "g" :: Bool ->: Nat
val leaves = recBTN(one)(f :-> (g :-> (add(g(ff))(g(tt))) ))
leaves(bleaf)
val t = bnode(b :-> bleaf)
val recBBT = BoolInd.rec(BT)
recBBT.typ
val ttn = recBBT(bleaf)(t)
val t2n = bnode(ttn)
leaves(t2n)

As some expresssions are very long, we import a method "FansiShow" that prints in a more concise way. In the REPL, this gives coloured output using ANSI strings.

import FansiShow._

We define the double of a number recursively, mainly for use later. Observe the partial simplification.

val recNN = NatInd.rec(Nat)
val double = recNN(zero)(m :-> (n :-> (succ(succ(n)))))
double(two) == four
assert(double(two) == four)
double(succ(n))

All our recursive definitions so far of functions f have ignored n in defining f(succ(n)), and are only in terms of f(n). We see a more complex definition, the sum of numbers up to n. Note that we are defining sumTo(succ(m)) in terms of m and n = sumTo(m), so this is add(succ(m))(n)

val sumTo = recNN(zero)(m :-> (n :-> (add(succ(m))(n))))
sumTo(one)
sumTo(three).fansi
val ten = succ(nine)
sumTo(four) == ten
assert(sumTo(four) == ten)

Inductive definitions

In homotopy type theory, inductive definitions are the analogues of recursive definitions for dependent functions. We see an example of such a definition.

The image is a family V : Nat ->: Type which we can think of as vectors of natural numbers indexed by length. Just like actual vectors, we have nil and cons introduction rules, but here they are purely formal.

val V = "Vec" :: Nat ->: Type
val nilv = "nil" :: V(zero)
val consv = "cons" :: n ~>: (Nat ->: V(n) ->: V(succ(n)))

We have an induction function taking data for the cases and returning a dependent function. This is defined by giving data for cases corresponding to the constructors. Namely to define the dependent function f, we must specify

We define inductively a countdown function, giving the vector counting down from n.

val indNV = NatInd.induc(V)

val v = "v_m" :: V(m)
val countdown = indNV(nilv)(m :~> (v :-> consv(m)(succ(m))(v)) )
countdown(zero)
countdown(one)
countdown(one).fansi
countdown(three).fansi
assert(countdown(three) ==
  consv(two)(three)(
    consv(one)(two)(
      consv(zero)(one)(nilv))))
countdown(zero) == nilv
countdown(nine).fansi

We now illustrate a simple instance of using propositions as proofs. The type family isEven : Nat ->: Type gives a type representing whether a natural number is even. This is an inductive type, but here we simply specify the type by its introduction rules (constructors). Such terms introduced by specifying types are logically axioms.

val isEven = "isEven" :: Nat ->: Type
val zeroEven = "0even" :: isEven(zero)
val plusTwoEven = "_+2even" :: (n ~>: (isEven(n) ->: isEven(succ(succ(n)))))

One can directly see that two and four are even.

val TwoEven = plusTwoEven(zero)(zeroEven)  !: isEven(two)
val FourEven = plusTwoEven(two)(TwoEven) !: isEven(four)

Here is a simple proof by induction. We prove the statement that the double of every natural number is even. The induc method gives a dependent function, which takes the base case and the induction step as arguments. The base case is inhabited by the constructor of type isEven(zero). The induction step for n is a term of type isEven(double(succ(n))) as a function of n and the induction hypothesis. Note that the induction hypothesis is a term of type isEven(double(n)).

val thmDoubleEven = n ~>: isEven(double(n))
val hyp = "isEven(double(n))" :: isEven(double(n))
val inducDoubleEven = NatInd.induc(n :-> isEven(double(n)))
val pfDoubleEven =
  inducDoubleEven(
    zeroEven){
      n :~> (
        hyp :-> (
          plusTwoEven(double(n))(hyp)
          )
          )
    }  !: thmDoubleEven

We next prove a more interesting statement, namely that for any natural number n, one of n and n+1 is even.

val succEven = n :-> (isEven(n) || isEven(succ(n)))
val base = succEven(zero).incl1(zeroEven) !: succEven(zero)
val thmSuccEven = n ~>: (succEven(n))
val hyp1 = "n-is-Even" :: isEven(n)
val hyp2 = "(n+1)-is-Even" :: isEven(succ(n))
val step = (succEven(n).rec(succEven(succ(n)))){hyp1 :-> (succEven(succ(n)).incl2(plusTwoEven(n)(hyp1)))}{hyp2 :-> (succEven(succ(n)).incl1((hyp2)))}
val inducSuccEven = NatInd.induc(succEven)
val pf = inducSuccEven(base)(n :~> step) !: thmSuccEven

We now prove a result that has been a goal, namely that for a function on Natural numbers if f(n)=f(n+1) for all n, f is constant.

val fn = "f" :: Nat ->: A
val ass = "assumption" :: n ~>: (fn(n) =:= fn(succ(n)))
val claim = n :-> (fn(zero) =:= fn(n))
val sbase = fn(zero).refl
val shyp = "hypothesis" :: (fn(zero) =:= fn(n))
val sstep = shyp :-> {IdentityTyp.trans(A)(fn(zero))(fn(n))(fn(succ(n)))(shyp)(ass(n)) }
val inducClaim = NatInd.induc(claim)
val spf = inducClaim(sbase)(n :~> sstep) !: (n ~>: (fn(zero) =:= fn(n)))

Indexed Inductive types

A generalization of inductive types are inductive type families, i.e., inductive types depending on an index. Unlike parametrized inductive types (such as lists), the constructors of an inductive type family involve in general several different indices. Further, the recursion and induction function only allow construction of (dependent) functions on the whole family.

A typical example is vectors, defined as a family indexed by their length.

val Vec = "Vec" :: Nat ->: Type

val VecInd =
  ("nil" ::: (Vec -> Vec(zero))) |: {
    "cons" ::: n ~>>: (A ->>: (Vec :> Vec(n)) -->>: (Vec -> Vec(succ(n))))
  } =:: Vec
val vnil :: vcons :: HNil = VecInd.intros

vcons.typ.fansi

We can define function recursively on vectors of all indices. For instance, we can define the size.

val vn = "v_n" :: Vec(n)
val recVN = VecInd.rec(Nat)
val vsize = recVN(zero)(n :~>(a :-> (vn :->(m :->(succ(m))))))
vsize(zero)(vnil)
val v1 = vcons(zero)(a)(vnil)
vsize(one)(v1)
assert(vsize(one)(v1) == one)

For a more interesting example, we consider vectors with entries natural numbers, and define the sum of entries.

val VecN = "Vec(Nat)" :: Nat ->: Type
val vnn  = "v_n" :: VecN(n)
val VecNInd =
  ("nil" ::: (VecN -> VecN(zero))) |: {
    "cons" ::: n ~>>:
      (Nat ->>: (VecN :> VecN(n)) -->>: (VecN -> VecN(succ(n))))
  } =:: VecN
val recVNN                  = VecNInd.rec(Nat)
val vnilN :: vconsN :: HNil = VecNInd.intros
val k = "k" :: Nat
val vsum = recVNN(zero)(n :~>(k :-> (vnn :->(m :-> (add(m)(k)) ))))
vsum(zero)(vnilN)
val v2 = vconsN(zero)(two)(vnilN)
vsum(one)(v2)
assert(vsum(one)(v2) == two)
val v3 = vconsN(one)(one)(v2)
v3.fansi
vsum(two)(v3)
assert(vsum(two)(v3) == three)

git commit hash when running tutorial: dba44cb5339f5c1c12afb49af558764c8efd887c